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,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
|
||||
}
|
||||
Reference in New Issue
Block a user