1// Package logind observes the current process's session and owns bounded
2// logind calls/inhibitor descriptors. It contains no lock or sleep policy.
3package logind
4
5import (
6 "context"
7 "errors"
8 "fmt"
9 "math"
10 "os"
11 "strings"
12 "time"
13
14 "github.com/bnema/neferafk/internal/ports"
15 "github.com/godbus/dbus/v5"
16 "golang.org/x/sys/unix"
17)
18
19const service = "org.freedesktop.login1"
20const managerPath dbus.ObjectPath = "/org/freedesktop/login1"
21const managerInterface = "org.freedesktop.login1.Manager"
22const sessionInterface = "org.freedesktop.login1.Session"
23const userInterface = "org.freedesktop.login1.User"
24const callTimeout = 3 * time.Second
25
26// Adapter is single-owner: Probe, Run and Close must not execute concurrently.
27// Run owns the connection and closes it and any inhibitor on every exit.
28type Adapter struct {
29 bus bus
30 requirements ports.SystemRequirements
31 owner string
32 session dbus.ObjectPath
33 // sessionSource names how session was found, for diagnostics.
34 sessionSource string
35 caps ports.SystemCapabilities
36 signals chan *dbus.Signal
37 inhibitor *os.File
38 cycle uint64
39 preparing bool
40 closed bool
41}
42
43// Open connects only when requested. Calling it is unnecessary when all system
44// features are off. Connection failure is an unsupported capability, not a
45// reason to force system initialization at daemon startup.
46func Open(ctx context.Context, requirements ports.SystemRequirements) (*Adapter, error) {
47 if !requirements.Session && !requirements.Sleep {
48 return nil, errors.New("logind not requested")
49 }
50 conn, err := dbus.ConnectSystemBus(dbus.WithContext(ctx))
51 if err != nil {
52 return nil, fmt.Errorf("system bus unavailable: %w", err)
53 }
54 a := newAdapter(connection{conn}, requirements)
55 if err := a.Probe(ctx); err != nil {
56 a.Close()
57 return nil, err
58 }
59 return a, nil
60}
61func newAdapter(b bus, r ports.SystemRequirements) *Adapter {
62 return &Adapter{bus: b, requirements: r, signals: make(chan *dbus.Signal, 32)}
63}
64func (a *Adapter) call(ctx context.Context, path dbus.ObjectPath, method string, args ...any) ([]any, error) {
65 ctx, cancel := context.WithTimeout(ctx, callTimeout)
66 defer cancel()
67 return a.bus.call(ctx, a.owner, path, method, args...)
68}
69func (a *Adapter) property(ctx context.Context, path dbus.ObjectPath, iface, name string) (any, error) {
70 body, err := a.call(ctx, path, "org.freedesktop.DBus.Properties.Get", iface, name)
71 if err != nil {
72 return nil, err
73 }
74 if len(body) != 1 {
75 return nil, errors.New("invalid property reply")
76 }
77 v, ok := body[0].(dbus.Variant)
78 if !ok {
79 return nil, errors.New("invalid property variant")
80 }
81 return v.Value(), nil
82}
83
84// Probe is read-only: no Lock/Unlock/Suspend/Inhibit call. It resolves the
85// unique service owner, the current session (PID, XDG_SESSION_ID or the user's
86// Display session) and its real UID; no user override exists.
87func (a *Adapter) Probe(ctx context.Context) error {
88 bounded, cancel := context.WithTimeout(ctx, callTimeout)
89 defer cancel()
90 body, err := a.bus.call(bounded, "org.freedesktop.DBus", "/org/freedesktop/DBus", "org.freedesktop.DBus.GetNameOwner", service)
91 if err != nil {
92 return fmt.Errorf("logind unavailable: %w", err)
93 }
94 if len(body) != 1 {
95 return errors.New("invalid logind owner reply")
96 }
97 owner, ok := body[0].(string)
98 if !ok || !strings.HasPrefix(owner, ":") {
99 return errors.New("invalid unique logind owner")
100 }
101 a.owner = owner
102 a.caps = ports.SystemCapabilities{}
103 a.session, a.sessionSource = "", ""
104 if a.requirements.Session {
105 var source string
106 body, source, err = a.currentSession(ctx)
107 if err != nil {
108 return err
109 }
110 if len(body) != 1 {
111 return errors.New("invalid session reply")
112 }
113 path, ok := body[0].(dbus.ObjectPath)
114 if !ok || !path.IsValid() || !strings.HasPrefix(string(path), "/org/freedesktop/login1/session/") {
115 return errors.New("invalid session path")
116 }
117 value, err := a.property(ctx, path, sessionInterface, "User")
118 if err != nil {
119 return err
120 }
121 fields, ok := value.([]any)
122 if !ok || len(fields) != 2 {
123 return errors.New("invalid session User property")
124 }
125 uid, ok := fields[0].(uint32)
126 if !ok || uid != uint32(os.Getuid()) {
127 return errors.New("session does not belong to real UID")
128 }
129 a.session, a.sessionSource = path, source
130 a.caps.Session = true
131 }
132 if a.requirements.Sleep {
133 body, err = a.call(ctx, managerPath, managerInterface+".CanSuspend")
134 if err != nil {
135 return fmt.Errorf("suspend capability unavailable: %w", err)
136 }
137 if len(body) != 1 {
138 return errors.New("invalid CanSuspend reply")
139 }
140 auth, ok := body[0].(string)
141 if !ok {
142 return errors.New("invalid CanSuspend type")
143 }
144 a.caps.SuspendAuthorization = auth
145 // Suspend(false) cannot request an interactive authorization prompt.
146 a.caps.Suspend = auth == "yes"
147 if !a.caps.Suspend {
148 a.caps.Missing = append(a.caps.Missing, "non-interactive suspend: "+auth)
149 }
150 value, err := a.property(ctx, managerPath, managerInterface, "InhibitDelayMaxUSec")
151 if err != nil {
152 return err
153 }
154 usec, ok := value.(uint64)
155 if !ok || usec == 0 || usec > uint64(math.MaxInt64/1000) {
156 return errors.New("invalid inhibitor delay maximum")
157 }
158 a.caps.DelayMax = time.Duration(usec) * time.Microsecond
159 if !a.bus.unixFDs() {
160 a.caps.Missing = append(a.caps.Missing, "D-Bus Unix FD passing")
161 a.caps.Suspend = false
162 }
163 }
164 return nil
165}
166
167// currentSession resolves the process's session by PID. A compositor started
168// as a systemd user service runs outside the session scope, so its children
169// have no PID session; the session the display manager opened is then taken
170// from XDG_SESSION_ID. A terminal launched by the compositor can inherit a
171// stale XDG_SESSION_ID, so the real UID's Display session is the last resort.
172// Fallback sessions must be live and graphical; Probe checks every result
173// against the real UID. It also returns which source chose the session.
174func (a *Adapter) currentSession(ctx context.Context) ([]any, string, error) {
175 body, err := a.call(ctx, managerPath, managerInterface+".GetSessionByPID", uint32(os.Getpid()))
176 if err == nil {
177 return body, "pid", nil
178 }
179 errs := []error{fmt.Errorf("PID: %w", err)}
180 if id := os.Getenv("XDG_SESSION_ID"); id != "" {
181 path, err := a.sessionByID(ctx, id)
182 if err == nil {
183 err = a.liveGraphical(ctx, path)
184 }
185 if err == nil {
186 return []any{path}, "XDG_SESSION_ID", nil
187 }
188 errs = append(errs, fmt.Errorf("XDG_SESSION_ID: %w", err))
189 }
190 path, err := a.displaySession(ctx)
191 if err == nil {
192 err = a.liveGraphical(ctx, path)
193 }
194 if err == nil {
195 return []any{path}, "user display", nil
196 }
197 errs = append(errs, fmt.Errorf("user display session: %w", err))
198 return nil, "", fmt.Errorf("current process session unavailable: %w", errors.Join(errs...))
199}
200
201func (a *Adapter) sessionByID(ctx context.Context, id string) (dbus.ObjectPath, error) {
202 body, err := a.call(ctx, managerPath, managerInterface+".GetSession", id)
203 if err != nil {
204 return "", err
205 }
206 if len(body) != 1 {
207 return "", errors.New("invalid session reply")
208 }
209 path, ok := body[0].(dbus.ObjectPath)
210 if !ok || !path.IsValid() {
211 return "", errors.New("invalid session path")
212 }
213 return path, nil
214}
215
216// liveGraphical rejects tty, closing and lingering sessions, so a fallback
217// never binds lock signals to a session the user is not looking at. "online"
218// (logged in, not foreground) is accepted so a daemon started while the user
219// is on another VT still binds; with two graphical sessions of the same user
220// a stale XDG_SESSION_ID may thus pick the background one (same UID only).
221func (a *Adapter) liveGraphical(ctx context.Context, path dbus.ObjectPath) error {
222 value, err := a.property(ctx, path, sessionInterface, "Type")
223 if err != nil {
224 return fmt.Errorf("%s: %w", path, err)
225 }
226 if t, _ := value.(string); t != "wayland" && t != "x11" {
227 return fmt.Errorf("%s: session type %q is not graphical", path, t)
228 }
229 value, err = a.property(ctx, path, sessionInterface, "State")
230 if err != nil {
231 return fmt.Errorf("%s: %w", path, err)
232 }
233 if s, _ := value.(string); s != "active" && s != "online" {
234 return fmt.Errorf("%s: session state %q is not live", path, s)
235 }
236 return nil
237}
238
239// displaySession returns the real UID's primary (Display) session from
240// logind. logind prefers a graphical one; liveGraphical enforces it.
241func (a *Adapter) displaySession(ctx context.Context) (dbus.ObjectPath, error) {
242 body, err := a.call(ctx, managerPath, managerInterface+".GetUser", uint32(os.Getuid()))
243 if err != nil {
244 return "", err
245 }
246 if len(body) != 1 {
247 return "", errors.New("invalid user reply")
248 }
249 user, ok := body[0].(dbus.ObjectPath)
250 if !ok || !user.IsValid() {
251 return "", errors.New("invalid user path")
252 }
253 value, err := a.property(ctx, user, userInterface, "Display")
254 if err != nil {
255 return "", err
256 }
257 fields, ok := value.([]any)
258 if !ok || len(fields) != 2 {
259 return "", errors.New("invalid user Display property")
260 }
261 path, ok := fields[1].(dbus.ObjectPath)
262 if !ok || !path.IsValid() || path == "/" {
263 return "", errors.New("no display session")
264 }
265 return path, nil
266}
267
268// Session reports the bound session path and how it was found.
269func (a *Adapter) Session() (path, source string) {
270 return string(a.session), a.sessionSource
271}
272
273func (a *Adapter) Capabilities() ports.SystemCapabilities {
274 c := a.caps
275 c.Missing = append([]string(nil), c.Missing...)
276 return c
277}
278func (a *Adapter) emit(ctx context.Context, out chan<- ports.SystemEvent, event ports.SystemEvent) error {
279 select {
280 case out <- event:
281 return nil
282 case <-ctx.Done():
283 return ctx.Err()
284 }
285}
286
287// Run starts subscriptions and holds a delay inhibitor only for enabled sleep.
288// A matching SleepReady releases it. Resume reacquires for the next cycle.
289// logind's own maximum bounds the delay even if policy never replies.
290func (a *Adapter) Run(ctx context.Context, commands <-chan ports.SystemCommand, events chan<- ports.SystemEvent) error {
291 defer a.Close()
292 if a.owner == "" {
293 return errors.New("logind must be probed before Run")
294 }
295 a.bus.signals(a.signals)
296 defer a.bus.removeSignals(a.signals)
297 paths := []struct {
298 path dbus.ObjectPath
299 iface string
300 }{}
301 if a.requirements.Session {
302 paths = append(paths, struct {
303 path dbus.ObjectPath
304 iface string
305 }{a.session, sessionInterface})
306 }
307 if a.requirements.Sleep {
308 paths = append(paths, struct {
309 path dbus.ObjectPath
310 iface string
311 }{managerPath, managerInterface})
312 }
313 for _, p := range paths {
314 rule := fmt.Sprintf("type='signal',sender='%s',path='%s',interface='%s'", a.owner, p.path, p.iface)
315 bounded, cancel := context.WithTimeout(ctx, callTimeout)
316 err := a.bus.match(bounded, rule)
317 cancel()
318 if err != nil {
319 return err
320 }
321 }
322 if a.requirements.Sleep && a.bus.unixFDs() {
323 if err := a.inhibit(ctx); err != nil {
324 a.caps.Missing = append(a.caps.Missing, "sleep delay inhibitor")
325 a.caps.Suspend = false
326 }
327 }
328 if err := a.emit(ctx, events, ports.SystemCapabilityReport{Capabilities: a.Capabilities()}); err != nil {
329 return err
330 }
331 var suspendReply <-chan *dbus.Call
332 var suspendCancel context.CancelFunc
333 defer func() {
334 if suspendCancel != nil {
335 suspendCancel()
336 }
337 }()
338 for {
339 select {
340 case reply := <-suspendReply:
341 suspendCancel()
342 suspendReply, suspendCancel = nil, nil
343 if reply == nil {
344 return errors.New("suspend reply channel closed")
345 }
346 if reply.Err != nil {
347 if err := a.emit(ctx, events, ports.SystemFailure{Operation: "suspend", Err: reply.Err}); err != nil {
348 return err
349 }
350 }
351 case <-ctx.Done():
352 return ctx.Err()
353 case sig, ok := <-a.signals:
354 if !ok {
355 return errors.New("system bus signal stream closed")
356 }
357 event, ok := parseSignal(sig, a.owner, a.session)
358 if !ok {
359 continue
360 }
361 if prep, ok := event.(ports.SleepPreparation); ok {
362 if prep.Preparing && !a.preparing {
363 a.cycle++
364 }
365 a.preparing = prep.Preparing
366 prep.Cycle, prep.MaxDelay, prep.ReceivedAt = a.cycle, a.caps.DelayMax, time.Now()
367 event = prep
368 if !prep.Preparing {
369 a.release()
370 if err := a.inhibit(ctx); err != nil {
371 if e := a.emit(ctx, events, ports.SystemFailure{Operation: "inhibit", Err: err}); e != nil {
372 return e
373 }
374 }
375 }
376 }
377 if err := a.emit(ctx, events, event); err != nil {
378 return err
379 }
380 case cmd, ok := <-commands:
381 if !ok {
382 return nil
383 }
384 switch c := cmd.(type) {
385 case ports.SystemSleepReady:
386 if a.preparing && c.Cycle == a.cycle {
387 a.release()
388 }
389 case ports.SystemSuspend:
390 var err error
391 if !a.requirements.Sleep || !a.caps.Suspend {
392 err = errors.New("non-interactive suspend unsupported")
393 } else if suspendReply != nil {
394 err = errors.New("suspend request already pending")
395 } else {
396 // Never override block inhibitors, including ones owned by
397 // this UID (which privileged logind policies may otherwise
398 // permit). Query immediately before the ordinary Suspend call.
399 blocked, e := a.property(ctx, managerPath, managerInterface, "BlockInhibited")
400 if e != nil {
401 err = e
402 } else if value, ok := blocked.(string); !ok {
403 err = errors.New("invalid block inhibitor property")
404 } else {
405 for _, item := range strings.Split(value, ":") {
406 if item == "sleep" {
407 err = errors.New("sleep blocked by inhibitor")
408 }
409 }
410 }
411 if err != nil {
412 if e := a.emit(ctx, events, ports.SystemFailure{Operation: "suspend", Err: err}); e != nil {
413 return e
414 }
415 continue
416 }
417 bounded, cancel := context.WithTimeout(ctx, callTimeout)
418 suspendCancel = cancel // released on reply or by the loop's defer
419 suspendReply = a.bus.suspend(bounded, a.owner)
420 }
421 if err != nil {
422 if e := a.emit(ctx, events, ports.SystemFailure{Operation: "suspend", Err: err}); e != nil {
423 return e
424 }
425 }
426 }
427 }
428 }
429}
430
431func parseSignal(sig *dbus.Signal, owner string, session dbus.ObjectPath) (ports.SystemEvent, bool) {
432 if sig == nil || sig.Sender != owner {
433 return nil, false
434 }
435 if sig.Path == session && session != "" && len(sig.Body) == 0 {
436 switch sig.Name {
437 case sessionInterface + ".Lock":
438 return ports.SystemLockRequested{}, true
439 case sessionInterface + ".Unlock":
440 return ports.SystemUnlockObserved{}, true
441 }
442 }
443 if sig.Path == managerPath && sig.Name == managerInterface+".PrepareForSleep" && len(sig.Body) == 1 {
444 preparing, ok := sig.Body[0].(bool)
445 if ok {
446 return ports.SleepPreparation{Preparing: preparing}, true
447 }
448 }
449 return nil, false
450}
451
452func (a *Adapter) inhibit(ctx context.Context) error {
453 if !a.requirements.Sleep || a.inhibitor != nil {
454 return nil
455 }
456 body, err := a.call(ctx, managerPath, managerInterface+".Inhibit", "sleep", "neferafk", "session protection", "delay")
457 if err != nil {
458 return err
459 }
460 if len(body) != 1 {
461 for _, value := range body {
462 if fd, ok := value.(dbus.UnixFD); ok && fd >= 0 {
463 _ = unix.Close(int(fd))
464 }
465 }
466 return errors.New("invalid inhibitor reply")
467 }
468 fd, ok := body[0].(dbus.UnixFD)
469 if !ok || fd < 0 {
470 return errors.New("invalid inhibitor descriptor")
471 }
472 // godbus returns the received descriptor directly, not an os.File and not
473 // an owned duplicate. Atomically duplicate CLOEXEC then close the received FD.
474 dup, err := unix.FcntlInt(uintptr(fd), unix.F_DUPFD_CLOEXEC, 0)
475 _ = unix.Close(int(fd))
476 if err != nil {
477 return err
478 }
479 a.inhibitor = os.NewFile(uintptr(dup), "logind-sleep-delay")
480 return nil
481}
482func (a *Adapter) release() {
483 if a.inhibitor != nil {
484 _ = a.inhibitor.Close()
485 a.inhibitor = nil
486 }
487}
488func (a *Adapter) Close() error {
489 if a.closed {
490 return nil
491 }
492 a.closed = true
493 a.release()
494 return a.bus.close()
495}