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.
344 lines
9.6 KiB
Go
344 lines
9.6 KiB
Go
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
|
|
}
|