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 }