From 0fa90e5cf210f7983dfd8409af8a78bb440be8b6 Mon Sep 17 00:00:00 2001 From: jochen Date: Sun, 4 Oct 2026 12:56:35 +0200 Subject: [PATCH] screen-lock: the lock screen as a module, claiming node-lock-screen and serving lock (hq ADR 0208) The distribution's i3lock behind a locker that releases xss-lock's sleep lock once it is up; timeouts and xss-lock from the session's xinitrc slot, ending with the session; i3lock-color and xscreensaver declared absent; Go tools lock, idle, inhibit and locked. --- modules/screen-lock/README.md | 73 +++ .../screen-lock/cmd/screen-lock-tools/args.go | 97 ++++ .../screen-lock/cmd/screen-lock-tools/lock.go | 337 ++++++++++++++ .../cmd/screen-lock-tools/lock_test.go | 189 ++++++++ .../screen-lock/cmd/screen-lock-tools/main.go | 78 ++++ .../manifest_helpers_test.go | 175 ++++++++ .../cmd/screen-lock-tools/manifest_test.go | 84 ++++ .../cmd/screen-lock-tools/session.go | 423 ++++++++++++++++++ .../cmd/screen-lock-tools/session_test.go | 174 +++++++ modules/screen-lock/files/bin/screen-lock | 34 ++ modules/screen-lock/go.mod | 5 + modules/screen-lock/go.sum | 2 + modules/screen-lock/module.json | 78 ++++ 13 files changed, 1749 insertions(+) create mode 100644 modules/screen-lock/README.md create mode 100644 modules/screen-lock/cmd/screen-lock-tools/args.go create mode 100644 modules/screen-lock/cmd/screen-lock-tools/lock.go create mode 100644 modules/screen-lock/cmd/screen-lock-tools/lock_test.go create mode 100644 modules/screen-lock/cmd/screen-lock-tools/main.go create mode 100644 modules/screen-lock/cmd/screen-lock-tools/manifest_helpers_test.go create mode 100644 modules/screen-lock/cmd/screen-lock-tools/manifest_test.go create mode 100644 modules/screen-lock/cmd/screen-lock-tools/session.go create mode 100644 modules/screen-lock/cmd/screen-lock-tools/session_test.go create mode 100755 modules/screen-lock/files/bin/screen-lock create mode 100644 modules/screen-lock/go.mod create mode 100644 modules/screen-lock/go.sum create mode 100644 modules/screen-lock/module.json diff --git a/modules/screen-lock/README.md b/modules/screen-lock/README.md new file mode 100644 index 0000000..1b3e442 --- /dev/null +++ b/modules/screen-lock/README.md @@ -0,0 +1,73 @@ +# screen-lock + +The lock screen, idle timeouts and display power as one module (novox/hq ADR 0208, research +026/04). + +- Installs `xss-lock` and the distribution's `i3lock`. Claims the mesh's `node-lock-screen` seat and + serves its verb `lock`. Requires `x11-display` on its own machine. +- **Declares absent** (ADR 0180), as replaced and not coming back: + - `i3lock-color`, the colour build from the user repository; + - `xscreensaver`, a second screensaver that was installed and deliberately never started. +- Places the locker `~/.local/bin/screen-lock`. The lock key (`$mod+Delete`) is the `i3` module's. + It asks logind to lock and names no locker, so it does not depend on which module holds the seat. +- At session start, through the `xinitrc` slot `normal`, it: + - sets the timeouts: lock after 30 minutes idle, displays to standby and suspend at 30 minutes and + off at 60; + - starts `xss-lock --transfer-sleep-lock`, which runs the locker on idle, before suspend and on + logind's Lock, so the lock key, a closed lid and a suspend all lead to one locker. + + xss-lock stays out of the units on purpose. It must find its own login session, and a user unit + runs in the service manager's session instead, where xss-lock silently finds none. + +## Tools + +| tool | does | +|---|---| +| `node-lock-screen.lock` | lock now, through logind, so the session's one locker answers; answers since when | +| `screen_lock_idle` | the idle and display power timeouts in force; change any of them for this session | +| `screen_lock_inhibit` | keep the screen on and unlocked for N minutes, then restore; 0 ends it early | +| `screen_lock_locked` | locked or not and since when, whether xss-lock runs, whether an inhibition holds | + +An inhibition runs under the account's service manager (`screen-lock-inhibit.service`). Stopping it +restores the timeouts at once. It holds off the idle lock and display power only: a lock asked for by +hand, by the lid or before suspend still locks. + +## Decided: the distribution's i3lock now + +The colour build lives in the user repository, and the colours are the only difference. The module +uses the official `i3lock` (black, failed attempts shown, an empty Enter ignored). The colour build +can come back as a pinned archive of this module (ADR 0205). That is a follow-up, and only the +locker's options change with it. + +## What it improves on what was found + +- **The machine no longer waits for the unlock to suspend.** The found wrapper started i3lock with + xss-lock's sleep lock inherited, so a suspend was held until logind's delay ran out. The new locker + follows xss-lock's own pattern: the lock is released as soon as i3lock is up. +- **One locker.** A second lock while locked does nothing. The power menu locks through logind + instead of starting its own i3lock. +- **The respawn loop ends with the session.** The found loop kept retrying every two seconds after + logout. +- **The timeouts are set once, in one place.** On the laptop, measured on 2026-10-04, the screensaver + timeout in force was 600 s, not the 1800 s the start script asked for. + +## What it leaves as found + +- `~/.xscreensaver`, xscreensaver's configuration file. Remove it once the package is gone. +- `~/scripts/my-i3lock`, the predecessor's wrapper, which only the colour build understands. + +## Migration (ADR 0182) + +1. **Before the first push,** remove the colour build by hand: `sudo pacman -R i3lock-color`. + `pacman --noconfirm` will not replace a conflicting package. If the host installs `i3lock` before + it removes `i3lock-color`, the first push fails on the conflict. +2. Once the `xorg` module writes the session's start, delete from your own part of `~/.xinitrc`: + - the `xset s` and `xset dpms` lines; + - the `while true; do xss-lock … my-i3lock; …; done &` loop. +3. Delete `~/.xscreensaver` and `~/scripts/my-i3lock`. + +## Blockers + +- `node-lock-screen`, `x11-display` and the `xinitrc` slot are ADR 0208's. Until the controller knows + them, `mctl` reads them as unknown. +- `xset` comes with the display server's module (`xorg`). diff --git a/modules/screen-lock/cmd/screen-lock-tools/args.go b/modules/screen-lock/cmd/screen-lock-tools/args.go new file mode 100644 index 0000000..9b5dfcf --- /dev/null +++ b/modules/screen-lock/cmd/screen-lock-tools/args.go @@ -0,0 +1,97 @@ +// Reading a tool's arguments: JSON numbers arrive as float64, and a missing argument is its default. +// The same in every desktop module that carries it. +package main + +import ( + "fmt" + "math" + "strings" + "time" +) + +// text is a string argument, trimmed; required says an empty one is refused. +func text(args map[string]any, key string, required bool) (string, error) { + v, present := args[key] + if !present || v == nil { + if required { + return "", fmt.Errorf("%s is required", key) + } + return "", nil + } + s, ok := v.(string) + if !ok { + return "", fmt.Errorf("%s is a string, not %T", key, v) + } + s = strings.TrimSpace(s) + if s == "" && required { + return "", fmt.Errorf("%s is required", key) + } + return s, nil +} + +// whole is a whole-number argument within [least, most], or def when absent. +func whole(args map[string]any, key string, def, least, most int) (int, error) { + v, present := args[key] + if !present || v == nil { + return def, nil + } + f, ok := v.(float64) + if !ok { + if i, isInt := v.(int); isInt { + f = float64(i) + } else { + return 0, fmt.Errorf("%s is a number, not %T", key, v) + } + } + if f != math.Trunc(f) { + return 0, fmt.Errorf("%s is a whole number, not %v", key, f) + } + n := int(f) + if n < least || n > most { + return 0, fmt.Errorf("%s is %d; it is between %d and %d", key, n, least, most) + } + return n, nil +} + +// flag is a boolean argument, or def when absent. +func flag(args map[string]any, key string, def bool) (bool, error) { + v, present := args[key] + if !present || v == nil { + return def, nil + } + b, ok := v.(bool) + if !ok { + return false, fmt.Errorf("%s is true or false, not %T", key, v) + } + return b, nil +} + +// texts is a list-of-strings argument. +func texts(args map[string]any, key string) ([]string, error) { + v, present := args[key] + if !present || v == nil { + return nil, nil + } + list, ok := v.([]any) + if !ok { + if ss, isStrings := v.([]string); isStrings { + return ss, nil + } + return nil, fmt.Errorf("%s is a list of strings, not %T", key, v) + } + out := make([]string, 0, len(list)) + for i, item := range list { + s, ok := item.(string) + if !ok { + return nil, fmt.Errorf("%s[%d] is a string, not %T", key, i, item) + } + out = append(out, s) + } + return out, nil +} + +// seconds is a timeout argument in seconds, defaulted and bounded below the runtime's call limit. +func seconds(args map[string]any, key string, def, most int) (time.Duration, error) { + n, err := whole(args, key, def, 1, most) + return time.Duration(n) * time.Second, err +} diff --git a/modules/screen-lock/cmd/screen-lock-tools/lock.go b/modules/screen-lock/cmd/screen-lock-tools/lock.go new file mode 100644 index 0000000..cd74ae9 --- /dev/null +++ b/modules/screen-lock/cmd/screen-lock-tools/lock.go @@ -0,0 +1,337 @@ +package main + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "regexp" + "strconv" + "strings" + "time" +) + +// The declared timeouts, which the session start sets (module.json's xinitrc contribution) and an +// inhibition returns to when it cannot read what was in force. +const ( + declaredLock = 1800 + declaredStandby = 1800 + declaredSuspend = 1800 + declaredOff = 3600 + + mostInhibit = 600 + + inhibitUnit = "screen-lock-inhibit" + lockerUnit = "screen-lock" +) + +// clockTicks is the kernel's USER_HZ, which /proc//stat counts a start time in: 100 on every +// architecture Arch Linux builds for. +const clockTicks = 100 + +func locker() string { return filepath.Join(operatorHome(), ".local", "bin", "screen-lock") } + +// LockState is what lock and locked answer. +type LockState struct { + Locked bool `json:"locked"` + // Since is when the locker started, when it runs. + Since string `json:"since,omitempty"` + PIDs []int `json:"pids"` + LockedHint *bool `json:"locked_hint,omitempty"` + Watcher bool `json:"watcher_running"` + Inhibited bool `json:"inhibited"` + Via string `json:"via,omitempty"` +} + +// Lock locks through logind when the watcher runs, else runs the locker itself. +func Lock() (LockState, error) { + s, err := findSession() + if err != nil { + return LockState{}, err + } + via := "" + switch { + case len(processesOf("i3lock")) > 0: + via = "already locked" + case len(processesOf("xss-lock")) > 0 && s.SessionID != "": + r, err := s.run(10*time.Second, "", "loginctl", "lock-session", s.SessionID) + if err != nil { + return LockState{}, err + } + if r.Code != 0 { + return LockState{}, fmt.Errorf("loginctl lock-session %s: %s", s.SessionID, strings.TrimSpace(r.Stderr)) + } + via = "logind, answered by xss-lock" + default: + // No watcher: the session start's loop is not running (a session begun before this module + // was assigned). The locker is run directly, under the account's service manager. + if err := s.detach(lockerUnit, locker()); err != nil { + return LockState{}, err + } + via = "the locker directly: xss-lock is not running in this session" + } + deadline := time.Now().Add(3 * time.Second) + for len(processesOf("i3lock")) == 0 && time.Now().Before(deadline) { + time.Sleep(100 * time.Millisecond) + } + state := lockState(s) + state.Via = via + if !state.Locked { + return state, errors.New("asked to lock, and no locker is running 3s later") + } + return state, nil +} + +// Locked answers the lock state without changing it. +func Locked() (LockState, error) { + s := findEnvironment() + return lockState(s), nil +} + +func lockState(s Session) LockState { + pids := processesOf("i3lock") + st := LockState{Locked: len(pids) > 0, PIDs: pids, Watcher: len(processesOf("xss-lock")) > 0} + if st.PIDs == nil { + st.PIDs = []int{} + } + if len(pids) > 0 { + if at, ok := startTime(pids[0]); ok { + st.Since = at.Format(time.RFC3339) + } + } + if s.SessionID != "" { + if r, err := s.run(5*time.Second, "", "loginctl", "show-session", s.SessionID, "-p", "LockedHint", "--value"); err == nil && r.Code == 0 { + hint := strings.TrimSpace(r.Stdout) == "yes" + st.LockedHint = &hint + } + } + if s.RuntimeDir != "" { + if r, err := s.run(5*time.Second, "", "systemctl", "--user", "is-active", inhibitUnit+".service"); err == nil { + st.Inhibited = strings.TrimSpace(r.Stdout) == "active" + } + } + return st +} + +// startTime is when a process started, from its start in clock ticks after boot and the boot time. +func startTime(pid int) (time.Time, bool) { + stat, err := os.ReadFile(filepath.Join(procRoot, strconv.Itoa(pid), "stat")) + if err != nil { + return time.Time{}, false + } + // The command name is in parentheses and may hold spaces; the fields after it are fixed. + end := strings.LastIndexByte(string(stat), ')') + if end < 0 { + return time.Time{}, false + } + fields := strings.Fields(string(stat[end+1:])) + // starttime is field 22 of the whole line; after "pid (comm)" it is the 20th. + if len(fields) < 20 { + return time.Time{}, false + } + ticks, err := strconv.ParseInt(fields[19], 10, 64) + if err != nil { + return time.Time{}, false + } + boot, ok := bootTime() + if !ok { + return time.Time{}, false + } + return boot.Add(time.Duration(ticks) * time.Second / clockTicks), true +} + +func bootTime() (time.Time, bool) { + raw, err := os.ReadFile(filepath.Join(procRoot, "stat")) + if err != nil { + return time.Time{}, false + } + for _, line := range strings.Split(string(raw), "\n") { + if v, ok := strings.CutPrefix(line, "btime "); ok { + n, err := strconv.ParseInt(strings.TrimSpace(v), 10, 64) + if err == nil { + return time.Unix(n, 0), true + } + } + } + return time.Time{}, false +} + +// IdleState is the screen's idle timeouts in force. +type IdleState struct { + LockAfterSeconds int `json:"lock_after_seconds"` + CycleSeconds int `json:"cycle_seconds"` + DPMSEnabled bool `json:"dpms_enabled"` + StandbySeconds int `json:"standby_seconds"` + SuspendSeconds int `json:"suspend_seconds"` + OffSeconds int `json:"off_seconds"` + MonitorOn bool `json:"monitor_on"` + Declared string `json:"declared"` + Note string `json:"note,omitempty"` +} + +var ( + screensaverLine = regexp.MustCompile(`timeout:\s+(\d+)\s+cycle:\s+(\d+)`) + dpmsLine = regexp.MustCompile(`Standby:\s+(\d+)\s+Suspend:\s+(\d+)\s+Off:\s+(\d+)`) +) + +func parseXsetQ(out string) (IdleState, error) { + var st IdleState + m := screensaverLine.FindStringSubmatch(out) + if m == nil { + return st, errors.New("xset q shows no screensaver timeout") + } + st.LockAfterSeconds, _ = strconv.Atoi(m[1]) + st.CycleSeconds, _ = strconv.Atoi(m[2]) + if d := dpmsLine.FindStringSubmatch(out); d != nil { + st.StandbySeconds, _ = strconv.Atoi(d[1]) + st.SuspendSeconds, _ = strconv.Atoi(d[2]) + st.OffSeconds, _ = strconv.Atoi(d[3]) + } + st.DPMSEnabled = strings.Contains(out, "DPMS is Enabled") + st.MonitorOn = strings.Contains(out, "Monitor is On") + st.Declared = fmt.Sprintf("lock after %ds; DPMS %d/%d/%d", declaredLock, declaredStandby, declaredSuspend, declaredOff) + return st, nil +} + +// IdleChange is what screen_lock_idle was asked to change; nil fields stay. +type IdleChange struct { + LockAfter, Standby, Suspend, Off *int +} + +func idleChangeOf(args map[string]any) (IdleChange, error) { + var c IdleChange + for key, into := range map[string]**int{ + "lock_after_seconds": &c.LockAfter, "standby_seconds": &c.Standby, + "suspend_seconds": &c.Suspend, "off_seconds": &c.Off, + } { + if _, given := args[key]; !given { + continue + } + n, err := whole(args, key, 0, 0, 24*3600) + if err != nil { + return c, err + } + *into = &n + } + return c, nil +} + +func (c IdleChange) empty() bool { + return c.LockAfter == nil && c.Standby == nil && c.Suspend == nil && c.Off == nil +} + +// Idle reads the timeouts, and changes those asked for. +func Idle(change IdleChange) (IdleState, error) { + s, err := findSession() + if err != nil { + return IdleState{}, err + } + before, err := xsetQ(s) + if err != nil { + return IdleState{}, err + } + if change.empty() { + return before, nil + } + if change.LockAfter != nil { + cycle := before.CycleSeconds + if *change.LockAfter > 0 && cycle == 0 { + cycle = *change.LockAfter + } + if err := xset(s, "s", strconv.Itoa(*change.LockAfter), strconv.Itoa(cycle)); err != nil { + return IdleState{}, err + } + } + if change.Standby != nil || change.Suspend != nil || change.Off != nil { + pick := func(c *int, was int) string { + if c != nil { + return strconv.Itoa(*c) + } + return strconv.Itoa(was) + } + if err := xset(s, "dpms", pick(change.Standby, before.StandbySeconds), pick(change.Suspend, before.SuspendSeconds), + pick(change.Off, before.OffSeconds)); err != nil { + return IdleState{}, err + } + } + after, err := xsetQ(s) + if err != nil { + return IdleState{}, err + } + after.Note = "changed for this session only; the declared timeouts return at the next login" + return after, nil +} + +func xsetQ(s Session) (IdleState, error) { + r, err := s.run(5*time.Second, "", "xset", "q") + if err != nil { + return IdleState{}, err + } + if r.Code != 0 { + return IdleState{}, fmt.Errorf("xset q: %s", strings.TrimSpace(r.Stderr)) + } + return parseXsetQ(r.Stdout) +} + +func xset(s Session, args ...string) error { + r, err := s.run(5*time.Second, "", "xset", args...) + if err != nil { + return err + } + if r.Code != 0 { + return fmt.Errorf("xset %s: %s", strings.Join(args, " "), strings.TrimSpace(r.Stderr)) + } + return nil +} + +// InhibitResult is what screen_lock_inhibit answers. +type InhibitResult struct { + Inhibited bool `json:"inhibited"` + Until string `json:"until,omitempty"` + Restores string `json:"restores,omitempty"` +} + +// Inhibit keeps the screen on for minutes, then restores the timeouts that were in force; 0 ends an +// inhibition now. The waiting runs under the account's service manager, so it outlives this call, +// and stopping it restores at once. +func Inhibit(minutes int) (InhibitResult, error) { + s, err := findSession() + if err != nil { + return InhibitResult{}, err + } + if minutes == 0 { + r, err := s.run(10*time.Second, "", "systemctl", "--user", "stop", inhibitUnit+".service") + if err != nil { + return InhibitResult{}, err + } + if r.Code != 0 { + return InhibitResult{}, fmt.Errorf("ending the inhibition: %s", strings.TrimSpace(r.Stderr)) + } + return InhibitResult{Inhibited: false}, nil + } + // What to return to: what is in force now, unless an inhibition is already holding it at off. + was, err := xsetQ(s) + if err != nil { + return InhibitResult{}, err + } + if lockState(s).Inhibited || (was.LockAfterSeconds == 0 && !was.DPMSEnabled) { + was = IdleState{LockAfterSeconds: declaredLock, CycleSeconds: declaredLock, DPMSEnabled: true, + StandbySeconds: declaredStandby, SuspendSeconds: declaredSuspend, OffSeconds: declaredOff} + } + script := inhibitScript(minutes, was) + if err := s.detach(inhibitUnit, "/bin/sh", "-c", script); err != nil { + return InhibitResult{}, err + } + return InhibitResult{Inhibited: true, Until: time.Now().Add(time.Duration(minutes) * time.Minute).Format(time.RFC3339), + Restores: fmt.Sprintf("lock after %ds; DPMS %d/%d/%d", was.LockAfterSeconds, was.StandbySeconds, was.SuspendSeconds, was.OffSeconds)}, nil +} + +func inhibitScript(minutes int, was IdleState) string { + dpms := "+dpms" + if !was.DPMSEnabled { + dpms = "-dpms" + } + return fmt.Sprintf("restore() { xset s %d %d; xset dpms %d %d %d; xset %s; }; "+ + "trap 'restore; exit 0' TERM INT; xset s off -dpms; sleep %d & wait; restore", + was.LockAfterSeconds, was.CycleSeconds, was.StandbySeconds, was.SuspendSeconds, was.OffSeconds, dpms, minutes*60) +} diff --git a/modules/screen-lock/cmd/screen-lock-tools/lock_test.go b/modules/screen-lock/cmd/screen-lock-tools/lock_test.go new file mode 100644 index 0000000..b6fc042 --- /dev/null +++ b/modules/screen-lock/cmd/screen-lock-tools/lock_test.go @@ -0,0 +1,189 @@ +package main + +import ( + "errors" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "testing" + "time" +) + +const nobody = 4194400 + +// xset q as the laptop answered it on 2026-10-04 (keyboard lines shortened). +const xsetQOutput = `Keyboard Control: + auto repeat: on key click percent: 0 LED mask: 00000000 +Screen Saver: + prefer blanking: yes allow exposures: yes + timeout: 600 cycle: 600 +Colors: + default colormap: 0x20 BlackPixel: 0x0 WhitePixel: 0xffffff +DPMS (Display Power Management Signaling): + Standby: 1800 Suspend: 1800 Off: 3600 + DPMS is Enabled + Monitor is On +` + +func TestTheTimeoutsAreReadFromXset(t *testing.T) { + st, err := parseXsetQ(xsetQOutput) + if err != nil { + t.Fatal(err) + } + if st.LockAfterSeconds != 600 || st.CycleSeconds != 600 || !st.DPMSEnabled || st.StandbySeconds != 1800 || + st.SuspendSeconds != 1800 || st.OffSeconds != 3600 || !st.MonitorOn { + t.Fatalf("%+v", st) + } + if _, err := parseXsetQ("nothing"); err == nil { + t.Fatal("an answer without a screensaver was accepted") + } +} + +func TestAProcessStartsWhenItsStatAndTheBootTimeSay(t *testing.T) { + fakeMachine(t) + fakeProcess(t, nobody, "i3lock") + // A command name with a space and a parenthesis, which a naive split gets wrong. + stat := strconv.Itoa(nobody) + " (i3 lock) x) S 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 12345 0 0\n" + if err := os.WriteFile(filepath.Join(procRoot, strconv.Itoa(nobody), "stat"), []byte(stat), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(procRoot, "stat"), []byte("cpu 1 2 3\nbtime 1700000000\n"), 0o644); err != nil { + t.Fatal(err) + } + at, ok := startTime(nobody) + if !ok || !at.Equal(time.Unix(1700000000, 0).Add(123450*time.Millisecond)) { + t.Fatalf("%v %v", at, ok) + } +} + +// lockingMachine is a session whose loginctl, asked to lock, starts a (fake) i3lock. +func lockingMachine(t *testing.T, watcher bool) string { + t.Helper() + fakeMachine(t) + fakeProcess(t, nobody, "i3", "DISPLAY=:1", "XDG_SESSION_ID=4") + if watcher { + fakeProcess(t, nobody+1, "xss-lock", "DISPLAY=:1") + } + if err := os.MkdirAll(filepath.Join(runUserDir, strconv.Itoa(os.Getuid())), 0o700); err != nil { + t.Fatal(err) + } + lockNow := `mkdir -p "$PROC/` + strconv.Itoa(nobody+2) + `" && echo i3lock > "$PROC/` + strconv.Itoa(nobody+2) + `/comm"` + bin := fakeBinaries(t, map[string]string{ + "loginctl": `echo "loginctl $*" >> "$LOG" +case "$1" in lock-session) ` + lockNow + ` ;; show-session) echo yes ;; esac`, + "systemd-run": `echo "systemd-run $*" >> "$LOG"; ` + lockNow, + "systemctl": `echo "systemctl $*" >> "$LOG"; echo inactive`, + }) + t.Setenv("LOG", filepath.Join(bin, "log")) + t.Setenv("PROC", procRoot) + return bin +} + +func TestLockGoesThroughLogindSoTheOneLockerAnswers(t *testing.T) { + bin := lockingMachine(t, true) + st, err := Lock() + if err != nil { + t.Fatal(err) + } + if !st.Locked || !st.Watcher || st.LockedHint == nil || !*st.LockedHint || !strings.Contains(st.Via, "logind") { + t.Fatalf("%+v", st) + } + log, _ := os.ReadFile(filepath.Join(bin, "log")) + if !strings.Contains(string(log), "loginctl lock-session 4\n") || strings.Contains(string(log), "systemd-run") { + t.Fatalf("asked:\n%s", log) + } + again, err := Lock() + if err != nil || again.Via != "already locked" { + t.Fatalf("locking a locked screen: %+v, %v", again, err) + } +} + +func TestWithoutTheWatcherTheLockerRunsUnderTheAccountsServiceManager(t *testing.T) { + bin := lockingMachine(t, false) + st, err := Lock() + if err != nil || !st.Locked || !strings.Contains(st.Via, "xss-lock is not running") { + t.Fatalf("%+v, %v", st, err) + } + log, _ := os.ReadFile(filepath.Join(bin, "log")) + if !strings.Contains(string(log), "--unit=screen-lock --setenv=DISPLAY=:1 --setenv=XDG_SESSION_ID=4 -- ") || + !strings.Contains(string(log), "/.local/bin/screen-lock") { + t.Fatalf("asked:\n%s", log) + } +} + +func TestLockedWithoutASessionIsAnAnswerNotAnError(t *testing.T) { + fakeMachine(t) + st, err := Locked() + if err != nil || st.Locked || st.Watcher || st.PIDs == nil { + t.Fatalf("%+v, %v", st, err) + } + if _, err := Lock(); !errors.Is(err, ErrNoSession) { + t.Fatalf("lock without a session: %v", err) + } +} + +func TestIdleChangesOnlyWhatWasAskedAndSaysForHowLong(t *testing.T) { + fakeMachine(t) + fakeProcess(t, nobody, "i3", "DISPLAY=:1") + bin := fakeBinaries(t, map[string]string{"xset": `echo "xset $*" >> "$LOG"; [ "$1" = q ] && cat "$Q"; true`}) + t.Setenv("LOG", filepath.Join(bin, "log")) + q := filepath.Join(bin, "q") + if err := os.WriteFile(q, []byte(xsetQOutput), 0o644); err != nil { + t.Fatal(err) + } + t.Setenv("Q", q) + if st, err := Idle(IdleChange{}); err != nil || st.Note != "" || st.LockAfterSeconds != 600 { + t.Fatalf("read: %+v, %v", st, err) + } + c, err := idleChangeOf(map[string]any{"lock_after_seconds": float64(900), "off_seconds": float64(7200)}) + if err != nil { + t.Fatal(err) + } + st, err := Idle(c) + if err != nil || !strings.Contains(st.Note, "next login") { + t.Fatalf("%+v, %v", st, err) + } + log, _ := os.ReadFile(filepath.Join(bin, "log")) + if !strings.Contains(string(log), "xset s 900 600\n") || !strings.Contains(string(log), "xset dpms 1800 1800 7200\n") { + t.Fatalf("asked:\n%s", log) + } + if _, err := idleChangeOf(map[string]any{"off_seconds": float64(-1)}); err == nil { + t.Fatal("a negative timeout was accepted") + } +} + +func TestAnInhibitionTurnsIdleOffAndRestoresWhatWasThereWhenItEndsOrIsStopped(t *testing.T) { + was := IdleState{LockAfterSeconds: 1800, CycleSeconds: 1800, DPMSEnabled: true, StandbySeconds: 1800, SuspendSeconds: 1800, OffSeconds: 3600} + script := inhibitScript(2, was) + if !strings.Contains(script, "xset s off -dpms; sleep 120 & wait; restore") || + !strings.Contains(script, "restore() { xset s 1800 1800; xset dpms 1800 1800 3600; xset +dpms; }") || + !strings.Contains(script, "trap 'restore; exit 0' TERM") { + t.Fatalf("%s", script) + } + // Run it for real with a fake xset, stopped early as systemctl stop would. + dir := t.TempDir() + bin := fakeBinaries(t, map[string]string{"xset": `echo "$*" >> "` + filepath.Join(dir, "log") + `"`}) + _ = bin + cmd := exec.Command("/bin/sh", "-c", inhibitScript(1, was)) + if err := cmd.Start(); err != nil { + t.Fatal(err) + } + time.Sleep(300 * time.Millisecond) + _ = cmd.Process.Signal(os.Interrupt) + _ = cmd.Wait() + got, _ := os.ReadFile(filepath.Join(dir, "log")) + if string(got) != "s off -dpms\ns 1800 1800\ndpms 1800 1800 3600\n+dpms\n" { + t.Fatalf("the timeouts were not restored on stop:\n%s", got) + } +} + +func TestTheDeclaredTimeoutsAreTheSessionStartsOwn(t *testing.T) { + m := readManifest(t) + code := m.Shell[0].Code + if !strings.Contains(code, "xset s "+strconv.Itoa(declaredLock)+" "+strconv.Itoa(declaredLock)+"\n") || + !strings.Contains(code, "xset dpms "+strconv.Itoa(declaredStandby)+" "+strconv.Itoa(declaredSuspend)+" "+strconv.Itoa(declaredOff)+"\n") { + t.Fatalf("the tools' declared values and the session start's disagree:\n%s", code) + } +} diff --git a/modules/screen-lock/cmd/screen-lock-tools/main.go b/modules/screen-lock/cmd/screen-lock-tools/main.go new file mode 100644 index 0000000..d5bbab9 --- /dev/null +++ b/modules/screen-lock/cmd/screen-lock-tools/main.go @@ -0,0 +1,78 @@ +// screen-lock's Go tools bundle (novox/hq ADR 0188, ADR 0193, ADR 0208): its implementation of +// node-lock-screen's verb `lock`, and its own tools for the idle timeouts, served by the node's +// runtime as the operator account. +package main + +import ( + "fmt" + "os" + + stdio "git.novox.be/novox/mesh-sdk/go" +) + +func main() { + if err := stdio.Serve("", tools()); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +func tools() []stdio.Tool { + return []stdio.Tool{ + { + Name: "node-lock-screen.lock", + Description: "Lock the operator's session now. It goes through logind, so the one locker the " + + "session runs answers it, as the lock key and a closed lid do. Answers whether the screen is " + + "locked and since when.", + Run: func(map[string]any) (any, error) { return Lock() }, + }, + { + Name: "screen_lock_idle", + Description: "The screen's idle timeouts: after how long idle the session locks (the X screensaver) " + + "and when the displays go to standby, suspend and off (DPMS). Give any of them to change it for " + + "this session; the declared ones return at the next login.", + Input: map[string]any{ + "lock_after_seconds": map[string]any{"type": "integer", "description": "idle time before the lock; 0 never"}, + "standby_seconds": map[string]any{"type": "integer", "description": "DPMS standby; 0 never"}, + "suspend_seconds": map[string]any{"type": "integer", "description": "DPMS suspend; 0 never"}, + "off_seconds": map[string]any{"type": "integer", "description": "DPMS off; 0 never"}, + }, + Run: func(args map[string]any) (any, error) { + change, err := idleChangeOf(args) + if err != nil { + return nil, err + } + return Idle(change) + }, + }, + { + Name: "screen_lock_inhibit", + Description: "Keep the screen on and unlocked for a while — a presentation, a film, a long read: " + + "no idle lock and no display power-off for `minutes`, then the timeouts as they were. 0 ends an " + + "inhibition early. A lock asked for by hand, by the lid or before suspend still locks.", + Input: map[string]any{ + "type": "object", + "properties": map[string]any{ + "minutes": map[string]any{"type": "integer", "description": fmt.Sprintf("how long, 1-%d; 0 ends it now", mostInhibit)}, + }, + "required": []string{"minutes"}, + }, + Run: func(args map[string]any) (any, error) { + if _, given := args["minutes"]; !given { + return nil, fmt.Errorf("minutes is required") + } + minutes, err := whole(args, "minutes", 0, 0, mostInhibit) + if err != nil { + return nil, err + } + return Inhibit(minutes) + }, + }, + { + Name: "screen_lock_locked", + Description: "Is the operator's session locked now, and since when; whether the lock watcher " + + "(xss-lock) runs, and whether an inhibition is keeping the screen on.", + Run: func(map[string]any) (any, error) { return Locked() }, + }, + } +} diff --git a/modules/screen-lock/cmd/screen-lock-tools/manifest_helpers_test.go b/modules/screen-lock/cmd/screen-lock-tools/manifest_helpers_test.go new file mode 100644 index 0000000..d4fb76d --- /dev/null +++ b/modules/screen-lock/cmd/screen-lock-tools/manifest_helpers_test.go @@ -0,0 +1,175 @@ +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +// The module's manifest, read the way the catalogue reads it, for the manifest tests. The same in +// every desktop module that carries it. + +type manifest struct { + Module string `json:"module"` + Version string `json:"version"` + Capabilities []string `json:"capabilities"` + Requires []string `json:"requires"` + Claims []claim `json:"claims"` + Seats []any `json:"seats"` + Tools []string `json:"tools"` + Environment *environment `json:"environment"` + Shell []shellCode `json:"shell"` + Resources []map[string]any `json:"resources"` + Build struct { + Artifacts []map[string]any `json:"artifacts"` + } `json:"build"` +} + +type claim struct { + Name string `json:"name"` + Scope string `json:"scope"` + Serves []string `json:"serves"` +} + +type environment struct { + Variables map[string]string `json:"variables"` + Path []map[string]any `json:"path"` +} + +type shellCode struct { + For string `json:"for"` + Slot string `json:"slot"` + Code string `json:"code"` +} + +func readManifest(t *testing.T) manifest { + t.Helper() + raw, err := os.ReadFile(filepath.Join("..", "..", "module.json")) + if err != nil { + t.Fatal(err) + } + dec := json.NewDecoder(strings.NewReader(string(raw))) + dec.DisallowUnknownFields() + var m manifest + if err := dec.Decode(&m); err != nil { + t.Fatalf("module.json: %v", err) + } + return m +} + +func (m manifest) resource(t *testing.T, id string) map[string]any { + t.Helper() + for _, r := range m.Resources { + if r["id"] == id { + return r + } + } + t.Fatalf("no resource %q", id) + return nil +} + +func (m manifest) packages() (present, absent []string) { + for _, r := range m.Resources { + if r["type"] == "package" { + if r["absent"] == true { + absent = append(absent, r["package"].(string)) + } else { + present = append(present, r["package"].(string)) + } + } + } + return present, absent +} + +// sameAsSource checks that a file resource's content is byte for byte the module's source file, so +// the readable file in the repository is what the machine gets. +func (m manifest) sameAsSource(t *testing.T, id, source string) { + t.Helper() + want, err := os.ReadFile(filepath.Join("..", "..", source)) + if err != nil { + t.Fatal(err) + } + r := m.resource(t, id) + if r["type"] != "file" { + t.Fatalf("%s is a %v, not a file", id, r["type"]) + } + if got, _ := r["content"].(string); got != string(want) { + t.Fatalf("resource %s's content is not %s: edit the source and copy it into module.json", id, source) + } + if r["owner"] != "${machine:account}" && !strings.HasPrefix(r["path"].(string), "/etc/") { + t.Fatalf("%s under the home is the account's", id) + } +} + +// checkTheToolsAgree checks that the manifest lists the module's own tools exactly, that the bundle +// serves each seat verb the claims promise as ., and that the Go bundle is declared. +func checkTheToolsAgree(t *testing.T, m manifest) { + t.Helper() + own, seat := map[string]bool{}, map[string]bool{} + for _, tool := range tools() { + if strings.Contains(tool.Name, ".") { + seat[tool.Name] = true + } else { + own[tool.Name] = true + } + if strings.TrimSpace(tool.Description) == "" { + t.Errorf("%s has no description", tool.Name) + } + } + listed := map[string]bool{} + for _, name := range m.Tools { + listed[name] = true + if !own[name] { + t.Errorf("module.json lists %s, which the bundle does not serve", name) + } + } + for name := range own { + if !listed[name] { + t.Errorf("the bundle serves %s, which module.json does not list", name) + } + if !strings.HasPrefix(name, strings.ReplaceAll(m.Module, "-", "_")+"_") { + t.Errorf("%s is not prefixed with the module's name", name) + } + } + promised := map[string]bool{} + for _, c := range m.Claims { + for _, verb := range c.Serves { + promised[c.Name+"."+verb] = true + if !seat[c.Name+"."+verb] { + t.Errorf("the claim on %s promises %s, which the bundle does not serve", c.Name, verb) + } + } + } + for name := range seat { + if !promised[name] { + t.Errorf("the bundle serves %s, which no claim promises", name) + } + } + var bundle map[string]any + for _, a := range m.Build.Artifacts { + if a["kind"] == "bundle" { + bundle = a + } + } + if bundle == nil || bundle["language"] != "go" || bundle["system"] != "arch" || + bundle["from"] != "cmd/"+m.Module+"-tools" || bundle["binary"] != m.Module+"-tools" { + t.Errorf("the Go tools bundle: %v", bundle) + } +} + +// checkNoSecretsOrInstallationNames refuses what a catalogue manifest must never carry. +func checkNoSecretsOrInstallationNames(t *testing.T) { + t.Helper() + raw, err := os.ReadFile(filepath.Join("..", "..", "module.json")) + if err != nil { + t.Fatal(err) + } + s := strings.ToLower(string(raw)) + for _, never := range []string{"/home/", "jochen", "g14", "shanks", "novox.be", "api_key", ".hal/", "greenclip daemon"} { + if strings.Contains(s, never) { + t.Errorf("module.json names %q", never) + } + } +} diff --git a/modules/screen-lock/cmd/screen-lock-tools/manifest_test.go b/modules/screen-lock/cmd/screen-lock-tools/manifest_test.go new file mode 100644 index 0000000..2b20ad7 --- /dev/null +++ b/modules/screen-lock/cmd/screen-lock-tools/manifest_test.go @@ -0,0 +1,84 @@ +package main + +import ( + "reflect" + "strings" + "testing" +) + +// screen-lock's shape (novox/hq ADR 0208, research 026/04): it claims node-lock-screen serving lock, +// requires the X display on its own machine, installs the watcher and the distribution's locker, +// declares the colour build and xscreensaver absent, places its locker, and starts the watcher and +// the timeouts once, from the session's start. The lock key is the window manager's. + +func TestItClaimsTheLockScreenSeatServingLockAndRequiresTheXDisplay(t *testing.T) { + m := readManifest(t) + if m.Module != "screen-lock" || m.Seats != nil { + t.Fatalf("module %q declares seats %v", m.Module, m.Seats) + } + if !reflect.DeepEqual(m.Claims, []claim{{Name: "node-lock-screen", Scope: "node", Serves: []string{"lock"}}}) { + t.Fatalf("claims: %+v", m.Claims) + } + if !reflect.DeepEqual(m.Requires, []string{"x11-display"}) { + t.Fatalf("requires: %v", m.Requires) + } +} + +func TestTheDistributionsLockerReplacesTheColourBuildAndXscreensaverGoes(t *testing.T) { + m := readManifest(t) + present, absent := m.packages() + if !reflect.DeepEqual(present, []string{"xss-lock", "i3lock"}) || !reflect.DeepEqual(absent, []string{"i3lock-color", "xscreensaver"}) { + t.Fatalf("packages: %v, absent %v", present, absent) + } + m.sameAsSource(t, "wrapper", "files/bin/screen-lock") + wrapper := m.resource(t, "wrapper") + if wrapper["mode"] != "0755" || wrapper["path"] != "${machine:account-home}/.local/bin/screen-lock" { + t.Fatalf("the locker: %v", wrapper) + } + c := wrapper["content"].(string) + for _, colourOnly := range []string{"--ring-color", "--blur", "--clock", "--indicator", "--time-str"} { + if strings.Contains(c, colourOnly) { + t.Errorf("the wrapper passes %s, which only the colour build knows", colourOnly) + } + } + if !strings.Contains(c, "XSS_SLEEP_LOCK_FD}<&-") { + t.Error("the locker must not inherit the sleep lock") + } +} + +func TestTheSessionStartSetsTheTimeoutsAndKeepsOneWatcherForTheSession(t *testing.T) { + m := readManifest(t) + if len(m.Shell) != 1 || m.Shell[0].For != "xinitrc" || m.Shell[0].Slot != "normal" { + t.Fatalf("one xinitrc contribution in normal: %+v", m.Shell) + } + code := m.Shell[0].Code + if strings.Count(code, "xss-lock --") != 1 || !strings.Contains(code, "--transfer-sleep-lock") || + !strings.Contains(code, "while kill -0 $$") || !strings.HasSuffix(strings.TrimSpace(code), "&") { + t.Fatalf("the watcher: %q", code) + } + if strings.Contains(code, "xscreensaver") || strings.Contains(code, "my-i3lock") { + t.Fatalf("names what it replaced: %q", code) + } + for _, r := range m.Resources { + if r["type"] == "service" || r["type"] == "process" { + t.Fatalf("xss-lock needs the login session and is never a unit: %v", r) + } + } +} + +func TestTheLockKeyIsTheWindowManagersNotThisModules(t *testing.T) { + m := readManifest(t) + // The i3 module binds $mod+Delete to logind's lock, which names no locker; a binding here as well + // would be i3's duplicate. + for _, r := range m.Resources { + if p, _ := r["path"].(string); strings.Contains(p, "i3/config.d") { + t.Fatalf("a key binding: %v", r) + } + } +} + +func TestTheToolsAgreeWithTheManifest(t *testing.T) { + m := readManifest(t) + checkTheToolsAgree(t, m) + checkNoSecretsOrInstallationNames(t) +} diff --git a/modules/screen-lock/cmd/screen-lock-tools/session.go b/modules/screen-lock/cmd/screen-lock-tools/session.go new file mode 100644 index 0000000..dc21774 --- /dev/null +++ b/modules/screen-lock/cmd/screen-lock-tools/session.go @@ -0,0 +1,423 @@ +// The operator's graphical session, as a tool the node's runtime runs finds it (novox/hq ADR 0208). +// +// The runtime is a system service running as the operator account (ADR 0175): it has the account's +// uid and none of the session's environment — no DISPLAY, no XAUTHORITY, no session bus. A tool that +// draws on the screen or talks to the desktop's D-Bus must find them. It reads them from a process of +// the account that is part of the session (the window manager first), the same thing `loginctl` and +// a person's own shell would point at, and says where it found them. +// +// Long-lived programs a tool starts go to the account's own service manager through `systemd-run +// --user`, never as children of the tool: the runtime's unit is a cgroup the service manager empties +// whenever the runtime restarts, and a compositor or a clipboard owner started from inside it would +// die with it. +// +// This file is the same in every desktop module that carries it; it moves into the Go SDK once a +// second consumer outside the desktop wants it. +package main + +import ( + "bytes" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "sort" + "strconv" + "strings" + "syscall" + "time" +) + +// Where the session is looked for. Variables so a test can point them at a fake tree. +var ( + procRoot = "/proc" + runUserDir = "/run/user" + x11Sockets = "/tmp/.X11-unix" +) + +// sessionHolders are the processes whose environment is the session's, best first: the window +// manager is the session, the rest are its children. Anything else carrying DISPLAY ranks after them. +var sessionHolders = []string{"i3", "sway", "i3bar", "picom", "xss-lock", "dunst", "clipmenud", "xterm"} + +// sessionKeys are the variables a session carries that a tool hands on to what it runs. +var sessionKeys = []string{"DISPLAY", "XAUTHORITY", "WAYLAND_DISPLAY", "DBUS_SESSION_BUS_ADDRESS", + "XDG_RUNTIME_DIR", "XDG_SESSION_ID", "I3SOCK"} + +// Session is what a tool needs to reach the operator's desktop. +type Session struct { + UID int `json:"uid"` + Display string `json:"display,omitempty"` + XAuthority string `json:"xauthority,omitempty"` + Wayland string `json:"wayland_display,omitempty"` + Bus string `json:"bus,omitempty"` + RuntimeDir string `json:"runtime_dir,omitempty"` + SessionID string `json:"session_id,omitempty"` + I3Sock string `json:"i3sock,omitempty"` + // From says where the values were found: the tool's own environment, a process, or the socket. + From string `json:"from"` +} + +// ErrNoSession is answered by a tool that needs the desktop when nobody is logged in to it. +var ErrNoSession = errors.New("no graphical session") + +// ErrTimedOut is what run answers for a command ended because it ran past its time. +var ErrTimedOut = errors.New("timed out") + +// ErrNoBus is answered by a tool that needs the session bus when the account has none. +var ErrNoBus = errors.New("no session bus") + +// operatorHome is the account's home: what the runtime was told, else the process's own. +func operatorHome() string { + if h := strings.TrimSpace(os.Getenv("MESH_OPERATOR_HOME")); h != "" { + return h + } + h, _ := os.UserHomeDir() + return h +} + +// findSession finds the graphical session of the account this tool runs as, or answers +// ErrNoSession with what it looked at. +func findSession() (Session, error) { + s := findEnvironment() + if s.Display == "" && s.Wayland == "" { + return s, fmt.Errorf("%w for uid %d on this machine: no process of the account carries DISPLAY "+ + "or WAYLAND_DISPLAY, and no X server socket in %s has an authority file to go with it. "+ + "Is anyone logged in to the desktop?", ErrNoSession, s.UID, x11Sockets) + } + return s, nil +} + +// findBus finds the account's session bus, which a logged-in account has whether or not a desktop +// is running. +func findBus() (Session, error) { + s := findEnvironment() + if s.Bus == "" { + return s, fmt.Errorf("%w for uid %d: DBUS_SESSION_BUS_ADDRESS is not set and %s does not exist "+ + "(the account is not logged in)", ErrNoBus, s.UID, filepath.Join(runUserDir, strconv.Itoa(s.UID), "bus")) + } + return s, nil +} + +func findEnvironment() Session { + uid := os.Getuid() + s := Session{UID: uid} + own := map[string]string{} + for _, k := range sessionKeys { + own[k] = os.Getenv(k) + } + if own["DISPLAY"] != "" || own["WAYLAND_DISPLAY"] != "" { + s.fill(own) + s.From = "the tool's own environment" + } else if pid, comm, env, ok := sessionProcess(uid); ok { + s.fill(env) + s.From = fmt.Sprintf("process %s (pid %d)", comm, pid) + } else if display, ok := lonelyX11Socket(); ok { + if a := filepath.Join(operatorHome(), ".Xauthority"); exists(a) { + s.Display, s.XAuthority = display, a + s.From = "the X server socket and the account's ~/.Xauthority" + } + s.fill(own) + } else { + s.fill(own) + s.From = "nothing: no session found" + } + // The bus and the runtime directory are the account's, whether or not the process named them. + runtime := filepath.Join(runUserDir, strconv.Itoa(uid)) + if s.RuntimeDir == "" && exists(runtime) { + s.RuntimeDir = runtime + } + if s.Bus == "" && s.RuntimeDir != "" && exists(filepath.Join(s.RuntimeDir, "bus")) { + s.Bus = "unix:path=" + filepath.Join(s.RuntimeDir, "bus") + } + return s +} + +func (s *Session) fill(env map[string]string) { + set := func(dst *string, key string) { + if *dst == "" { + *dst = env[key] + } + } + set(&s.Display, "DISPLAY") + set(&s.XAuthority, "XAUTHORITY") + set(&s.Wayland, "WAYLAND_DISPLAY") + set(&s.Bus, "DBUS_SESSION_BUS_ADDRESS") + set(&s.RuntimeDir, "XDG_RUNTIME_DIR") + set(&s.SessionID, "XDG_SESSION_ID") + set(&s.I3Sock, "I3SOCK") +} + +// sessionProcess is the best process of this uid whose environment names a display. +func sessionProcess(uid int) (int, string, map[string]string, bool) { + entries, err := os.ReadDir(procRoot) + if err != nil { + return 0, "", nil, false + } + type candidate struct { + pid int + comm string + env map[string]string + rank int + } + var found []candidate + for _, e := range entries { + pid, err := strconv.Atoi(e.Name()) + if err != nil { + continue + } + dir := filepath.Join(procRoot, e.Name()) + if owner, ok := ownerOf(dir); !ok || owner != uid { + continue + } + raw, err := os.ReadFile(filepath.Join(dir, "environ")) + if err != nil { + continue + } + env := parseEnviron(raw) + if env["DISPLAY"] == "" && env["WAYLAND_DISPLAY"] == "" { + continue + } + comm := readTrimmed(filepath.Join(dir, "comm")) + rank := len(sessionHolders) + for i, h := range sessionHolders { + if h == comm { + rank = i + break + } + } + found = append(found, candidate{pid, comm, env, rank}) + } + if len(found) == 0 { + return 0, "", nil, false + } + sort.Slice(found, func(i, j int) bool { + if found[i].rank != found[j].rank { + return found[i].rank < found[j].rank + } + return found[i].pid > found[j].pid // the newer of two equals + }) + best := found[0] + return best.pid, best.comm, best.env, true +} + +func parseEnviron(raw []byte) map[string]string { + env := map[string]string{} + for _, kv := range bytes.Split(raw, []byte{0}) { + if i := bytes.IndexByte(kv, '='); i > 0 { + env[string(kv[:i])] = string(kv[i+1:]) + } + } + return env +} + +func ownerOf(path string) (int, bool) { + info, err := os.Stat(path) + if err != nil { + return 0, false + } + st, ok := info.Sys().(*syscall.Stat_t) + if !ok { + return 0, false + } + return int(st.Uid), true +} + +// lonelyX11Socket is the display of the one X server socket there is, when there is exactly one. +func lonelyX11Socket() (string, bool) { + entries, err := os.ReadDir(x11Sockets) + if err != nil { + return "", false + } + var displays []string + for _, e := range entries { + if n := strings.TrimPrefix(e.Name(), "X"); n != e.Name() { + if _, err := strconv.Atoi(n); err == nil { + displays = append(displays, ":"+n) + } + } + } + if len(displays) != 1 { + return "", false + } + return displays[0], true +} + +func readTrimmed(path string) string { + b, err := os.ReadFile(path) + if err != nil { + return "" + } + return strings.TrimSpace(string(b)) +} + +func exists(path string) bool { + _, err := os.Stat(path) + return err == nil +} + +// Env is this process's environment with the session's variables in place of its own. +func (s Session) Env() []string { + drop := map[string]bool{} + for _, k := range sessionKeys { + drop[k] = true + } + var env []string + for _, kv := range os.Environ() { + if i := strings.IndexByte(kv, '='); i > 0 && drop[kv[:i]] { + continue + } + env = append(env, kv) + } + add := func(k, v string) { + if v != "" { + env = append(env, k+"="+v) + } + } + add("DISPLAY", s.Display) + add("XAUTHORITY", s.XAuthority) + add("WAYLAND_DISPLAY", s.Wayland) + add("DBUS_SESSION_BUS_ADDRESS", s.Bus) + add("XDG_RUNTIME_DIR", s.RuntimeDir) + add("XDG_SESSION_ID", s.SessionID) + add("I3SOCK", s.I3Sock) + return env +} + +// mostOutput bounds what a command may answer with, per stream. +const mostOutput = 256 << 10 + +// Result is what a command did. +type Result struct { + Stdout string `json:"stdout"` + Stderr string `json:"stderr,omitempty"` + Code int `json:"code"` + Truncated bool `json:"truncated,omitempty"` +} + +// run runs a command in the session's environment, its input given, ended with everything it +// started after timeout. A command that is not installed is an error naming it; one that exits +// non-zero is a Result with its code, for the caller to judge. +func (s Session) run(timeout time.Duration, stdin string, name string, args ...string) (Result, error) { + path, err := exec.LookPath(name) + if err != nil { + return Result{}, fmt.Errorf("%s is not installed on this machine", name) + } + cmd := exec.Command(path, args...) + cmd.Env = s.Env() + if home := operatorHome(); exists(home) { + cmd.Dir = home + } + if stdin != "" { + cmd.Stdin = strings.NewReader(stdin) + } + var out, errOut capped + cmd.Stdout, cmd.Stderr = &out, &errOut + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + if err := cmd.Start(); err != nil { + return Result{}, fmt.Errorf("%s: %w", name, err) + } + done := make(chan error, 1) + go func() { done <- cmd.Wait() }() + select { + case err = <-done: + case <-time.After(timeout): + _ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) + <-done + return Result{Stdout: out.String(), Stderr: errOut.String()}, + fmt.Errorf("%s did not finish within %s and was ended: %w", name, timeout, ErrTimedOut) + } + r := Result{Stdout: out.String(), Stderr: errOut.String(), Truncated: out.cut || errOut.cut} + var exit *exec.ExitError + if errors.As(err, &exit) { + r.Code = exit.ExitCode() + } else if err != nil { + return r, fmt.Errorf("%s: %w", name, err) + } + return r, nil +} + +// detach starts a long-lived program under the account's own service manager, as a transient unit +// that carries the session's display, so it outlives the runtime that asked for it. A unit already +// running under the same name is stopped first, so a fixed name means "at most one". +func (s Session) detach(unit string, args ...string) error { + if s.RuntimeDir == "" { + return fmt.Errorf("%w: the account's runtime directory is missing, so its service manager "+ + "cannot be reached", ErrNoBus) + } + _, _ = s.run(5*time.Second, "", "systemctl", "--user", "stop", unit+".service") + call := []string{"--user", "--collect", "--quiet", "--unit=" + unit} + for _, kv := range [][2]string{{"DISPLAY", s.Display}, {"XAUTHORITY", s.XAuthority}, + {"WAYLAND_DISPLAY", s.Wayland}, {"XDG_SESSION_ID", s.SessionID}, {"I3SOCK", s.I3Sock}} { + if kv[1] != "" { + call = append(call, "--setenv="+kv[0]+"="+kv[1]) + } + } + call = append(call, "--") + call = append(call, args...) + r, err := s.run(10*time.Second, "", "systemd-run", call...) + if err != nil { + return err + } + if r.Code != 0 { + return fmt.Errorf("systemd-run %s: %s", unit, strings.TrimSpace(r.Stderr)) + } + return nil +} + +// uniqueUnit is a transient unit name that will not collide with an earlier one. +func uniqueUnit(prefix string) string { + return fmt.Sprintf("%s-%d", prefix, time.Now().UnixNano()) +} + +type capped struct { + bytes.Buffer + cut bool +} + +func (c *capped) Write(p []byte) (int, error) { + if room := mostOutput - c.Len(); room < len(p) { + if room > 0 { + c.Buffer.Write(p[:room]) + } + c.cut = true + return len(p), nil + } + return c.Buffer.Write(p) +} + +// processesOf are the pids of this uid's processes whose command name is comm, oldest first. +func processesOf(comm string) []int { + entries, err := os.ReadDir(procRoot) + if err != nil { + return nil + } + uid := os.Getuid() + var pids []int + for _, e := range entries { + pid, err := strconv.Atoi(e.Name()) + if err != nil { + continue + } + dir := filepath.Join(procRoot, e.Name()) + if owner, ok := ownerOf(dir); !ok || owner != uid { + continue + } + if readTrimmed(filepath.Join(dir, "comm")) == comm { + pids = append(pids, pid) + } + } + sort.Ints(pids) + return pids +} + +// signalAll sends sig to every process of this uid named comm, and answers the pids it reached. +func signalAll(comm string, sig syscall.Signal) []int { + var reached []int + for _, pid := range processesOf(comm) { + if syscall.Kill(pid, sig) == nil { + reached = append(reached, pid) + } + } + return reached +} diff --git a/modules/screen-lock/cmd/screen-lock-tools/session_test.go b/modules/screen-lock/cmd/screen-lock-tools/session_test.go new file mode 100644 index 0000000..800cc6d --- /dev/null +++ b/modules/screen-lock/cmd/screen-lock-tools/session_test.go @@ -0,0 +1,174 @@ +package main + +import ( + "errors" + "os" + "path/filepath" + "strconv" + "strings" + "testing" + "time" +) + +// fakeMachine points the session finder at a temporary /proc, /run/user and X socket directory, with +// none of the test process's own session variables, and gives back the root. +func fakeMachine(t *testing.T) string { + t.Helper() + root := t.TempDir() + procRoot, runUserDir, x11Sockets = filepath.Join(root, "proc"), filepath.Join(root, "run-user"), filepath.Join(root, "x11") + for _, d := range []string{procRoot, runUserDir, x11Sockets} { + if err := os.MkdirAll(d, 0o755); err != nil { + t.Fatal(err) + } + } + for _, k := range sessionKeys { + t.Setenv(k, "") + } + t.Setenv("MESH_OPERATOR_HOME", filepath.Join(root, "home")) + t.Cleanup(func() { procRoot, runUserDir, x11Sockets = "/proc", "/run/user", "/tmp/.X11-unix" }) + return root +} + +func fakeProcess(t *testing.T, pid int, comm string, env ...string) { + t.Helper() + dir := filepath.Join(procRoot, strconv.Itoa(pid)) + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "comm"), []byte(comm+"\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "environ"), []byte(strings.Join(env, "\x00")+"\x00"), 0o600); err != nil { + t.Fatal(err) + } +} + +func TestTheSessionIsReadFromTheWindowManagerBeforeAnyOtherProcess(t *testing.T) { + fakeMachine(t) + fakeProcess(t, 900, "xterm", "DISPLAY=:9", "XAUTHORITY=/elsewhere") + fakeProcess(t, 100, "i3", "DISPLAY=:1", "XAUTHORITY=/home/op/.Xauthority", + "DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1000/bus", "XDG_SESSION_ID=3", "SECRET_TOKEN=never-copied") + fakeProcess(t, 50, "bash", "PATH=/usr/bin") + s, err := findSession() + if err != nil { + t.Fatal(err) + } + if s.Display != ":1" || s.XAuthority != "/home/op/.Xauthority" || s.SessionID != "3" || !strings.Contains(s.From, "i3 (pid 100)") { + t.Fatalf("the window manager's environment: %+v", s) + } + for _, kv := range s.Env() { + if strings.HasPrefix(kv, "SECRET_TOKEN=") { + t.Fatal("a variable of the session process that is not a session variable was handed on") + } + } +} + +func TestAnyProcessCarryingADisplayServesWhenTheWindowManagerIsNotFound(t *testing.T) { + fakeMachine(t) + fakeProcess(t, 10, "firefox", "DISPLAY=:0") + fakeProcess(t, 20, "firefox", "DISPLAY=:2") + s, err := findSession() + if err != nil || s.Display != ":2" { + t.Fatalf("the newest of two equals: %+v, %v", s, err) + } +} + +func TestNoSessionIsAClearAnswerNotAGuess(t *testing.T) { + fakeMachine(t) + fakeProcess(t, 10, "sshd", "PATH=/usr/bin") + _, err := findSession() + if !errors.Is(err, ErrNoSession) || !strings.Contains(err.Error(), "logged in to the desktop") { + t.Fatalf("no session: %v", err) + } +} + +func TestOneXSocketAndTheAccountsAuthorityFileAreASession(t *testing.T) { + root := fakeMachine(t) + if err := os.WriteFile(filepath.Join(x11Sockets, "X0"), nil, 0o644); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(root, "home"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "home", ".Xauthority"), nil, 0o600); err != nil { + t.Fatal(err) + } + s, err := findSession() + if err != nil || s.Display != ":0" || !strings.HasSuffix(s.XAuthority, "/home/.Xauthority") { + t.Fatalf("socket and authority: %+v, %v", s, err) + } +} + +func TestTheBusIsTheAccountsRuntimeDirectoryWhenNoProcessNamesIt(t *testing.T) { + fakeMachine(t) + runtime := filepath.Join(runUserDir, strconv.Itoa(os.Getuid())) + if _, err := findBus(); !errors.Is(err, ErrNoBus) { + t.Fatalf("no runtime directory is no bus: %v", err) + } + if err := os.MkdirAll(runtime, 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(runtime, "bus"), nil, 0o600); err != nil { + t.Fatal(err) + } + s, err := findBus() + if err != nil || s.Bus != "unix:path="+filepath.Join(runtime, "bus") || s.RuntimeDir != runtime { + t.Fatalf("bus: %+v, %v", s, err) + } + env := strings.Join(s.Env(), "\n") + if !strings.Contains(env, "XDG_RUNTIME_DIR="+runtime) || !strings.Contains(env, "DBUS_SESSION_BUS_ADDRESS=unix:path=") { + t.Fatalf("the bus is handed on: %s", env) + } +} + +func TestACommandIsBoundedAndANonZeroExitIsAResult(t *testing.T) { + fakeMachine(t) + s := Session{} + r, err := s.run(5*time.Second, "in", "sh", "-c", "cat; echo err >&2; exit 3") + if err != nil || r.Stdout != "in" || r.Code != 3 || strings.TrimSpace(r.Stderr) != "err" { + t.Fatalf("result: %+v, %v", r, err) + } + start := time.Now() + if _, err := s.run(200*time.Millisecond, "", "sh", "-c", "sleep 30 & sleep 30"); err == nil || time.Since(start) > 5*time.Second { + t.Fatalf("a command past its time is ended with what it started: %v after %s", err, time.Since(start)) + } + if _, err := s.run(time.Second, "", "no-such-program-here"); err == nil || !strings.Contains(err.Error(), "not installed") { + t.Fatalf("a missing program: %v", err) + } +} + +func TestDetachAsksTheAccountsServiceManagerWithTheSessionsDisplay(t *testing.T) { + fakeMachine(t) + bin := fakeBinaries(t, map[string]string{ + "systemctl": `echo "systemctl $*" >> "$LOG"`, + "systemd-run": `echo "systemd-run $*" >> "$LOG"`, + }) + log := filepath.Join(bin, "log") + t.Setenv("LOG", log) + s := Session{Display: ":1", XAuthority: "/x", RuntimeDir: "/run/user/1"} + if err := s.detach("picom-session", "picom", "--config", "/c"); err != nil { + t.Fatal(err) + } + got, _ := os.ReadFile(log) + want := "systemctl --user stop picom-session.service\n" + + "systemd-run --user --collect --quiet --unit=picom-session --setenv=DISPLAY=:1 --setenv=XAUTHORITY=/x -- picom --config /c\n" + if string(got) != want { + t.Fatalf("detach ran:\n%s\nwant:\n%s", got, want) + } + if err := (Session{}).detach("x", "y"); !errors.Is(err, ErrNoBus) { + t.Fatalf("no runtime directory: %v", err) + } +} + +// fakeBinaries puts shell scripts named for programs first on PATH, and answers their directory. +func fakeBinaries(t *testing.T, scripts map[string]string) string { + t.Helper() + dir := t.TempDir() + for name, body := range scripts { + if err := os.WriteFile(filepath.Join(dir, name), []byte("#!/bin/sh\n"+body+"\n"), 0o755); err != nil { + t.Fatal(err) + } + } + t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) + return dir +} diff --git a/modules/screen-lock/files/bin/screen-lock b/modules/screen-lock/files/bin/screen-lock new file mode 100755 index 0000000..7e13946 --- /dev/null +++ b/modules/screen-lock/files/bin/screen-lock @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +# screen-lock (module screen-lock, novox/hq ADR 0208): the locker. xss-lock runs it on idle, before +# suspend and on logind's Lock, and node-lock-screen's `lock` ends in it. +# +# The distribution's i3lock: a black screen, failed attempts shown, an empty Enter ignored. The colour +# build the predecessor used is not in the distribution; it can come back as a pinned archive +# (ADR 0205), and then only these options change. +# +# Before a suspend, xss-lock hands this script a lock on the sleep (XSS_SLEEP_LOCK_FD) and suspends +# once it is released. The locker must not inherit it, or the machine would wait for the unlock +# before sleeping; it is released once i3lock is up, so the machine never sleeps unlocked. This is +# xss-lock's own documented pattern for i3lock. +set -u + +options=(--color=000000 --show-failed-attempts --ignore-empty-password) + +# One locker: a second press of the key, or a lock while locked, changes nothing. +if pgrep -xu "$EUID" i3lock >/dev/null; then + exit 0 +fi + +if [[ -e /dev/fd/${XSS_SLEEP_LOCK_FD:--1} ]]; then + kill_i3lock() { pkill -xu "$EUID" "$@" i3lock; } + trap kill_i3lock TERM INT + i3lock "${options[@]}" {XSS_SLEEP_LOCK_FD}<&- + exec {XSS_SLEEP_LOCK_FD}<&- + while kill_i3lock -0; do + sleep 0.5 + done +else + trap 'kill %%' TERM INT + i3lock --nofork "${options[@]}" & + wait +fi diff --git a/modules/screen-lock/go.mod b/modules/screen-lock/go.mod new file mode 100644 index 0000000..b049dcf --- /dev/null +++ b/modules/screen-lock/go.mod @@ -0,0 +1,5 @@ +module screenlock + +go 1.22 + +require git.novox.be/novox/mesh-sdk/go v0.1.7 diff --git a/modules/screen-lock/go.sum b/modules/screen-lock/go.sum new file mode 100644 index 0000000..b474419 --- /dev/null +++ b/modules/screen-lock/go.sum @@ -0,0 +1,2 @@ +git.novox.be/novox/mesh-sdk/go v0.1.7 h1:C0sTQmtTiyYH7bnqZb7PusXnqA37gKuT7Nqjn9gG47w= +git.novox.be/novox/mesh-sdk/go v0.1.7/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= diff --git a/modules/screen-lock/module.json b/modules/screen-lock/module.json new file mode 100644 index 0000000..834b301 --- /dev/null +++ b/modules/screen-lock/module.json @@ -0,0 +1,78 @@ +{ + "module": "screen-lock", + "version": "1", + "capabilities": [ + "package-manager" + ], + "requires": [ + "x11-display" + ], + "claims": [ + { + "name": "node-lock-screen", + "scope": "node", + "serves": [ + "lock" + ] + } + ], + "tools": [ + "screen_lock_idle", + "screen_lock_inhibit", + "screen_lock_locked" + ], + "shell": [ + { + "for": "xinitrc", + "slot": "normal", + "code": "# The lock screen (module screen-lock, novox/hq ADR 0208): the session locks after 30 minutes idle,\n# the displays go to standby and suspend then and off after an hour, and xss-lock runs the locker on\n# idle, before suspend and on logind's Lock. xss-lock needs this login session, so it starts here and\n# not as a unit. It is started again if it exits, for as long as this session lasts ($$ is the\n# session's own process, which becomes the window manager).\nxset s 1800 1800\nxset dpms 1800 1800 3600\n(while kill -0 $$ 2>/dev/null; do xss-lock --transfer-sleep-lock -- \"$HOME/.local/bin/screen-lock\"; sleep 2; done) &\n" + } + ], + "resources": [ + { + "id": "watcher", + "type": "package", + "package": "xss-lock" + }, + { + "id": "locker", + "type": "package", + "package": "i3lock" + }, + { + "id": "colour-locker", + "type": "package", + "package": "i3lock-color", + "absent": true + }, + { + "id": "screensaver", + "type": "package", + "package": "xscreensaver", + "absent": true + }, + { + "id": "wrapper", + "type": "file", + "path": "${machine:account-home}/.local/bin/screen-lock", + "owner": "${machine:account}", + "mode": "0755", + "content": "#!/usr/bin/env bash\n# screen-lock (module screen-lock, novox/hq ADR 0208): the locker. xss-lock runs it on idle, before\n# suspend and on logind's Lock, and node-lock-screen's `lock` ends in it.\n#\n# The distribution's i3lock: a black screen, failed attempts shown, an empty Enter ignored. The colour\n# build the predecessor used is not in the distribution; it can come back as a pinned archive\n# (ADR 0205), and then only these options change.\n#\n# Before a suspend, xss-lock hands this script a lock on the sleep (XSS_SLEEP_LOCK_FD) and suspends\n# once it is released. The locker must not inherit it, or the machine would wait for the unlock\n# before sleeping; it is released once i3lock is up, so the machine never sleeps unlocked. This is\n# xss-lock's own documented pattern for i3lock.\nset -u\n\noptions=(--color=000000 --show-failed-attempts --ignore-empty-password)\n\n# One locker: a second press of the key, or a lock while locked, changes nothing.\nif pgrep -xu \"$EUID\" i3lock >/dev/null; then\n\texit 0\nfi\n\nif [[ -e /dev/fd/${XSS_SLEEP_LOCK_FD:--1} ]]; then\n\tkill_i3lock() { pkill -xu \"$EUID\" \"$@\" i3lock; }\n\ttrap kill_i3lock TERM INT\n\ti3lock \"${options[@]}\" {XSS_SLEEP_LOCK_FD}<&-\n\texec {XSS_SLEEP_LOCK_FD}<&-\n\twhile kill_i3lock -0; do\n\t\tsleep 0.5\n\tdone\nelse\n\ttrap 'kill %%' TERM INT\n\ti3lock --nofork \"${options[@]}\" &\n\twait\nfi\n" + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/screen-lock-tools", + "binary": "screen-lock-tools", + "loads": [ + "screen-lock-tools" + ] + } + ] + } +}