// Package logind observes the current process's session and owns bounded // logind calls/inhibitor descriptors. It contains no lock or sleep policy. package logind import ( "context" "errors" "fmt" "math" "os" "strings" "time" "github.com/bnema/neferafk/internal/ports" "github.com/godbus/dbus/v5" "golang.org/x/sys/unix" ) const service = "org.freedesktop.login1" const managerPath dbus.ObjectPath = "/org/freedesktop/login1" const managerInterface = "org.freedesktop.login1.Manager" const sessionInterface = "org.freedesktop.login1.Session" const userInterface = "org.freedesktop.login1.User" const callTimeout = 3 * time.Second // Adapter is single-owner: Probe, Run and Close must not execute concurrently. // Run owns the connection and closes it and any inhibitor on every exit. type Adapter struct { bus bus requirements ports.SystemRequirements owner string session dbus.ObjectPath // sessionSource names how session was found, for diagnostics. sessionSource string caps ports.SystemCapabilities signals chan *dbus.Signal inhibitor *os.File cycle uint64 preparing bool closed bool } // Open connects only when requested. Calling it is unnecessary when all system // features are off. Connection failure is an unsupported capability, not a // reason to force system initialization at daemon startup. func Open(ctx context.Context, requirements ports.SystemRequirements) (*Adapter, error) { if !requirements.Session && !requirements.Sleep { return nil, errors.New("logind not requested") } conn, err := dbus.ConnectSystemBus(dbus.WithContext(ctx)) if err != nil { return nil, fmt.Errorf("system bus unavailable: %w", err) } a := newAdapter(connection{conn}, requirements) if err := a.Probe(ctx); err != nil { a.Close() return nil, err } return a, nil } func newAdapter(b bus, r ports.SystemRequirements) *Adapter { return &Adapter{bus: b, requirements: r, signals: make(chan *dbus.Signal, 32)} } func (a *Adapter) call(ctx context.Context, path dbus.ObjectPath, method string, args ...any) ([]any, error) { ctx, cancel := context.WithTimeout(ctx, callTimeout) defer cancel() return a.bus.call(ctx, a.owner, path, method, args...) } func (a *Adapter) property(ctx context.Context, path dbus.ObjectPath, iface, name string) (any, error) { body, err := a.call(ctx, path, "org.freedesktop.DBus.Properties.Get", iface, name) if err != nil { return nil, err } if len(body) != 1 { return nil, errors.New("invalid property reply") } v, ok := body[0].(dbus.Variant) if !ok { return nil, errors.New("invalid property variant") } return v.Value(), nil } // Probe is read-only: no Lock/Unlock/Suspend/Inhibit call. It resolves the // unique service owner, the current session (PID, XDG_SESSION_ID or the user's // Display session) and its real UID; no user override exists. func (a *Adapter) Probe(ctx context.Context) error { bounded, cancel := context.WithTimeout(ctx, callTimeout) defer cancel() body, err := a.bus.call(bounded, "org.freedesktop.DBus", "/org/freedesktop/DBus", "org.freedesktop.DBus.GetNameOwner", service) if err != nil { return fmt.Errorf("logind unavailable: %w", err) } if len(body) != 1 { return errors.New("invalid logind owner reply") } owner, ok := body[0].(string) if !ok || !strings.HasPrefix(owner, ":") { return errors.New("invalid unique logind owner") } a.owner = owner a.caps = ports.SystemCapabilities{} a.session, a.sessionSource = "", "" if a.requirements.Session { var source string body, source, err = a.currentSession(ctx) if err != nil { return err } if len(body) != 1 { return errors.New("invalid session reply") } path, ok := body[0].(dbus.ObjectPath) if !ok || !path.IsValid() || !strings.HasPrefix(string(path), "/org/freedesktop/login1/session/") { return errors.New("invalid session path") } value, err := a.property(ctx, path, sessionInterface, "User") if err != nil { return err } fields, ok := value.([]any) if !ok || len(fields) != 2 { return errors.New("invalid session User property") } uid, ok := fields[0].(uint32) if !ok || uid != uint32(os.Getuid()) { return errors.New("session does not belong to real UID") } a.session, a.sessionSource = path, source a.caps.Session = true } if a.requirements.Sleep { body, err = a.call(ctx, managerPath, managerInterface+".CanSuspend") if err != nil { return fmt.Errorf("suspend capability unavailable: %w", err) } if len(body) != 1 { return errors.New("invalid CanSuspend reply") } auth, ok := body[0].(string) if !ok { return errors.New("invalid CanSuspend type") } a.caps.SuspendAuthorization = auth // Suspend(false) cannot request an interactive authorization prompt. a.caps.Suspend = auth == "yes" if !a.caps.Suspend { a.caps.Missing = append(a.caps.Missing, "non-interactive suspend: "+auth) } value, err := a.property(ctx, managerPath, managerInterface, "InhibitDelayMaxUSec") if err != nil { return err } usec, ok := value.(uint64) if !ok || usec == 0 || usec > uint64(math.MaxInt64/1000) { return errors.New("invalid inhibitor delay maximum") } a.caps.DelayMax = time.Duration(usec) * time.Microsecond if !a.bus.unixFDs() { a.caps.Missing = append(a.caps.Missing, "D-Bus Unix FD passing") a.caps.Suspend = false } } return nil } // currentSession resolves the process's session by PID. A compositor started // as a systemd user service runs outside the session scope, so its children // have no PID session; the session the display manager opened is then taken // from XDG_SESSION_ID. A terminal launched by the compositor can inherit a // stale XDG_SESSION_ID, so the real UID's Display session is the last resort. // Fallback sessions must be live and graphical; Probe checks every result // against the real UID. It also returns which source chose the session. func (a *Adapter) currentSession(ctx context.Context) ([]any, string, error) { body, err := a.call(ctx, managerPath, managerInterface+".GetSessionByPID", uint32(os.Getpid())) if err == nil { return body, "pid", nil } errs := []error{fmt.Errorf("PID: %w", err)} if id := os.Getenv("XDG_SESSION_ID"); id != "" { path, err := a.sessionByID(ctx, id) if err == nil { err = a.liveGraphical(ctx, path) } if err == nil { return []any{path}, "XDG_SESSION_ID", nil } errs = append(errs, fmt.Errorf("XDG_SESSION_ID: %w", err)) } path, err := a.displaySession(ctx) if err == nil { err = a.liveGraphical(ctx, path) } if err == nil { return []any{path}, "user display", nil } errs = append(errs, fmt.Errorf("user display session: %w", err)) return nil, "", fmt.Errorf("current process session unavailable: %w", errors.Join(errs...)) } func (a *Adapter) sessionByID(ctx context.Context, id string) (dbus.ObjectPath, error) { body, err := a.call(ctx, managerPath, managerInterface+".GetSession", id) if err != nil { return "", err } if len(body) != 1 { return "", errors.New("invalid session reply") } path, ok := body[0].(dbus.ObjectPath) if !ok || !path.IsValid() { return "", errors.New("invalid session path") } return path, nil } // liveGraphical rejects tty, closing and lingering sessions, so a fallback // never binds lock signals to a session the user is not looking at. "online" // (logged in, not foreground) is accepted so a daemon started while the user // is on another VT still binds; with two graphical sessions of the same user // a stale XDG_SESSION_ID may thus pick the background one (same UID only). func (a *Adapter) liveGraphical(ctx context.Context, path dbus.ObjectPath) error { value, err := a.property(ctx, path, sessionInterface, "Type") if err != nil { return fmt.Errorf("%s: %w", path, err) } if t, _ := value.(string); t != "wayland" && t != "x11" { return fmt.Errorf("%s: session type %q is not graphical", path, t) } value, err = a.property(ctx, path, sessionInterface, "State") if err != nil { return fmt.Errorf("%s: %w", path, err) } if s, _ := value.(string); s != "active" && s != "online" { return fmt.Errorf("%s: session state %q is not live", path, s) } return nil } // displaySession returns the real UID's primary (Display) session from // logind. logind prefers a graphical one; liveGraphical enforces it. func (a *Adapter) displaySession(ctx context.Context) (dbus.ObjectPath, error) { body, err := a.call(ctx, managerPath, managerInterface+".GetUser", uint32(os.Getuid())) if err != nil { return "", err } if len(body) != 1 { return "", errors.New("invalid user reply") } user, ok := body[0].(dbus.ObjectPath) if !ok || !user.IsValid() { return "", errors.New("invalid user path") } value, err := a.property(ctx, user, userInterface, "Display") if err != nil { return "", err } fields, ok := value.([]any) if !ok || len(fields) != 2 { return "", errors.New("invalid user Display property") } path, ok := fields[1].(dbus.ObjectPath) if !ok || !path.IsValid() || path == "/" { return "", errors.New("no display session") } return path, nil } // Session reports the bound session path and how it was found. func (a *Adapter) Session() (path, source string) { return string(a.session), a.sessionSource } func (a *Adapter) Capabilities() ports.SystemCapabilities { c := a.caps c.Missing = append([]string(nil), c.Missing...) return c } func (a *Adapter) emit(ctx context.Context, out chan<- ports.SystemEvent, event ports.SystemEvent) error { select { case out <- event: return nil case <-ctx.Done(): return ctx.Err() } } // Run starts subscriptions and holds a delay inhibitor only for enabled sleep. // A matching SleepReady releases it. Resume reacquires for the next cycle. // logind's own maximum bounds the delay even if policy never replies. func (a *Adapter) Run(ctx context.Context, commands <-chan ports.SystemCommand, events chan<- ports.SystemEvent) error { defer a.Close() if a.owner == "" { return errors.New("logind must be probed before Run") } a.bus.signals(a.signals) defer a.bus.removeSignals(a.signals) paths := []struct { path dbus.ObjectPath iface string }{} if a.requirements.Session { paths = append(paths, struct { path dbus.ObjectPath iface string }{a.session, sessionInterface}) } if a.requirements.Sleep { paths = append(paths, struct { path dbus.ObjectPath iface string }{managerPath, managerInterface}) } for _, p := range paths { rule := fmt.Sprintf("type='signal',sender='%s',path='%s',interface='%s'", a.owner, p.path, p.iface) bounded, cancel := context.WithTimeout(ctx, callTimeout) err := a.bus.match(bounded, rule) cancel() if err != nil { return err } } if a.requirements.Sleep && a.bus.unixFDs() { if err := a.inhibit(ctx); err != nil { a.caps.Missing = append(a.caps.Missing, "sleep delay inhibitor") a.caps.Suspend = false } } if err := a.emit(ctx, events, ports.SystemCapabilityReport{Capabilities: a.Capabilities()}); err != nil { return err } var suspendReply <-chan *dbus.Call var suspendCancel context.CancelFunc defer func() { if suspendCancel != nil { suspendCancel() } }() for { select { case reply := <-suspendReply: suspendCancel() suspendReply, suspendCancel = nil, nil if reply == nil { return errors.New("suspend reply channel closed") } if reply.Err != nil { if err := a.emit(ctx, events, ports.SystemFailure{Operation: "suspend", Err: reply.Err}); err != nil { return err } } case <-ctx.Done(): return ctx.Err() case sig, ok := <-a.signals: if !ok { return errors.New("system bus signal stream closed") } event, ok := parseSignal(sig, a.owner, a.session) if !ok { continue } if prep, ok := event.(ports.SleepPreparation); ok { if prep.Preparing && !a.preparing { a.cycle++ } a.preparing = prep.Preparing prep.Cycle, prep.MaxDelay, prep.ReceivedAt = a.cycle, a.caps.DelayMax, time.Now() event = prep if !prep.Preparing { a.release() if err := a.inhibit(ctx); err != nil { if e := a.emit(ctx, events, ports.SystemFailure{Operation: "inhibit", Err: err}); e != nil { return e } } } } if err := a.emit(ctx, events, event); err != nil { return err } case cmd, ok := <-commands: if !ok { return nil } switch c := cmd.(type) { case ports.SystemSleepReady: if a.preparing && c.Cycle == a.cycle { a.release() } case ports.SystemSuspend: var err error if !a.requirements.Sleep || !a.caps.Suspend { err = errors.New("non-interactive suspend unsupported") } else if suspendReply != nil { err = errors.New("suspend request already pending") } else { // Never override block inhibitors, including ones owned by // this UID (which privileged logind policies may otherwise // permit). Query immediately before the ordinary Suspend call. blocked, e := a.property(ctx, managerPath, managerInterface, "BlockInhibited") if e != nil { err = e } else if value, ok := blocked.(string); !ok { err = errors.New("invalid block inhibitor property") } else { for _, item := range strings.Split(value, ":") { if item == "sleep" { err = errors.New("sleep blocked by inhibitor") } } } if err != nil { if e := a.emit(ctx, events, ports.SystemFailure{Operation: "suspend", Err: err}); e != nil { return e } continue } bounded, cancel := context.WithTimeout(ctx, callTimeout) suspendCancel = cancel // released on reply or by the loop's defer suspendReply = a.bus.suspend(bounded, a.owner) } if err != nil { if e := a.emit(ctx, events, ports.SystemFailure{Operation: "suspend", Err: err}); e != nil { return e } } } } } } func parseSignal(sig *dbus.Signal, owner string, session dbus.ObjectPath) (ports.SystemEvent, bool) { if sig == nil || sig.Sender != owner { return nil, false } if sig.Path == session && session != "" && len(sig.Body) == 0 { switch sig.Name { case sessionInterface + ".Lock": return ports.SystemLockRequested{}, true case sessionInterface + ".Unlock": return ports.SystemUnlockObserved{}, true } } if sig.Path == managerPath && sig.Name == managerInterface+".PrepareForSleep" && len(sig.Body) == 1 { preparing, ok := sig.Body[0].(bool) if ok { return ports.SleepPreparation{Preparing: preparing}, true } } return nil, false } func (a *Adapter) inhibit(ctx context.Context) error { if !a.requirements.Sleep || a.inhibitor != nil { return nil } body, err := a.call(ctx, managerPath, managerInterface+".Inhibit", "sleep", "neferafk", "session protection", "delay") if err != nil { return err } if len(body) != 1 { for _, value := range body { if fd, ok := value.(dbus.UnixFD); ok && fd >= 0 { _ = unix.Close(int(fd)) } } return errors.New("invalid inhibitor reply") } fd, ok := body[0].(dbus.UnixFD) if !ok || fd < 0 { return errors.New("invalid inhibitor descriptor") } // godbus returns the received descriptor directly, not an os.File and not // an owned duplicate. Atomically duplicate CLOEXEC then close the received FD. dup, err := unix.FcntlInt(uintptr(fd), unix.F_DUPFD_CLOEXEC, 0) _ = unix.Close(int(fd)) if err != nil { return err } a.inhibitor = os.NewFile(uintptr(dup), "logind-sleep-delay") return nil } func (a *Adapter) release() { if a.inhibitor != nil { _ = a.inhibitor.Close() a.inhibitor = nil } } func (a *Adapter) Close() error { if a.closed { return nil } a.closed = true a.release() return a.bus.close() }