lemurs: the login manager holds node-login-manager, its config in the current format (hq ADR 0208)
The official package in place of lemurs-git, and /etc/lemurs/config.toml in lemurs 0.4's structure. It offers only the session scripts that modules place in /etc/lemurs/wms and /etc/lemurs/wayland, never a package's bare desktop entry, which skips the session's start. The service is enabled and never started, stopped or restarted by a push. The tools are the seat's sessions, the default session (lemurs's cache, through sudo -n) and logins from the journal and lemurs's own log. The package swap from lemurs-git is a one-off step for the operator, listed in the README.
This commit is contained in:
@@ -0,0 +1,101 @@
|
||||
# lemurs
|
||||
|
||||
The login manager as a module (novox/hq ADR 0208, research 026, to-be 42 phase 2, step 3).
|
||||
|
||||
- **Claims `node-login-manager`** and serves its verb `sessions`.
|
||||
- **Package `lemurs`**, from the official repositories. It replaces the user repository's
|
||||
`lemurs-git` that both workstations run.
|
||||
- **Service `lemurs.service`, enabled at boot.** It is its own `display-manager.service` alias. Its
|
||||
state is declared as nothing: a push never starts, stops or restarts the login manager, because
|
||||
restarting it ends the session it started. A change to its configuration applies at its next
|
||||
start (a reboot).
|
||||
- **Gated by `package-manager`, `service-manager` and `seat`.** A machine without a display has
|
||||
nothing to log into.
|
||||
|
||||
## What it owns
|
||||
|
||||
| path | class | what |
|
||||
|---|---|---|
|
||||
| `/etc/lemurs/config.toml` | owned (the found file is kept once, ADR 0102) | lemurs 0.4's configuration in its current format: the structure of the shipped file, every option present, as lemurs requires. The source is [`config/config.toml`](config/config.toml), carried whole in the manifest |
|
||||
| `/etc/lemurs/xsessions/`, `/etc/lemurs/wayland-sessions/` | owned, empty | where the configuration points lemurs for desktop entries, so none is offered |
|
||||
|
||||
**Sessions are drop-ins.** The sessions offered are the executable files session modules place in
|
||||
`/etc/lemurs/wms/` (X) and `/etc/lemurs/wayland/` (Wayland). The file's name is the session's name.
|
||||
`i3` places `/etc/lemurs/wms/i3`, and `sway` will place its own in `wayland/`. The desktop entries
|
||||
packages install (`/usr/share/xsessions/i3.desktop`, `i3-with-shmlog.desktop`) are no longer offered.
|
||||
They start the window manager bare, skipping `~/.xinitrc`, which holds the environment, the
|
||||
resources and every module's session lines. Today the workstations log in through exactly such an
|
||||
entry (`i3`). It reaches the session's start only because `~/.xprofile` sources `~/.xinitrc`.
|
||||
|
||||
**What the configuration changes** from the shipped file, each marked `mesh:` in it:
|
||||
|
||||
- the two desktop-entry directories, as above;
|
||||
- `switcher_visibility = "F3"`. The session switcher stays hidden as today, and F3 shows it once a
|
||||
second session (sway) exists.
|
||||
|
||||
Everything else is lemurs's default, which is also what runs today: X on `:1`, tty 2, the cache in
|
||||
`/var/cache/lemurs`, `remember = true`. The workstations' current file is two releases old. The
|
||||
running `lemurs-git` ignores its X keys and uses these defaults already, which is why X is on `:1`
|
||||
although the file says `:0`.
|
||||
|
||||
## Tools
|
||||
|
||||
| tool | | what |
|
||||
|---|---|---|
|
||||
| `node-login-manager.sessions` | r | each session offered: name, X11 or Wayland, script or desktop entry, the file and what it runs, whether lemurs can run it (a script that is not executable is skipped); the default session and account from the cache |
|
||||
| `lemurs_default_session` | r/a | the preselected session; set it to one that is offered. It writes the cache through `sudo -n`, and shows when lemurs next starts |
|
||||
| `lemurs_logins` | r | logins from the journal (opened, closed, failed passwords), and which entry lemurs started each session with (its own log) |
|
||||
|
||||
None needs the graphical session.
|
||||
|
||||
## What it improves
|
||||
|
||||
- the official package instead of a `-git` build from the user repository;
|
||||
- the configuration in the format the binary reads. Today's file is mostly ignored, and the unmerged
|
||||
`.pacnew` sits beside it;
|
||||
- one session per session module, each starting through the session's start, and no bare desktop
|
||||
entries;
|
||||
- a dead entry gone: `/etc/lemurs/wms/i3wm` (`exec startx`) would start a second X server inside
|
||||
the one lemurs started.
|
||||
|
||||
## What it leaves found
|
||||
|
||||
- `/etc/lemurs/xsetup.sh`, the package's;
|
||||
- `/etc/pam.d/lemurs`, the package's (it unlocks the login keyring through `pam_gnome_keyring`);
|
||||
- `/var/cache/lemurs`, lemurs's own.
|
||||
|
||||
## The one-off migration (ADR 0182)
|
||||
|
||||
1. **Replace the package by hand, once per workstation, at a moment you choose:**
|
||||
`sudo pacman -S lemurs`, answering yes to removing `lemurs-git` (and `lemurs-git-debug` on the
|
||||
laptop). The host cannot do this. `pacman -Q lemurs` answers with `lemurs-git`, which provides
|
||||
`lemurs`, so the declared package already reads as installed. A non-interactive install would
|
||||
also refuse the conflict. The binary is replaced on disk, and the running login manager keeps
|
||||
running the old one until the next boot.
|
||||
2. **Delete `/etc/lemurs/config.toml.pacnew`.** The module's file is that structure.
|
||||
3. **Delete `/etc/lemurs/wms/i3wm`** once `i3` is assigned and `/etc/lemurs/wms/i3` exists.
|
||||
4. **Delete `~/.xprofile`**, or its `. ~/.xinitrc` line (see `xorg`'s README). Until then the X setup
|
||||
runs `~/.xinitrc` from `.xprofile` before it reaches the session's entry. That still works, once.
|
||||
|
||||
Steps 1, 2 and 3 are harmless in any order. Step 4 waits for `i3`.
|
||||
|
||||
## What changes when it is assigned
|
||||
|
||||
| | g14 | shanks |
|
||||
|---|---|---|
|
||||
| package | nothing until step 1 (`lemurs-git` reads as installed) | the same |
|
||||
| `/etc/lemurs/config.toml` | the found file kept once, then the module's written | the same |
|
||||
| `/etc/lemurs/xsessions/`, `wayland-sessions/` | created, empty | the same |
|
||||
| `lemurs.service` | already enabled: nothing | the same |
|
||||
| the running login manager and session | **nothing** | **nothing** |
|
||||
| next boot | the login screen offers `i3wm` until step 3, and `i3` once the `i3` module is assigned. It no longer offers the two desktop entries. The remembered session `i3` matches the `i3` module's entry by name | the same |
|
||||
|
||||
**Order matters at one point:** assign `i3` before the next reboot after `lemurs`. Otherwise the
|
||||
screen offers only `i3wm`, which runs `startx` inside lemurs's own X server and fails. Assigned in
|
||||
to-be 42's order (`xorg`, `lemurs`, `i3` in one sitting), this cannot happen.
|
||||
|
||||
## Blockers
|
||||
|
||||
- **The package swap is a person's act** (step 1). The host's package resource cannot replace a
|
||||
package that provides the same name.
|
||||
- **No restart, by design.** A configuration change takes a reboot to show.
|
||||
@@ -0,0 +1,144 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"lemurs/internal/desktop"
|
||||
)
|
||||
|
||||
// aMachine lays out a login manager in a directory: its configuration naming directories under it,
|
||||
// two X scripts (one not executable), a package's desktop entry and a cache.
|
||||
func aMachine(t *testing.T) (lemurs, string, *[]string) {
|
||||
root := t.TempDir()
|
||||
for _, d := range []string{"wms", "wayland", "xsessions", "wayland-sessions"} {
|
||||
os.MkdirAll(filepath.Join(root, d), 0o755)
|
||||
}
|
||||
os.WriteFile(filepath.Join(root, "wms", "i3"), []byte("#!/bin/sh\n# the session\nexec /bin/sh \"$HOME/.xinitrc\"\n"), 0o755)
|
||||
os.WriteFile(filepath.Join(root, "wms", "notes"), []byte("not a session\n"), 0o644)
|
||||
os.WriteFile(filepath.Join(root, "xsessions", "i3.desktop"), []byte("[Desktop Entry]\nName=i3\nExec=i3\nType=Application\n"), 0o644)
|
||||
os.WriteFile(filepath.Join(root, "cache"), []byte("i3\nop\n"), 0o644)
|
||||
config := strings.Join([]string{
|
||||
`tty = 2`, `cache_path = "` + root + `/cache"`,
|
||||
`[x11]`, `x11_display = ":1"`, `scripts_path = "` + root + `/wms"`, `xsessions_path = "` + root + `/xsessions"`,
|
||||
`[wayland]`, `scripts_path = "` + root + `/wayland"`, `wayland_sessions_path = "` + root + `/wayland-sessions"`,
|
||||
}, "\n")
|
||||
os.WriteFile(filepath.Join(root, "config.toml"), []byte(config), 0o644)
|
||||
var ran []string
|
||||
d := desktop.Desk{
|
||||
Find: func() (*desktop.Session, error) { return nil, &desktop.NoSession{Reason: "none"} },
|
||||
Run: func(_ context.Context, _ []string, stdin []byte, name string, args ...string) desktop.Result {
|
||||
line := strings.Join(append([]string{name}, args...), " ")
|
||||
ran = append(ran, line+" <<"+string(stdin))
|
||||
if name == "journalctl" {
|
||||
return desktop.Result{Stdout: journal}
|
||||
}
|
||||
return desktop.Result{}
|
||||
},
|
||||
}
|
||||
return lemurs{d: d, config: filepath.Join(root, "config.toml"), log: filepath.Join(root, "lemurs.log"), uid: 1000}, root, &ran
|
||||
}
|
||||
|
||||
const journal = `-- Boot a0 --
|
||||
2026-10-02T13:08:44+02:00 host lemurs[1001]: gkr-pam: unable to locate daemon control file
|
||||
2026-10-02T13:08:40+02:00 host lemurs[1001]: pam_unix(lemurs:auth): authentication failure; logname= uid=0 euid=0 tty=tty2 ruser= rhost= user=op
|
||||
2026-10-02T13:08:44+02:00 host lemurs[2141]: pam_unix(lemurs:session): session opened for user op(uid=1000) by op(uid=0)
|
||||
2026-10-02T18:00:01+02:00 host lemurs[2141]: pam_unix(lemurs:session): session closed for user op
|
||||
`
|
||||
|
||||
func TestTheSessionsAreTheEntriesLemursOffersInItsOrder(t *testing.T) {
|
||||
l, root, _ := aMachine(t)
|
||||
got, err := l.sessions(context.Background(), desktop.Args{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := got.(map[string]any)["sessions"].([]Session)
|
||||
if len(s) != 3 || s[0].Source != "desktop-entry" || s[0].Exec != "i3" || s[1].Name != "i3" || s[1].Source != "script" {
|
||||
t.Fatalf("%+v", s)
|
||||
}
|
||||
if s[1].Exec != `exec /bin/sh "$HOME/.xinitrc"` || !s[1].Offered {
|
||||
t.Fatalf("a script answers what it runs: %+v", s[1])
|
||||
}
|
||||
if s[2].Name != "notes" || s[2].Executable || s[2].Offered {
|
||||
t.Fatalf("a script that is not executable is not offered: %+v", s[2])
|
||||
}
|
||||
if d := got.(map[string]any)["default"].(Cached); d.Session != "i3" || d.Account != "op" {
|
||||
t.Fatalf("%+v", d)
|
||||
}
|
||||
_ = root
|
||||
}
|
||||
|
||||
func TestTheModulesConfigurationIsReadAsLemursReadsIt(t *testing.T) {
|
||||
raw, err := os.ReadFile(filepath.Join("..", "..", "config", "config.toml"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c := ParseConfig(string(raw))
|
||||
want := Config{Cache: "/var/cache/lemurs", X11Scripts: "/etc/lemurs/wms", X11Sessions: "/etc/lemurs/xsessions",
|
||||
WaylandScripts: "/etc/lemurs/wayland", WaylandEntries: "/etc/lemurs/wayland-sessions", Display: ":1", TTY: 2}
|
||||
if c != want {
|
||||
t.Fatalf("%+v", c)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheDefaultSessionIsOneLemursOffersAndIsWrittenThroughSudo(t *testing.T) {
|
||||
l, root, ran := aMachine(t)
|
||||
if _, err := l.defaultSession(context.Background(), desktop.Args{"session": "notes"}); err == nil {
|
||||
t.Fatal("a session lemurs does not offer is refused")
|
||||
}
|
||||
if _, err := l.defaultSession(context.Background(), desktop.Args{"session": "i3", "account": "op; rm"}); err == nil {
|
||||
t.Fatal("an account that is not a name is refused")
|
||||
}
|
||||
got, err := l.defaultSession(context.Background(), desktop.Args{"session": "i3"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(*ran) != 1 || (*ran)[0] != "sudo -n tee "+root+"/cache <<i3\nop\n" {
|
||||
t.Fatalf("%q", *ran)
|
||||
}
|
||||
if !strings.Contains(asJSON(got), `"shown"`) {
|
||||
t.Fatalf("it says when it shows: %s", asJSON(got))
|
||||
}
|
||||
l.uid = 0
|
||||
*ran = nil
|
||||
l.defaultSession(context.Background(), desktop.Args{"session": "i3"})
|
||||
if !strings.HasPrefix((*ran)[0], "tee ") {
|
||||
t.Fatalf("as root, no sudo: %q", *ran)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginsAreReadFromTheJournalAndLemursLog(t *testing.T) {
|
||||
l, root, _ := aMachine(t)
|
||||
os.WriteFile(filepath.Join(root, "lemurs.log"), []byte(
|
||||
"[2026-10-02T11:08:44Z INFO lemurs] Starting new session for 'op' in environment 'X { xinitrc_path: \"i3\" }'\n"+
|
||||
"[2026-10-02T11:08:44Z INFO lemurs::auth] Login attempt for 'op'\n"), 0o644)
|
||||
got, err := l.logins(context.Background(), desktop.Args{"limit": float64(2)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m := got.(map[string]any)
|
||||
ev := m["events"].([]Login)
|
||||
if len(ev) != 2 || ev[0].Event != "opened" || ev[1].Event != "closed" || ev[1].Account != "op" || m["cut"] != true {
|
||||
t.Fatalf("the newest two, cut: %+v", ev)
|
||||
}
|
||||
all := ParseJournal(journal)
|
||||
if len(all) != 3 || all[0].Event != "failed" || all[0].Account != "op" {
|
||||
t.Fatalf("%+v", all)
|
||||
}
|
||||
st := m["started"].([]Started)
|
||||
if len(st) != 1 || st[0].Environment != `X { xinitrc_path: "i3" }` {
|
||||
t.Fatalf("%+v", st)
|
||||
}
|
||||
if _, err := l.logins(context.Background(), desktop.Args{"since": "-1d; reboot"}); err == nil {
|
||||
t.Fatal("since is a time")
|
||||
}
|
||||
}
|
||||
|
||||
func asJSON(v any) string {
|
||||
b, _ := json.Marshal(v)
|
||||
return string(b)
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
// lemurs's tools (novox/hq ADR 0208, research 026/05): node-login-manager's verb `sessions`, and the
|
||||
// module's own `default_session` and `logins`.
|
||||
//
|
||||
// None of them needs the graphical session: they read the login manager's configuration, its session
|
||||
// directories, its cache and the journal. Changing the default session writes the login manager's
|
||||
// cache, which is root's, through `sudo -n` as the packet filter's tools escalate (to-be 38 WP4).
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
|
||||
"lemurs/internal/desktop"
|
||||
)
|
||||
|
||||
func main() {
|
||||
l := lemurs{d: desktop.Machine(), config: "/etc/lemurs/config.toml", log: "/var/log/lemurs.log", uid: os.Getuid()}
|
||||
if err := stdio.Serve("", tools(l)); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func call(run func(ctx context.Context, a desktop.Args) (any, error)) func(map[string]any) (any, error) {
|
||||
return func(args map[string]any) (any, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 25*time.Second)
|
||||
defer cancel()
|
||||
return run(ctx, desktop.Args(args))
|
||||
}
|
||||
}
|
||||
|
||||
func tools(l lemurs) []stdio.Tool {
|
||||
return []stdio.Tool{
|
||||
{
|
||||
Name: "node-login-manager.sessions",
|
||||
Description: "The sessions the login screen offers on this machine — each with its name, X11 or Wayland, " +
|
||||
"the file it runs and whether that file is executable (lemurs skips one that is not) — and the " +
|
||||
"session and account it starts with by default (its cache).",
|
||||
Input: desktop.Schema(map[string]any{}),
|
||||
Run: call(l.sessions),
|
||||
},
|
||||
{
|
||||
Name: "lemurs_default_session",
|
||||
Description: "The session the login screen preselects. With session (one of the names sessions lists), " +
|
||||
"make it the default: written into lemurs's cache, which lemurs reads when it starts, so it shows at " +
|
||||
"the next start of the login screen (a reboot, or lemurs restarted). Escalates with sudo -n.",
|
||||
Input: desktop.Schema(map[string]any{
|
||||
"session": desktop.Str("the session to preselect (optional)"),
|
||||
"account": desktop.Str("the account to preselect with it (optional; the cached one)"),
|
||||
}),
|
||||
Run: call(l.defaultSession),
|
||||
},
|
||||
{
|
||||
Name: "lemurs_logins",
|
||||
Description: "Who logged in through the login screen and when, newest last: sessions opened and closed " +
|
||||
"and failed passwords, from the journal, and the sessions lemurs started (which entry, from its own " +
|
||||
"log since it last started).",
|
||||
Input: desktop.Schema(map[string]any{
|
||||
"since": desktop.Str("how far back, as journalctl reads it (default -7d)"),
|
||||
"limit": desktop.Int("at most this many events (default 50, at most 500)"),
|
||||
}),
|
||||
Run: call(l.logins),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package main
|
||||
|
||||
// lemurs's shape (novox/hq ADR 0208): it holds node-login-manager; it owns the configuration in
|
||||
// lemurs 0.4's format and enables the service without ever starting, stopping or restarting it,
|
||||
// because the running login manager is the operator's way in.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type manifest struct {
|
||||
Module string `json:"module"`
|
||||
Capabilities []string `json:"capabilities"`
|
||||
Claims []struct {
|
||||
Name string `json:"name"`
|
||||
Scope string `json:"scope"`
|
||||
Serves []string `json:"serves"`
|
||||
} `json:"claims"`
|
||||
Seats any `json:"seats"`
|
||||
Tools []string `json:"tools"`
|
||||
Resources []map[string]any `json:"resources"`
|
||||
Build struct {
|
||||
Artifacts []map[string]any `json:"artifacts"`
|
||||
} `json:"build"`
|
||||
}
|
||||
|
||||
func readManifest(t *testing.T) manifest {
|
||||
raw, err := os.ReadFile(filepath.Join("..", "..", "module.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var m manifest
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func resource(t *testing.T, m manifest, id string) map[string]any {
|
||||
for _, r := range m.Resources {
|
||||
if r["id"] == id {
|
||||
return r
|
||||
}
|
||||
}
|
||||
t.Fatalf("no resource %s", id)
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestItHoldsTheLoginManagerSeatAndServesSessions(t *testing.T) {
|
||||
m := readManifest(t)
|
||||
if m.Seats != nil || len(m.Claims) != 1 || m.Claims[0].Name != "node-login-manager" || strings.Join(m.Claims[0].Serves, ",") != "sessions" {
|
||||
t.Fatalf("%+v", m.Claims)
|
||||
}
|
||||
served := map[string]bool{}
|
||||
for _, tool := range tools(lemurs{}) {
|
||||
served[tool.Name] = true
|
||||
}
|
||||
if !served["node-login-manager.sessions"] || len(served) != 1+len(m.Tools) {
|
||||
t.Fatalf("served %v, manifest %v", served, m.Tools)
|
||||
}
|
||||
for _, name := range m.Tools {
|
||||
if !served[name] {
|
||||
t.Errorf("%s is listed and not served", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheOfficialPackageAndItsServiceEnabledButNeverRestarted(t *testing.T) {
|
||||
m := readManifest(t)
|
||||
if p := resource(t, m, "package"); p["package"] != "lemurs" {
|
||||
t.Fatalf("the official package, not lemurs-git: %v", p)
|
||||
}
|
||||
s := resource(t, m, "service")
|
||||
if s["unit"] != "lemurs.service" || s["boot"] != "enabled" {
|
||||
t.Fatalf("%v", s)
|
||||
}
|
||||
for _, k := range []string{"state", "restart-on", "reload-on"} {
|
||||
if _, ok := s[k]; ok {
|
||||
t.Fatalf("the login manager is never started, stopped or restarted by a push (%s): a change applies at its next start", k)
|
||||
}
|
||||
}
|
||||
if strings.Join(m.Capabilities, ",") != "package-manager,service-manager,seat" {
|
||||
t.Fatalf("%v", m.Capabilities)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheConfigurationIsTheModulesFileAndOffersOnlyTheSessionsModulesPlace(t *testing.T) {
|
||||
m := readManifest(t)
|
||||
c := resource(t, m, "config")
|
||||
raw, _ := os.ReadFile(filepath.Join("..", "..", "config", "config.toml"))
|
||||
if c["path"] != "/etc/lemurs/config.toml" || c["content"] != string(raw) || c["into"] != nil {
|
||||
t.Fatal("the manifest carries config/config.toml whole, as an owned file")
|
||||
}
|
||||
text := c["content"].(string)
|
||||
for _, want := range []string{"[x11]", "[wayland]", `xsetup_path = "/etc/lemurs/xsetup.sh"`, `scripts_path = "/etc/lemurs/wms"`,
|
||||
`scripts_path = "/etc/lemurs/wayland"`, `xsessions_path = "/etc/lemurs/xsessions"`, `wayland_sessions_path = "/etc/lemurs/wayland-sessions"`,
|
||||
`tty = 2`, `x11_display = ":1"`, `remember = true`} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Errorf("the configuration lacks %s", want)
|
||||
}
|
||||
}
|
||||
for _, l := range strings.Split(text, "\n") {
|
||||
if (strings.HasPrefix(l, "xsessions_path") || strings.HasPrefix(l, "wayland_sessions_path")) && strings.Contains(l, "/usr/share") {
|
||||
t.Fatalf("a package's bare desktop entry would be offered: %s", l)
|
||||
}
|
||||
}
|
||||
for _, id := range []string{"xsessions", "wayland-sessions"} {
|
||||
if d := resource(t, m, id); d["type"] != "directory" || d["path"] != "/etc/lemurs/"+id {
|
||||
t.Fatalf("%v", d)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,343 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"lemurs/internal/desktop"
|
||||
)
|
||||
|
||||
type lemurs struct {
|
||||
d desktop.Desk
|
||||
config string
|
||||
log string
|
||||
uid int
|
||||
}
|
||||
|
||||
// Config is the part of lemurs's configuration the tools read, with lemurs 0.4's defaults.
|
||||
type Config struct {
|
||||
Cache string `json:"cache_path"`
|
||||
X11Scripts string `json:"x11_scripts"`
|
||||
X11Sessions string `json:"x11_desktop_entries"`
|
||||
WaylandScripts string `json:"wayland_scripts"`
|
||||
WaylandEntries string `json:"wayland_desktop_entries"`
|
||||
Display string `json:"x11_display"`
|
||||
TTY int `json:"tty"`
|
||||
}
|
||||
|
||||
// ParseConfig reads the keys it needs from lemurs's TOML: `[section]` headers and `key = value`
|
||||
// lines, a quoted value unquoted. Enough for this file, which holds no nested values it needs.
|
||||
func ParseConfig(text string) Config {
|
||||
c := Config{
|
||||
Cache: "/var/cache/lemurs", X11Scripts: "/etc/lemurs/wms", X11Sessions: "/usr/share/xsessions",
|
||||
WaylandScripts: "/etc/lemurs/wayland", WaylandEntries: "/usr/share/wayland-sessions", Display: ":1", TTY: 2,
|
||||
}
|
||||
section := ""
|
||||
sc := bufio.NewScanner(strings.NewReader(text))
|
||||
for sc.Scan() {
|
||||
line := strings.TrimSpace(sc.Text())
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(line, "[") {
|
||||
section = strings.Trim(line, "[] ")
|
||||
continue
|
||||
}
|
||||
k, v, ok := strings.Cut(line, "=")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
k, v = strings.TrimSpace(k), strings.TrimSpace(v)
|
||||
if uq, err := strconv.Unquote(v); err == nil {
|
||||
v = uq
|
||||
}
|
||||
switch section + "." + k {
|
||||
case ".cache_path":
|
||||
c.Cache = v
|
||||
case ".tty":
|
||||
if n, err := strconv.Atoi(v); err == nil {
|
||||
c.TTY = n
|
||||
}
|
||||
case "x11.scripts_path":
|
||||
c.X11Scripts = v
|
||||
case "x11.xsessions_path":
|
||||
c.X11Sessions = v
|
||||
case "x11.x11_display":
|
||||
c.Display = v
|
||||
case "wayland.scripts_path":
|
||||
c.WaylandScripts = v
|
||||
case "wayland.wayland_sessions_path":
|
||||
c.WaylandEntries = v
|
||||
}
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// Session is one entry of the login screen.
|
||||
type Session struct {
|
||||
Name string `json:"name"`
|
||||
Kind string `json:"kind"` // x11 or wayland
|
||||
Source string `json:"source"` // script or desktop-entry
|
||||
Path string `json:"path"`
|
||||
Exec string `json:"exec,omitempty"`
|
||||
Executable bool `json:"executable"`
|
||||
Offered bool `json:"offered"`
|
||||
}
|
||||
|
||||
// ListSessions is every entry lemurs offers, in its order: X desktop entries, Wayland desktop
|
||||
// entries, X scripts, Wayland scripts (lemurs's get_envs). A script that is not executable is listed
|
||||
// as not offered, because lemurs skips it with only a warning in its log.
|
||||
func ListSessions(c Config) []Session {
|
||||
var out []Session
|
||||
entries := func(dir, kind string) {
|
||||
files, _ := os.ReadDir(dir)
|
||||
for _, f := range files {
|
||||
p := filepath.Join(dir, f.Name())
|
||||
name, exec, ok := desktopEntry(p)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
out = append(out, Session{Name: name, Kind: kind, Source: "desktop-entry", Path: p, Exec: exec, Executable: true, Offered: true})
|
||||
}
|
||||
}
|
||||
scripts := func(dir, kind string) {
|
||||
files, _ := os.ReadDir(dir)
|
||||
for _, f := range files {
|
||||
p := filepath.Join(dir, f.Name())
|
||||
info, err := os.Stat(p)
|
||||
if err != nil || info.IsDir() {
|
||||
continue
|
||||
}
|
||||
x := info.Mode()&0o111 != 0
|
||||
out = append(out, Session{Name: f.Name(), Kind: kind, Source: "script", Path: p, Exec: firstCommand(p), Executable: x, Offered: x})
|
||||
}
|
||||
}
|
||||
entries(c.X11Sessions, "x11")
|
||||
entries(c.WaylandEntries, "wayland")
|
||||
scripts(c.X11Scripts, "x11")
|
||||
scripts(c.WaylandScripts, "wayland")
|
||||
return out
|
||||
}
|
||||
|
||||
// desktopEntry reads Name and Exec from a session's desktop entry, as lemurs does.
|
||||
func desktopEntry(path string) (string, string, bool) {
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return "", "", false
|
||||
}
|
||||
in, name, exec := false, "", ""
|
||||
for _, l := range strings.Split(string(b), "\n") {
|
||||
l = strings.TrimSpace(l)
|
||||
if strings.HasPrefix(l, "[") {
|
||||
in = l == "[Desktop Entry]"
|
||||
continue
|
||||
}
|
||||
if !in {
|
||||
continue
|
||||
}
|
||||
if v, ok := strings.CutPrefix(l, "Name="); ok && name == "" {
|
||||
name = v
|
||||
}
|
||||
if v, ok := strings.CutPrefix(l, "Exec="); ok && exec == "" {
|
||||
exec = v
|
||||
}
|
||||
}
|
||||
if exec == "" {
|
||||
return "", "", false
|
||||
}
|
||||
if name == "" {
|
||||
name = exec
|
||||
}
|
||||
return name, exec, true
|
||||
}
|
||||
|
||||
// firstCommand is a script's first line that is neither blank nor a comment: what it runs.
|
||||
func firstCommand(path string) string {
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
for _, l := range strings.Split(string(b), "\n") {
|
||||
if l = strings.TrimSpace(l); l != "" && !strings.HasPrefix(l, "#") {
|
||||
return l
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Cached is lemurs's cache file: the session on its first line, the account on its second.
|
||||
type Cached struct {
|
||||
Session string `json:"session"`
|
||||
Account string `json:"account"`
|
||||
}
|
||||
|
||||
func readCache(path string) (Cached, error) {
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return Cached{}, err
|
||||
}
|
||||
lines := strings.Split(strings.TrimSpace(string(b)), "\n")
|
||||
c := Cached{Session: strings.TrimSpace(lines[0])}
|
||||
if len(lines) > 1 {
|
||||
c.Account = strings.TrimSpace(lines[1])
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (l lemurs) readConfig() (Config, error) {
|
||||
b, err := os.ReadFile(l.config)
|
||||
if err != nil {
|
||||
return Config{}, fmt.Errorf("lemurs's configuration cannot be read (%v): is the lemurs module's package installed?", err)
|
||||
}
|
||||
return ParseConfig(string(b)), nil
|
||||
}
|
||||
|
||||
func (l lemurs) sessions(ctx context.Context, a desktop.Args) (any, error) {
|
||||
c, err := l.readConfig()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
answer := map[string]any{"config": l.config, "sessions": ListSessions(c), "directories": c}
|
||||
if cached, err := readCache(c.Cache); err == nil {
|
||||
answer["default"] = cached
|
||||
} else {
|
||||
answer["default"] = nil
|
||||
}
|
||||
return answer, nil
|
||||
}
|
||||
|
||||
var accountName = regexp.MustCompile(`^[a-z_][a-z0-9_-]{0,31}$`)
|
||||
|
||||
func (l lemurs) defaultSession(ctx context.Context, a desktop.Args) (any, error) {
|
||||
c, err := l.readConfig()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cached, _ := readCache(c.Cache)
|
||||
want := a.Opt("session", "")
|
||||
if want == "" {
|
||||
return map[string]any{"default": cached, "cache": c.Cache}, nil
|
||||
}
|
||||
known := false
|
||||
var names []string
|
||||
for _, s := range ListSessions(c) {
|
||||
if s.Offered {
|
||||
names = append(names, s.Name)
|
||||
known = known || s.Name == want
|
||||
}
|
||||
}
|
||||
if !known {
|
||||
sort.Strings(names)
|
||||
return nil, fmt.Errorf("%q is not a session lemurs offers; it offers %s", want, strings.Join(names, ", "))
|
||||
}
|
||||
account := a.Opt("account", cached.Account)
|
||||
if account == "" {
|
||||
account = os.Getenv("MESH_OPERATOR_ACCOUNT")
|
||||
}
|
||||
if !accountName.MatchString(account) {
|
||||
return nil, fmt.Errorf("%q is not an account name", account)
|
||||
}
|
||||
content := []byte(want + "\n" + account + "\n")
|
||||
var res desktop.Result
|
||||
if l.uid == 0 {
|
||||
res = l.d.Run(ctx, l.d.Base, content, "tee", c.Cache)
|
||||
} else {
|
||||
res = l.d.Run(ctx, l.d.Base, content, "sudo", "-n", "tee", c.Cache)
|
||||
}
|
||||
if !res.OK() {
|
||||
return nil, fmt.Errorf("writing %s: %w", c.Cache, res.Err())
|
||||
}
|
||||
return map[string]any{
|
||||
"default": Cached{Session: want, Account: account}, "was": cached, "cache": c.Cache,
|
||||
"shown": "when lemurs next starts (a reboot, or the login manager restarted); lemurs rewrites it after each login when remember is on",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Login is one event of the login screen.
|
||||
type Login struct {
|
||||
Time string `json:"time"`
|
||||
Event string `json:"event"` // opened, closed or failed
|
||||
Account string `json:"account,omitempty"`
|
||||
}
|
||||
|
||||
var (
|
||||
opened = regexp.MustCompile(`pam_unix\(lemurs:session\): session opened for user ([^(\s]+)`)
|
||||
closed = regexp.MustCompile(`pam_unix\(lemurs:session\): session closed for user ([^(\s]+)`)
|
||||
failed = regexp.MustCompile(`pam_unix\(lemurs:auth\): authentication failure;.*?user=(\S+)`)
|
||||
started = regexp.MustCompile(`^\[(\S+) INFO\s+lemurs\] Starting new session for '([^']*)' in environment '(.*)'$`)
|
||||
)
|
||||
|
||||
// ParseJournal reads `journalctl -o short-iso` lines of lemurs into login events.
|
||||
func ParseJournal(text string) []Login {
|
||||
var out []Login
|
||||
for _, line := range strings.Split(text, "\n") {
|
||||
f := strings.Fields(line)
|
||||
if len(f) < 3 || strings.HasPrefix(line, "--") {
|
||||
continue
|
||||
}
|
||||
for _, p := range []struct {
|
||||
re *regexp.Regexp
|
||||
event string
|
||||
}{{opened, "opened"}, {closed, "closed"}, {failed, "failed"}} {
|
||||
if m := p.re.FindStringSubmatch(line); m != nil {
|
||||
out = append(out, Login{Time: f[0], Event: p.event, Account: m[1]})
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Started is one session lemurs started, from its own log.
|
||||
type Started struct {
|
||||
Time string `json:"time"`
|
||||
Account string `json:"account"`
|
||||
Environment string `json:"environment"`
|
||||
}
|
||||
|
||||
// ParseStarts reads lemurs's own log for the sessions it started and which entry each ran.
|
||||
func ParseStarts(text string) []Started {
|
||||
var out []Started
|
||||
for _, line := range strings.Split(text, "\n") {
|
||||
if m := started.FindStringSubmatch(line); m != nil {
|
||||
out = append(out, Started{Time: m[1], Account: m[2], Environment: m[3]})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
var since = regexp.MustCompile(`^[-+]?[0-9A-Za-z :.]{1,40}$`)
|
||||
|
||||
func (l lemurs) logins(ctx context.Context, a desktop.Args) (any, error) {
|
||||
from := a.Opt("since", "-7d")
|
||||
if !since.MatchString(from) {
|
||||
return nil, fmt.Errorf("since is a time journalctl reads, e.g. -7d or 2026-10-01")
|
||||
}
|
||||
limit, err := a.Whole("limit", 50, 1, 500)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
res := l.d.Plain(ctx, "journalctl", "_COMM=lemurs", "--since", from, "-o", "short-iso", "--no-pager", "-q")
|
||||
if !res.OK() {
|
||||
return nil, res.Err()
|
||||
}
|
||||
events := ParseJournal(res.Stdout)
|
||||
cut := false
|
||||
if len(events) > limit {
|
||||
events, cut = events[len(events)-limit:], true
|
||||
}
|
||||
answer := map[string]any{"since": from, "events": events}
|
||||
if cut {
|
||||
answer["cut"] = true
|
||||
}
|
||||
if b, err := os.ReadFile(l.log); err == nil {
|
||||
answer["started"] = ParseStarts(string(b))
|
||||
}
|
||||
return answer, nil
|
||||
}
|
||||
@@ -0,0 +1,368 @@
|
||||
# The login manager's configuration, written by the mesh (module lemurs, novox/hq ADR 0208). Replaced
|
||||
# at every push; change the module instead. The structure is lemurs 0.4's own (the shipped file, with
|
||||
# every option, as lemurs requires); what the mesh changes from it is marked "mesh:".
|
||||
#
|
||||
# The sessions offered are the executable files other modules place in the two scripts directories
|
||||
# below (/etc/lemurs/wms for X, /etc/lemurs/wayland for Wayland), one per session module. A file there
|
||||
# is named as the session is offered. Desktop entries that packages install (/usr/share/xsessions) are
|
||||
# not offered: they start the window manager bare, skipping the session's start (~/.xinitrc), which is
|
||||
# where the account's environment, the X resources and every module's session lines are.
|
||||
#
|
||||
# Lemurs configuration file.
|
||||
# Contains all the customization options of lemurs.
|
||||
#
|
||||
# Note: that as of now you need to have all options in the selected
|
||||
# configuration file. Otherwise Lemurs will not work.
|
||||
#
|
||||
# Colors:
|
||||
# ---------
|
||||
# There is a list of predefined colors. These include:
|
||||
# - black
|
||||
# - white
|
||||
# - (dark) gray
|
||||
# - (light) red
|
||||
# - (light) blue
|
||||
# - (light) green
|
||||
# - (light) magenta
|
||||
# - (light) cyan
|
||||
# - (light) yellow
|
||||
# - orange
|
||||
#
|
||||
# You can also utilize custom colors with hex color codes.
|
||||
# "#87CEEB" will create a Sky Blue color.
|
||||
#
|
||||
# Note: If the color wasn't recognized, it will default to white.
|
||||
# ---------
|
||||
#
|
||||
# Modifiers:
|
||||
# ---------
|
||||
# There is a number of modifiers you can use. These can be combined by
|
||||
# delimiting them with a comma (e.g. "bold,italic"). The modifiers are:
|
||||
# - bold
|
||||
# - dim
|
||||
# - italic
|
||||
# - underlined
|
||||
# - reverse
|
||||
# - crossed out
|
||||
# - hidden
|
||||
# ---------
|
||||
#
|
||||
|
||||
# The tty which contains lemurs. This has to be mirrored in the lemurs.service
|
||||
tty = 2
|
||||
|
||||
# Where to log the main lemurs control flow.
|
||||
main_log_path = "/var/log/lemurs.log"
|
||||
|
||||
# Where to log to for the client. The Client is the Desktop Environment or
|
||||
# Window Manager for Xorg, the Compositor for Wayland and the Shell for TTY.
|
||||
client_log_path = "/var/log/lemurs.client.log"
|
||||
|
||||
# At which point to point the cache. If you want to disable the cache globally
|
||||
# you can use `/dev/null`.
|
||||
cache_path = "/var/cache/lemurs"
|
||||
|
||||
# Disable all logging. This is overwritten by the `--no-log` flag.
|
||||
do_log = true
|
||||
|
||||
# The PAM service that should be used to login
|
||||
pam_service = "lemurs"
|
||||
|
||||
# Path to system shell that gets used to execute linux commands. In almost all
|
||||
# cases, this should refer to a bash shell.
|
||||
system_shell = "/bin/sh"
|
||||
|
||||
# Initial state of the `PATH` environment variable.
|
||||
initial_path = "/usr/local/sbin:/usr/local/bin:/usr/bin"
|
||||
|
||||
# The type flag that will be appended to the shell that calls the session
|
||||
# environment. This may depend on your shell. Options:
|
||||
# - 'none'. Disables calling a login shell
|
||||
# - 'short'. Produces the `-l` flag. Supported by most shells.
|
||||
# - 'long'. This produces the `--login` flag and is suited for bash and zsh.
|
||||
shell_login_flag = "short"
|
||||
|
||||
# Focus behaviour of fields when Lemurs is initially started
|
||||
#
|
||||
# Possible values:
|
||||
# - default: Initially focus on first non-cached value
|
||||
# - no-focus: No initial focus
|
||||
# - environment: Initially focus on the environment selector
|
||||
# - username: Initially focus on the username field
|
||||
# - password: Initially focus on the password field
|
||||
focus_behaviour = "default"
|
||||
|
||||
# General settings for background style
|
||||
[background]
|
||||
|
||||
# Control whether to render background widget or not
|
||||
show_background = false
|
||||
|
||||
[background.style]
|
||||
# Allow to set the default background color for the login shell
|
||||
color = "black"
|
||||
# Settings for the background block's borders
|
||||
show_border = true
|
||||
border_color = "white"
|
||||
|
||||
[power_controls]
|
||||
# The margin between hints
|
||||
hint_margin = 2
|
||||
|
||||
# There are no additional entries by default
|
||||
entries = []
|
||||
|
||||
# Example
|
||||
# Reboot to another os option
|
||||
#[[power_controls.entries]]
|
||||
## The text in the top-left to display how to reboot.
|
||||
#hint = "Reboot to OS"
|
||||
#
|
||||
## The color and modifiers of the hint in the top-left corner
|
||||
#hint_color = "dark gray"
|
||||
#hint_modifiers = ""
|
||||
#
|
||||
## The key used to reboot. Possibilities are F1 to F12.
|
||||
#key = "F3"
|
||||
## The command that is executed when the key is pressed
|
||||
#cmd = "efibootmgr -n0 && systemctl reboot -l"
|
||||
|
||||
|
||||
# If you want to remove the base_entries
|
||||
# base_entries = []
|
||||
|
||||
# Shutdown option
|
||||
[[power_controls.base_entries]]
|
||||
# The text in the top-left to display how to shutdown.
|
||||
hint = "Shutdown"
|
||||
|
||||
# The color and modifiers of the hint in the top-left corner
|
||||
hint_color = "dark gray"
|
||||
hint_modifiers = ""
|
||||
|
||||
# The key used to shutdown. Possibilities are F1 to F12.
|
||||
key = "F1"
|
||||
# The command that is executed when the key is pressed
|
||||
cmd = "systemctl poweroff -l"
|
||||
|
||||
# Reboot option
|
||||
[[power_controls.base_entries]]
|
||||
# The text in the top-left to display how to reboot.
|
||||
hint = "Reboot"
|
||||
|
||||
# The color and modifiers of the hint in the top-left corner
|
||||
hint_color = "dark gray"
|
||||
hint_modifiers = ""
|
||||
|
||||
# The key used to reboot. Possibilities are F1 to F12.
|
||||
key = "F2"
|
||||
# The command that is executed when the key is pressed
|
||||
cmd = "systemctl reboot -l"
|
||||
|
||||
# Setting for the selector of the desktop environment you are using.
|
||||
[environment_switcher]
|
||||
# Terms:
|
||||
# ---------
|
||||
# Movers: indicators which show which direction one can move whilst selecting
|
||||
# the desktop environment
|
||||
# Selected: The currently selected desktop environment.
|
||||
# Neighbours: The adjacent desktop environment to the one current selected
|
||||
#
|
||||
# Visualisation:
|
||||
#
|
||||
# < i3 bspwm awesome >
|
||||
#
|
||||
# ^ ^ ^ ^ ^
|
||||
# | | | | |
|
||||
# mover | selected | mover
|
||||
# | |
|
||||
# neighbour neighbour
|
||||
# ---------
|
||||
#
|
||||
|
||||
# Control the visibility of the switcher
|
||||
# Options:
|
||||
# - "visible" - Always show the switcher [default]
|
||||
# - "hidden" - Always hide the switcher
|
||||
# - [key] - F1-F12 to be able to toggle the visibility
|
||||
# mesh: hidden, as both workstations had it, but F3 shows it, so a second session can be chosen.
|
||||
switcher_visibility = "F3"
|
||||
|
||||
# The text in the top-left to display how to toggle the switcher. The text
|
||||
# '%key%' will be replaced with the switcher_visibility key. This is not shown
|
||||
# if switcher_visibility is set to "visible" or "hidden".
|
||||
toggle_hint = "Switcher %key%"
|
||||
|
||||
# The color and modifiers of the hint in the top-left corner
|
||||
toggle_hint_color = "dark gray"
|
||||
toggle_hint_modifiers = ""
|
||||
|
||||
|
||||
# Show an option for the TTY shell when logging in as one of the environments.
|
||||
# NOTE: it is always shown when no viable options are found.
|
||||
include_tty_shell = false
|
||||
|
||||
# Remember the selected environment after logging in for the next time
|
||||
remember = true
|
||||
|
||||
# Enables showing the movers
|
||||
show_movers = true
|
||||
|
||||
# Mover's color and modifiers whilst the selector is unfocused
|
||||
mover_color = "dark gray"
|
||||
mover_modifiers = ""
|
||||
|
||||
# Mover's color and modifiers whilst the selector is focused
|
||||
mover_color_focused = "orange"
|
||||
mover_modifiers_focused = "bold"
|
||||
|
||||
# The characters used to display the movers. Suggestions are:
|
||||
# - "<" ">"
|
||||
# - "<-" "->"
|
||||
# - "<<" ">>"
|
||||
# - "[" "]"
|
||||
left_mover = "<"
|
||||
right_mover = ">"
|
||||
|
||||
# The margin between the movers and the neighbours or selected (depending on
|
||||
# `show_neighbours`)
|
||||
mover_margin = 1
|
||||
|
||||
# Enables showing the neighbours
|
||||
show_neighbours = true
|
||||
|
||||
# Neighbours' color and modifiers whilst the selector is unfocused
|
||||
neighbour_color = "dark gray"
|
||||
neighbour_modifiers = ""
|
||||
|
||||
# Neighbours' color and modifiers whilst the selector is focused
|
||||
neighbour_color_focused = "gray"
|
||||
neighbour_modifiers_focused = ""
|
||||
|
||||
# Margin between neighbours and selected
|
||||
neighbour_margin = 1
|
||||
|
||||
# Selected's color and modifiers whilst the selector is unfocused
|
||||
selected_color = "gray"
|
||||
selected_modifiers = "underlined"
|
||||
|
||||
# Selected's color and modifiers whilst the selector is focused
|
||||
selected_color_focused = "white"
|
||||
selected_modifiers_focused = "bold"
|
||||
|
||||
# The length of the name of the desktop environment which is displayed.
|
||||
max_display_length = 8
|
||||
|
||||
# The text used when no desktop environments are available
|
||||
no_envs_text = "No environments..."
|
||||
|
||||
# The color and modifiers of the 'no desktop environments available text'
|
||||
# whilst the selector is unfocused
|
||||
no_envs_color = "white"
|
||||
no_envs_modifiers = ""
|
||||
|
||||
# The color and modifiers of the 'no desktop environments available text'
|
||||
# whilst the selector is focused
|
||||
no_envs_color_focused = "red"
|
||||
no_envs_modifiers_focused = ""
|
||||
|
||||
[username_field]
|
||||
|
||||
# Remember the username for the next time after a successful login attempt.
|
||||
remember = true
|
||||
|
||||
[username_field.style]
|
||||
# Enables showing a title
|
||||
show_title = true
|
||||
# The text used within the title
|
||||
title = "Login"
|
||||
|
||||
# The title's color and modifiers whilst the username field is unfocused
|
||||
title_color = "white"
|
||||
content_color = "white"
|
||||
|
||||
# The title's color and modifiers whilst the username field is focused
|
||||
title_color_focused = "orange"
|
||||
content_color_focused = "orange"
|
||||
|
||||
# Enables showing the borders
|
||||
show_border = true
|
||||
# The borders' color and modifiers whilst the username field is unfocused
|
||||
border_color = "white"
|
||||
# The borders' color and modifiers whilst the username field is focused
|
||||
border_color_focused = "orange"
|
||||
|
||||
# Constrain the width of the username field
|
||||
use_max_width = true
|
||||
# The constraint of the username field's width
|
||||
max_width = 48
|
||||
|
||||
[password_field]
|
||||
|
||||
# The character used for replacement when typing a password. Leave empty for no
|
||||
# feedback.
|
||||
# Note: Only one character is accepted.
|
||||
content_replacement_character = "*"
|
||||
|
||||
[password_field.style]
|
||||
# Enables showing a title
|
||||
show_title = true
|
||||
# The text used within the title
|
||||
title = "Password"
|
||||
|
||||
# The title's color and modifiers whilst the password field is unfocused
|
||||
title_color = "white"
|
||||
content_color = "white"
|
||||
|
||||
# The title's color and modifiers whilst the password field is focused
|
||||
title_color_focused = "orange"
|
||||
content_color_focused = "orange"
|
||||
|
||||
# Enables showing the borders
|
||||
show_border = true
|
||||
# The borders' color and modifiers whilst the password field is unfocused
|
||||
border_color = "white"
|
||||
# The borders' color and modifiers whilst the password field is focused
|
||||
border_color_focused = "orange"
|
||||
|
||||
# Constrain the width of the password field
|
||||
use_max_width = true
|
||||
# The constraint of the password field's width
|
||||
max_width = 48
|
||||
|
||||
[x11]
|
||||
# Where to log to for the XServer.
|
||||
xserver_log_path = "/var/log/lemurs.xorg.log"
|
||||
|
||||
# The value of the `DISPLAY` environment variable for X11 sessions
|
||||
x11_display = ":1"
|
||||
|
||||
# How many seconds to give the X server to start. To make it infinitely, put it
|
||||
# to 0.
|
||||
xserver_timeout_secs = 60
|
||||
|
||||
# Where to find the X11 server binary
|
||||
xserver_path = "/usr/bin/X"
|
||||
|
||||
# Where to find the X11 xauth binary
|
||||
xauth_path = "/usr/bin/xauth"
|
||||
|
||||
# Path to the directory where the startup scripts for the X11 sessions are found
|
||||
scripts_path = "/etc/lemurs/wms"
|
||||
|
||||
# Path to the xsetup script that is needed for the environment setup of the
|
||||
# window manager.
|
||||
xsetup_path = "/etc/lemurs/xsetup.sh"
|
||||
|
||||
# The directory to use for desktop entries X11 sessions.
|
||||
# mesh: an empty directory of the module's own, so no package's desktop entry is offered.
|
||||
xsessions_path = "/etc/lemurs/xsessions"
|
||||
|
||||
[wayland]
|
||||
# Path to the directory where the startup scripts for the Wayland sessions are
|
||||
# found
|
||||
scripts_path = "/etc/lemurs/wayland"
|
||||
|
||||
# The directory to use for desktop entries wayland sessions.
|
||||
# mesh: likewise for Wayland.
|
||||
wayland_sessions_path = "/etc/lemurs/wayland-sessions"
|
||||
@@ -0,0 +1,5 @@
|
||||
module lemurs
|
||||
|
||||
go 1.22
|
||||
|
||||
require git.novox.be/novox/mesh-sdk/go v0.1.7
|
||||
@@ -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=
|
||||
@@ -0,0 +1,160 @@
|
||||
package desktop
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Args reads a tool's arguments as JSON decoded them: strings, float64 numbers, booleans.
|
||||
type Args map[string]any
|
||||
|
||||
// Text is a required string, trimmed.
|
||||
func (a Args) Text(name string) (string, error) {
|
||||
v, ok := a[name].(string)
|
||||
if !ok || strings.TrimSpace(v) == "" {
|
||||
return "", fmt.Errorf("%s is required, as text", name)
|
||||
}
|
||||
return strings.TrimSpace(v), nil
|
||||
}
|
||||
|
||||
// Opt is an optional string, trimmed, or def.
|
||||
func (a Args) Opt(name, def string) string {
|
||||
if v, ok := a[name].(string); ok && strings.TrimSpace(v) != "" {
|
||||
return strings.TrimSpace(v)
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
// Has is whether the caller gave the argument at all.
|
||||
func (a Args) Has(name string) bool {
|
||||
v, ok := a[name]
|
||||
return ok && v != nil
|
||||
}
|
||||
|
||||
// Bool is an optional boolean: its value, and whether it was given.
|
||||
func (a Args) Bool(name string) (bool, bool, error) {
|
||||
v, ok := a[name]
|
||||
if !ok || v == nil {
|
||||
return false, false, nil
|
||||
}
|
||||
b, isBool := v.(bool)
|
||||
if !isBool {
|
||||
return false, false, fmt.Errorf("%s is true or false", name)
|
||||
}
|
||||
return b, true, nil
|
||||
}
|
||||
|
||||
// Number is an optional number: its value, and whether it was given.
|
||||
func (a Args) Number(name string) (float64, bool, error) {
|
||||
v, ok := a[name]
|
||||
if !ok || v == nil {
|
||||
return 0, false, nil
|
||||
}
|
||||
f, isNum := v.(float64)
|
||||
if !isNum || math.IsNaN(f) || math.IsInf(f, 0) {
|
||||
return 0, false, fmt.Errorf("%s is a number", name)
|
||||
}
|
||||
return f, true, nil
|
||||
}
|
||||
|
||||
// Whole is an optional whole number within [lo, hi], or def.
|
||||
func (a Args) Whole(name string, def, lo, hi int) (int, error) {
|
||||
f, given, err := a.Number(name)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if !given {
|
||||
return def, nil
|
||||
}
|
||||
if f != math.Trunc(f) || f < float64(lo) || f > float64(hi) {
|
||||
return 0, fmt.Errorf("%s is a whole number from %d to %d", name, lo, hi)
|
||||
}
|
||||
return int(f), nil
|
||||
}
|
||||
|
||||
// OneOf is an optional string that must be one of choices, or def.
|
||||
func (a Args) OneOf(name, def string, choices ...string) (string, error) {
|
||||
v := a.Opt(name, def)
|
||||
for _, c := range choices {
|
||||
if v == c {
|
||||
return v, nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("%s is one of %s", name, strings.Join(choices, ", "))
|
||||
}
|
||||
|
||||
// Strings is an optional list of strings.
|
||||
func (a Args) Strings(name string) ([]string, error) {
|
||||
v, ok := a[name]
|
||||
if !ok || v == nil {
|
||||
return nil, nil
|
||||
}
|
||||
list, isList := v.([]any)
|
||||
if !isList {
|
||||
return nil, fmt.Errorf("%s is a list of text", name)
|
||||
}
|
||||
out := make([]string, 0, len(list))
|
||||
for _, x := range list {
|
||||
s, isText := x.(string)
|
||||
if !isText {
|
||||
return nil, fmt.Errorf("%s is a list of text", name)
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Home is the operator account's home: the runtime's word for it, else this process's.
|
||||
func Home() string {
|
||||
if h := os.Getenv("MESH_OPERATOR_HOME"); h != "" {
|
||||
return h
|
||||
}
|
||||
if h, err := os.UserHomeDir(); err == nil {
|
||||
return h
|
||||
}
|
||||
return "/"
|
||||
}
|
||||
|
||||
// InHome resolves a path the caller gave: `~/x` and a relative path are under the home. A path
|
||||
// that leaves the home through `..` is refused, so a tool that writes never writes outside it.
|
||||
func InHome(path string) (string, error) {
|
||||
home := Home()
|
||||
switch {
|
||||
case path == "~":
|
||||
path = home
|
||||
case strings.HasPrefix(path, "~/"):
|
||||
path = filepath.Join(home, path[2:])
|
||||
case !filepath.IsAbs(path):
|
||||
path = filepath.Join(home, path)
|
||||
}
|
||||
path = filepath.Clean(path)
|
||||
if path != home && !strings.HasPrefix(path, home+string(filepath.Separator)) {
|
||||
return "", fmt.Errorf("%s is outside the account's home", path)
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
|
||||
// Schema builds a tool's input schema from property descriptions; required names those that must
|
||||
// be given. A property is a string unless its description object says otherwise.
|
||||
func Schema(props map[string]any, required ...string) map[string]any {
|
||||
s := map[string]any{"type": "object", "properties": props}
|
||||
if len(required) > 0 {
|
||||
s["required"] = required
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// Str, Num, Flag, List and Enum describe one property.
|
||||
func Str(desc string) map[string]any { return map[string]any{"type": "string", "description": desc} }
|
||||
func Num(desc string) map[string]any { return map[string]any{"type": "number", "description": desc} }
|
||||
func Int(desc string) map[string]any { return map[string]any{"type": "integer", "description": desc} }
|
||||
func Flag(desc string) map[string]any { return map[string]any{"type": "boolean", "description": desc} }
|
||||
func List(desc string) map[string]any {
|
||||
return map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "description": desc}
|
||||
}
|
||||
func Enum(desc string, values ...string) map[string]any {
|
||||
return map[string]any{"type": "string", "enum": values, "description": desc}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package desktop
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The desktop modules that carry this package. Each builds alone, so each has its own copy; this
|
||||
// test, itself one of the copied files, holds them to one text wherever the siblings are present.
|
||||
var carriers = []string{"xorg", "lemurs", "i3", "xterm", "adwaita"}
|
||||
|
||||
func TestEveryDesktopModuleCarriesTheSameCopy(t *testing.T) {
|
||||
mine, err := filepath.Glob("*.go")
|
||||
if err != nil || len(mine) == 0 {
|
||||
t.Fatal("no files of this package found", err)
|
||||
}
|
||||
compared := 0
|
||||
for _, module := range carriers {
|
||||
dir := filepath.Join("..", "..", "..", module, "internal", "desktop")
|
||||
if _, err := os.Stat(dir); err != nil {
|
||||
continue
|
||||
}
|
||||
theirs, _ := filepath.Glob(filepath.Join(dir, "*.go"))
|
||||
if len(theirs) != len(mine) {
|
||||
t.Errorf("%s carries %d files of this package, this copy %d", module, len(theirs), len(mine))
|
||||
continue
|
||||
}
|
||||
for _, f := range mine {
|
||||
a, _ := os.ReadFile(f)
|
||||
b, err := os.ReadFile(filepath.Join(dir, f))
|
||||
if err != nil || !bytes.Equal(a, b) {
|
||||
t.Errorf("%s's copy of %s differs from this one: change every copy together", module, f)
|
||||
}
|
||||
}
|
||||
compared++
|
||||
}
|
||||
if compared == 0 {
|
||||
t.Log("no sibling copies beside this module")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
package desktop
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Bounds on a command a tool runs: well below the runtime's 30 s call limit, and an answer that
|
||||
// fits in a tool's reply.
|
||||
const (
|
||||
DefaultTimeout = 10 * time.Second
|
||||
MostOutput = 256 << 10
|
||||
)
|
||||
|
||||
// Result is what one command did.
|
||||
type Result struct {
|
||||
Command []string `json:"command"`
|
||||
Code int `json:"exit_code"`
|
||||
Stdout string `json:"stdout,omitempty"`
|
||||
Stderr string `json:"stderr,omitempty"`
|
||||
Truncated bool `json:"truncated,omitempty"`
|
||||
TimedOut bool `json:"timed_out,omitempty"`
|
||||
}
|
||||
|
||||
// OK is whether the command ran and exited 0.
|
||||
func (r Result) OK() bool { return r.Code == 0 && !r.TimedOut }
|
||||
|
||||
// Err is the command's failure as an error naming it and what it said, or nil.
|
||||
func (r Result) Err() error {
|
||||
if r.OK() {
|
||||
return nil
|
||||
}
|
||||
said := strings.TrimSpace(r.Stderr)
|
||||
if said == "" {
|
||||
said = strings.TrimSpace(r.Stdout)
|
||||
}
|
||||
if r.TimedOut {
|
||||
return fmt.Errorf("%s did not finish in time", strings.Join(r.Command, " "))
|
||||
}
|
||||
return fmt.Errorf("%s exited %d: %s", strings.Join(r.Command, " "), r.Code, said)
|
||||
}
|
||||
|
||||
// Runner runs a command with an environment and answers what it did. Tools take one, so their
|
||||
// tests replace the machine with a table of answers.
|
||||
type Runner func(ctx context.Context, env []string, stdin []byte, name string, args ...string) Result
|
||||
|
||||
// Exec is the machine's Runner: the command in its own process group, ended with everything it
|
||||
// started at the deadline, each stream cut at MostOutput.
|
||||
func Exec(ctx context.Context, env []string, stdin []byte, name string, args ...string) Result {
|
||||
if _, ok := ctx.Deadline(); !ok {
|
||||
var cancel context.CancelFunc
|
||||
ctx, cancel = context.WithTimeout(ctx, DefaultTimeout)
|
||||
defer cancel()
|
||||
}
|
||||
res := Result{Command: append([]string{name}, args...)}
|
||||
cmd := exec.Command(name, args...)
|
||||
cmd.Env = env
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||
if stdin != nil {
|
||||
cmd.Stdin = bytes.NewReader(stdin)
|
||||
}
|
||||
out, errb := &capped{}, &capped{}
|
||||
cmd.Stdout, cmd.Stderr = out, errb
|
||||
if err := cmd.Start(); err != nil {
|
||||
res.Code = 127
|
||||
res.Stderr = err.Error()
|
||||
return res
|
||||
}
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- cmd.Wait() }()
|
||||
var err error
|
||||
select {
|
||||
case err = <-done:
|
||||
case <-ctx.Done():
|
||||
_ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
|
||||
err = <-done
|
||||
res.TimedOut = true
|
||||
}
|
||||
res.Stdout, res.Stderr = out.String(), errb.String()
|
||||
res.Truncated = out.cut || errb.cut
|
||||
var exit *exec.ExitError
|
||||
switch {
|
||||
case err == nil:
|
||||
case errors.As(err, &exit):
|
||||
res.Code = exit.ExitCode()
|
||||
if res.Code < 0 {
|
||||
res.Code = 128
|
||||
}
|
||||
default:
|
||||
res.Code = 1
|
||||
if res.Stderr == "" {
|
||||
res.Stderr = err.Error()
|
||||
}
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
// capped keeps the first MostOutput bytes written to it. Its buffer is a field, not embedded: an
|
||||
// embedded bytes.Buffer brings ReadFrom along, and io.Copy would use it and never call Write.
|
||||
type capped struct {
|
||||
buf bytes.Buffer
|
||||
cut bool
|
||||
}
|
||||
|
||||
func (c *capped) Write(p []byte) (int, error) {
|
||||
if room := MostOutput - c.buf.Len(); room < len(p) {
|
||||
if room > 0 {
|
||||
c.buf.Write(p[:room])
|
||||
}
|
||||
c.cut = true
|
||||
return len(p), nil
|
||||
}
|
||||
return c.buf.Write(p)
|
||||
}
|
||||
|
||||
func (c *capped) String() string { return c.buf.String() }
|
||||
|
||||
// Desk is what a desktop tool needs: how to find the session, and how to run a command.
|
||||
type Desk struct {
|
||||
Find func() (*Session, error)
|
||||
Run Runner
|
||||
// Base is the environment a command starts from, before the session's words.
|
||||
Base []string
|
||||
}
|
||||
|
||||
// Machine is the real Desk, preferring the named processes as the session's.
|
||||
func Machine(prefer ...string) Desk {
|
||||
return Desk{
|
||||
Find: func() (*Session, error) { return Find(prefer...) },
|
||||
Run: Exec,
|
||||
Base: os.Environ(),
|
||||
}
|
||||
}
|
||||
|
||||
// InSession runs a command in the operator's session, or answers NoSession.
|
||||
func (d Desk) InSession(ctx context.Context, name string, args ...string) (Result, *Session, error) {
|
||||
s, err := d.Find()
|
||||
if err != nil {
|
||||
return Result{}, nil, err
|
||||
}
|
||||
return d.Run(ctx, s.Env(d.Base), nil, name, args...), s, nil
|
||||
}
|
||||
|
||||
// InSessionWith is InSession with standard input.
|
||||
func (d Desk) InSessionWith(ctx context.Context, stdin []byte, name string, args ...string) (Result, *Session, error) {
|
||||
s, err := d.Find()
|
||||
if err != nil {
|
||||
return Result{}, nil, err
|
||||
}
|
||||
return d.Run(ctx, s.Env(d.Base), stdin, name, args...), s, nil
|
||||
}
|
||||
|
||||
// Plain runs a command with the base environment: for what needs no session.
|
||||
func (d Desk) Plain(ctx context.Context, name string, args ...string) Result {
|
||||
return d.Run(ctx, d.Base, nil, name, args...)
|
||||
}
|
||||
|
||||
// AsUser runs a command with the account's own runtime directory and bus, and no display.
|
||||
func (d Desk) AsUser(ctx context.Context, name string, args ...string) Result {
|
||||
return d.Run(ctx, UserEnv(d.Base, os.Getuid()), nil, name, args...)
|
||||
}
|
||||
|
||||
// Launched is how a program was started in the session.
|
||||
type Launched struct {
|
||||
Unit string `json:"unit,omitempty"`
|
||||
PID int `json:"pid,omitempty"`
|
||||
How string `json:"how"`
|
||||
}
|
||||
|
||||
// Launch starts a program in the operator's session that outlives the call and the runtime.
|
||||
//
|
||||
// **Not as a child of this process.** The runtime is a system service; everything it starts is in
|
||||
// its control group, and the service manager ends that group whenever the runtime restarts — which
|
||||
// is every push that changes it. So the program is handed to the account's own service manager as a
|
||||
// transient unit (`systemd-run --user`), with the session's words set on it, and lives as long as the
|
||||
// operator's user manager does. Without a user manager it is started detached as a last resort, and
|
||||
// the answer says it will end with the runtime.
|
||||
func (d Desk) Launch(ctx context.Context, s *Session, name string, argv ...string) (Launched, error) {
|
||||
if len(argv) == 0 {
|
||||
return Launched{}, errors.New("nothing to launch")
|
||||
}
|
||||
env := s.Env(d.Base)
|
||||
unit := "mesh-" + name + "-" + token()
|
||||
args := []string{"--user", "--collect", "--quiet", "--unit=" + unit}
|
||||
for _, w := range []string{"DISPLAY", "WAYLAND_DISPLAY", "XAUTHORITY", "XDG_SESSION_TYPE", "XDG_CURRENT_DESKTOP", "XDG_SESSION_DESKTOP", "I3SOCK", "SWAYSOCK"} {
|
||||
if v := lookup(env, w); v != "" {
|
||||
args = append(args, "--setenv="+w+"="+v)
|
||||
}
|
||||
}
|
||||
args = append(args, "--")
|
||||
args = append(args, argv...)
|
||||
res := d.Run(ctx, env, nil, "systemd-run", args...)
|
||||
if res.OK() {
|
||||
return Launched{Unit: unit, How: "a transient unit of the account's service manager; ends when it exits or when the operator logs out"}, nil
|
||||
}
|
||||
if s.Bus != "" {
|
||||
return Launched{}, res.Err()
|
||||
}
|
||||
cmd := exec.Command(argv[0], argv[1:]...)
|
||||
cmd.Env = env
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
|
||||
if err := cmd.Start(); err != nil {
|
||||
return Launched{}, err
|
||||
}
|
||||
pid := cmd.Process.Pid
|
||||
go func() { _ = cmd.Wait() }()
|
||||
return Launched{PID: pid, How: "detached from the runtime with no user manager to hand it to; it ends when the runtime restarts"}, nil
|
||||
}
|
||||
|
||||
func lookup(env []string, name string) string {
|
||||
for i := len(env) - 1; i >= 0; i-- {
|
||||
if k, v, ok := strings.Cut(env[i], "="); ok && k == name {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func token() string {
|
||||
b := make([]byte, 4)
|
||||
_, _ = rand.Read(b)
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
@@ -0,0 +1,445 @@
|
||||
// Package desktop is how a desktop module's tools act in the operator's graphical session
|
||||
// (novox/hq ADR 0208, research 026/05).
|
||||
//
|
||||
// **One question, answered once for every desktop tool.** A tool runs inside the node's runtime: a
|
||||
// process of node-tools.service, started by the system's service manager as the operator account,
|
||||
// with no session around it — no DISPLAY, no XAUTHORITY, no session bus. The session it must act in
|
||||
// was started elsewhere, by the login manager, and the only place its values are written down is
|
||||
// the environment of the processes it started. So this package finds the session the way a person
|
||||
// would: it looks at the operator account's own processes, takes the one that is plainly the
|
||||
// session's (the window manager, or the oldest process carrying a display), confirms with logind
|
||||
// that its session is a live local one, and checks that the display's socket is really there.
|
||||
//
|
||||
// **Only the session's own words are read.** A session's processes also carry whatever its start
|
||||
// script exported — on the workstations that was a file of secrets — so the environment is filtered
|
||||
// to a fixed list of names while it is read, and nothing else ever leaves /proc.
|
||||
//
|
||||
// The D-Bus address handed on is the user manager's socket, `unix:path=$XDG_RUNTIME_DIR/bus`,
|
||||
// whenever it exists, because that is where the portal, the notifier and every user service
|
||||
// listen. A session started on a private bus (a stale session, measured on one workstation) is
|
||||
// reported as `session_bus` beside it, so the difference is visible rather than guessed at.
|
||||
//
|
||||
// The same copy of this package is vendored into every desktop module (xorg, lemurs, i3, xterm,
|
||||
// adwaita); the catalogue builds each module alone, so it cannot be imported across them. Change
|
||||
// every copy together — the modules' tests compare them.
|
||||
package desktop
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
// SessionWords are the only environment words read from a session's process: the ones that say
|
||||
// where the session is. Everything else in that environment is the operator's, and is never read.
|
||||
var SessionWords = []string{
|
||||
"DISPLAY", "WAYLAND_DISPLAY", "XAUTHORITY",
|
||||
"XDG_SESSION_ID", "XDG_SESSION_TYPE", "XDG_SESSION_DESKTOP", "XDG_CURRENT_DESKTOP",
|
||||
"XDG_RUNTIME_DIR", "DBUS_SESSION_BUS_ADDRESS", "XDG_SEAT", "XDG_VTNR",
|
||||
"I3SOCK", "SWAYSOCK",
|
||||
}
|
||||
|
||||
// Session is the operator's running graphical session, as a tool needs it.
|
||||
type Session struct {
|
||||
UID int `json:"uid"`
|
||||
ID string `json:"session_id,omitempty"`
|
||||
Type string `json:"type"`
|
||||
Display string `json:"display,omitempty"`
|
||||
WaylandDisplay string `json:"wayland_display,omitempty"`
|
||||
XAuthority string `json:"xauthority,omitempty"`
|
||||
RuntimeDir string `json:"runtime_dir"`
|
||||
Bus string `json:"bus,omitempty"`
|
||||
SessionBus string `json:"session_bus,omitempty"`
|
||||
Desktop string `json:"desktop,omitempty"`
|
||||
// FoundIn is the process whose environment named the session.
|
||||
FoundIn Process `json:"found_in"`
|
||||
// Active is logind's word on the session, when logind answered.
|
||||
Active *bool `json:"active,omitempty"`
|
||||
|
||||
words map[string]string
|
||||
}
|
||||
|
||||
// Process is one process the search looked at.
|
||||
type Process struct {
|
||||
PID int `json:"pid"`
|
||||
Command string `json:"command"`
|
||||
start uint64
|
||||
}
|
||||
|
||||
// NoSession is the answer when there is no graphical session to act in. Its text is JSON, so a tool
|
||||
// that returns it as its error still answers structured data.
|
||||
type NoSession struct {
|
||||
Reason string `json:"reason"`
|
||||
Looked []string `json:"looked"`
|
||||
}
|
||||
|
||||
func (e *NoSession) Error() string {
|
||||
b, _ := json.Marshal(map[string]any{"error": "no-graphical-session", "reason": e.Reason, "looked": e.Looked})
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// IsNoSession is whether err says there is no session.
|
||||
func IsNoSession(err error) bool {
|
||||
var n *NoSession
|
||||
return errors.As(err, &n)
|
||||
}
|
||||
|
||||
// Finder holds where the search looks, so a test can point it at a tree of its own.
|
||||
type Finder struct {
|
||||
Proc string // the process table: /proc
|
||||
X11Sockets string // where X servers listen: /tmp/.X11-unix
|
||||
RuntimeBase string // the parent of every XDG_RUNTIME_DIR: /run/user
|
||||
UID int // whose session
|
||||
// Prefer names the processes that are the session's own, best first: the session's holder.
|
||||
Prefer []string
|
||||
// Logind answers `loginctl show-session` for one id; nil skips the check.
|
||||
Logind func(id string) (map[string]string, error)
|
||||
}
|
||||
|
||||
// DefaultFinder is the machine's: the account this process runs as, or — when it runs as root — the
|
||||
// operator account the runtime names (MESH_OPERATOR_ACCOUNT).
|
||||
func DefaultFinder(prefer ...string) Finder {
|
||||
uid := os.Getuid()
|
||||
if uid == 0 {
|
||||
if name := os.Getenv("MESH_OPERATOR_ACCOUNT"); name != "" {
|
||||
if u, err := user.Lookup(name); err == nil {
|
||||
if n, err := strconv.Atoi(u.Uid); err == nil {
|
||||
uid = n
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return Finder{
|
||||
Proc: "/proc", X11Sockets: "/tmp/.X11-unix", RuntimeBase: "/run/user",
|
||||
UID: uid, Prefer: prefer, Logind: loginctl,
|
||||
}
|
||||
}
|
||||
|
||||
// Find is the operator's session on this machine, preferring a process named in prefer.
|
||||
func Find(prefer ...string) (*Session, error) {
|
||||
return DefaultFinder(prefer...).Find()
|
||||
}
|
||||
|
||||
type candidate struct {
|
||||
proc Process
|
||||
words map[string]string
|
||||
rank int
|
||||
logind map[string]string
|
||||
}
|
||||
|
||||
// Find looks for the session.
|
||||
func (f Finder) Find() (*Session, error) {
|
||||
entries, err := os.ReadDir(f.Proc)
|
||||
if err != nil {
|
||||
return nil, &NoSession{Reason: "the process table cannot be read: " + err.Error(), Looked: []string{f.Proc}}
|
||||
}
|
||||
looked := []string{fmt.Sprintf("the processes of uid %d in %s", f.UID, f.Proc)}
|
||||
var found []candidate
|
||||
stale := 0
|
||||
for _, e := range entries {
|
||||
pid, err := strconv.Atoi(e.Name())
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
dir := filepath.Join(f.Proc, e.Name())
|
||||
info, err := os.Stat(dir)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if st, ok := info.Sys().(*syscall.Stat_t); !ok || int(st.Uid) != f.UID {
|
||||
continue
|
||||
}
|
||||
words := readWords(filepath.Join(dir, "environ"))
|
||||
if words["DISPLAY"] == "" && words["WAYLAND_DISPLAY"] == "" {
|
||||
continue
|
||||
}
|
||||
if !f.reachable(words) {
|
||||
stale++
|
||||
continue
|
||||
}
|
||||
found = append(found, candidate{proc: Process{PID: pid, Command: comm(dir), start: startTime(dir)}, words: words})
|
||||
}
|
||||
if len(found) == 0 {
|
||||
reason := fmt.Sprintf("no process of uid %d carries a display", f.UID)
|
||||
if stale > 0 {
|
||||
reason = fmt.Sprintf("%d process(es) of uid %d name a display whose socket is gone: the session they belonged to has ended", stale, f.UID)
|
||||
}
|
||||
return nil, &NoSession{Reason: reason, Looked: append(looked, f.X11Sockets, f.RuntimeBase)}
|
||||
}
|
||||
|
||||
// logind's word on each session the candidates name, asked once per session.
|
||||
asked := map[string]map[string]string{}
|
||||
for i := range found {
|
||||
id := found[i].words["XDG_SESSION_ID"]
|
||||
if f.Logind == nil || id == "" {
|
||||
found[i].rank = 1
|
||||
continue
|
||||
}
|
||||
props, done := asked[id]
|
||||
if !done {
|
||||
props, _ = f.Logind(id)
|
||||
asked[id] = props
|
||||
}
|
||||
found[i].logind = props
|
||||
switch {
|
||||
case props == nil:
|
||||
found[i].rank = 1
|
||||
case props["Remote"] == "yes":
|
||||
found[i].rank = 3
|
||||
case props["Active"] == "yes" && props["State"] != "closing":
|
||||
found[i].rank = 0
|
||||
case props["State"] == "closing":
|
||||
found[i].rank = 3
|
||||
default:
|
||||
found[i].rank = 2
|
||||
}
|
||||
}
|
||||
if f.Logind != nil {
|
||||
looked = append(looked, "logind's sessions")
|
||||
}
|
||||
preferred := func(c candidate) int {
|
||||
for i, p := range f.Prefer {
|
||||
if c.proc.Command == p {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return len(f.Prefer)
|
||||
}
|
||||
sort.SliceStable(found, func(i, j int) bool {
|
||||
a, b := found[i], found[j]
|
||||
if a.rank != b.rank {
|
||||
return a.rank < b.rank
|
||||
}
|
||||
if pa, pb := preferred(a), preferred(b); pa != pb {
|
||||
return pa < pb
|
||||
}
|
||||
if a.proc.start != b.proc.start {
|
||||
return a.proc.start < b.proc.start
|
||||
}
|
||||
return a.proc.PID < b.proc.PID
|
||||
})
|
||||
best := found[0]
|
||||
if best.rank == 3 {
|
||||
return nil, &NoSession{Reason: "the only sessions found are remote or closing", Looked: looked}
|
||||
}
|
||||
return f.session(best), nil
|
||||
}
|
||||
|
||||
func (f Finder) session(c candidate) *Session {
|
||||
w := c.words
|
||||
s := &Session{
|
||||
UID: f.UID, ID: w["XDG_SESSION_ID"], Display: w["DISPLAY"], WaylandDisplay: w["WAYLAND_DISPLAY"],
|
||||
XAuthority: w["XAUTHORITY"], RuntimeDir: w["XDG_RUNTIME_DIR"], FoundIn: c.proc, words: w,
|
||||
}
|
||||
s.Desktop = w["XDG_CURRENT_DESKTOP"]
|
||||
if s.Desktop == "" {
|
||||
s.Desktop = w["XDG_SESSION_DESKTOP"]
|
||||
}
|
||||
switch {
|
||||
case w["XDG_SESSION_TYPE"] != "":
|
||||
s.Type = w["XDG_SESSION_TYPE"]
|
||||
case s.WaylandDisplay != "":
|
||||
s.Type = "wayland"
|
||||
default:
|
||||
s.Type = "x11"
|
||||
}
|
||||
if s.RuntimeDir == "" {
|
||||
s.RuntimeDir = filepath.Join(f.RuntimeBase, strconv.Itoa(f.UID))
|
||||
}
|
||||
if isSocket(filepath.Join(s.RuntimeDir, "bus")) {
|
||||
s.Bus = "unix:path=" + filepath.Join(s.RuntimeDir, "bus")
|
||||
}
|
||||
if own := w["DBUS_SESSION_BUS_ADDRESS"]; own != "" && own != s.Bus {
|
||||
s.SessionBus = own
|
||||
}
|
||||
if c.logind != nil {
|
||||
active := c.logind["Active"] == "yes"
|
||||
s.Active = &active
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// reachable is whether the display a process names is still served: the X server's socket, or the
|
||||
// Wayland compositor's. A process outliving its session still carries the session's words.
|
||||
func (f Finder) reachable(w map[string]string) bool {
|
||||
if d := w["WAYLAND_DISPLAY"]; d != "" {
|
||||
path := d
|
||||
if !filepath.IsAbs(d) {
|
||||
dir := w["XDG_RUNTIME_DIR"]
|
||||
if dir == "" {
|
||||
dir = filepath.Join(f.RuntimeBase, strconv.Itoa(f.UID))
|
||||
}
|
||||
path = filepath.Join(dir, d)
|
||||
}
|
||||
if isSocket(path) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
n, ok := DisplayNumber(w["DISPLAY"])
|
||||
return ok && isSocket(filepath.Join(f.X11Sockets, "X"+strconv.Itoa(n)))
|
||||
}
|
||||
|
||||
// DisplayNumber is the server number of a local X display (":1", ":1.0", "unix:1"); a display on
|
||||
// another host — an ssh session's forwarded one — is not the local session and answers false.
|
||||
func DisplayNumber(display string) (int, bool) {
|
||||
host, rest, ok := strings.Cut(display, ":")
|
||||
if !ok || (host != "" && host != "unix") {
|
||||
return 0, false
|
||||
}
|
||||
num, _, _ := strings.Cut(rest, ".")
|
||||
n, err := strconv.Atoi(num)
|
||||
if err != nil || n < 0 {
|
||||
return 0, false
|
||||
}
|
||||
return n, true
|
||||
}
|
||||
|
||||
// Word is one of the session's words as its process had it ("" when it had none).
|
||||
func (s *Session) Word(name string) string { return s.words[name] }
|
||||
|
||||
// Env is base with the session's words in place of whatever base said for them.
|
||||
func (s *Session) Env(base []string) []string {
|
||||
drop := map[string]bool{}
|
||||
for _, w := range SessionWords {
|
||||
drop[w] = true
|
||||
}
|
||||
out := make([]string, 0, len(base)+8)
|
||||
for _, kv := range base {
|
||||
k, _, _ := strings.Cut(kv, "=")
|
||||
if !drop[k] {
|
||||
out = append(out, kv)
|
||||
}
|
||||
}
|
||||
bus := s.Bus
|
||||
if bus == "" {
|
||||
bus = s.SessionBus
|
||||
}
|
||||
for _, kv := range [][2]string{
|
||||
{"DISPLAY", s.Display}, {"WAYLAND_DISPLAY", s.WaylandDisplay}, {"XAUTHORITY", s.XAuthority},
|
||||
{"XDG_RUNTIME_DIR", s.RuntimeDir}, {"DBUS_SESSION_BUS_ADDRESS", bus},
|
||||
{"XDG_SESSION_TYPE", s.Type}, {"XDG_SESSION_ID", s.ID},
|
||||
{"XDG_CURRENT_DESKTOP", s.words["XDG_CURRENT_DESKTOP"]},
|
||||
{"XDG_SESSION_DESKTOP", s.words["XDG_SESSION_DESKTOP"]},
|
||||
{"I3SOCK", s.words["I3SOCK"]}, {"SWAYSOCK", s.words["SWAYSOCK"]},
|
||||
} {
|
||||
if kv[1] != "" {
|
||||
out = append(out, kv[0]+"="+kv[1])
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// UserEnv is base with the account's own runtime directory and bus, for a tool that talks to the
|
||||
// user manager or the session bus and needs no display — it works with no session at all.
|
||||
func UserEnv(base []string, uid int) []string {
|
||||
dir := filepath.Join("/run/user", strconv.Itoa(uid))
|
||||
out := make([]string, 0, len(base)+2)
|
||||
for _, kv := range base {
|
||||
k, _, _ := strings.Cut(kv, "=")
|
||||
if k != "XDG_RUNTIME_DIR" && k != "DBUS_SESSION_BUS_ADDRESS" {
|
||||
out = append(out, kv)
|
||||
}
|
||||
}
|
||||
return append(out, "XDG_RUNTIME_DIR="+dir, "DBUS_SESSION_BUS_ADDRESS=unix:path="+filepath.Join(dir, "bus"))
|
||||
}
|
||||
|
||||
// readWords reads a process's environment and keeps only SessionWords.
|
||||
func readWords(path string) map[string]string {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
keep := map[string]bool{}
|
||||
for _, w := range SessionWords {
|
||||
keep[w] = true
|
||||
}
|
||||
out := map[string]string{}
|
||||
for _, kv := range bytes.Split(raw, []byte{0}) {
|
||||
k, v, ok := bytes.Cut(kv, []byte{'='})
|
||||
if ok && keep[string(k)] {
|
||||
out[string(k)] = string(v)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func comm(dir string) string {
|
||||
b, err := os.ReadFile(filepath.Join(dir, "comm"))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(string(b))
|
||||
}
|
||||
|
||||
// startTime is field 22 of /proc/<pid>/stat: when the process started, in clock ticks since boot.
|
||||
// Read after the command's closing parenthesis, because the command may hold spaces.
|
||||
func startTime(dir string) uint64 {
|
||||
b, err := os.ReadFile(filepath.Join(dir, "stat"))
|
||||
if err != nil {
|
||||
return ^uint64(0)
|
||||
}
|
||||
i := bytes.LastIndexByte(b, ')')
|
||||
if i < 0 {
|
||||
return ^uint64(0)
|
||||
}
|
||||
fields := strings.Fields(string(b[i+1:]))
|
||||
// fields[0] is the state, field 3 of the line; start time is field 22.
|
||||
if len(fields) < 20 {
|
||||
return ^uint64(0)
|
||||
}
|
||||
n, err := strconv.ParseUint(fields[19], 10, 64)
|
||||
if err != nil {
|
||||
return ^uint64(0)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func isSocket(path string) bool {
|
||||
info, err := os.Stat(path)
|
||||
return err == nil && info.Mode()&os.ModeSocket != 0
|
||||
}
|
||||
|
||||
// loginctl asks logind about one session, by its property lines.
|
||||
func loginctl(id string) (map[string]string, error) {
|
||||
cmd := exec.Command("loginctl", "show-session", id, "-p", "Active", "-p", "State", "-p", "Remote", "-p", "Type", "-p", "Class")
|
||||
var out bytes.Buffer
|
||||
cmd.Stdout = &out
|
||||
done := make(chan error, 1)
|
||||
if err := cmd.Start(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
go func() { done <- cmd.Wait() }()
|
||||
select {
|
||||
case err := <-done:
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
case <-time.After(3 * time.Second):
|
||||
_ = cmd.Process.Kill()
|
||||
return nil, errors.New("loginctl did not answer in 3s")
|
||||
}
|
||||
return ParseProperties(out.String()), nil
|
||||
}
|
||||
|
||||
// ParseProperties reads `Key=Value` lines, as loginctl and systemctl show print them.
|
||||
func ParseProperties(text string) map[string]string {
|
||||
out := map[string]string{}
|
||||
sc := bufio.NewScanner(strings.NewReader(text))
|
||||
for sc.Scan() {
|
||||
if k, v, ok := strings.Cut(sc.Text(), "="); ok {
|
||||
out[k] = v
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
package desktop
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A machine in a directory: a process table, the X servers' socket directory and a runtime base.
|
||||
type fakeMachine struct {
|
||||
t *testing.T
|
||||
proc, x11, runtime string
|
||||
uid int
|
||||
}
|
||||
|
||||
func newMachine(t *testing.T) *fakeMachine {
|
||||
root, err := os.MkdirTemp("", "desk")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { os.RemoveAll(root) })
|
||||
m := &fakeMachine{t: t, proc: filepath.Join(root, "p"), x11: filepath.Join(root, "x"), runtime: filepath.Join(root, "r"), uid: os.Getuid()}
|
||||
for _, d := range []string{m.proc, m.x11, filepath.Join(m.runtime, strconv.Itoa(m.uid))} {
|
||||
if err := os.MkdirAll(d, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func (m *fakeMachine) socket(path string) {
|
||||
l, err := net.Listen("unix", path)
|
||||
if err != nil {
|
||||
m.t.Fatal(err)
|
||||
}
|
||||
m.t.Cleanup(func() { l.Close() })
|
||||
}
|
||||
|
||||
func (m *fakeMachine) process(pid int, comm string, start int, env ...string) {
|
||||
dir := filepath.Join(m.proc, strconv.Itoa(pid))
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
m.t.Fatal(err)
|
||||
}
|
||||
os.WriteFile(filepath.Join(dir, "environ"), []byte(strings.Join(env, "\x00")+"\x00"), 0o600)
|
||||
os.WriteFile(filepath.Join(dir, "comm"), []byte(comm+"\n"), 0o644)
|
||||
// pid (comm) state ppid pgrp session tty tpgid flags minflt cminflt majflt cmajflt utime stime
|
||||
// cutime cstime priority nice threads itrealvalue starttime ...
|
||||
stat := strconv.Itoa(pid) + " (" + comm + ") S 1 1 1 0 -1 0 0 0 0 0 0 0 0 0 20 0 1 0 " + strconv.Itoa(start) + " 0 0"
|
||||
os.WriteFile(filepath.Join(dir, "stat"), []byte(stat), 0o644)
|
||||
}
|
||||
|
||||
func (m *fakeMachine) finder(logind func(string) (map[string]string, error), prefer ...string) Finder {
|
||||
return Finder{Proc: m.proc, X11Sockets: m.x11, RuntimeBase: m.runtime, UID: m.uid, Prefer: prefer, Logind: logind}
|
||||
}
|
||||
|
||||
func active(id string) (map[string]string, error) {
|
||||
return map[string]string{"Active": "yes", "State": "active", "Remote": "no", "Type": "x11"}, nil
|
||||
}
|
||||
|
||||
func TestTheSessionIsFoundInTheWindowManagersEnvironmentAndOnlyItsWordsAreRead(t *testing.T) {
|
||||
m := newMachine(t)
|
||||
m.socket(filepath.Join(m.x11, "X1"))
|
||||
run := filepath.Join(m.runtime, strconv.Itoa(m.uid))
|
||||
m.socket(filepath.Join(run, "bus"))
|
||||
m.process(100, "lemurs-child", 5, "DISPLAY=:1", "XDG_SESSION_ID=1")
|
||||
m.process(200, "i3", 10, "DISPLAY=:1", "XAUTHORITY=/home/op/.Xauthority", "XDG_SESSION_ID=1",
|
||||
"XDG_SESSION_TYPE=x11", "XDG_CURRENT_DESKTOP=i3", "XDG_RUNTIME_DIR="+run,
|
||||
"DBUS_SESSION_BUS_ADDRESS=unix:path=/tmp/dbus-private", "NPM_TOKEN=secret", "OPENAI_API_KEY=secret")
|
||||
m.process(300, "zsh", 50, "TERM=xterm") // no display: not a candidate
|
||||
|
||||
s, err := m.finder(active, "i3").Find()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.FoundIn.PID != 200 || s.Display != ":1" || s.XAuthority != "/home/op/.Xauthority" || s.ID != "1" || s.Type != "x11" || s.Desktop != "i3" {
|
||||
t.Fatalf("session: %+v", s)
|
||||
}
|
||||
if s.Bus != "unix:path="+filepath.Join(run, "bus") || s.SessionBus != "unix:path=/tmp/dbus-private" {
|
||||
t.Fatalf("the user manager's bus first, the session's private one reported beside it: %q %q", s.Bus, s.SessionBus)
|
||||
}
|
||||
if s.Active == nil || !*s.Active {
|
||||
t.Fatal("logind's word is carried")
|
||||
}
|
||||
env := strings.Join(s.Env([]string{"PATH=/usr/bin", "DISPLAY=:9", "HOME=/home/op"}), "\n")
|
||||
for _, want := range []string{"PATH=/usr/bin", "HOME=/home/op", "DISPLAY=:1", "XAUTHORITY=/home/op/.Xauthority", "DBUS_SESSION_BUS_ADDRESS=unix:path=" + filepath.Join(run, "bus"), "XDG_RUNTIME_DIR=" + run} {
|
||||
if !strings.Contains(env, want) {
|
||||
t.Errorf("env lacks %s:\n%s", want, env)
|
||||
}
|
||||
}
|
||||
if strings.Contains(env, ":9") || strings.Contains(env, "secret") || strings.Contains(env, "NPM_TOKEN") {
|
||||
t.Fatalf("the base's display is replaced and no other word of the session's process passes:\n%s", env)
|
||||
}
|
||||
b, _ := json.Marshal(s)
|
||||
if strings.Contains(string(b), "secret") {
|
||||
t.Fatal("the answer carries a word outside the session's")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithoutAPreferenceTheOldestProcessOfTheLiveSessionWins(t *testing.T) {
|
||||
m := newMachine(t)
|
||||
m.socket(filepath.Join(m.x11, "X0"))
|
||||
m.process(410, "xterm", 90, "DISPLAY=:0", "XDG_SESSION_ID=3")
|
||||
m.process(400, "openbox", 20, "DISPLAY=:0", "XDG_SESSION_ID=3")
|
||||
s, err := m.finder(nil).Find()
|
||||
if err != nil || s.FoundIn.PID != 400 {
|
||||
t.Fatalf("%+v %v", s, err)
|
||||
}
|
||||
if s.RuntimeDir != filepath.Join(m.runtime, strconv.Itoa(m.uid)) || s.Bus != "" {
|
||||
t.Fatalf("an absent runtime directory word falls back to the account's, and no bus socket means no bus: %+v", s)
|
||||
}
|
||||
}
|
||||
|
||||
func TestALeftoverProcessOfAnEndedSessionIsNotTheSession(t *testing.T) {
|
||||
m := newMachine(t)
|
||||
m.process(500, "i3", 10, "DISPLAY=:2", "XDG_SESSION_ID=7") // no X2 socket
|
||||
_, err := m.finder(active, "i3").Find()
|
||||
if !IsNoSession(err) || !strings.Contains(err.Error(), "socket is gone") {
|
||||
t.Fatalf("%v", err)
|
||||
}
|
||||
var answer map[string]any
|
||||
if json.Unmarshal([]byte(err.Error()), &answer) != nil || answer["error"] != "no-graphical-session" {
|
||||
t.Fatalf("the refusal is structured: %s", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoProcessWithADisplayIsAClearNoSession(t *testing.T) {
|
||||
m := newMachine(t)
|
||||
m.process(600, "sshd", 1, "SSH_CONNECTION=x")
|
||||
_, err := m.finder(active).Find()
|
||||
if !IsNoSession(err) || !strings.Contains(err.Error(), "no process of uid") {
|
||||
t.Fatalf("%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnActiveLocalSessionBeatsAnInactiveOneAndARemoteOneIsRefused(t *testing.T) {
|
||||
m := newMachine(t)
|
||||
m.socket(filepath.Join(m.x11, "X0"))
|
||||
m.socket(filepath.Join(m.x11, "X1"))
|
||||
m.process(700, "i3", 5, "DISPLAY=:0", "XDG_SESSION_ID=a")
|
||||
m.process(800, "i3", 9, "DISPLAY=:1", "XDG_SESSION_ID=b")
|
||||
logind := func(id string) (map[string]string, error) {
|
||||
if id == "a" {
|
||||
return map[string]string{"Active": "no", "State": "online", "Remote": "no"}, nil
|
||||
}
|
||||
return map[string]string{"Active": "yes", "State": "active", "Remote": "no"}, nil
|
||||
}
|
||||
s, err := m.finder(logind, "i3").Find()
|
||||
if err != nil || s.FoundIn.PID != 800 || s.Display != ":1" {
|
||||
t.Fatalf("the active session: %+v %v", s, err)
|
||||
}
|
||||
remote := func(string) (map[string]string, error) {
|
||||
return map[string]string{"Active": "yes", "Remote": "yes"}, nil
|
||||
}
|
||||
if _, err := m.finder(remote).Find(); !IsNoSession(err) {
|
||||
t.Fatalf("a remote session is not the operator's desktop: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAWaylandSessionIsFoundByItsCompositorsSocket(t *testing.T) {
|
||||
m := newMachine(t)
|
||||
run := filepath.Join(m.runtime, strconv.Itoa(m.uid))
|
||||
m.socket(filepath.Join(run, "wayland-1"))
|
||||
m.process(900, "sway", 3, "WAYLAND_DISPLAY=wayland-1", "XDG_RUNTIME_DIR="+run, "SWAYSOCK=/run/x.sock")
|
||||
s, err := m.finder(nil, "sway").Find()
|
||||
if err != nil || s.Type != "wayland" || s.WaylandDisplay != "wayland-1" {
|
||||
t.Fatalf("%+v %v", s, err)
|
||||
}
|
||||
if !strings.Contains(strings.Join(s.Env(nil), " "), "SWAYSOCK=/run/x.sock") {
|
||||
t.Fatal("the compositor's socket word passes")
|
||||
}
|
||||
}
|
||||
|
||||
func TestADisplayOnAnotherHostIsNotTheLocalSession(t *testing.T) {
|
||||
for d, want := range map[string]bool{":0": true, ":1.0": true, "unix:2": true, "localhost:10.0": false, "host:0": false, "": false, ":x": false} {
|
||||
if _, ok := DisplayNumber(d); ok != want {
|
||||
t.Errorf("%q: %v", d, ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestACommandIsBoundedAndItsFailureNamed(t *testing.T) {
|
||||
r := Exec(context.Background(), os.Environ(), []byte("hello"), "cat")
|
||||
if !r.OK() || r.Stdout != "hello" {
|
||||
t.Fatalf("%+v", r)
|
||||
}
|
||||
r = Exec(context.Background(), os.Environ(), nil, "sh", "-c", "echo no >&2; exit 3")
|
||||
if r.OK() || r.Code != 3 || !strings.Contains(r.Err().Error(), "exited 3: no") {
|
||||
t.Fatalf("%+v", r)
|
||||
}
|
||||
r = Exec(context.Background(), os.Environ(), nil, "no-such-program-here")
|
||||
if r.OK() || r.Code != 127 {
|
||||
t.Fatalf("%+v", r)
|
||||
}
|
||||
r = Exec(context.Background(), os.Environ(), nil, "sh", "-c", "head -c 400000 /dev/zero")
|
||||
if !r.Truncated || len(r.Stdout) != MostOutput {
|
||||
t.Fatalf("cut at %d: %d %v", MostOutput, len(r.Stdout), r.Truncated)
|
||||
}
|
||||
}
|
||||
|
||||
func TestArgumentsAreReadStrictly(t *testing.T) {
|
||||
a := Args{"name": " x ", "n": float64(3), "f": 1.5, "b": true, "l": []any{"a", "b"}}
|
||||
if v, err := a.Text("name"); err != nil || v != "x" {
|
||||
t.Fatal(v, err)
|
||||
}
|
||||
if _, err := a.Text("missing"); err == nil {
|
||||
t.Fatal("a missing required text")
|
||||
}
|
||||
if n, err := a.Whole("n", 0, 1, 5); err != nil || n != 3 {
|
||||
t.Fatal(n, err)
|
||||
}
|
||||
if _, err := a.Whole("f", 0, 0, 5); err == nil {
|
||||
t.Fatal("1.5 is not whole")
|
||||
}
|
||||
if _, err := a.Whole("n", 0, 4, 5); err == nil {
|
||||
t.Fatal("out of range")
|
||||
}
|
||||
if b, given, err := a.Bool("b"); !b || !given || err != nil {
|
||||
t.Fatal("bool")
|
||||
}
|
||||
if _, _, err := a.Bool("name"); err == nil {
|
||||
t.Fatal("text is not a bool")
|
||||
}
|
||||
if l, err := a.Strings("l"); err != nil || len(l) != 2 {
|
||||
t.Fatal(l, err)
|
||||
}
|
||||
if _, err := a.OneOf("name", "", "y", "z"); err == nil {
|
||||
t.Fatal("not one of")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPathIsKeptInsideTheHome(t *testing.T) {
|
||||
t.Setenv("MESH_OPERATOR_HOME", "/home/op")
|
||||
for in, want := range map[string]string{"~/a.png": "/home/op/a.png", "b/c": "/home/op/b/c", "/home/op/d": "/home/op/d", "~": "/home/op"} {
|
||||
if got, err := InHome(in); err != nil || got != want {
|
||||
t.Errorf("%s: %s %v", in, got, err)
|
||||
}
|
||||
}
|
||||
for _, out := range []string{"/etc/passwd", "~/../other", "../x"} {
|
||||
if _, err := InHome(out); err == nil {
|
||||
t.Errorf("%s was accepted", out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPropertiesAreParsed(t *testing.T) {
|
||||
p := ParseProperties("Active=yes\nState=active\nDisplay=\n")
|
||||
if p["Active"] != "yes" || p["State"] != "active" || p["Display"] != "" {
|
||||
t.Fatal(p)
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user