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 callTimeout = 3 * time.Second
24
25// Adapter is single-owner: Probe, Run and Close must not execute concurrently.
26// Run owns the connection and closes it and any inhibitor on every exit.
27type Adapter struct {
28 bus bus
29 requirements ports.SystemRequirements
30 owner string
31 session dbus.ObjectPath
32 caps ports.SystemCapabilities
33 signals chan *dbus.Signal
34 inhibitor *os.File
35 cycle uint64
36 preparing bool
37 closed bool
38}
39
40// Open connects only when requested. Calling it is unnecessary when all system
41// features are off. Connection failure is an unsupported capability, not a
42// reason to force system initialization at daemon startup.
43func Open(ctx context.Context, requirements ports.SystemRequirements) (*Adapter, error) {
44 if !requirements.Session && !requirements.Sleep {
45 return nil, errors.New("logind not requested")
46 }
47 conn, err := dbus.ConnectSystemBus(dbus.WithContext(ctx))
48 if err != nil {
49 return nil, fmt.Errorf("system bus unavailable: %w", err)
50 }
51 a := newAdapter(connection{conn}, requirements)
52 if err := a.Probe(ctx); err != nil {
53 a.Close()
54 return nil, err
55 }
56 return a, nil
57}
58func newAdapter(b bus, r ports.SystemRequirements) *Adapter {
59 return &Adapter{bus: b, requirements: r, signals: make(chan *dbus.Signal, 32)}
60}
61func (a *Adapter) call(ctx context.Context, path dbus.ObjectPath, method string, args ...any) ([]any, error) {
62 ctx, cancel := context.WithTimeout(ctx, callTimeout)
63 defer cancel()
64 return a.bus.call(ctx, a.owner, path, method, args...)
65}
66func (a *Adapter) property(ctx context.Context, path dbus.ObjectPath, iface, name string) (any, error) {
67 body, err := a.call(ctx, path, "org.freedesktop.DBus.Properties.Get", iface, name)
68 if err != nil {
69 return nil, err
70 }
71 if len(body) != 1 {
72 return nil, errors.New("invalid property reply")
73 }
74 v, ok := body[0].(dbus.Variant)
75 if !ok {
76 return nil, errors.New("invalid property variant")
77 }
78 return v.Value(), nil
79}
80
81// Probe is read-only: no Lock/Unlock/Suspend/Inhibit call. It resolves the
82// unique service owner, PID's session and its real UID; no user override exists.
83func (a *Adapter) Probe(ctx context.Context) error {
84 bounded, cancel := context.WithTimeout(ctx, callTimeout)
85 defer cancel()
86 body, err := a.bus.call(bounded, "org.freedesktop.DBus", "/org/freedesktop/DBus", "org.freedesktop.DBus.GetNameOwner", service)
87 if err != nil {
88 return fmt.Errorf("logind unavailable: %w", err)
89 }
90 if len(body) != 1 {
91 return errors.New("invalid logind owner reply")
92 }
93 owner, ok := body[0].(string)
94 if !ok || !strings.HasPrefix(owner, ":") {
95 return errors.New("invalid unique logind owner")
96 }
97 a.owner = owner
98 a.caps = ports.SystemCapabilities{}
99 if a.requirements.Session {
100 body, err = a.call(ctx, managerPath, managerInterface+".GetSessionByPID", uint32(os.Getpid()))
101 if err != nil {
102 return fmt.Errorf("current process session unavailable: %w", err)
103 }
104 if len(body) != 1 {
105 return errors.New("invalid session reply")
106 }
107 path, ok := body[0].(dbus.ObjectPath)
108 if !ok || !path.IsValid() || !strings.HasPrefix(string(path), "/org/freedesktop/login1/session/") {
109 return errors.New("invalid session path")
110 }
111 value, err := a.property(ctx, path, sessionInterface, "User")
112 if err != nil {
113 return err
114 }
115 fields, ok := value.([]any)
116 if !ok || len(fields) != 2 {
117 return errors.New("invalid session User property")
118 }
119 uid, ok := fields[0].(uint32)
120 if !ok || uid != uint32(os.Getuid()) {
121 return errors.New("session does not belong to real UID")
122 }
123 a.session = path
124 a.caps.Session = true
125 }
126 if a.requirements.Sleep {
127 body, err = a.call(ctx, managerPath, managerInterface+".CanSuspend")
128 if err != nil {
129 return fmt.Errorf("suspend capability unavailable: %w", err)
130 }
131 if len(body) != 1 {
132 return errors.New("invalid CanSuspend reply")
133 }
134 auth, ok := body[0].(string)
135 if !ok {
136 return errors.New("invalid CanSuspend type")
137 }
138 a.caps.SuspendAuthorization = auth
139 // Suspend(false) cannot request an interactive authorization prompt.
140 a.caps.Suspend = auth == "yes"
141 if !a.caps.Suspend {
142 a.caps.Missing = append(a.caps.Missing, "non-interactive suspend: "+auth)
143 }
144 value, err := a.property(ctx, managerPath, managerInterface, "InhibitDelayMaxUSec")
145 if err != nil {
146 return err
147 }
148 usec, ok := value.(uint64)
149 if !ok || usec == 0 || usec > uint64(math.MaxInt64/1000) {
150 return errors.New("invalid inhibitor delay maximum")
151 }
152 a.caps.DelayMax = time.Duration(usec) * time.Microsecond
153 if !a.bus.unixFDs() {
154 a.caps.Missing = append(a.caps.Missing, "D-Bus Unix FD passing")
155 a.caps.Suspend = false
156 }
157 }
158 return nil
159}
160func (a *Adapter) Capabilities() ports.SystemCapabilities {
161 c := a.caps
162 c.Missing = append([]string(nil), c.Missing...)
163 return c
164}
165func (a *Adapter) emit(ctx context.Context, out chan<- ports.SystemEvent, event ports.SystemEvent) error {
166 select {
167 case out <- event:
168 return nil
169 case <-ctx.Done():
170 return ctx.Err()
171 }
172}
173
174// Run starts subscriptions and holds a delay inhibitor only for enabled sleep.
175// A matching SleepReady releases it. Resume reacquires for the next cycle.
176// logind's own maximum bounds the delay even if policy never replies.
177func (a *Adapter) Run(ctx context.Context, commands <-chan ports.SystemCommand, events chan<- ports.SystemEvent) error {
178 defer a.Close()
179 if a.owner == "" {
180 return errors.New("logind must be probed before Run")
181 }
182 a.bus.signals(a.signals)
183 defer a.bus.removeSignals(a.signals)
184 paths := []struct {
185 path dbus.ObjectPath
186 iface string
187 }{}
188 if a.requirements.Session {
189 paths = append(paths, struct {
190 path dbus.ObjectPath
191 iface string
192 }{a.session, sessionInterface})
193 }
194 if a.requirements.Sleep {
195 paths = append(paths, struct {
196 path dbus.ObjectPath
197 iface string
198 }{managerPath, managerInterface})
199 }
200 for _, p := range paths {
201 rule := fmt.Sprintf("type='signal',sender='%s',path='%s',interface='%s'", a.owner, p.path, p.iface)
202 bounded, cancel := context.WithTimeout(ctx, callTimeout)
203 err := a.bus.match(bounded, rule)
204 cancel()
205 if err != nil {
206 return err
207 }
208 }
209 if a.requirements.Sleep && a.bus.unixFDs() {
210 if err := a.inhibit(ctx); err != nil {
211 a.caps.Missing = append(a.caps.Missing, "sleep delay inhibitor")
212 a.caps.Suspend = false
213 }
214 }
215 if err := a.emit(ctx, events, ports.SystemCapabilityReport{Capabilities: a.Capabilities()}); err != nil {
216 return err
217 }
218 var suspendReply <-chan *dbus.Call
219 var suspendCancel context.CancelFunc
220 defer func() {
221 if suspendCancel != nil {
222 suspendCancel()
223 }
224 }()
225 for {
226 select {
227 case reply := <-suspendReply:
228 suspendCancel()
229 suspendReply, suspendCancel = nil, nil
230 if reply == nil {
231 return errors.New("suspend reply channel closed")
232 }
233 if reply.Err != nil {
234 if err := a.emit(ctx, events, ports.SystemFailure{Operation: "suspend", Err: reply.Err}); err != nil {
235 return err
236 }
237 }
238 case <-ctx.Done():
239 return ctx.Err()
240 case sig, ok := <-a.signals:
241 if !ok {
242 return errors.New("system bus signal stream closed")
243 }
244 event, ok := parseSignal(sig, a.owner, a.session)
245 if !ok {
246 continue
247 }
248 if prep, ok := event.(ports.SleepPreparation); ok {
249 if prep.Preparing && !a.preparing {
250 a.cycle++
251 }
252 a.preparing = prep.Preparing
253 prep.Cycle, prep.MaxDelay, prep.ReceivedAt = a.cycle, a.caps.DelayMax, time.Now()
254 event = prep
255 if !prep.Preparing {
256 a.release()
257 if err := a.inhibit(ctx); err != nil {
258 if e := a.emit(ctx, events, ports.SystemFailure{Operation: "inhibit", Err: err}); e != nil {
259 return e
260 }
261 }
262 }
263 }
264 if err := a.emit(ctx, events, event); err != nil {
265 return err
266 }
267 case cmd, ok := <-commands:
268 if !ok {
269 return nil
270 }
271 switch c := cmd.(type) {
272 case ports.SystemSleepReady:
273 if a.preparing && c.Cycle == a.cycle {
274 a.release()
275 }
276 case ports.SystemSuspend:
277 var err error
278 if !a.requirements.Sleep || !a.caps.Suspend {
279 err = errors.New("non-interactive suspend unsupported")
280 } else if suspendReply != nil {
281 err = errors.New("suspend request already pending")
282 } else {
283 // Never override block inhibitors, including ones owned by
284 // this UID (which privileged logind policies may otherwise
285 // permit). Query immediately before the ordinary Suspend call.
286 blocked, e := a.property(ctx, managerPath, managerInterface, "BlockInhibited")
287 if e != nil {
288 err = e
289 } else if value, ok := blocked.(string); !ok {
290 err = errors.New("invalid block inhibitor property")
291 } else {
292 for _, item := range strings.Split(value, ":") {
293 if item == "sleep" {
294 err = errors.New("sleep blocked by inhibitor")
295 }
296 }
297 }
298 if err != nil {
299 if e := a.emit(ctx, events, ports.SystemFailure{Operation: "suspend", Err: err}); e != nil {
300 return e
301 }
302 continue
303 }
304 bounded, cancel := context.WithTimeout(ctx, callTimeout)
305 suspendCancel = cancel // released on reply or by the loop's defer
306 suspendReply = a.bus.suspend(bounded, a.owner)
307 }
308 if err != nil {
309 if e := a.emit(ctx, events, ports.SystemFailure{Operation: "suspend", Err: err}); e != nil {
310 return e
311 }
312 }
313 }
314 }
315 }
316}
317
318func parseSignal(sig *dbus.Signal, owner string, session dbus.ObjectPath) (ports.SystemEvent, bool) {
319 if sig == nil || sig.Sender != owner {
320 return nil, false
321 }
322 if sig.Path == session && session != "" && len(sig.Body) == 0 {
323 switch sig.Name {
324 case sessionInterface + ".Lock":
325 return ports.SystemLockRequested{}, true
326 case sessionInterface + ".Unlock":
327 return ports.SystemUnlockObserved{}, true
328 }
329 }
330 if sig.Path == managerPath && sig.Name == managerInterface+".PrepareForSleep" && len(sig.Body) == 1 {
331 preparing, ok := sig.Body[0].(bool)
332 if ok {
333 return ports.SleepPreparation{Preparing: preparing}, true
334 }
335 }
336 return nil, false
337}
338
339func (a *Adapter) inhibit(ctx context.Context) error {
340 if !a.requirements.Sleep || a.inhibitor != nil {
341 return nil
342 }
343 body, err := a.call(ctx, managerPath, managerInterface+".Inhibit", "sleep", "neferafk", "session protection", "delay")
344 if err != nil {
345 return err
346 }
347 if len(body) != 1 {
348 for _, value := range body {
349 if fd, ok := value.(dbus.UnixFD); ok && fd >= 0 {
350 _ = unix.Close(int(fd))
351 }
352 }
353 return errors.New("invalid inhibitor reply")
354 }
355 fd, ok := body[0].(dbus.UnixFD)
356 if !ok || fd < 0 {
357 return errors.New("invalid inhibitor descriptor")
358 }
359 // godbus returns the received descriptor directly, not an os.File and not
360 // an owned duplicate. Atomically duplicate CLOEXEC then close the received FD.
361 dup, err := unix.FcntlInt(uintptr(fd), unix.F_DUPFD_CLOEXEC, 0)
362 _ = unix.Close(int(fd))
363 if err != nil {
364 return err
365 }
366 a.inhibitor = os.NewFile(uintptr(dup), "logind-sleep-delay")
367 return nil
368}
369func (a *Adapter) release() {
370 if a.inhibitor != nil {
371 _ = a.inhibitor.Close()
372 a.inhibitor = nil
373 }
374}
375func (a *Adapter) Close() error {
376 if a.closed {
377 return nil
378 }
379 a.closed = true
380 a.release()
381 return a.bus.close()
382}