diff --git a/modules/lemurs/README.md b/modules/lemurs/README.md new file mode 100644 index 0000000..47670ad --- /dev/null +++ b/modules/lemurs/README.md @@ -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. diff --git a/modules/lemurs/cmd/lemurs-tools/lemurs_test.go b/modules/lemurs/cmd/lemurs-tools/lemurs_test.go new file mode 100644 index 0000000..c9580cc --- /dev/null +++ b/modules/lemurs/cmd/lemurs-tools/lemurs_test.go @@ -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 < 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 +} diff --git a/modules/lemurs/config/config.toml b/modules/lemurs/config/config.toml new file mode 100644 index 0000000..e60297c --- /dev/null +++ b/modules/lemurs/config/config.toml @@ -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" diff --git a/modules/lemurs/go.mod b/modules/lemurs/go.mod new file mode 100644 index 0000000..971dd9a --- /dev/null +++ b/modules/lemurs/go.mod @@ -0,0 +1,5 @@ +module lemurs + +go 1.22 + +require git.novox.be/novox/mesh-sdk/go v0.1.7 diff --git a/modules/lemurs/go.sum b/modules/lemurs/go.sum new file mode 100644 index 0000000..b474419 --- /dev/null +++ b/modules/lemurs/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/lemurs/internal/desktop/args.go b/modules/lemurs/internal/desktop/args.go new file mode 100644 index 0000000..d6be351 --- /dev/null +++ b/modules/lemurs/internal/desktop/args.go @@ -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} +} diff --git a/modules/lemurs/internal/desktop/copies_test.go b/modules/lemurs/internal/desktop/copies_test.go new file mode 100644 index 0000000..d6bc1b3 --- /dev/null +++ b/modules/lemurs/internal/desktop/copies_test.go @@ -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") + } +} diff --git a/modules/lemurs/internal/desktop/run.go b/modules/lemurs/internal/desktop/run.go new file mode 100644 index 0000000..cb9898c --- /dev/null +++ b/modules/lemurs/internal/desktop/run.go @@ -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) +} diff --git a/modules/lemurs/internal/desktop/session.go b/modules/lemurs/internal/desktop/session.go new file mode 100644 index 0000000..22d432e --- /dev/null +++ b/modules/lemurs/internal/desktop/session.go @@ -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//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 +} diff --git a/modules/lemurs/internal/desktop/session_test.go b/modules/lemurs/internal/desktop/session_test.go new file mode 100644 index 0000000..119c16d --- /dev/null +++ b/modules/lemurs/internal/desktop/session_test.go @@ -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) + } +} diff --git a/modules/lemurs/module.json b/modules/lemurs/module.json new file mode 100644 index 0000000..1c79a7c --- /dev/null +++ b/modules/lemurs/module.json @@ -0,0 +1,69 @@ +{ + "module": "lemurs", + "version": "1", + "capabilities": [ + "package-manager", + "service-manager", + "seat" + ], + "claims": [ + { + "name": "node-login-manager", + "scope": "node", + "serves": [ + "sessions" + ] + } + ], + "tools": [ + "lemurs_default_session", + "lemurs_logins" + ], + "resources": [ + { + "id": "package", + "type": "package", + "package": "lemurs" + }, + { + "id": "config", + "type": "file", + "path": "/etc/lemurs/config.toml", + "mode": "0644", + "content": "# The login manager's configuration, written by the mesh (module lemurs, novox/hq ADR 0208). Replaced\n# at every push; change the module instead. The structure is lemurs 0.4's own (the shipped file, with\n# every option, as lemurs requires); what the mesh changes from it is marked \"mesh:\".\n#\n# The sessions offered are the executable files other modules place in the two scripts directories\n# below (/etc/lemurs/wms for X, /etc/lemurs/wayland for Wayland), one per session module. A file there\n# is named as the session is offered. Desktop entries that packages install (/usr/share/xsessions) are\n# not offered: they start the window manager bare, skipping the session's start (~/.xinitrc), which is\n# where the account's environment, the X resources and every module's session lines are.\n#\n# Lemurs configuration file.\n# Contains all the customization options of lemurs.\n#\n# Note: that as of now you need to have all options in the selected\n# configuration file. Otherwise Lemurs will not work.\n#\n# Colors:\n# ---------\n# There is a list of predefined colors. These include:\n# - black\n# - white\n# - (dark) gray\n# - (light) red\n# - (light) blue\n# - (light) green\n# - (light) magenta\n# - (light) cyan\n# - (light) yellow\n# - orange\n#\n# You can also utilize custom colors with hex color codes.\n# \"#87CEEB\" will create a Sky Blue color.\n#\n# Note: If the color wasn't recognized, it will default to white.\n# ---------\n#\n# Modifiers:\n# ---------\n# There is a number of modifiers you can use. These can be combined by\n# delimiting them with a comma (e.g. \"bold,italic\"). The modifiers are:\n# - bold\n# - dim\n# - italic\n# - underlined\n# - reverse\n# - crossed out\n# - hidden\n# ---------\n#\n\n# The tty which contains lemurs. This has to be mirrored in the lemurs.service\ntty = 2\n\n# Where to log the main lemurs control flow.\nmain_log_path = \"/var/log/lemurs.log\"\n\n# Where to log to for the client. The Client is the Desktop Environment or\n# Window Manager for Xorg, the Compositor for Wayland and the Shell for TTY.\nclient_log_path = \"/var/log/lemurs.client.log\"\n\n# At which point to point the cache. If you want to disable the cache globally\n# you can use `/dev/null`.\ncache_path = \"/var/cache/lemurs\"\n\n# Disable all logging. This is overwritten by the `--no-log` flag.\ndo_log = true\n\n# The PAM service that should be used to login\npam_service = \"lemurs\"\n\n# Path to system shell that gets used to execute linux commands. In almost all\n# cases, this should refer to a bash shell.\nsystem_shell = \"/bin/sh\"\n\n# Initial state of the `PATH` environment variable.\ninitial_path = \"/usr/local/sbin:/usr/local/bin:/usr/bin\"\n\n# The type flag that will be appended to the shell that calls the session\n# environment. This may depend on your shell. Options:\n# - 'none'. Disables calling a login shell\n# - 'short'. Produces the `-l` flag. Supported by most shells.\n# - 'long'. This produces the `--login` flag and is suited for bash and zsh.\nshell_login_flag = \"short\"\n\n# Focus behaviour of fields when Lemurs is initially started\n#\n# Possible values:\n# - default: Initially focus on first non-cached value\n# - no-focus: No initial focus\n# - environment: Initially focus on the environment selector\n# - username: Initially focus on the username field\n# - password: Initially focus on the password field\nfocus_behaviour = \"default\"\n\n# General settings for background style\n[background]\n\n# Control whether to render background widget or not\nshow_background = false\n\n[background.style]\n# Allow to set the default background color for the login shell\ncolor = \"black\"\n# Settings for the background block's borders\nshow_border = true\nborder_color = \"white\"\n\n[power_controls]\n# The margin between hints\nhint_margin = 2\n\n# There are no additional entries by default\nentries = []\n\n# Example\n# Reboot to another os option\n#[[power_controls.entries]]\n## The text in the top-left to display how to reboot.\n#hint = \"Reboot to OS\"\n#\n## The color and modifiers of the hint in the top-left corner\n#hint_color = \"dark gray\"\n#hint_modifiers = \"\"\n#\n## The key used to reboot. Possibilities are F1 to F12.\n#key = \"F3\"\n## The command that is executed when the key is pressed\n#cmd = \"efibootmgr -n0 && systemctl reboot -l\"\n\n\n# If you want to remove the base_entries\n# base_entries = []\n\n# Shutdown option\n[[power_controls.base_entries]]\n# The text in the top-left to display how to shutdown.\nhint = \"Shutdown\"\n\n# The color and modifiers of the hint in the top-left corner\nhint_color = \"dark gray\"\nhint_modifiers = \"\"\n\n# The key used to shutdown. Possibilities are F1 to F12.\nkey = \"F1\"\n# The command that is executed when the key is pressed\ncmd = \"systemctl poweroff -l\"\n\n# Reboot option\n[[power_controls.base_entries]]\n# The text in the top-left to display how to reboot.\nhint = \"Reboot\"\n\n# The color and modifiers of the hint in the top-left corner\nhint_color = \"dark gray\"\nhint_modifiers = \"\"\n\n# The key used to reboot. Possibilities are F1 to F12.\nkey = \"F2\"\n# The command that is executed when the key is pressed\ncmd = \"systemctl reboot -l\"\n\n# Setting for the selector of the desktop environment you are using.\n[environment_switcher]\n# Terms:\n# ---------\n# Movers: indicators which show which direction one can move whilst selecting\n# the desktop environment\n# Selected: The currently selected desktop environment.\n# Neighbours: The adjacent desktop environment to the one current selected\n#\n# Visualisation:\n#\n# < i3 bspwm awesome >\n#\n# ^ ^ ^ ^ ^\n# | | | | |\n# mover | selected | mover\n# | |\n# neighbour neighbour\n# ---------\n#\n\n# Control the visibility of the switcher\n# Options:\n# - \"visible\" - Always show the switcher [default]\n# - \"hidden\" - Always hide the switcher\n# - [key] - F1-F12 to be able to toggle the visibility\n# mesh: hidden, as both workstations had it, but F3 shows it, so a second session can be chosen.\nswitcher_visibility = \"F3\"\n\n# The text in the top-left to display how to toggle the switcher. The text\n# '%key%' will be replaced with the switcher_visibility key. This is not shown\n# if switcher_visibility is set to \"visible\" or \"hidden\".\ntoggle_hint = \"Switcher %key%\"\n\n# The color and modifiers of the hint in the top-left corner\ntoggle_hint_color = \"dark gray\"\ntoggle_hint_modifiers = \"\"\n\n\n# Show an option for the TTY shell when logging in as one of the environments.\n# NOTE: it is always shown when no viable options are found.\ninclude_tty_shell = false\n\n# Remember the selected environment after logging in for the next time\nremember = true\n\n# Enables showing the movers\nshow_movers = true\n\n# Mover's color and modifiers whilst the selector is unfocused\nmover_color = \"dark gray\"\nmover_modifiers = \"\"\n\n# Mover's color and modifiers whilst the selector is focused\nmover_color_focused = \"orange\"\nmover_modifiers_focused = \"bold\"\n\n# The characters used to display the movers. Suggestions are:\n# - \"<\" \">\"\n# - \"<-\" \"->\"\n# - \"<<\" \">>\"\n# - \"[\" \"]\"\nleft_mover = \"<\"\nright_mover = \">\"\n\n# The margin between the movers and the neighbours or selected (depending on\n# `show_neighbours`)\nmover_margin = 1\n\n# Enables showing the neighbours\nshow_neighbours = true\n\n# Neighbours' color and modifiers whilst the selector is unfocused\nneighbour_color = \"dark gray\"\nneighbour_modifiers = \"\"\n\n# Neighbours' color and modifiers whilst the selector is focused\nneighbour_color_focused = \"gray\"\nneighbour_modifiers_focused = \"\"\n\n# Margin between neighbours and selected\nneighbour_margin = 1\n\n# Selected's color and modifiers whilst the selector is unfocused\nselected_color = \"gray\"\nselected_modifiers = \"underlined\"\n\n# Selected's color and modifiers whilst the selector is focused\nselected_color_focused = \"white\"\nselected_modifiers_focused = \"bold\"\n\n# The length of the name of the desktop environment which is displayed.\nmax_display_length = 8\n\n# The text used when no desktop environments are available\nno_envs_text = \"No environments...\"\n\n# The color and modifiers of the 'no desktop environments available text'\n# whilst the selector is unfocused\nno_envs_color = \"white\"\nno_envs_modifiers = \"\"\n\n# The color and modifiers of the 'no desktop environments available text'\n# whilst the selector is focused\nno_envs_color_focused = \"red\"\nno_envs_modifiers_focused = \"\"\n\n[username_field]\n\n# Remember the username for the next time after a successful login attempt.\nremember = true\n\n[username_field.style]\n# Enables showing a title\nshow_title = true\n# The text used within the title\ntitle = \"Login\"\n\n# The title's color and modifiers whilst the username field is unfocused\ntitle_color = \"white\"\ncontent_color = \"white\"\n\n# The title's color and modifiers whilst the username field is focused\ntitle_color_focused = \"orange\"\ncontent_color_focused = \"orange\"\n\n# Enables showing the borders\nshow_border = true\n# The borders' color and modifiers whilst the username field is unfocused\nborder_color = \"white\"\n# The borders' color and modifiers whilst the username field is focused\nborder_color_focused = \"orange\"\n\n# Constrain the width of the username field\nuse_max_width = true\n# The constraint of the username field's width\nmax_width = 48\n\n[password_field]\n\n# The character used for replacement when typing a password. Leave empty for no\n# feedback.\n# Note: Only one character is accepted.\ncontent_replacement_character = \"*\"\n\n[password_field.style]\n# Enables showing a title\nshow_title = true\n# The text used within the title\ntitle = \"Password\"\n\n# The title's color and modifiers whilst the password field is unfocused\ntitle_color = \"white\"\ncontent_color = \"white\"\n\n# The title's color and modifiers whilst the password field is focused\ntitle_color_focused = \"orange\"\ncontent_color_focused = \"orange\"\n\n# Enables showing the borders\nshow_border = true\n# The borders' color and modifiers whilst the password field is unfocused\nborder_color = \"white\"\n# The borders' color and modifiers whilst the password field is focused\nborder_color_focused = \"orange\"\n\n# Constrain the width of the password field\nuse_max_width = true\n# The constraint of the password field's width\nmax_width = 48\n\n[x11]\n# Where to log to for the XServer.\nxserver_log_path = \"/var/log/lemurs.xorg.log\"\n\n# The value of the `DISPLAY` environment variable for X11 sessions\nx11_display = \":1\"\n\n# How many seconds to give the X server to start. To make it infinitely, put it\n# to 0.\nxserver_timeout_secs = 60\n\n# Where to find the X11 server binary\nxserver_path = \"/usr/bin/X\"\n\n# Where to find the X11 xauth binary\nxauth_path = \"/usr/bin/xauth\"\n\n# Path to the directory where the startup scripts for the X11 sessions are found\nscripts_path = \"/etc/lemurs/wms\"\n\n# Path to the xsetup script that is needed for the environment setup of the\n# window manager.\nxsetup_path = \"/etc/lemurs/xsetup.sh\"\n\n# The directory to use for desktop entries X11 sessions.\n# mesh: an empty directory of the module's own, so no package's desktop entry is offered.\nxsessions_path = \"/etc/lemurs/xsessions\"\n\n[wayland]\n# Path to the directory where the startup scripts for the Wayland sessions are\n# found\nscripts_path = \"/etc/lemurs/wayland\"\n\n# The directory to use for desktop entries wayland sessions.\n# mesh: likewise for Wayland.\nwayland_sessions_path = \"/etc/lemurs/wayland-sessions\"\n" + }, + { + "id": "xsessions", + "type": "directory", + "path": "/etc/lemurs/xsessions", + "mode": "0755" + }, + { + "id": "wayland-sessions", + "type": "directory", + "path": "/etc/lemurs/wayland-sessions", + "mode": "0755" + }, + { + "id": "service", + "type": "service", + "unit": "lemurs.service", + "boot": "enabled" + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/lemurs-tools", + "binary": "lemurs-tools", + "loads": [ + "lemurs-tools" + ] + } + ] + } +}