diff --git a/modules/blueman/README.md b/modules/blueman/README.md new file mode 100644 index 0000000..6d1e602 --- /dev/null +++ b/modules/blueman/README.md @@ -0,0 +1,82 @@ +# blueman + +The Bluetooth tray applet on the workstations, as a module (novox/hq ADR 0208). It requires +`x11-display`, so it is assigned only where a display server is held on the same machine. + +## Owns + +| what | where | +|---|---| +| the applet, the manager window, send-to | package `blueman`, from the official repositories | + +Nothing else. It holds no seat, makes no contribution and writes no file. + +- **No AUR.** Both workstations run the official package (`extra`), installed explicitly. +- **The Bluetooth stack is not this module's.** `bluez`, `bluez-utils` and `bluetooth.service` belong + to the `bluetooth` module. This module declares none of them (ADR 0210 §4: one package, one module). + The devices, pairing and power are that module's tools (`bluetooth_*`). This module's tools are + about the applet. +- **The operator's applet settings are found.** blueman keeps them in dconf (`org.blueman.*`): the + plugin switches, the recent connections, auto-connect and auto-power-on. The module neither sets nor + resets them. `blueman_status` shows the plugin switches. + +## How it starts: the package's autostart entry, and nothing else + +One process has one starter (the rule `picom` states for the desktop modules). The package ships +`/etc/xdg/autostart/blueman.desktop` (`blueman-applet`). The session runs it once at login through +the `i3` module's `dex --autostart --environment i3`. **That entry is the applet's one start.** The +module adds no `xinitrc` slot and no `node-display-session` exec, because either would start it a +second time. + +- **Excluded:** the window manager's `exec … blueman-applet`, which the `i3` module's configuration + dropped. +- **Not a start:** the package's user unit `blueman-applet.service` is static. It exists for D-Bus + activation of `org.blueman.Applet`. A program that calls the applet's bus name while no applet runs + starts one through it. The applet is single-instance on that name, so this never makes a second + one. The tools always ask the bus with `--auto-start=no`, so asking never starts it. +- The applet starts its tray icon, `blueman-tray`, itself. + +## Tools + +They are served by the node's runtime as the operator account (ADR 0175). + +| tool | does | +|---|---| +| `blueman_status` (r) | | +| `blueman_restart` (a) | asks the applet and the tray icon to end (SIGTERM), forces them after 5 s, and starts `blueman-applet` in the operator's session. The start is a transient user unit `mesh-blueman-applet`, so it outlives the tools runtime. Answers the pids. Refused plainly when nobody is logged in to the desktop | +| `blueman_check` (r) | Each finding says what to do | + +The tools find the session's `DISPLAY` and `XAUTHORITY` from the window manager's own environment, +as `clipmenu` and `screen-lock` do. Every command has a timeout and capped output. Everything runs +through an injected runner and a fake root in the tests. + +## What changes when it is assigned + +| | g14 | shanks | +|---|---|---| +| package | none: `blueman` 2.4.6 is installed, explicitly, from `extra` | the same | +| start | none: dex starts the applet from the package's entry, in the login session's scope; the tray icon with it | none on disk. **The applet running now came from the predecessor's window-manager line** (a child of i3, since the session of 2026-10-04 16:00). That session began before the `i3` module dropped the line and installed `dex`, so the next login is the first that starts it from the entry | +| settings (dconf) | defaults, no plugin switched; recent connections only | no plugin switched; auto-connect for one headset, auto-power-on set | + +The workstations are already in the state this module describes. + +## Migration (ADR 0182) + +Nothing is required on either machine. On shanks, log out and in once, or run `blueman_restart`, and +the applet runs from its one start. `blueman_check` then answers `ok`. + +## Leaves as found + +- The applet's dconf settings (`/org/blueman/`). +- `/etc/xdg/autostart/blueman.desktop`, the package's own file. + +## Relies on + +- **The `bluetooth` module, for bluez and its daemon.** Without them the applet has nothing to + manage. There is no dependency mechanism between two modules that hold no seat. So nothing refuses + `blueman` on a node without `bluetooth`. `blueman_check` reports it instead, from + `bluetooth.service`. **Assign both.** When the stack becomes a seat (`node-bluetooth`), this module + depends on the seat. +- **`i3`'s `dex` line for the start**, which is equally undeclared: XDG autostart has no seat. + Assigned without `i3`, the applet is installed and does not start. `blueman_check` says so. +- A display server on the same machine (`x11-display`, ADR 0208 §3). diff --git a/modules/blueman/cmd/blueman-tools/args.go b/modules/blueman/cmd/blueman-tools/args.go new file mode 100644 index 0000000..9b5dfcf --- /dev/null +++ b/modules/blueman/cmd/blueman-tools/args.go @@ -0,0 +1,97 @@ +// Reading a tool's arguments: JSON numbers arrive as float64, and a missing argument is its default. +// The same in every desktop module that carries it. +package main + +import ( + "fmt" + "math" + "strings" + "time" +) + +// text is a string argument, trimmed; required says an empty one is refused. +func text(args map[string]any, key string, required bool) (string, error) { + v, present := args[key] + if !present || v == nil { + if required { + return "", fmt.Errorf("%s is required", key) + } + return "", nil + } + s, ok := v.(string) + if !ok { + return "", fmt.Errorf("%s is a string, not %T", key, v) + } + s = strings.TrimSpace(s) + if s == "" && required { + return "", fmt.Errorf("%s is required", key) + } + return s, nil +} + +// whole is a whole-number argument within [least, most], or def when absent. +func whole(args map[string]any, key string, def, least, most int) (int, error) { + v, present := args[key] + if !present || v == nil { + return def, nil + } + f, ok := v.(float64) + if !ok { + if i, isInt := v.(int); isInt { + f = float64(i) + } else { + return 0, fmt.Errorf("%s is a number, not %T", key, v) + } + } + if f != math.Trunc(f) { + return 0, fmt.Errorf("%s is a whole number, not %v", key, f) + } + n := int(f) + if n < least || n > most { + return 0, fmt.Errorf("%s is %d; it is between %d and %d", key, n, least, most) + } + return n, nil +} + +// flag is a boolean argument, or def when absent. +func flag(args map[string]any, key string, def bool) (bool, error) { + v, present := args[key] + if !present || v == nil { + return def, nil + } + b, ok := v.(bool) + if !ok { + return false, fmt.Errorf("%s is true or false, not %T", key, v) + } + return b, nil +} + +// texts is a list-of-strings argument. +func texts(args map[string]any, key string) ([]string, error) { + v, present := args[key] + if !present || v == nil { + return nil, nil + } + list, ok := v.([]any) + if !ok { + if ss, isStrings := v.([]string); isStrings { + return ss, nil + } + return nil, fmt.Errorf("%s is a list of strings, not %T", key, v) + } + out := make([]string, 0, len(list)) + for i, item := range list { + s, ok := item.(string) + if !ok { + return nil, fmt.Errorf("%s[%d] is a string, not %T", key, i, item) + } + out = append(out, s) + } + return out, nil +} + +// seconds is a timeout argument in seconds, defaulted and bounded below the runtime's call limit. +func seconds(args map[string]any, key string, def, most int) (time.Duration, error) { + n, err := whole(args, key, def, 1, most) + return time.Duration(n) * time.Second, err +} diff --git a/modules/blueman/cmd/blueman-tools/blueman.go b/modules/blueman/cmd/blueman-tools/blueman.go new file mode 100644 index 0000000..7398827 --- /dev/null +++ b/modules/blueman/cmd/blueman-tools/blueman.go @@ -0,0 +1,221 @@ +package main + +// blueman's applet as the tools see it: its processes, its XDG autostart entry (the package's), the +// applet's own answers on the session bus, and its settings in gsettings. Every bus call is made with +// --auto-start=no: the applet is D-Bus activatable, and a question must never start it. + +import ( + "encoding/json" + "fmt" + "sort" + "strings" + "time" +) + +const ( + appletComm = "blueman-applet" + trayComm = "blueman-tray" + appletBin = "/usr/bin/blueman-applet" + entryName = "blueman.desktop" + restartAs = "mesh-blueman-applet" + packageFor = "blueman" + busName = "org.blueman.Applet" + busPath = "/org/blueman/Applet" + stackUnit = "bluetooth.service" +) + +// appletCall asks the running applet one question over the session bus and answers busctl's JSON. +func (m *Machine) appletCall(method string) (json.RawMessage, error) { + args := []string{"--user", "--auto-start=no", "--json=short", "call", busName, busPath, busName, method} + o := m.cmd(5*time.Second, m.bus(), "busctl", args...) + if err := failed(o, "busctl", args...); err != nil { + return nil, err + } + var doc struct { + Data []json.RawMessage `json:"data"` + } + if err := json.Unmarshal([]byte(o.Stdout), &doc); err != nil || len(doc.Data) != 1 { + return nil, fmt.Errorf("the applet's answer to %s is not busctl's JSON: %q", method, tail(o.Stdout, 200)) + } + return doc.Data[0], nil +} + +func (m *Machine) appletStrings(method string) ([]string, error) { + raw, err := m.appletCall(method) + if err != nil { + return nil, err + } + var out []string + if err := json.Unmarshal(raw, &out); err != nil { + return nil, fmt.Errorf("the applet's %s is not a list of names: %w", method, err) + } + sort.Strings(out) + return out, nil +} + +// gvariantStrings reads gsettings' printed array of strings: "@as []" or "['a', '!b']". +func gvariantStrings(s string) []string { + s = strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(s), "@as")) + s = strings.TrimSuffix(strings.TrimPrefix(s, "["), "]") + out := []string{} + for _, item := range strings.Split(s, ",") { + item = strings.Trim(strings.TrimSpace(item), `'"`) + if item != "" { + out = append(out, item) + } + } + return out +} + +// Plugins is which applet plugins run, and what the operator's settings say about them. +type Plugins struct { + // Loaded are the plugins the running applet answers it has loaded. + Loaded []string `json:"loaded"` + // NotLoaded are the plugins it knows but did not load: switched off, or not fit for this machine. + NotLoaded []string `json:"not_loaded"` + // Switched is the operator's plugin-list setting: a name to load it, !name to keep it off. Empty + // is blueman's defaults. + Switched []string `json:"switched"` +} + +// AppletStatus is what blueman_status answers. +type AppletStatus struct { + Installed string `json:"installed,omitempty"` + Applet []Proc `json:"applet"` + Tray []Proc `json:"tray"` + StartedBy Autostart `json:"started_by"` + // Bluetooth is the applet's own view of the adapter's power, when it runs. + Bluetooth *bool `json:"bluetooth_on,omitempty"` + Plugins Plugins `json:"plugins"` + // Unanswered says why the running applet's view is missing. + Unanswered string `json:"unanswered,omitempty"` +} + +// Status reads the applet: running or not, how it starts, and its plugins. +func (m *Machine) Status() (AppletStatus, error) { + s := AppletStatus{Applet: m.procs(appletComm), Tray: m.procs(trayComm), StartedBy: m.autostart(entryName), + Plugins: Plugins{Loaded: []string{}, NotLoaded: []string{}, Switched: []string{}}} + if s.Applet == nil { + s.Applet = []Proc{} + } + if s.Tray == nil { + s.Tray = []Proc{} + } + if v, err := m.installed(packageFor); err == nil { + s.Installed = v + } + o := m.cmd(5*time.Second, m.bus(), "gsettings", "get", "org.blueman.general", "plugin-list") + if o.Err == nil && o.Code == 0 { + s.Plugins.Switched = gvariantStrings(o.Stdout) + } + if len(s.Applet) == 0 { + s.Unanswered = "the applet is not running" + return s, nil + } + loaded, err := m.appletStrings("QueryPlugins") + if err != nil { + s.Unanswered = err.Error() + return s, nil + } + s.Plugins.Loaded = loaded + if all, err := m.appletStrings("QueryAvailablePlugins"); err == nil { + in := map[string]bool{} + for _, p := range loaded { + in[p] = true + } + for _, p := range all { + if !in[p] { + s.Plugins.NotLoaded = append(s.Plugins.NotLoaded, p) + } + } + } + if raw, err := m.appletCall("GetBluetoothStatus"); err == nil { + var on bool + if json.Unmarshal(raw, &on) == nil { + s.Bluetooth = &on + } + } + return s, nil +} + +// RestartAnswer is what blueman_restart answers. +type RestartAnswer struct { + Ended []int `json:"ended"` + Killed []int `json:"killed,omitempty"` + Running []Proc `json:"running"` + Session Session `json:"session"` + Unit string `json:"unit"` +} + +// Restart ends the applet and its tray icon, and starts the applet again in the operator's session +// under the account's service manager. The applet starts its tray icon itself. +func (m *Machine) Restart() (RestartAnswer, error) { + s, err := m.session() + if err != nil { + return RestartAnswer{}, err + } + a := RestartAnswer{Session: s, Unit: restartAs + ".service"} + a.Ended, a.Killed = m.stop(5*time.Second, appletComm, trayComm) + if err := m.detach(s, restartAs, appletBin); err != nil { + return a, err + } + a.Running = m.waitFor(appletComm, 4*time.Second) + if len(a.Running) == 0 { + return a, fmt.Errorf("the applet was started as %s but no %s process appeared within 4 s: "+ + "see `journalctl --user -u %s`", a.Unit, appletComm, a.Unit) + } + return a, nil +} + +// CheckAnswer is what blueman_check answers. +type CheckAnswer struct { + OK bool `json:"ok"` + Findings []Finding `json:"findings"` + Starts []string `json:"starts"` +} + +// Check verifies what the module promises and relies on: the package; one start (the package's +// autostart entry, which the session's dex runs); the applet running once in a session; and the +// Bluetooth daemon it manages, which is the bluetooth module's. +func (m *Machine) Check() (CheckAnswer, error) { + a := CheckAnswer{Findings: []Finding{}, Starts: []string{}} + add := func(what, do string) { a.Findings = append(a.Findings, Finding{what, do}) } + v, err := m.installed(packageFor) + if err != nil { + return a, err + } + if v == "" { + add("the package blueman is not installed", "push the module to the node") + } + entry := m.autostart(entryName) + if entry.Starts { + a.Starts = append(a.Starts, "XDG autostart: "+entry.From) + if entry.From != "/etc/xdg/autostart/"+entryName { + add("the account's own "+entry.From+" replaces the package's entry", "remove it, so the package's entry is the one start") + } + } else { + add("the applet does not start with the session ("+entry.Because+")", "remove ~/.config/autostart/"+entryName+" if it hides the package's entry") + } + if o := m.cmd(0, nil, "dex", "--version"); o.Err != nil { + add("dex, which runs the XDG autostart entries at login, is not installed", "assign the i3 module, which installs it and runs it") + } + for _, l := range m.i3Starts(appletComm) { + a.Starts = append(a.Starts, "window manager: "+l) + add("a second start: "+l, "remove the line; the package's autostart entry is the applet's one start") + } + if o := m.cmd(0, nil, "systemctl", "is-active", stackUnit); o.Err != nil || strings.TrimSpace(o.Stdout) != "active" { + add("the Bluetooth daemon ("+stackUnit+") is not running: the applet has nothing to manage", + "assign the bluetooth module, which owns bluez and its daemon") + } + running := m.procs(appletComm) + if _, err := m.session(); err == nil { + switch { + case len(running) == 0: + add("no applet runs in the desktop session", "blueman_restart") + case len(running) > 1: + add(fmt.Sprintf("%d applets run", len(running)), "blueman_restart ends them all and starts one") + } + } + a.OK = len(a.Findings) == 0 + return a, nil +} diff --git a/modules/blueman/cmd/blueman-tools/blueman_test.go b/modules/blueman/cmd/blueman-tools/blueman_test.go new file mode 100644 index 0000000..f6970d8 --- /dev/null +++ b/modules/blueman/cmd/blueman-tools/blueman_test.go @@ -0,0 +1,126 @@ +package main + +import ( + "strings" + "testing" +) + +func newApplet(t *testing.T, running bool) *fake { + f := newFake(t) + f.write("/etc/xdg/autostart/blueman.desktop", "[Desktop Entry]\nName=Blueman Applet\nExec=blueman-applet\nType=Application\n") + if running { + f.proc(3859, 1000, appletComm, []string{"/usr/bin/python", "/usr/bin/blueman-applet"}, "session-c1.scope") + f.proc(4084, 1000, trayComm, []string{"/usr/bin/python", "/usr/bin/blueman-tray"}, "session-c1.scope") + } + f.answer = func(name string, args []string) Output { + call := name + " " + strings.Join(args, " ") + switch { + case name == "pacman": + return Output{Stdout: "blueman 2.4.6-2\n"} + case name == "gsettings": + return Output{Stdout: "['!NetUsage', 'DhcpClient']\n"} + case strings.HasSuffix(call, " QueryPlugins"): + return Output{Stdout: `{"type":"as","data":[["StatusIcon","AuthAgent","PowerManager"]]}`} + case strings.HasSuffix(call, " QueryAvailablePlugins"): + return Output{Stdout: `{"type":"as","data":[["StatusIcon","AuthAgent","PowerManager","NetUsage"]]}`} + case strings.HasSuffix(call, " GetBluetoothStatus"): + return Output{Stdout: `{"type":"b","data":[true]}`} + case name == "systemctl" && len(args) > 1 && args[0] == "is-active": + return Output{Stdout: "active\n"} + } + return Output{} + } + return f +} + +func TestStatusAsksTheRunningAppletAndNeverStartsIt(t *testing.T) { + f := newApplet(t, true) + s, err := f.Status() + if err != nil { + t.Fatal(err) + } + if s.Installed != "2.4.6-2" || len(s.Applet) != 1 || len(s.Tray) != 1 || !s.StartedBy.Starts || s.Bluetooth == nil || !*s.Bluetooth { + t.Fatalf("%+v", s) + } + if strings.Join(s.Plugins.Loaded, ",") != "AuthAgent,PowerManager,StatusIcon" || strings.Join(s.Plugins.NotLoaded, ",") != "NetUsage" || + strings.Join(s.Plugins.Switched, ",") != "!NetUsage,DhcpClient" { + t.Fatalf("%+v", s.Plugins) + } + for _, c := range f.calls { + if strings.HasPrefix(c, "busctl") && !strings.Contains(c, "--auto-start=no") { + t.Fatalf("a bus call that could start the applet: %s", c) + } + } + + stopped := newApplet(t, false) + s, err = stopped.Status() + if err != nil || s.Unanswered != "the applet is not running" || len(s.Applet) != 0 || s.Bluetooth != nil { + t.Fatalf("%+v %v", s, err) + } + if stopped.called("busctl") { + t.Fatalf("asked the bus with no applet running: %q", stopped.calls) + } +} + +func TestSettingsListsAreReadAsGSettingsPrintsThem(t *testing.T) { + for in, want := range map[string]string{"@as []\n": "", "['a']": "a", "['!a', 'b']\n": "!a,b"} { + if got := strings.Join(gvariantStrings(in), ","); got != want { + t.Errorf("%q: %q, not %q", in, got, want) + } + } +} + +func TestRestartEndsAppletAndTrayAndStartsTheApplet(t *testing.T) { + f := newApplet(t, true) + if _, err := f.Restart(); err == nil || !strings.Contains(err.Error(), "no graphical session") { + t.Fatalf("without a desktop: %v", err) + } + f.desktopSession() + f.onStart = func(argv []string) { f.proc(9100, 1000, appletComm, argv, "app.slice/"+restartAs+".service") } + a, err := f.Restart() + if err != nil { + t.Fatal(err) + } + if len(a.Ended) != 2 || len(a.Running) != 1 || a.Running[0].PID != 9100 || a.Running[0].Command != appletBin { + t.Fatalf("%+v", a) + } + if !f.called("systemd-run --user --collect --quiet --unit=" + restartAs + " --setenv=DISPLAY=:1") { + t.Fatalf("%q", f.calls) + } +} + +func TestCheckPassesThePackagesOneStartAndNamesEveryOther(t *testing.T) { + f := newApplet(t, true) + f.desktopSession() + c, err := f.Check() + if err != nil || !c.OK || len(c.Starts) != 1 || c.Starts[0] != "XDG autostart: /etc/xdg/autostart/blueman.desktop" { + t.Fatalf("%+v %v", c, err) + } + + f.write(testHome+"/.config/i3/config", "exec --no-startup-id blueman-applet\n") + f.write(testHome+"/.config/autostart/blueman.desktop", "[Desktop Entry]\nExec=blueman-applet\nHidden=true\n") + f.proc(3860, 1000, appletComm, []string{"blueman-applet"}, "session-c1.scope") + f.answer = func(name string, args []string) Output { + switch name { + case "pacman": + return Output{Code: 1} + case "systemctl": + return Output{Stdout: "inactive\n", Code: 3} + case "dex": + return Output{Code: 127, Err: ErrNotInstalled} + } + return Output{} + } + c, _ = f.Check() + var all []string + for _, x := range c.Findings { + all = append(all, x.What) + } + got := strings.Join(all, "\n") + for _, want := range []string{"not installed", "does not start with the session (Hidden=true)", "dex", "a second start: ~/.config/i3/config:1", + "bluetooth.service", "2 applets run"} { + if !strings.Contains(got, want) { + t.Errorf("no finding %q in\n%s", want, got) + } + } +} diff --git a/modules/blueman/cmd/blueman-tools/desktop.go b/modules/blueman/cmd/blueman-tools/desktop.go new file mode 100644 index 0000000..f2efcf4 --- /dev/null +++ b/modules/blueman/cmd/blueman-tools/desktop.go @@ -0,0 +1,574 @@ +package main + +// desktop.go is the same file in the nextcloud-client and blueman bundles: a tray application of the +// operator's graphical session, seen from the node's tool runtime (novox/hq ADR 0208). +// +// The runtime is a system service running as the operator account (ADR 0175): it has the account's +// uid and none of the session's environment. A tool that starts something on the desktop finds the +// session from a process of the account that carries DISPLAY (the window manager first), and starts +// the program under the account's own service manager with `systemd-run --user`, never as its own +// child: the runtime's unit is a cgroup that is emptied whenever the runtime restarts. +// +// Everything a tool touches goes through a Machine: its filesystem root, its commands (a Runner) and +// its signals are injected, so the tests run against a fake /proc and a fake home. +// +// Bounds: one command gets at most CallTimeout (below the runtime's 30 s call limit) and is ended +// with everything it started when it takes longer; each stream is kept to MostOutput; a file is read +// to at most MostRead. + +import ( + "bufio" + "bytes" + "context" + "errors" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "sort" + "strconv" + "strings" + "syscall" + "time" +) + +// Bounds every command and read is held to. +const ( + CallTimeout = 10 * time.Second + MostOutput = 256 << 10 + MostRead = 16 << 20 +) + +// Output is what a command did. +type Output struct { + Stdout string + Stderr string + Code int + // Err is why it did not run to an answer: not installed, ended on its timeout, or the spawn error. + Err error + Cut bool +} + +// ErrNotInstalled and ErrTimedOut are what a Runner answers in Output.Err. +var ( + ErrNotInstalled = errors.New("not installed") + ErrTimedOut = errors.New("timed out") + // ErrNoSession is answered by a tool that needs the desktop when nobody is logged in to it. + ErrNoSession = errors.New("no graphical session") +) + +// Runner runs one command with extra environment, within the context's deadline. Tests replace it. +type Runner func(ctx context.Context, env []string, name string, args ...string) Output + +// Machine is what the tools read and act on. +type Machine struct { + Root string // "" on the machine; a fake root in tests + Home string // the operator's home, as the machine names it + UID int + Run Runner + Kill func(pid int, sig syscall.Signal) error + Sleep func(time.Duration) + Now func() time.Time + Timeout time.Duration +} + +// NewMachine is the machine the bundle runs on. +func NewMachine() *Machine { + return &Machine{Home: operatorHome(), UID: os.Getuid(), Run: execRun, Kill: syscall.Kill, + Sleep: time.Sleep, Now: time.Now, Timeout: CallTimeout} +} + +// operatorHome is the account's home: what the runtime was told, else the process's own. +func operatorHome() string { + if h := strings.TrimSpace(os.Getenv("MESH_OPERATOR_HOME")); h != "" { + return h + } + h, _ := os.UserHomeDir() + return h +} + +func (m *Machine) path(p string) string { return filepath.Join(m.Root, p) } + +// home is a path under the operator's home, on this machine's filesystem. +func (m *Machine) home(rel ...string) string { + return filepath.Join(append([]string{m.Root, m.Home}, rel...)...) +} + +// tilde shows a path under the home as ~/…, so an answer does not carry the account's name. +func (m *Machine) tilde(p string) string { + if m.Home != "" && m.Home != "/" { + h := strings.TrimSuffix(m.Home, "/") + if p == h { + return "~" + } + if strings.HasPrefix(p, h+"/") { + return "~/" + strings.TrimPrefix(p, h+"/") + } + } + return p +} + +// cmd runs a command within the machine's timeout (or a shorter one). +func (m *Machine) cmd(timeout time.Duration, env []string, name string, args ...string) Output { + if timeout <= 0 || timeout > m.Timeout { + timeout = m.Timeout + } + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + return m.Run(ctx, env, name, args...) +} + +// failed names how a command failed, or answers nil when it ran and exited 0. +func failed(o Output, name string, args ...string) error { + switch { + case errors.Is(o.Err, ErrNotInstalled): + return fmt.Errorf("%s is not installed on this machine", name) + case errors.Is(o.Err, ErrTimedOut): + return fmt.Errorf("%s gave no answer in time and was ended", name) + case o.Err != nil: + return fmt.Errorf("%s did not run: %v", name, o.Err) + case o.Code != 0: + said := strings.TrimSpace(o.Stderr) + if said == "" { + said = strings.TrimSpace(o.Stdout) + } + if said == "" { + said = "and said nothing" + } + return fmt.Errorf("%s %s exited %d: %s", name, strings.Join(args, " "), o.Code, tail(said, 1000)) + } + return nil +} + +func tail(s string, n int) string { + if len(s) <= n { + return s + } + return "…" + s[len(s)-n:] +} + +type capped struct { + b bytes.Buffer + cut bool +} + +func (c *capped) Write(p []byte) (int, error) { + if room := MostOutput - c.b.Len(); room < len(p) { + if room > 0 { + c.b.Write(p[:room]) + } + c.cut = true + return len(p), nil + } + return c.b.Write(p) +} + +func execRun(ctx context.Context, env []string, name string, args ...string) Output { + path, err := exec.LookPath(name) + if err != nil { + return Output{Code: 127, Err: ErrNotInstalled} + } + cmd := exec.CommandContext(ctx, path, args...) + cmd.Env = append(append(os.Environ(), "LC_ALL=C"), env...) + // Its own process group, so that ending it on a timeout ends what it started too. + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + cmd.Cancel = func() error { + if cmd.Process != nil { + _ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) + } + return nil + } + cmd.WaitDelay = 2 * time.Second + var out, errs capped + cmd.Stdout, cmd.Stderr = &out, &errs + err = cmd.Run() + o := Output{Stdout: out.b.String(), Stderr: errs.b.String(), Cut: out.cut || errs.cut} + var exit *exec.ExitError + switch { + case err == nil: + case ctx.Err() == context.DeadlineExceeded: + o.Code, o.Err = 124, ErrTimedOut + case errors.As(err, &exit): + o.Code = exit.ExitCode() + default: + o.Code, o.Err = 127, err + } + return o +} + +// readBounded reads a file to at most MostRead bytes. +func readBounded(path string) ([]byte, error) { + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer f.Close() + return io.ReadAll(io.LimitReader(f, MostRead)) +} + +// Proc is one process of the account. +type Proc struct { + PID int `json:"pid"` + Command string `json:"command"` + // StartedIn is the unit or scope it runs in: the login session's scope when the session's start + // (dex, the window manager) started it, a mesh-… unit when a tool restarted it. + StartedIn string `json:"started_in,omitempty"` + Since string `json:"since,omitempty"` +} + +// procs are this account's processes named comm, oldest first. +func (m *Machine) procs(comm string) []Proc { + entries, err := os.ReadDir(m.path("/proc")) + if err != nil { + return nil + } + boot := m.bootTime() + var out []Proc + for _, e := range entries { + pid, err := strconv.Atoi(e.Name()) + if err != nil { + continue + } + dir := m.path(filepath.Join("/proc", e.Name())) + if readTrimmed(filepath.Join(dir, "comm")) != comm || m.uidOf(dir) != m.UID { + continue + } + p := Proc{PID: pid, Command: strings.TrimSpace(strings.ReplaceAll(readTrimmed(filepath.Join(dir, "cmdline")), "\x00", " "))} + if p.Command == "" { + p.Command = comm + } + if cg := readTrimmed(filepath.Join(dir, "cgroup")); cg != "" { + line := strings.Split(cg, "\n")[0] + p.StartedIn = filepath.Base(line[strings.LastIndexByte(line, ':')+1:]) + } + if t, ok := startOf(readTrimmed(filepath.Join(dir, "stat")), boot); ok { + p.Since = t.UTC().Format(time.RFC3339) + } + out = append(out, p) + } + sort.Slice(out, func(i, j int) bool { return out[i].PID < out[j].PID }) + return out +} + +// uidOf is the real uid on a process's status, -1 when unreadable. +func (m *Machine) uidOf(dir string) int { + for _, l := range strings.Split(readTrimmed(filepath.Join(dir, "status")), "\n") { + if f := strings.Fields(l); len(f) > 1 && f[0] == "Uid:" { + if n, err := strconv.Atoi(f[1]); err == nil { + return n + } + } + } + return -1 +} + +func (m *Machine) bootTime() int64 { + for _, l := range strings.Split(readTrimmed(m.path("/proc/stat")), "\n") { + if f := strings.Fields(l); len(f) == 2 && f[0] == "btime" { + n, _ := strconv.ParseInt(f[1], 10, 64) + return n + } + } + return 0 +} + +// startOf reads a process's start from its stat line (field 22, in clock ticks of 1/100 s since boot). +func startOf(stat string, boot int64) (time.Time, bool) { + i := strings.LastIndexByte(stat, ')') + if i < 0 || boot == 0 { + return time.Time{}, false + } + f := strings.Fields(stat[i+1:]) + if len(f) < 20 { + return time.Time{}, false + } + ticks, err := strconv.ParseInt(f[19], 10, 64) + if err != nil { + return time.Time{}, false + } + return time.Unix(boot+ticks/100, 0), true +} + +func readTrimmed(path string) string { + b, err := os.ReadFile(path) + if err != nil { + return "" + } + return strings.TrimSpace(string(b)) +} + +func exists(path string) bool { + _, err := os.Stat(path) + return err == nil +} + +// Session is what a tool needs to start something on the operator's desktop. +type Session struct { + Display string `json:"display"` + XAuthority string `json:"xauthority,omitempty"` + Bus string `json:"bus,omitempty"` + RuntimeDir string `json:"runtime_dir,omitempty"` + From string `json:"found_in"` +} + +// sessionHolders are the processes whose environment is the session's, best first. +var sessionHolders = []string{"i3", "sway", "i3bar", "picom", "dunst", "xterm"} + +// session finds the account's graphical session, or ErrNoSession saying what it looked at. +func (m *Machine) session() (Session, error) { + entries, _ := os.ReadDir(m.path("/proc")) + best, bestRank := -1, len(sessionHolders)+1 + var env map[string]string + var from string + for _, e := range entries { + pid, err := strconv.Atoi(e.Name()) + if err != nil { + continue + } + dir := m.path(filepath.Join("/proc", e.Name())) + if m.uidOf(dir) != m.UID { + continue + } + raw, err := os.ReadFile(filepath.Join(dir, "environ")) + if err != nil { + continue + } + vars := parseEnviron(raw) + if vars["DISPLAY"] == "" { + continue + } + comm := readTrimmed(filepath.Join(dir, "comm")) + rank := len(sessionHolders) + for i, h := range sessionHolders { + if h == comm { + rank = i + } + } + if rank < bestRank || (rank == bestRank && pid > best) { + best, bestRank, env, from = pid, rank, vars, fmt.Sprintf("process %s (pid %d)", comm, pid) + } + } + if env == nil { + return Session{}, fmt.Errorf("%w for uid %d on this machine: no process of the account carries DISPLAY. "+ + "Is anyone logged in to the desktop?", ErrNoSession, m.UID) + } + s := Session{Display: env["DISPLAY"], XAuthority: env["XAUTHORITY"], Bus: env["DBUS_SESSION_BUS_ADDRESS"], + RuntimeDir: env["XDG_RUNTIME_DIR"], From: from} + if s.RuntimeDir == "" { + s.RuntimeDir = fmt.Sprintf("/run/user/%d", m.UID) + } + if s.Bus == "" && exists(m.path(filepath.Join(s.RuntimeDir, "bus"))) { + s.Bus = "unix:path=" + filepath.Join(s.RuntimeDir, "bus") + } + return s, nil +} + +// bus is the account's session bus environment, which a logged-in account has with or without a +// desktop: what a command needs to reach the user's service manager or a bus name. +func (m *Machine) bus() []string { + runtime := fmt.Sprintf("/run/user/%d", m.UID) + return []string{"XDG_RUNTIME_DIR=" + runtime, "DBUS_SESSION_BUS_ADDRESS=unix:path=" + runtime + "/bus"} +} + +// Env is the session's variables, for a command that draws or speaks to the desktop. +func (s Session) Env() []string { + var env []string + for _, kv := range [][2]string{{"DISPLAY", s.Display}, {"XAUTHORITY", s.XAuthority}, + {"DBUS_SESSION_BUS_ADDRESS", s.Bus}, {"XDG_RUNTIME_DIR", s.RuntimeDir}} { + if kv[1] != "" { + env = append(env, kv[0]+"="+kv[1]) + } + } + return env +} + +func parseEnviron(raw []byte) map[string]string { + env := map[string]string{} + for _, kv := range bytes.Split(raw, []byte{0}) { + if i := bytes.IndexByte(kv, '='); i > 0 { + env[string(kv[:i])] = string(kv[i+1:]) + } + } + return env +} + +// detach starts a long-lived program under the account's service manager, as a transient unit that +// carries the session's display. A unit left by an earlier start under the same name is stopped +// first, so the fixed name means at most one. +func (m *Machine) detach(s Session, unit string, argv ...string) error { + _ = m.cmd(5*time.Second, s.Env(), "systemctl", "--user", "stop", unit+".service") + call := []string{"--user", "--collect", "--quiet", "--unit=" + unit} + for _, kv := range [][2]string{{"DISPLAY", s.Display}, {"XAUTHORITY", s.XAuthority}} { + if kv[1] != "" { + call = append(call, "--setenv="+kv[0]+"="+kv[1]) + } + } + call = append(append(call, "--"), argv...) + return failed(m.cmd(8*time.Second, s.Env(), "systemd-run", call...), "systemd-run", call...) +} + +// stop ends every process of the account named in comms: SIGTERM, then SIGKILL for what is still +// there after grace. It answers the pids that ended and those that had to be killed. +func (m *Machine) stop(grace time.Duration, comms ...string) (ended, killed []int) { + var pids []int + for _, c := range comms { + for _, p := range m.procs(c) { + if m.Kill(p.PID, syscall.SIGTERM) == nil { + pids = append(pids, p.PID) + } + } + } + alive := func() []int { + var left []int + for _, pid := range pids { + if exists(m.path(filepath.Join("/proc", strconv.Itoa(pid)))) { + left = append(left, pid) + } + } + return left + } + step := 200 * time.Millisecond + for waited := time.Duration(0); waited < grace && len(alive()) > 0; waited += step { + m.Sleep(step) + } + left := alive() + for _, pid := range left { + if m.Kill(pid, syscall.SIGKILL) == nil { + killed = append(killed, pid) + } + } + gone := map[int]bool{} + for _, pid := range left { + gone[pid] = true + } + for _, pid := range pids { + if !gone[pid] { + ended = append(ended, pid) + } + } + return ended, killed +} + +// waitFor waits up to d for a process of the account named comm, and answers what it found. +func (m *Machine) waitFor(comm string, d time.Duration) []Proc { + step := 250 * time.Millisecond + for waited := time.Duration(0); ; waited += step { + if p := m.procs(comm); len(p) > 0 || waited >= d { + return p + } + m.Sleep(step) + } +} + +// desktopEntry reads the [Desktop Entry] group of an XDG desktop file; nil when there is none. +func desktopEntry(path string) map[string]string { + raw, err := readBounded(path) + if err != nil { + return nil + } + out := map[string]string{} + in := false + s := bufio.NewScanner(bytes.NewReader(raw)) + for s.Scan() { + l := strings.TrimSpace(s.Text()) + switch { + case strings.HasPrefix(l, "["): + in = l == "[Desktop Entry]" + case in && l != "" && !strings.HasPrefix(l, "#"): + if i := strings.IndexByte(l, '='); i > 0 { + out[strings.TrimSpace(l[:i])] = strings.TrimSpace(l[i+1:]) + } + } + } + return out +} + +// Autostart is what XDG autostart does with one entry: the account's file overrides the system's +// of the same name, and Hidden=true (or the GNOME switch off) means it is not started. +type Autostart struct { + Entry string `json:"entry"` + From string `json:"from"` + Exec string `json:"exec,omitempty"` + Starts bool `json:"starts"` + Because string `json:"because,omitempty"` +} + +// autostart resolves one XDG autostart entry by its file name, the account's directory first. +func (m *Machine) autostart(name string) Autostart { + a := Autostart{Entry: name} + user := m.home(".config", "autostart", name) + system := m.path(filepath.Join("/etc/xdg/autostart", name)) + var e map[string]string + switch { + case exists(user): + e, a.From = desktopEntry(user), m.tilde(filepath.Join(m.Home, ".config/autostart", name)) + case exists(system): + e, a.From = desktopEntry(system), filepath.Join("/etc/xdg/autostart", name) + default: + a.Because = "no such entry in ~/.config/autostart or /etc/xdg/autostart" + return a + } + a.Exec = e["Exec"] + switch { + case strings.EqualFold(e["Hidden"], "true"): + a.Because = "Hidden=true" + case strings.EqualFold(e["X-GNOME-Autostart-enabled"], "false"): + a.Because = "X-GNOME-Autostart-enabled=false" + case a.Exec == "": + a.Because = "the entry has no Exec" + default: + a.Starts = true + } + return a +} + +// i3Starts are the window manager's start-up lines (exec, exec_always) that run a program named +// word, in the configuration and its config.d: a second start beside an autostart entry. +func (m *Machine) i3Starts(word string) []string { + files := []string{m.home(".config", "i3", "config")} + more, _ := filepath.Glob(m.home(".config", "i3", "config.d", "*.conf")) + files = append(files, more...) + var out []string + for _, f := range files { + raw, err := readBounded(f) + if err != nil { + continue + } + for n, l := range strings.Split(string(raw), "\n") { + t := strings.TrimSpace(l) + if !strings.HasPrefix(t, "exec ") && !strings.HasPrefix(t, "exec_always ") { + continue + } + for _, w := range strings.Fields(t)[1:] { + if filepath.Base(strings.Trim(w, `"'`)) == word { + out = append(out, fmt.Sprintf("%s:%d: %s", m.tilde(strings.TrimPrefix(f, m.Root)), n+1, t)) + break + } + } + } + } + return out +} + +// installed asks the package manager for one package's version; "" when it is not installed. +func (m *Machine) installed(pkg string) (string, error) { + o := m.cmd(0, nil, "pacman", "-Q", pkg) + if o.Err != nil { + return "", failed(o, "pacman", "-Q", pkg) + } + if o.Code != 0 { + return "", nil + } + f := strings.Fields(o.Stdout) + if len(f) < 2 { + return "", fmt.Errorf("pacman -Q %s answered %q", pkg, o.Stdout) + } + return f[1], nil +} + +// Finding is one thing a check found wrong, and what to do about it. +type Finding struct { + What string `json:"what"` + Do string `json:"do,omitempty"` +} diff --git a/modules/blueman/cmd/blueman-tools/desktop_test.go b/modules/blueman/cmd/blueman-tools/desktop_test.go new file mode 100644 index 0000000..19b27df --- /dev/null +++ b/modules/blueman/cmd/blueman-tools/desktop_test.go @@ -0,0 +1,202 @@ +package main + +// The fake machine the tests run against, and the tests of desktop.go. The same in the +// nextcloud-client and blueman bundles. + +import ( + "context" + "os" + "path/filepath" + "strconv" + "strings" + "sync" + "syscall" + "testing" + "time" +) + +const testHome = "/home/operator" + +// fake is a machine with a fake root, a scripted Runner and signals that end fake processes. +type fake struct { + *Machine + t *testing.T + mu sync.Mutex + calls []string + answer func(name string, args []string) Output + // onStart is run when systemd-run starts something, to let a fake process appear. + onStart func(argv []string) + // stubborn pids ignore SIGTERM. + stubborn map[int]bool + signals []string +} + +func newFake(t *testing.T) *fake { + t.Helper() + root := t.TempDir() + f := &fake{t: t, stubborn: map[int]bool{}} + f.Machine = &Machine{Root: root, Home: testHome, UID: 1000, Timeout: CallTimeout, + Sleep: func(time.Duration) {}, Now: func() time.Time { return time.Unix(1_800_000_000, 0) }} + f.Run = func(_ context.Context, env []string, name string, args ...string) Output { + f.mu.Lock() + f.calls = append(f.calls, strings.TrimSpace(name+" "+strings.Join(args, " "))) + f.mu.Unlock() + if name == "systemd-run" && f.onStart != nil { + for i, a := range args { + if a == "--" { + f.onStart(args[i+1:]) + } + } + } + if f.answer != nil { + return f.answer(name, args) + } + return Output{} + } + f.Kill = func(pid int, sig syscall.Signal) error { + f.signals = append(f.signals, strconv.Itoa(pid)+":"+sig.String()) + if sig == syscall.SIGKILL || !f.stubborn[pid] { + return os.RemoveAll(filepath.Join(root, "proc", strconv.Itoa(pid))) + } + return nil + } + f.write("/proc/stat", "cpu 1 2 3\nbtime 1799990000\n") + return f +} + +func (f *fake) write(path, content string) { + f.t.Helper() + p := filepath.Join(f.Root, path) + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + f.t.Fatal(err) + } + if err := os.WriteFile(p, []byte(content), 0o644); err != nil { + f.t.Fatal(err) + } +} + +// proc adds a process of uid with a command name, argv, cgroup and environment. +func (f *fake) proc(pid, uid int, comm string, argv []string, cgroup string, env ...string) { + d := "/proc/" + strconv.Itoa(pid) + "/" + f.write(d+"comm", comm+"\n") + f.write(d+"status", "Name:\t"+comm+"\nUid:\t"+strconv.Itoa(uid)+"\t"+strconv.Itoa(uid)+"\t"+strconv.Itoa(uid)+"\t"+strconv.Itoa(uid)+"\n") + f.write(d+"cmdline", strings.Join(argv, "\x00")+"\x00") + f.write(d+"cgroup", "0::/user.slice/user-"+strconv.Itoa(uid)+".slice/"+cgroup+"\n") + f.write(d+"environ", strings.Join(env, "\x00")+"\x00") + // starttime (field 22) is 1000 ticks: 10 s after boot. + f.write(d+"stat", strconv.Itoa(pid)+" ("+comm+") S 1 1 1 0 -1 0 0 0 0 0 0 0 0 0 20 0 1 0 1000 0 0\n") +} + +func (f *fake) desktopSession() { + f.proc(3700, 1000, "i3", []string{"i3"}, "session-c1.scope", "DISPLAY=:1", "XAUTHORITY="+testHome+"/.Xauthority") + f.write("/run/user/1000/bus", "") +} + +func (f *fake) called(prefix string) bool { + for _, c := range f.calls { + if strings.HasPrefix(c, prefix) { + return true + } + } + return false +} + +func TestProcessesAreTheAccountsOwnWithWhereAndWhenTheyStarted(t *testing.T) { + f := newFake(t) + f.proc(10, 1000, "worker", []string{"/usr/bin/worker", "--background"}, "session-c1.scope") + f.proc(11, 1001, "worker", []string{"/usr/bin/worker"}, "session-c2.scope") + f.proc(12, 1000, "other", []string{"other"}, "x.scope") + got := f.procs("worker") + if len(got) != 1 || got[0].PID != 10 || got[0].Command != "/usr/bin/worker --background" || + got[0].StartedIn != "session-c1.scope" || got[0].Since != time.Unix(1799990010, 0).UTC().Format(time.RFC3339) { + t.Fatalf("%+v", got) + } +} + +func TestTheSessionIsTheWindowManagersAndNoneIsSaidPlainly(t *testing.T) { + f := newFake(t) + if _, err := f.session(); err == nil || !strings.Contains(err.Error(), "no graphical session") { + t.Fatalf("%v", err) + } + f.proc(50, 1000, "xterm", []string{"xterm"}, "s.scope", "DISPLAY=:9") + f.desktopSession() + f.proc(60, 1001, "i3", []string{"i3"}, "s.scope", "DISPLAY=:5") + s, err := f.session() + if err != nil || s.Display != ":1" || s.XAuthority != testHome+"/.Xauthority" || s.Bus != "unix:path=/run/user/1000/bus" || + !strings.Contains(s.From, "i3") { + t.Fatalf("%+v %v", s, err) + } +} + +func TestStopAsksThenForcesAndDetachStartsUnderTheServiceManager(t *testing.T) { + f := newFake(t) + f.desktopSession() + f.proc(20, 1000, "app", []string{"app"}, "s.scope") + f.proc(21, 1000, "app", []string{"app"}, "s.scope") + f.stubborn[21] = true + ended, killed := f.stop(time.Second, "app") + if len(ended) != 1 || ended[0] != 20 || len(killed) != 1 || killed[0] != 21 { + t.Fatalf("ended %v killed %v (%v)", ended, killed, f.signals) + } + s, _ := f.session() + if err := f.detach(s, "mesh-app", "/usr/bin/app", "--background"); err != nil { + t.Fatal(err) + } + want := "systemd-run --user --collect --quiet --unit=mesh-app --setenv=DISPLAY=:1 --setenv=XAUTHORITY=" + testHome + + "/.Xauthority -- /usr/bin/app --background" + if !f.called("systemctl --user stop mesh-app.service") || !f.called(want) { + t.Fatalf("%q", f.calls) + } +} + +func TestAnAutostartEntryOfTheAccountOverridesTheSystemsAndHiddenStartsNothing(t *testing.T) { + f := newFake(t) + if a := f.autostart("x.desktop"); a.Starts || a.Because == "" { + t.Fatalf("%+v", a) + } + f.write("/etc/xdg/autostart/x.desktop", "[Desktop Entry]\nExec=x-applet\n[Desktop Action y]\nExec=other\n") + if a := f.autostart("x.desktop"); !a.Starts || a.Exec != "x-applet" || a.From != "/etc/xdg/autostart/x.desktop" { + t.Fatalf("%+v", a) + } + f.write(testHome+"/.config/autostart/x.desktop", "[Desktop Entry]\nExec=x-applet\nHidden=true\n") + if a := f.autostart("x.desktop"); a.Starts || a.Because != "Hidden=true" || a.From != "~/.config/autostart/x.desktop" { + t.Fatalf("%+v", a) + } +} + +func TestAWindowManagerStartIsFoundInTheConfigurationAndItsDropIns(t *testing.T) { + f := newFake(t) + f.write(testHome+"/.config/i3/config", "exec --no-startup-id dex --autostart --environment i3\n# exec app\nbindsym $mod+a exec app\n") + f.write(testHome+"/.config/i3/config.d/50-x.conf", "exec_always --no-startup-id /usr/bin/app --flag\n") + got := f.i3Starts("app") + if len(got) != 1 || got[0] != "~/.config/i3/config.d/50-x.conf:1: exec_always --no-startup-id /usr/bin/app --flag" { + t.Fatalf("%q", got) + } +} + +func TestACommandThatFailsIsNamed(t *testing.T) { + if err := failed(Output{Code: 127, Err: ErrNotInstalled}, "dex"); err == nil || !strings.Contains(err.Error(), "dex is not installed") { + t.Fatal(err) + } + if err := failed(Output{Code: 1, Stderr: "nope"}, "pacman", "-Q", "x"); err == nil || !strings.Contains(err.Error(), "pacman -Q x exited 1: nope") { + t.Fatal(err) + } + if err := failed(Output{}, "true"); err != nil { + t.Fatal(err) + } +} + +func TestTheRealRunnerBoundsTimeAndOutput(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond) + defer cancel() + if o := execRun(ctx, nil, "sleep", "5"); o.Err != ErrTimedOut { + t.Fatalf("%+v", o) + } + if o := execRun(context.Background(), nil, "no-such-program-here"); o.Err != ErrNotInstalled { + t.Fatalf("%+v", o) + } + o := execRun(context.Background(), nil, "head", "-c", strconv.Itoa(MostOutput+10), "/dev/zero") + if !o.Cut || len(o.Stdout) != MostOutput { + t.Fatalf("cut %v, %d bytes", o.Cut, len(o.Stdout)) + } +} diff --git a/modules/blueman/cmd/blueman-tools/main.go b/modules/blueman/cmd/blueman-tools/main.go new file mode 100644 index 0000000..a51a4a9 --- /dev/null +++ b/modules/blueman/cmd/blueman-tools/main.go @@ -0,0 +1,48 @@ +// The blueman module's Go tools bundle (novox/hq ADR 0188, ADR 0193, ADR 0208): the Bluetooth tray +// applet in the operator's session, served by the node's runtime as the operator account. The module +// holds no seat, so every tool is its own. The devices themselves are the bluetooth module's tools. +package main + +import ( + "fmt" + "os" + + stdio "git.novox.be/novox/mesh-sdk/go" +) + +func main() { + if err := stdio.Serve("", tools()); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +var machine = NewMachine() + +func tools() []stdio.Tool { + return []stdio.Tool{ + { + Name: "blueman_status", + Description: "The Bluetooth applet: whether it and its tray icon run (pid, since, and the unit or " + + "session scope they run in), the installed version, what starts it at login, the plugins the " + + "running applet has loaded and those it has not, the plugin switches in the operator's settings, " + + "and whether the applet sees Bluetooth on. Never starts the applet. (r)", + Run: func(map[string]any) (any, error) { return machine.Status() }, + }, + { + Name: "blueman_restart", + Description: "End the applet and its tray icon (asked first, then forced after 5 s) and start the " + + "applet again in the operator's desktop session, under the account's service manager. Answers " + + "the pids ended and the new one. Needs someone logged in to the desktop. (a)", + Run: func(map[string]any) (any, error) { return machine.Restart() }, + }, + { + Name: "blueman_check", + Description: "Check what the module promises and relies on: the package is installed; the applet has " + + "exactly one start (the package's XDG autostart entry, which the session's dex runs; no " + + "window-manager exec); it runs once in a desktop session; and the Bluetooth daemon is running " + + "(the bluetooth module's). Answers ok and each finding with what to do. (r)", + Run: func(map[string]any) (any, error) { return machine.Check() }, + }, + } +} diff --git a/modules/blueman/cmd/blueman-tools/manifest_test.go b/modules/blueman/cmd/blueman-tools/manifest_test.go new file mode 100644 index 0000000..de3f7fa --- /dev/null +++ b/modules/blueman/cmd/blueman-tools/manifest_test.go @@ -0,0 +1,109 @@ +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "reflect" + "strings" + "testing" +) + +// blueman's shape (novox/hq ADR 0208, ADR 0210): one official package, no seat, the X display on its +// own machine, no start of its own (the package's autostart entry is the one start), nothing of the +// bluetooth module's (bluez, its utilities, its daemon), and the Go bundle serving exactly the listed +// blueman_ tools. + +type manifest struct { + Module string `json:"module"` + Version string `json:"version"` + Capabilities []string `json:"capabilities"` + Requires []string `json:"requires"` + Tools []string `json:"tools"` + Resources []map[string]any `json:"resources"` + Claims []any `json:"claims"` + Seats []any `json:"seats"` + Shell []any `json:"shell"` + Contributions []any `json:"contributions"` + Environment any `json:"environment"` + Build struct { + Artifacts []map[string]any `json:"artifacts"` + } `json:"build"` +} + +func readManifest(t *testing.T) (manifest, string) { + t.Helper() + raw, err := os.ReadFile(filepath.Join("..", "..", "module.json")) + if err != nil { + t.Fatal(err) + } + dec := json.NewDecoder(strings.NewReader(string(raw))) + dec.DisallowUnknownFields() + var m manifest + if err := dec.Decode(&m); err != nil { + t.Fatalf("module.json: %v", err) + } + return m, string(raw) +} + +func TestItInstallsTheAppletAndNothingElse(t *testing.T) { + m, _ := readManifest(t) + if m.Module != "blueman" || !reflect.DeepEqual(m.Requires, []string{"x11-display"}) || + !reflect.DeepEqual(m.Capabilities, []string{"package-manager"}) { + t.Fatalf("%+v", m) + } + if len(m.Resources) != 1 || m.Resources[0]["type"] != "package" || m.Resources[0]["package"] != packageFor { + t.Fatalf("resources: %v", m.Resources) + } + if m.Claims != nil || m.Seats != nil || m.Environment != nil { + t.Fatal("it holds no seat and sets no environment") + } +} + +func TestItAddsNoSecondStartAndDeclaresNothingOfTheBluetoothModule(t *testing.T) { + m, raw := readManifest(t) + // The package ships its XDG autostart entry, which the session's dex runs: an xinitrc slot or a + // window-manager exec would start it twice. + if m.Shell != nil || m.Contributions != nil { + t.Fatalf("a second start: shell %v, contributions %v", m.Shell, m.Contributions) + } + for _, never := range []string{"autostart", "service", "bluez", "/etc/bluetooth"} { + if strings.Contains(raw, never) { + t.Errorf("module.json names %q: the start is the package's, the stack the bluetooth module's", never) + } + } +} + +func TestTheToolsAgreeWithTheManifest(t *testing.T) { + m, raw := readManifest(t) + served := map[string]bool{} + for _, tool := range tools() { + served[tool.Name] = true + if !strings.HasPrefix(tool.Name, "blueman_") || strings.TrimSpace(tool.Description) == "" { + t.Errorf("%s: prefixed blueman_ and described", tool.Name) + } + } + for _, name := range m.Tools { + if !served[name] { + t.Errorf("module.json lists %s, which the bundle does not serve", name) + } + delete(served, name) + } + for name := range served { + t.Errorf("the bundle serves %s, which module.json does not list", name) + } + if len(m.Build.Artifacts) != 1 { + t.Fatalf("%v", m.Build.Artifacts) + } + b := m.Build.Artifacts[0] + if b["kind"] != "bundle" || b["language"] != "go" || b["system"] != "arch" || + b["from"] != "cmd/blueman-tools" || b["binary"] != "blueman-tools" { + t.Errorf("the Go tools bundle: %v", b) + } + s := strings.ToLower(raw) + for _, never := range []string{"/home/", "jochen", "g14", "shanks", "novox.be", "http", "password", "token"} { + if strings.Contains(s, never) { + t.Errorf("module.json names %q", never) + } + } +} diff --git a/modules/blueman/go.mod b/modules/blueman/go.mod new file mode 100644 index 0000000..b3fb186 --- /dev/null +++ b/modules/blueman/go.mod @@ -0,0 +1,5 @@ +module blueman + +go 1.22 + +require git.novox.be/novox/mesh-sdk/go v0.1.7 diff --git a/modules/blueman/go.sum b/modules/blueman/go.sum new file mode 100644 index 0000000..b474419 --- /dev/null +++ b/modules/blueman/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/blueman/module.json b/modules/blueman/module.json new file mode 100644 index 0000000..73a7640 --- /dev/null +++ b/modules/blueman/module.json @@ -0,0 +1,37 @@ +{ + "module": "blueman", + "version": "1", + "capabilities": [ + "package-manager" + ], + "requires": [ + "x11-display" + ], + "tools": [ + "blueman_status", + "blueman_restart", + "blueman_check" + ], + "resources": [ + { + "id": "package", + "type": "package", + "package": "blueman" + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/blueman-tools", + "binary": "blueman-tools", + "loads": [ + "blueman-tools" + ] + } + ] + } +} diff --git a/modules/bluetooth/README.md b/modules/bluetooth/README.md index 08c6a71..1353d6a 100644 --- a/modules/bluetooth/README.md +++ b/modules/bluetooth/README.md @@ -48,6 +48,6 @@ running. `bluez` becomes explicitly the mesh's. ## Leaves as found - The paired devices and their keys under `/var/lib/bluetooth` (bluez's state). -- `blueman` on both workstations, and its applet, which the window manager's configuration starts. - That line is the `i3` module's to keep or drop. +- `blueman` and its applet: the `blueman` module's, which relies on this one for the stack and + declares none of its packages. The applet starts from the package's XDG autostart entry. - `bluez-obex` and the AUR terminal client `bluetuith-bin` (with its `-debug`) on the laptop. diff --git a/modules/nextcloud-client/README.md b/modules/nextcloud-client/README.md new file mode 100644 index 0000000..1ef7b1f --- /dev/null +++ b/modules/nextcloud-client/README.md @@ -0,0 +1,106 @@ +# nextcloud-client + +The Nextcloud desktop sync client on the workstations, as a module (novox/hq ADR 0208). It requires +`x11-display`, so it is assigned only where a display server is held on the same machine. + +## Owns + +| what | where | +|---|---| +| the client | package `nextcloud-client`, from the official repositories | + +Nothing else. It holds no seat, makes no contribution and writes no file. + +- **No AUR, no vendored copy.** Both workstations run the official package (`extra`), installed + explicitly. ADR 0205 does not apply. +- **The account configuration stays the operator's.** `~/.config/Nextcloud/nextcloud.cfg` is the + client's own file, and the client rewrites it. That makes it *found* in ADR 0182's terms: the module + never declares, reads into or writes it. The tools only read it. The accounts, the sync folders, the + server and the credentials are set in the client. + +## How it starts: the client's own autostart entry, and nothing else + +One process has one starter (the rule `picom` states for the desktop modules). The client's starter is +**its own XDG autostart entry**, `~/.config/autostart/Nextcloud.desktop` (`nextcloud --background`). + +- The client writes that entry itself while its setting *Launch on system startup* is ticked, and + removes it when the setting is unticked. +- The session runs every XDG autostart entry once at login: the `i3` module's + `dex --autostart --environment i3`. +- The package ships no `/etc/xdg/autostart` entry. + +**Why not a contribution to `node-display-session` or the `xinitrc` slot:** the client would still +write its own entry whenever the setting is ticked, and the session would start it twice. The module +cannot own the entry either, because the client rewrites it on every start. Declaring that file would +make two writers of one file. So the module adds no start, and `nextcloud_check` holds the rule +instead: it names any second start it finds. + +- **Excluded:** the window manager's `exec … nextcloud` (the `i3` module's configuration dropped it), + and the package's user unit `com.nextcloud.desktopclient.nextcloud.service`, which stays disabled. + User-scoped units are not declarable yet (mesh-host #72). +- **One caveat:** `dex` ignores the entry's `X-GNOME-Autostart-Delay=10`, so the client starts with + the session. It retries its connection by itself, so that is harmless. + +## Tools + +They are served by the node's runtime as the operator account (ADR 0175), and are read-only except +`restart`. **No answer carries the server's address, the account's user ids or a credential.** + +- `nextcloud.cfg` is read only to learn what to hide. +- The log tools replace the server's host with `` and the user ids with ``. +- Anything shaped like a credential (`Authorization:`, `token=`, `password=`, a cookie) becomes ``. + +| tool | does | +|---|---| +| `nextcloud_status` (r) |
  • whether the client runs: pid, since, and the scope or unit it runs in
  • the installed version, and what starts it at login
  • each account by display name and auth type, with each sync folder: local path (`~/…`), remote path, paused, virtual files, journal present
  • each folder's **last sync run**: started, finished or still running, items, errors, the first ten failing files
  • the latest warnings and worse in the client's log
| +| `nextcloud_log` (r) | the last `lines` (default 100, at most 2000) of the client's log (`source: client`). The log rotates every two hours, and older gzipped files are read until the count is reached. `problems: true` keeps warnings and worse. `source: sync` gives the sync runs' log. Answers are capped at 256 KiB | +| `nextcloud_restart` (a) | asks the client to end (SIGTERM), forces it after 6 s, and starts `nextcloud --background` in the operator's session. The start is a transient user unit `mesh-nextcloud-client`, so it outlives the tools runtime. Answers the pids. Refused plainly when nobody is logged in to the desktop | +| `nextcloud_check` (r) |
  • the package is installed
  • exactly one start: the entry is present and enabled, and `dex` is installed
  • no window-manager exec and no enabled user unit
  • one client runs in a desktop session
  • an account exists, its folders exist with a journal, and none is paused
Each finding says what to do | + +**Where the tools read:** + +- The client's settings are read from `~/.config/Nextcloud/nextcloud.cfg`. +- Its log is read from `~/.config/Nextcloud/logs/*_nextcloud.log*`. +- Each folder's sync runs are read from the `*_sync.log` whose first line is that folder's path. That + file is in `~/.local/share/Nextcloud/`, or in `~/.config/Nextcloud/` for older clients, and the + newest one wins. + +The tools find the session's `DISPLAY` and `XAUTHORITY` from the window manager's own environment, +as `clipmenu` and `screen-lock` do. Every command has a timeout and capped output. Everything runs +through an injected runner and a fake root in the tests. + +## What changes when it is assigned + +| | g14 | shanks | +|---|---|---| +| package | none: `nextcloud-client` 34.0.4 is installed, explicitly, from `extra` | the same | +| start | none: dex starts it from the client's own entry (`--background`, in the login session's scope) | none on disk. **The client running now came from the predecessor's window-manager line** (`nextcloud`, a child of i3, since the session of 2026-10-04 16:00). That session began before the `i3` module dropped the line and installed `dex`, so the next login is the first that starts it from its entry | +| settings | one account, one folder (`~/Nextcloud/`, whole server), not paused, no virtual files; *Launch on system startup* on | the same | + +The workstations are already in the state this module describes. + +## Migration (ADR 0182) + +Nothing is required on either machine. + +- **shanks:** log out and in once, or run `nextcloud_restart`, and the client runs from its one start. + `nextcloud_check` then answers `ok`. +- **Optional, both:** the client has kept a `nextcloud.cfg.backup__` from every upgrade + since 2023 (about twenty on each machine). It also keeps a sync log that it no longer writes, at + `~/.config/Nextcloud/Nextcloud_sync.log`, from 2024 on g14 and 2023 on shanks. They are the + operator's to delete. The module leaves them. + +## Leaves as found + +- `~/.config/Nextcloud/`: the settings, their backups, `cookies0.db`, `sync-exclude.lst`, the logs. +- `~/.local/share/Nextcloud/`: the sync runs' log. +- `~/.config/autostart/Nextcloud.desktop`, the client's. +- Every sync folder and its `.sync_*.db` journal. + +## Relies on + +- **`i3`'s `dex` line for the start.** Nothing in the mesh says so yet: XDG autostart has no seat, and + a module without a seat or contribution has no way to depend on another module. Assigned without + `i3`, the client is installed and does not start. `nextcloud_check` says so. +- A display server on the same machine (`x11-display`, ADR 0208 §3). Under sway the client runs on + Wayland as well. A Wayland twin then requires `wayland-display`. diff --git a/modules/nextcloud-client/cmd/nextcloud-client-tools/args.go b/modules/nextcloud-client/cmd/nextcloud-client-tools/args.go new file mode 100644 index 0000000..9b5dfcf --- /dev/null +++ b/modules/nextcloud-client/cmd/nextcloud-client-tools/args.go @@ -0,0 +1,97 @@ +// Reading a tool's arguments: JSON numbers arrive as float64, and a missing argument is its default. +// The same in every desktop module that carries it. +package main + +import ( + "fmt" + "math" + "strings" + "time" +) + +// text is a string argument, trimmed; required says an empty one is refused. +func text(args map[string]any, key string, required bool) (string, error) { + v, present := args[key] + if !present || v == nil { + if required { + return "", fmt.Errorf("%s is required", key) + } + return "", nil + } + s, ok := v.(string) + if !ok { + return "", fmt.Errorf("%s is a string, not %T", key, v) + } + s = strings.TrimSpace(s) + if s == "" && required { + return "", fmt.Errorf("%s is required", key) + } + return s, nil +} + +// whole is a whole-number argument within [least, most], or def when absent. +func whole(args map[string]any, key string, def, least, most int) (int, error) { + v, present := args[key] + if !present || v == nil { + return def, nil + } + f, ok := v.(float64) + if !ok { + if i, isInt := v.(int); isInt { + f = float64(i) + } else { + return 0, fmt.Errorf("%s is a number, not %T", key, v) + } + } + if f != math.Trunc(f) { + return 0, fmt.Errorf("%s is a whole number, not %v", key, f) + } + n := int(f) + if n < least || n > most { + return 0, fmt.Errorf("%s is %d; it is between %d and %d", key, n, least, most) + } + return n, nil +} + +// flag is a boolean argument, or def when absent. +func flag(args map[string]any, key string, def bool) (bool, error) { + v, present := args[key] + if !present || v == nil { + return def, nil + } + b, ok := v.(bool) + if !ok { + return false, fmt.Errorf("%s is true or false, not %T", key, v) + } + return b, nil +} + +// texts is a list-of-strings argument. +func texts(args map[string]any, key string) ([]string, error) { + v, present := args[key] + if !present || v == nil { + return nil, nil + } + list, ok := v.([]any) + if !ok { + if ss, isStrings := v.([]string); isStrings { + return ss, nil + } + return nil, fmt.Errorf("%s is a list of strings, not %T", key, v) + } + out := make([]string, 0, len(list)) + for i, item := range list { + s, ok := item.(string) + if !ok { + return nil, fmt.Errorf("%s[%d] is a string, not %T", key, i, item) + } + out = append(out, s) + } + return out, nil +} + +// seconds is a timeout argument in seconds, defaulted and bounded below the runtime's call limit. +func seconds(args map[string]any, key string, def, most int) (time.Duration, error) { + n, err := whole(args, key, def, 1, most) + return time.Duration(n) * time.Second, err +} diff --git a/modules/nextcloud-client/cmd/nextcloud-client-tools/desktop.go b/modules/nextcloud-client/cmd/nextcloud-client-tools/desktop.go new file mode 100644 index 0000000..f2efcf4 --- /dev/null +++ b/modules/nextcloud-client/cmd/nextcloud-client-tools/desktop.go @@ -0,0 +1,574 @@ +package main + +// desktop.go is the same file in the nextcloud-client and blueman bundles: a tray application of the +// operator's graphical session, seen from the node's tool runtime (novox/hq ADR 0208). +// +// The runtime is a system service running as the operator account (ADR 0175): it has the account's +// uid and none of the session's environment. A tool that starts something on the desktop finds the +// session from a process of the account that carries DISPLAY (the window manager first), and starts +// the program under the account's own service manager with `systemd-run --user`, never as its own +// child: the runtime's unit is a cgroup that is emptied whenever the runtime restarts. +// +// Everything a tool touches goes through a Machine: its filesystem root, its commands (a Runner) and +// its signals are injected, so the tests run against a fake /proc and a fake home. +// +// Bounds: one command gets at most CallTimeout (below the runtime's 30 s call limit) and is ended +// with everything it started when it takes longer; each stream is kept to MostOutput; a file is read +// to at most MostRead. + +import ( + "bufio" + "bytes" + "context" + "errors" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "sort" + "strconv" + "strings" + "syscall" + "time" +) + +// Bounds every command and read is held to. +const ( + CallTimeout = 10 * time.Second + MostOutput = 256 << 10 + MostRead = 16 << 20 +) + +// Output is what a command did. +type Output struct { + Stdout string + Stderr string + Code int + // Err is why it did not run to an answer: not installed, ended on its timeout, or the spawn error. + Err error + Cut bool +} + +// ErrNotInstalled and ErrTimedOut are what a Runner answers in Output.Err. +var ( + ErrNotInstalled = errors.New("not installed") + ErrTimedOut = errors.New("timed out") + // ErrNoSession is answered by a tool that needs the desktop when nobody is logged in to it. + ErrNoSession = errors.New("no graphical session") +) + +// Runner runs one command with extra environment, within the context's deadline. Tests replace it. +type Runner func(ctx context.Context, env []string, name string, args ...string) Output + +// Machine is what the tools read and act on. +type Machine struct { + Root string // "" on the machine; a fake root in tests + Home string // the operator's home, as the machine names it + UID int + Run Runner + Kill func(pid int, sig syscall.Signal) error + Sleep func(time.Duration) + Now func() time.Time + Timeout time.Duration +} + +// NewMachine is the machine the bundle runs on. +func NewMachine() *Machine { + return &Machine{Home: operatorHome(), UID: os.Getuid(), Run: execRun, Kill: syscall.Kill, + Sleep: time.Sleep, Now: time.Now, Timeout: CallTimeout} +} + +// operatorHome is the account's home: what the runtime was told, else the process's own. +func operatorHome() string { + if h := strings.TrimSpace(os.Getenv("MESH_OPERATOR_HOME")); h != "" { + return h + } + h, _ := os.UserHomeDir() + return h +} + +func (m *Machine) path(p string) string { return filepath.Join(m.Root, p) } + +// home is a path under the operator's home, on this machine's filesystem. +func (m *Machine) home(rel ...string) string { + return filepath.Join(append([]string{m.Root, m.Home}, rel...)...) +} + +// tilde shows a path under the home as ~/…, so an answer does not carry the account's name. +func (m *Machine) tilde(p string) string { + if m.Home != "" && m.Home != "/" { + h := strings.TrimSuffix(m.Home, "/") + if p == h { + return "~" + } + if strings.HasPrefix(p, h+"/") { + return "~/" + strings.TrimPrefix(p, h+"/") + } + } + return p +} + +// cmd runs a command within the machine's timeout (or a shorter one). +func (m *Machine) cmd(timeout time.Duration, env []string, name string, args ...string) Output { + if timeout <= 0 || timeout > m.Timeout { + timeout = m.Timeout + } + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + return m.Run(ctx, env, name, args...) +} + +// failed names how a command failed, or answers nil when it ran and exited 0. +func failed(o Output, name string, args ...string) error { + switch { + case errors.Is(o.Err, ErrNotInstalled): + return fmt.Errorf("%s is not installed on this machine", name) + case errors.Is(o.Err, ErrTimedOut): + return fmt.Errorf("%s gave no answer in time and was ended", name) + case o.Err != nil: + return fmt.Errorf("%s did not run: %v", name, o.Err) + case o.Code != 0: + said := strings.TrimSpace(o.Stderr) + if said == "" { + said = strings.TrimSpace(o.Stdout) + } + if said == "" { + said = "and said nothing" + } + return fmt.Errorf("%s %s exited %d: %s", name, strings.Join(args, " "), o.Code, tail(said, 1000)) + } + return nil +} + +func tail(s string, n int) string { + if len(s) <= n { + return s + } + return "…" + s[len(s)-n:] +} + +type capped struct { + b bytes.Buffer + cut bool +} + +func (c *capped) Write(p []byte) (int, error) { + if room := MostOutput - c.b.Len(); room < len(p) { + if room > 0 { + c.b.Write(p[:room]) + } + c.cut = true + return len(p), nil + } + return c.b.Write(p) +} + +func execRun(ctx context.Context, env []string, name string, args ...string) Output { + path, err := exec.LookPath(name) + if err != nil { + return Output{Code: 127, Err: ErrNotInstalled} + } + cmd := exec.CommandContext(ctx, path, args...) + cmd.Env = append(append(os.Environ(), "LC_ALL=C"), env...) + // Its own process group, so that ending it on a timeout ends what it started too. + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + cmd.Cancel = func() error { + if cmd.Process != nil { + _ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) + } + return nil + } + cmd.WaitDelay = 2 * time.Second + var out, errs capped + cmd.Stdout, cmd.Stderr = &out, &errs + err = cmd.Run() + o := Output{Stdout: out.b.String(), Stderr: errs.b.String(), Cut: out.cut || errs.cut} + var exit *exec.ExitError + switch { + case err == nil: + case ctx.Err() == context.DeadlineExceeded: + o.Code, o.Err = 124, ErrTimedOut + case errors.As(err, &exit): + o.Code = exit.ExitCode() + default: + o.Code, o.Err = 127, err + } + return o +} + +// readBounded reads a file to at most MostRead bytes. +func readBounded(path string) ([]byte, error) { + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer f.Close() + return io.ReadAll(io.LimitReader(f, MostRead)) +} + +// Proc is one process of the account. +type Proc struct { + PID int `json:"pid"` + Command string `json:"command"` + // StartedIn is the unit or scope it runs in: the login session's scope when the session's start + // (dex, the window manager) started it, a mesh-… unit when a tool restarted it. + StartedIn string `json:"started_in,omitempty"` + Since string `json:"since,omitempty"` +} + +// procs are this account's processes named comm, oldest first. +func (m *Machine) procs(comm string) []Proc { + entries, err := os.ReadDir(m.path("/proc")) + if err != nil { + return nil + } + boot := m.bootTime() + var out []Proc + for _, e := range entries { + pid, err := strconv.Atoi(e.Name()) + if err != nil { + continue + } + dir := m.path(filepath.Join("/proc", e.Name())) + if readTrimmed(filepath.Join(dir, "comm")) != comm || m.uidOf(dir) != m.UID { + continue + } + p := Proc{PID: pid, Command: strings.TrimSpace(strings.ReplaceAll(readTrimmed(filepath.Join(dir, "cmdline")), "\x00", " "))} + if p.Command == "" { + p.Command = comm + } + if cg := readTrimmed(filepath.Join(dir, "cgroup")); cg != "" { + line := strings.Split(cg, "\n")[0] + p.StartedIn = filepath.Base(line[strings.LastIndexByte(line, ':')+1:]) + } + if t, ok := startOf(readTrimmed(filepath.Join(dir, "stat")), boot); ok { + p.Since = t.UTC().Format(time.RFC3339) + } + out = append(out, p) + } + sort.Slice(out, func(i, j int) bool { return out[i].PID < out[j].PID }) + return out +} + +// uidOf is the real uid on a process's status, -1 when unreadable. +func (m *Machine) uidOf(dir string) int { + for _, l := range strings.Split(readTrimmed(filepath.Join(dir, "status")), "\n") { + if f := strings.Fields(l); len(f) > 1 && f[0] == "Uid:" { + if n, err := strconv.Atoi(f[1]); err == nil { + return n + } + } + } + return -1 +} + +func (m *Machine) bootTime() int64 { + for _, l := range strings.Split(readTrimmed(m.path("/proc/stat")), "\n") { + if f := strings.Fields(l); len(f) == 2 && f[0] == "btime" { + n, _ := strconv.ParseInt(f[1], 10, 64) + return n + } + } + return 0 +} + +// startOf reads a process's start from its stat line (field 22, in clock ticks of 1/100 s since boot). +func startOf(stat string, boot int64) (time.Time, bool) { + i := strings.LastIndexByte(stat, ')') + if i < 0 || boot == 0 { + return time.Time{}, false + } + f := strings.Fields(stat[i+1:]) + if len(f) < 20 { + return time.Time{}, false + } + ticks, err := strconv.ParseInt(f[19], 10, 64) + if err != nil { + return time.Time{}, false + } + return time.Unix(boot+ticks/100, 0), true +} + +func readTrimmed(path string) string { + b, err := os.ReadFile(path) + if err != nil { + return "" + } + return strings.TrimSpace(string(b)) +} + +func exists(path string) bool { + _, err := os.Stat(path) + return err == nil +} + +// Session is what a tool needs to start something on the operator's desktop. +type Session struct { + Display string `json:"display"` + XAuthority string `json:"xauthority,omitempty"` + Bus string `json:"bus,omitempty"` + RuntimeDir string `json:"runtime_dir,omitempty"` + From string `json:"found_in"` +} + +// sessionHolders are the processes whose environment is the session's, best first. +var sessionHolders = []string{"i3", "sway", "i3bar", "picom", "dunst", "xterm"} + +// session finds the account's graphical session, or ErrNoSession saying what it looked at. +func (m *Machine) session() (Session, error) { + entries, _ := os.ReadDir(m.path("/proc")) + best, bestRank := -1, len(sessionHolders)+1 + var env map[string]string + var from string + for _, e := range entries { + pid, err := strconv.Atoi(e.Name()) + if err != nil { + continue + } + dir := m.path(filepath.Join("/proc", e.Name())) + if m.uidOf(dir) != m.UID { + continue + } + raw, err := os.ReadFile(filepath.Join(dir, "environ")) + if err != nil { + continue + } + vars := parseEnviron(raw) + if vars["DISPLAY"] == "" { + continue + } + comm := readTrimmed(filepath.Join(dir, "comm")) + rank := len(sessionHolders) + for i, h := range sessionHolders { + if h == comm { + rank = i + } + } + if rank < bestRank || (rank == bestRank && pid > best) { + best, bestRank, env, from = pid, rank, vars, fmt.Sprintf("process %s (pid %d)", comm, pid) + } + } + if env == nil { + return Session{}, fmt.Errorf("%w for uid %d on this machine: no process of the account carries DISPLAY. "+ + "Is anyone logged in to the desktop?", ErrNoSession, m.UID) + } + s := Session{Display: env["DISPLAY"], XAuthority: env["XAUTHORITY"], Bus: env["DBUS_SESSION_BUS_ADDRESS"], + RuntimeDir: env["XDG_RUNTIME_DIR"], From: from} + if s.RuntimeDir == "" { + s.RuntimeDir = fmt.Sprintf("/run/user/%d", m.UID) + } + if s.Bus == "" && exists(m.path(filepath.Join(s.RuntimeDir, "bus"))) { + s.Bus = "unix:path=" + filepath.Join(s.RuntimeDir, "bus") + } + return s, nil +} + +// bus is the account's session bus environment, which a logged-in account has with or without a +// desktop: what a command needs to reach the user's service manager or a bus name. +func (m *Machine) bus() []string { + runtime := fmt.Sprintf("/run/user/%d", m.UID) + return []string{"XDG_RUNTIME_DIR=" + runtime, "DBUS_SESSION_BUS_ADDRESS=unix:path=" + runtime + "/bus"} +} + +// Env is the session's variables, for a command that draws or speaks to the desktop. +func (s Session) Env() []string { + var env []string + for _, kv := range [][2]string{{"DISPLAY", s.Display}, {"XAUTHORITY", s.XAuthority}, + {"DBUS_SESSION_BUS_ADDRESS", s.Bus}, {"XDG_RUNTIME_DIR", s.RuntimeDir}} { + if kv[1] != "" { + env = append(env, kv[0]+"="+kv[1]) + } + } + return env +} + +func parseEnviron(raw []byte) map[string]string { + env := map[string]string{} + for _, kv := range bytes.Split(raw, []byte{0}) { + if i := bytes.IndexByte(kv, '='); i > 0 { + env[string(kv[:i])] = string(kv[i+1:]) + } + } + return env +} + +// detach starts a long-lived program under the account's service manager, as a transient unit that +// carries the session's display. A unit left by an earlier start under the same name is stopped +// first, so the fixed name means at most one. +func (m *Machine) detach(s Session, unit string, argv ...string) error { + _ = m.cmd(5*time.Second, s.Env(), "systemctl", "--user", "stop", unit+".service") + call := []string{"--user", "--collect", "--quiet", "--unit=" + unit} + for _, kv := range [][2]string{{"DISPLAY", s.Display}, {"XAUTHORITY", s.XAuthority}} { + if kv[1] != "" { + call = append(call, "--setenv="+kv[0]+"="+kv[1]) + } + } + call = append(append(call, "--"), argv...) + return failed(m.cmd(8*time.Second, s.Env(), "systemd-run", call...), "systemd-run", call...) +} + +// stop ends every process of the account named in comms: SIGTERM, then SIGKILL for what is still +// there after grace. It answers the pids that ended and those that had to be killed. +func (m *Machine) stop(grace time.Duration, comms ...string) (ended, killed []int) { + var pids []int + for _, c := range comms { + for _, p := range m.procs(c) { + if m.Kill(p.PID, syscall.SIGTERM) == nil { + pids = append(pids, p.PID) + } + } + } + alive := func() []int { + var left []int + for _, pid := range pids { + if exists(m.path(filepath.Join("/proc", strconv.Itoa(pid)))) { + left = append(left, pid) + } + } + return left + } + step := 200 * time.Millisecond + for waited := time.Duration(0); waited < grace && len(alive()) > 0; waited += step { + m.Sleep(step) + } + left := alive() + for _, pid := range left { + if m.Kill(pid, syscall.SIGKILL) == nil { + killed = append(killed, pid) + } + } + gone := map[int]bool{} + for _, pid := range left { + gone[pid] = true + } + for _, pid := range pids { + if !gone[pid] { + ended = append(ended, pid) + } + } + return ended, killed +} + +// waitFor waits up to d for a process of the account named comm, and answers what it found. +func (m *Machine) waitFor(comm string, d time.Duration) []Proc { + step := 250 * time.Millisecond + for waited := time.Duration(0); ; waited += step { + if p := m.procs(comm); len(p) > 0 || waited >= d { + return p + } + m.Sleep(step) + } +} + +// desktopEntry reads the [Desktop Entry] group of an XDG desktop file; nil when there is none. +func desktopEntry(path string) map[string]string { + raw, err := readBounded(path) + if err != nil { + return nil + } + out := map[string]string{} + in := false + s := bufio.NewScanner(bytes.NewReader(raw)) + for s.Scan() { + l := strings.TrimSpace(s.Text()) + switch { + case strings.HasPrefix(l, "["): + in = l == "[Desktop Entry]" + case in && l != "" && !strings.HasPrefix(l, "#"): + if i := strings.IndexByte(l, '='); i > 0 { + out[strings.TrimSpace(l[:i])] = strings.TrimSpace(l[i+1:]) + } + } + } + return out +} + +// Autostart is what XDG autostart does with one entry: the account's file overrides the system's +// of the same name, and Hidden=true (or the GNOME switch off) means it is not started. +type Autostart struct { + Entry string `json:"entry"` + From string `json:"from"` + Exec string `json:"exec,omitempty"` + Starts bool `json:"starts"` + Because string `json:"because,omitempty"` +} + +// autostart resolves one XDG autostart entry by its file name, the account's directory first. +func (m *Machine) autostart(name string) Autostart { + a := Autostart{Entry: name} + user := m.home(".config", "autostart", name) + system := m.path(filepath.Join("/etc/xdg/autostart", name)) + var e map[string]string + switch { + case exists(user): + e, a.From = desktopEntry(user), m.tilde(filepath.Join(m.Home, ".config/autostart", name)) + case exists(system): + e, a.From = desktopEntry(system), filepath.Join("/etc/xdg/autostart", name) + default: + a.Because = "no such entry in ~/.config/autostart or /etc/xdg/autostart" + return a + } + a.Exec = e["Exec"] + switch { + case strings.EqualFold(e["Hidden"], "true"): + a.Because = "Hidden=true" + case strings.EqualFold(e["X-GNOME-Autostart-enabled"], "false"): + a.Because = "X-GNOME-Autostart-enabled=false" + case a.Exec == "": + a.Because = "the entry has no Exec" + default: + a.Starts = true + } + return a +} + +// i3Starts are the window manager's start-up lines (exec, exec_always) that run a program named +// word, in the configuration and its config.d: a second start beside an autostart entry. +func (m *Machine) i3Starts(word string) []string { + files := []string{m.home(".config", "i3", "config")} + more, _ := filepath.Glob(m.home(".config", "i3", "config.d", "*.conf")) + files = append(files, more...) + var out []string + for _, f := range files { + raw, err := readBounded(f) + if err != nil { + continue + } + for n, l := range strings.Split(string(raw), "\n") { + t := strings.TrimSpace(l) + if !strings.HasPrefix(t, "exec ") && !strings.HasPrefix(t, "exec_always ") { + continue + } + for _, w := range strings.Fields(t)[1:] { + if filepath.Base(strings.Trim(w, `"'`)) == word { + out = append(out, fmt.Sprintf("%s:%d: %s", m.tilde(strings.TrimPrefix(f, m.Root)), n+1, t)) + break + } + } + } + } + return out +} + +// installed asks the package manager for one package's version; "" when it is not installed. +func (m *Machine) installed(pkg string) (string, error) { + o := m.cmd(0, nil, "pacman", "-Q", pkg) + if o.Err != nil { + return "", failed(o, "pacman", "-Q", pkg) + } + if o.Code != 0 { + return "", nil + } + f := strings.Fields(o.Stdout) + if len(f) < 2 { + return "", fmt.Errorf("pacman -Q %s answered %q", pkg, o.Stdout) + } + return f[1], nil +} + +// Finding is one thing a check found wrong, and what to do about it. +type Finding struct { + What string `json:"what"` + Do string `json:"do,omitempty"` +} diff --git a/modules/nextcloud-client/cmd/nextcloud-client-tools/desktop_test.go b/modules/nextcloud-client/cmd/nextcloud-client-tools/desktop_test.go new file mode 100644 index 0000000..19b27df --- /dev/null +++ b/modules/nextcloud-client/cmd/nextcloud-client-tools/desktop_test.go @@ -0,0 +1,202 @@ +package main + +// The fake machine the tests run against, and the tests of desktop.go. The same in the +// nextcloud-client and blueman bundles. + +import ( + "context" + "os" + "path/filepath" + "strconv" + "strings" + "sync" + "syscall" + "testing" + "time" +) + +const testHome = "/home/operator" + +// fake is a machine with a fake root, a scripted Runner and signals that end fake processes. +type fake struct { + *Machine + t *testing.T + mu sync.Mutex + calls []string + answer func(name string, args []string) Output + // onStart is run when systemd-run starts something, to let a fake process appear. + onStart func(argv []string) + // stubborn pids ignore SIGTERM. + stubborn map[int]bool + signals []string +} + +func newFake(t *testing.T) *fake { + t.Helper() + root := t.TempDir() + f := &fake{t: t, stubborn: map[int]bool{}} + f.Machine = &Machine{Root: root, Home: testHome, UID: 1000, Timeout: CallTimeout, + Sleep: func(time.Duration) {}, Now: func() time.Time { return time.Unix(1_800_000_000, 0) }} + f.Run = func(_ context.Context, env []string, name string, args ...string) Output { + f.mu.Lock() + f.calls = append(f.calls, strings.TrimSpace(name+" "+strings.Join(args, " "))) + f.mu.Unlock() + if name == "systemd-run" && f.onStart != nil { + for i, a := range args { + if a == "--" { + f.onStart(args[i+1:]) + } + } + } + if f.answer != nil { + return f.answer(name, args) + } + return Output{} + } + f.Kill = func(pid int, sig syscall.Signal) error { + f.signals = append(f.signals, strconv.Itoa(pid)+":"+sig.String()) + if sig == syscall.SIGKILL || !f.stubborn[pid] { + return os.RemoveAll(filepath.Join(root, "proc", strconv.Itoa(pid))) + } + return nil + } + f.write("/proc/stat", "cpu 1 2 3\nbtime 1799990000\n") + return f +} + +func (f *fake) write(path, content string) { + f.t.Helper() + p := filepath.Join(f.Root, path) + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + f.t.Fatal(err) + } + if err := os.WriteFile(p, []byte(content), 0o644); err != nil { + f.t.Fatal(err) + } +} + +// proc adds a process of uid with a command name, argv, cgroup and environment. +func (f *fake) proc(pid, uid int, comm string, argv []string, cgroup string, env ...string) { + d := "/proc/" + strconv.Itoa(pid) + "/" + f.write(d+"comm", comm+"\n") + f.write(d+"status", "Name:\t"+comm+"\nUid:\t"+strconv.Itoa(uid)+"\t"+strconv.Itoa(uid)+"\t"+strconv.Itoa(uid)+"\t"+strconv.Itoa(uid)+"\n") + f.write(d+"cmdline", strings.Join(argv, "\x00")+"\x00") + f.write(d+"cgroup", "0::/user.slice/user-"+strconv.Itoa(uid)+".slice/"+cgroup+"\n") + f.write(d+"environ", strings.Join(env, "\x00")+"\x00") + // starttime (field 22) is 1000 ticks: 10 s after boot. + f.write(d+"stat", strconv.Itoa(pid)+" ("+comm+") S 1 1 1 0 -1 0 0 0 0 0 0 0 0 0 20 0 1 0 1000 0 0\n") +} + +func (f *fake) desktopSession() { + f.proc(3700, 1000, "i3", []string{"i3"}, "session-c1.scope", "DISPLAY=:1", "XAUTHORITY="+testHome+"/.Xauthority") + f.write("/run/user/1000/bus", "") +} + +func (f *fake) called(prefix string) bool { + for _, c := range f.calls { + if strings.HasPrefix(c, prefix) { + return true + } + } + return false +} + +func TestProcessesAreTheAccountsOwnWithWhereAndWhenTheyStarted(t *testing.T) { + f := newFake(t) + f.proc(10, 1000, "worker", []string{"/usr/bin/worker", "--background"}, "session-c1.scope") + f.proc(11, 1001, "worker", []string{"/usr/bin/worker"}, "session-c2.scope") + f.proc(12, 1000, "other", []string{"other"}, "x.scope") + got := f.procs("worker") + if len(got) != 1 || got[0].PID != 10 || got[0].Command != "/usr/bin/worker --background" || + got[0].StartedIn != "session-c1.scope" || got[0].Since != time.Unix(1799990010, 0).UTC().Format(time.RFC3339) { + t.Fatalf("%+v", got) + } +} + +func TestTheSessionIsTheWindowManagersAndNoneIsSaidPlainly(t *testing.T) { + f := newFake(t) + if _, err := f.session(); err == nil || !strings.Contains(err.Error(), "no graphical session") { + t.Fatalf("%v", err) + } + f.proc(50, 1000, "xterm", []string{"xterm"}, "s.scope", "DISPLAY=:9") + f.desktopSession() + f.proc(60, 1001, "i3", []string{"i3"}, "s.scope", "DISPLAY=:5") + s, err := f.session() + if err != nil || s.Display != ":1" || s.XAuthority != testHome+"/.Xauthority" || s.Bus != "unix:path=/run/user/1000/bus" || + !strings.Contains(s.From, "i3") { + t.Fatalf("%+v %v", s, err) + } +} + +func TestStopAsksThenForcesAndDetachStartsUnderTheServiceManager(t *testing.T) { + f := newFake(t) + f.desktopSession() + f.proc(20, 1000, "app", []string{"app"}, "s.scope") + f.proc(21, 1000, "app", []string{"app"}, "s.scope") + f.stubborn[21] = true + ended, killed := f.stop(time.Second, "app") + if len(ended) != 1 || ended[0] != 20 || len(killed) != 1 || killed[0] != 21 { + t.Fatalf("ended %v killed %v (%v)", ended, killed, f.signals) + } + s, _ := f.session() + if err := f.detach(s, "mesh-app", "/usr/bin/app", "--background"); err != nil { + t.Fatal(err) + } + want := "systemd-run --user --collect --quiet --unit=mesh-app --setenv=DISPLAY=:1 --setenv=XAUTHORITY=" + testHome + + "/.Xauthority -- /usr/bin/app --background" + if !f.called("systemctl --user stop mesh-app.service") || !f.called(want) { + t.Fatalf("%q", f.calls) + } +} + +func TestAnAutostartEntryOfTheAccountOverridesTheSystemsAndHiddenStartsNothing(t *testing.T) { + f := newFake(t) + if a := f.autostart("x.desktop"); a.Starts || a.Because == "" { + t.Fatalf("%+v", a) + } + f.write("/etc/xdg/autostart/x.desktop", "[Desktop Entry]\nExec=x-applet\n[Desktop Action y]\nExec=other\n") + if a := f.autostart("x.desktop"); !a.Starts || a.Exec != "x-applet" || a.From != "/etc/xdg/autostart/x.desktop" { + t.Fatalf("%+v", a) + } + f.write(testHome+"/.config/autostart/x.desktop", "[Desktop Entry]\nExec=x-applet\nHidden=true\n") + if a := f.autostart("x.desktop"); a.Starts || a.Because != "Hidden=true" || a.From != "~/.config/autostart/x.desktop" { + t.Fatalf("%+v", a) + } +} + +func TestAWindowManagerStartIsFoundInTheConfigurationAndItsDropIns(t *testing.T) { + f := newFake(t) + f.write(testHome+"/.config/i3/config", "exec --no-startup-id dex --autostart --environment i3\n# exec app\nbindsym $mod+a exec app\n") + f.write(testHome+"/.config/i3/config.d/50-x.conf", "exec_always --no-startup-id /usr/bin/app --flag\n") + got := f.i3Starts("app") + if len(got) != 1 || got[0] != "~/.config/i3/config.d/50-x.conf:1: exec_always --no-startup-id /usr/bin/app --flag" { + t.Fatalf("%q", got) + } +} + +func TestACommandThatFailsIsNamed(t *testing.T) { + if err := failed(Output{Code: 127, Err: ErrNotInstalled}, "dex"); err == nil || !strings.Contains(err.Error(), "dex is not installed") { + t.Fatal(err) + } + if err := failed(Output{Code: 1, Stderr: "nope"}, "pacman", "-Q", "x"); err == nil || !strings.Contains(err.Error(), "pacman -Q x exited 1: nope") { + t.Fatal(err) + } + if err := failed(Output{}, "true"); err != nil { + t.Fatal(err) + } +} + +func TestTheRealRunnerBoundsTimeAndOutput(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond) + defer cancel() + if o := execRun(ctx, nil, "sleep", "5"); o.Err != ErrTimedOut { + t.Fatalf("%+v", o) + } + if o := execRun(context.Background(), nil, "no-such-program-here"); o.Err != ErrNotInstalled { + t.Fatalf("%+v", o) + } + o := execRun(context.Background(), nil, "head", "-c", strconv.Itoa(MostOutput+10), "/dev/zero") + if !o.Cut || len(o.Stdout) != MostOutput { + t.Fatalf("cut %v, %d bytes", o.Cut, len(o.Stdout)) + } +} diff --git a/modules/nextcloud-client/cmd/nextcloud-client-tools/main.go b/modules/nextcloud-client/cmd/nextcloud-client-tools/main.go new file mode 100644 index 0000000..692a6e7 --- /dev/null +++ b/modules/nextcloud-client/cmd/nextcloud-client-tools/main.go @@ -0,0 +1,81 @@ +// The nextcloud-client module's Go tools bundle (novox/hq ADR 0188, ADR 0193, ADR 0208): the +// Nextcloud desktop sync client in the operator's session, served by the node's runtime as the +// operator account. The module holds no seat, so every tool is its own. +// +// The client's account configuration is the operator's: the tools read the client's own files and +// never write them, and no answer carries the server's address, the account's ids or a credential. +package main + +import ( + "fmt" + "os" + + stdio "git.novox.be/novox/mesh-sdk/go" +) + +func main() { + if err := stdio.Serve("", tools()); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +var machine = NewMachine() + +func tools() []stdio.Tool { + return []stdio.Tool{ + { + Name: "nextcloud_status", + Description: "The Nextcloud desktop client: whether it runs (pid, since, and the unit or session scope " + + "it runs in), the installed version, what starts it at login, each account by name (never the " + + "server's address or a credential) with its sync folders' local paths, remote paths, paused, and " + + "the last sync run (started, finished, items, errors and the first failing files), and the latest " + + "warnings in the client's log. (r)", + Run: func(map[string]any) (any, error) { return machine.Status() }, + }, + { + Name: "nextcloud_log", + Description: "The last lines of the client's log (source client, the default) or of the sync runs' " + + "log (source sync, file by file), newest last. With problems, only warnings and worse. The " + + "server's address and the account's ids are replaced by and . (r)", + Input: map[string]any{ + "lines": map[string]any{"type": "integer", "description": "how many lines (default 100, at most 2000)"}, + "source": map[string]any{"type": "string", "enum": []string{"client", "sync"}, "description": "client (default) or sync"}, + "problems": map[string]any{"type": "boolean", "description": "only warning, critical and fatal lines of the client's log (default false)"}, + }, + Run: func(args map[string]any) (any, error) { + n, err := whole(args, "lines", 100, 1, 2000) + if err != nil { + return nil, err + } + source, err := text(args, "source", false) + if err != nil { + return nil, err + } + if source == "" { + source = "client" + } + problems, err := flag(args, "problems", false) + if err != nil { + return nil, err + } + return machine.Log(source, n, problems) + }, + }, + { + Name: "nextcloud_restart", + Description: "End the running client (asked first, then forced after 6 s) and start it again in the " + + "operator's desktop session, in the tray, under the account's service manager. Answers the pids " + + "ended and the new one. Needs someone logged in to the desktop. (a)", + Run: func(map[string]any) (any, error) { return machine.Restart() }, + }, + { + Name: "nextcloud_check", + Description: "Check what the module promises: the package is installed; the client has exactly one " + + "start (its own XDG autostart entry, which the session's dex runs; no window-manager exec, no " + + "enabled user unit); it runs once in a desktop session; it has an account and its folders exist " + + "and are not paused. Answers ok and each finding with what to do. (r)", + Run: func(map[string]any) (any, error) { return machine.Check() }, + }, + } +} diff --git a/modules/nextcloud-client/cmd/nextcloud-client-tools/manifest_test.go b/modules/nextcloud-client/cmd/nextcloud-client-tools/manifest_test.go new file mode 100644 index 0000000..08b67b7 --- /dev/null +++ b/modules/nextcloud-client/cmd/nextcloud-client-tools/manifest_test.go @@ -0,0 +1,108 @@ +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "reflect" + "strings" + "testing" +) + +// nextcloud-client's shape (novox/hq ADR 0208, ADR 0210, ADR 0182): one official package, no seat, +// the X display on its own machine, no start of its own (the client's own autostart entry is the one +// start), no file of the client's, and the Go bundle serving exactly the listed nextcloud_ tools. + +type manifest struct { + Module string `json:"module"` + Version string `json:"version"` + Capabilities []string `json:"capabilities"` + Requires []string `json:"requires"` + Tools []string `json:"tools"` + Resources []map[string]any `json:"resources"` + Claims []any `json:"claims"` + Seats []any `json:"seats"` + Shell []any `json:"shell"` + Contributions []any `json:"contributions"` + Environment any `json:"environment"` + Build struct { + Artifacts []map[string]any `json:"artifacts"` + } `json:"build"` +} + +func readManifest(t *testing.T) (manifest, string) { + t.Helper() + raw, err := os.ReadFile(filepath.Join("..", "..", "module.json")) + if err != nil { + t.Fatal(err) + } + dec := json.NewDecoder(strings.NewReader(string(raw))) + dec.DisallowUnknownFields() + var m manifest + if err := dec.Decode(&m); err != nil { + t.Fatalf("module.json: %v", err) + } + return m, string(raw) +} + +func TestItInstallsTheClientAndNothingElse(t *testing.T) { + m, _ := readManifest(t) + if m.Module != "nextcloud-client" || !reflect.DeepEqual(m.Requires, []string{"x11-display"}) || + !reflect.DeepEqual(m.Capabilities, []string{"package-manager"}) { + t.Fatalf("%+v", m) + } + if len(m.Resources) != 1 || m.Resources[0]["type"] != "package" || m.Resources[0]["package"] != packageFor { + t.Fatalf("resources: %v", m.Resources) + } + if m.Claims != nil || m.Seats != nil || m.Environment != nil { + t.Fatal("it holds no seat and sets no environment") + } +} + +func TestItAddsNoSecondStartAndOwnsNoneOfTheClientsFiles(t *testing.T) { + m, raw := readManifest(t) + // The client writes its own XDG autostart entry, which the session's dex runs: an xinitrc slot + // or a window-manager exec would start it twice. + if m.Shell != nil || m.Contributions != nil { + t.Fatalf("a second start: shell %v, contributions %v", m.Shell, m.Contributions) + } + for _, never := range []string{"nextcloud.cfg", "autostart", "service"} { + if strings.Contains(raw, never) { + t.Errorf("module.json names %q: the client's files and start are its own", never) + } + } +} + +func TestTheToolsAgreeWithTheManifest(t *testing.T) { + m, raw := readManifest(t) + served := map[string]bool{} + for _, tool := range tools() { + served[tool.Name] = true + if !strings.HasPrefix(tool.Name, "nextcloud_") || strings.TrimSpace(tool.Description) == "" { + t.Errorf("%s: prefixed nextcloud_ and described", tool.Name) + } + } + for _, name := range m.Tools { + if !served[name] { + t.Errorf("module.json lists %s, which the bundle does not serve", name) + } + delete(served, name) + } + for name := range served { + t.Errorf("the bundle serves %s, which module.json does not list", name) + } + if len(m.Build.Artifacts) != 1 { + t.Fatalf("%v", m.Build.Artifacts) + } + b := m.Build.Artifacts[0] + if b["kind"] != "bundle" || b["language"] != "go" || b["system"] != "arch" || + b["from"] != "cmd/nextcloud-client-tools" || b["binary"] != "nextcloud-client-tools" { + t.Errorf("the Go tools bundle: %v", b) + } + s := strings.ToLower(raw) + for _, never := range []string{"/home/", "jochen", "g14", "shanks", "novox.be", "http", "password", "token"} { + if strings.Contains(s, never) { + t.Errorf("module.json names %q", never) + } + } +} diff --git a/modules/nextcloud-client/cmd/nextcloud-client-tools/nextcloud.go b/modules/nextcloud-client/cmd/nextcloud-client-tools/nextcloud.go new file mode 100644 index 0000000..a414d63 --- /dev/null +++ b/modules/nextcloud-client/cmd/nextcloud-client-tools/nextcloud.go @@ -0,0 +1,667 @@ +package main + +// The Nextcloud desktop client as the tools see it, read from the client's own files only: +// - ~/.config/Nextcloud/nextcloud.cfg, the client's settings (Qt's INI form), which the client +// rewrites and the module never writes. Accounts and sync folders are read from it. The server's +// address, the account's user ids and every credential-like key are never answered: they are +// read only to be hidden in what the log tools answer; +// - the sync-run log of each folder (/Nextcloud/*_sync.log), whose first line is the +// folder's local path, and whose run markers and per-file lines give the last sync's state and +// errors; +// - the client's log directory, ~/.config/Nextcloud/logs, rotated by the client every two hours +// (the newest file plain, the older ones gzipped). + +import ( + "bufio" + "bytes" + "compress/gzip" + "fmt" + "io" + "net/url" + "os" + "path/filepath" + "regexp" + "sort" + "strconv" + "strings" + "time" +) + +// Where the client keeps things, under the operator's home. +const ( + configFile = ".config/Nextcloud/nextcloud.cfg" + logDir = ".config/Nextcloud/logs" + entryName = "Nextcloud.desktop" + clientComm = "nextcloud" + clientBin = "/usr/bin/nextcloud" + restartAs = "mesh-nextcloud-client" + packageFor = "nextcloud-client" + userUnit = "com.nextcloud.desktopclient.nextcloud.service" +) + +// syncLogDirs are where the client has kept its sync-run logs, newest convention first. +var syncLogDirs = []string{".local/share/Nextcloud", ".config/Nextcloud"} + +// ini is a Qt INI file: section → key → value. Keys keep Qt's backslash-separated groups. +type ini map[string]map[string]string + +func parseINI(raw []byte) ini { + out := ini{} + section := "General" + s := bufio.NewScanner(bytes.NewReader(raw)) + s.Buffer(make([]byte, 64<<10), 4<<20) // a certificate is one long line + for s.Scan() { + l := strings.TrimSpace(s.Text()) + if l == "" || strings.HasPrefix(l, ";") || strings.HasPrefix(l, "#") { + continue + } + if strings.HasPrefix(l, "[") && strings.HasSuffix(l, "]") { + section = l[1 : len(l)-1] + continue + } + i := strings.IndexByte(l, '=') + if i <= 0 { + continue + } + v := strings.TrimSpace(l[i+1:]) + if len(v) >= 2 && v[0] == '"' && v[len(v)-1] == '"' { + v = v[1 : len(v)-1] + } + if out[section] == nil { + out[section] = map[string]string{} + } + out[section][strings.TrimSpace(l[:i])] = v + } + return out +} + +// Folder is one sync folder as the client is configured. +type Folder struct { + LocalPath string `json:"-"` + Local string `json:"local_path"` + RemotePath string `json:"remote_path"` + Paused bool `json:"paused"` + VirtualFiles string `json:"virtual_files,omitempty"` + Journal string `json:"-"` + JournalFound bool `json:"journal_found"` + LastSync *Run `json:"last_sync,omitempty"` +} + +// Account is one account, by name only. +type Account struct { + ID string `json:"id"` + Name string `json:"name"` + Auth string `json:"auth,omitempty"` + Folders []Folder `json:"folders"` + // hidden are the values never answered: the server's host and the account's user ids. + hosts []string + users []string +} + +// Config is what the tools read from nextcloud.cfg. +type Config struct { + Found bool `json:"found"` + ClientVersion string `json:"client_version,omitempty"` + LaunchAtStartup *bool `json:"launch_on_system_startup,omitempty"` + Accounts []Account `json:"accounts"` +} + +func (m *Machine) config() (Config, error) { + raw, err := readBounded(m.home(configFile)) + if os.IsNotExist(err) { + return Config{Accounts: []Account{}}, nil + } + if err != nil { + return Config{}, fmt.Errorf("reading the client's settings: %w", err) + } + f := parseINI(raw) + c := Config{Found: true, ClientVersion: f["General"]["clientVersion"], Accounts: []Account{}} + if v, ok := f["General"]["launchOnSystemStartup"]; ok { + b := v == "true" + c.LaunchAtStartup = &b + } + byID := map[string]*Account{} + folders := map[string]map[string]*Folder{} + var ids []string + for k, v := range f["Accounts"] { + parts := strings.Split(k, `\`) + if len(parts) < 2 { + continue + } + id := parts[0] + if _, err := strconv.Atoi(id); err != nil { + continue + } + a := byID[id] + if a == nil { + a = &Account{ID: id, Folders: []Folder{}} + byID[id] = a + folders[id] = map[string]*Folder{} + ids = append(ids, id) + } + if len(parts) == 2 { + switch parts[1] { + case "displayName": + a.Name = v + case "authType": + a.Auth = v + case "url": + if u, err := url.Parse(v); err == nil && u.Host != "" { + a.hosts = append(a.hosts, u.Host) + if u.Hostname() != u.Host { + a.hosts = append(a.hosts, u.Hostname()) + } + } + case "user", "dav_user", "webflow_user", "http_user": + if v != "" { + a.users = append(a.users, v) + } + } + continue + } + // \Folders\\, and the other folder groups (FoldersWithPlaceholders, Multifolders). + if len(parts) == 4 && strings.HasPrefix(parts[1], "Folders") || len(parts) == 4 && parts[1] == "Multifolders" { + key := parts[1] + `\` + parts[2] + fo := folders[id][key] + if fo == nil { + fo = &Folder{} + folders[id][key] = fo + } + switch parts[3] { + case "localPath": + fo.LocalPath = v + case "targetPath": + fo.RemotePath = v + case "paused": + fo.Paused = v == "true" + case "virtualFilesMode": + fo.VirtualFiles = v + case "journalPath": + fo.Journal = v + } + } + } + sort.Strings(ids) + for _, id := range ids { + a := byID[id] + if a.Name == "" { + a.Name = "account " + id + } + var keys []string + for k := range folders[id] { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + fo := *folders[id][k] + if fo.LocalPath == "" { + continue + } + fo.Local = m.tilde(fo.LocalPath) + if fo.Journal != "" { + fo.JournalFound = exists(filepath.Join(m.Root, fo.LocalPath, fo.Journal)) + } + a.Folders = append(a.Folders, fo) + } + c.Accounts = append(c.Accounts, *a) + } + return c, nil +} + +// redactor hides what an answer must never carry: the servers' hosts, the accounts' user ids, and +// anything shaped like a credential. +type redactor struct{ hosts, users []string } + +var credential = regexp.MustCompile(`(?i)\b(authorization|bearer|basic|token|apppassword|app_password|password|passwd|secret|cookie|set-cookie)(["']?\s*[:=]\s*["']?|\s+)(?:(?:bearer|basic)\s+)?[^\s"',;&]+`) + +func (c Config) redactor() redactor { + var r redactor + for _, a := range c.Accounts { + r.hosts = append(r.hosts, a.hosts...) + r.users = append(r.users, a.users...) + } + // Longest first, so a host's port form is replaced before its bare name. + sort.Slice(r.hosts, func(i, j int) bool { return len(r.hosts[i]) > len(r.hosts[j]) }) + sort.Slice(r.users, func(i, j int) bool { return len(r.users[i]) > len(r.users[j]) }) + return r +} + +func (r redactor) clean(s string) string { + s = credential.ReplaceAllString(s, "${1}${2}") + for _, u := range r.users { + s = strings.ReplaceAll(s, u, "") + } + for _, h := range r.hosts { + s = strings.ReplaceAll(s, h, "") + } + return s +} + +// Run is one folder's last sync run, from its sync-run log. +type Run struct { + Started string `json:"started,omitempty"` + Finished string `json:"finished,omitempty"` + // State is "finished", "running", or "never" when the log has no run. + State string `json:"state"` + Changes int `json:"items"` + Errors int `json:"errors"` + Problems []Problem `json:"problems,omitempty"` + Log string `json:"log"` +} + +// Problem is one item of a run that ended with an error. +type Problem struct { + File string `json:"file"` + Error string `json:"error"` + HTTP string `json:"http,omitempty"` +} + +const mostProblems = 10 + +// syncLogFor finds the sync-run log whose first line is the folder's local path; the newest wins. +func (m *Machine) syncLogFor(local string) string { + want := strings.TrimSuffix(local, "/") + best, bestAt := "", time.Time{} + for _, d := range syncLogDirs { + files, _ := filepath.Glob(m.home(d, "*_sync.log")) + for _, f := range files { + h, err := os.Open(f) + if err != nil { + continue + } + first, _ := bufio.NewReader(io.LimitReader(h, 4096)).ReadString('\n') + h.Close() + if strings.TrimSuffix(strings.TrimSpace(first), "/") != want { + continue + } + if st, err := os.Stat(f); err == nil && st.ModTime().After(bestAt) { + best, bestAt = f, st.ModTime() + } + } + } + return best +} + +// lastRun reads the end of a sync-run log: the last run's markers and its items. +func (m *Machine) lastRun(path string, r redactor) (*Run, error) { + lines, err := tailLines(path, 4000) + if err != nil { + return nil, err + } + run := &Run{State: "never", Log: m.tilde(strings.TrimPrefix(path, m.Root))} + start := -1 + for i := len(lines) - 1; i >= 0; i-- { + if strings.HasPrefix(lines[i], "#=#=#=# Syncrun started ") { + start = i + break + } + } + if start < 0 { + return run, nil + } + run.Started = marker(lines[start], "#=#=#=# Syncrun started ") + run.State = "running" + for _, l := range lines[start+1:] { + switch { + case strings.HasPrefix(l, "#=#=#=# Syncrun finished "): + run.Finished = marker(l, "#=#=#=# Syncrun finished ") + run.State = "finished" + case strings.HasPrefix(l, "#"): + default: + f := strings.Split(l, "|") + if len(f) < 12 { + continue + } + run.Changes++ + if e := strings.TrimSpace(f[10]); e != "" { + run.Errors++ + if len(run.Problems) < mostProblems { + run.Problems = append(run.Problems, Problem{File: r.clean(f[2]), Error: r.clean(e), HTTP: strings.TrimSpace(f[11])}) + } + } + } + } + return run, nil +} + +func marker(line, prefix string) string { + f := strings.Fields(strings.TrimPrefix(line, prefix)) + if len(f) == 0 { + return "" + } + return f[0] +} + +// tailLines answers the last n lines of a file, plain or gzipped, read to at most MostRead. +func tailLines(path string, n int) ([]string, error) { + h, err := os.Open(path) + if err != nil { + return nil, err + } + defer h.Close() + var r io.Reader = h + if strings.HasSuffix(path, ".gz") { + z, err := gzip.NewReader(h) + if err != nil { + return nil, fmt.Errorf("%s: %w", filepath.Base(path), err) + } + defer z.Close() + r = z + } else if st, err := h.Stat(); err == nil && st.Size() > MostRead { + // A plain file is read from its end. + if _, err := h.Seek(st.Size()-MostRead, io.SeekStart); err != nil { + return nil, err + } + } + raw, err := io.ReadAll(io.LimitReader(r, MostRead)) + if err != nil { + return nil, err + } + all := strings.Split(strings.TrimRight(string(raw), "\n"), "\n") + if len(all) == 1 && all[0] == "" { + all = nil + } + if len(all) > n { + all = all[len(all)-n:] + } + return all, nil +} + +// clientLogs are the client's log files, newest first. +func (m *Machine) clientLogs() []string { + entries, err := os.ReadDir(m.home(logDir)) + if err != nil { + return nil + } + type file struct { + path string + at time.Time + } + var files []file + for _, e := range entries { + // The client's own log, not its permanent-delete records beside it. + if e.IsDir() || !strings.Contains(e.Name(), "_nextcloud.log") { + continue + } + if info, err := e.Info(); err == nil { + files = append(files, file{m.home(logDir, e.Name()), info.ModTime()}) + } + } + sort.Slice(files, func(i, j int) bool { return files[i].at.After(files[j].at) }) + out := make([]string, len(files)) + for i, f := range files { + out[i] = f.path + } + return out +} + +// level is a client log line's level: "[ warning category file:line ]:" → warning. +func level(line string) string { + i := strings.Index(line, "[ ") + if i < 0 { + return "" + } + f := strings.Fields(line[i+2:]) + if len(f) == 0 { + return "" + } + return f[0] +} + +func isProblem(line string) bool { + switch level(line) { + case "warning", "critical", "fatal": + return true + } + return false +} + +// LogAnswer is what nextcloud_log answers. +type LogAnswer struct { + Source string `json:"source"` + Files []string `json:"files"` + Lines []string `json:"lines"` + Cut bool `json:"cut,omitempty"` + Note string `json:"note,omitempty"` +} + +const mostAnswer = 256 << 10 + +// Log answers the last n lines of the client's log (or only its warnings and worse), or of the sync +// runs' log, the server's address and the account's ids hidden. +func (m *Machine) Log(source string, n int, problemsOnly bool) (LogAnswer, error) { + c, err := m.config() + if err != nil { + return LogAnswer{}, err + } + r := c.redactor() + a := LogAnswer{Source: source, Files: []string{}, Lines: []string{}} + var files []string + switch source { + case "client": + files = m.clientLogs() + if len(files) == 0 { + a.Note = "the client keeps no log files in ~/" + logDir + ": it writes them there only while its logging is switched on" + return a, nil + } + case "sync": + for _, acc := range c.Accounts { + for _, f := range acc.Folders { + if p := m.syncLogFor(f.LocalPath); p != "" { + files = append(files, p) + } + } + } + if len(files) == 0 { + a.Note = "no sync-run log found for any configured folder" + return a, nil + } + default: + return a, fmt.Errorf("source is client or sync, not %q", source) + } + // The newest file first; older ones until n lines are found, at most four files. + var got []string + for i, f := range files { + if i == 4 || len(got) >= n { + break + } + lines, err := tailLines(f, 1<<20) + if err != nil { + return a, err + } + if problemsOnly { + var keep []string + for _, l := range lines { + if isProblem(l) { + keep = append(keep, l) + } + } + lines = keep + } + if len(lines) > n-len(got) { + lines = lines[len(lines)-(n-len(got)):] + } + got = append(lines, got...) + a.Files = append(a.Files, m.tilde(strings.TrimPrefix(f, m.Root))) + if source == "sync" { + // Each folder's log is its own story: the newest folder's only, unless asked again. + break + } + } + size := 0 + for i := len(got) - 1; i >= 0; i-- { + l := r.clean(got[i]) + if size+len(l) > mostAnswer { + a.Cut = true + got = got[i+1:] + break + } + size += len(l) + 1 + got[i] = l + } + if got == nil { + got = []string{} + } + a.Lines = got + return a, nil +} + +// Status is what nextcloud_status answers. +type Status struct { + Installed string `json:"installed,omitempty"` + Running []Proc `json:"running"` + Config Config `json:"settings"` + Problems []string `json:"recent_client_problems"` + StartedBy Autostart `json:"started_by"` +} + +// Status reads the client: whether it runs, its accounts and folders with their last sync, and the +// warnings and worse at the end of its log. +func (m *Machine) Status() (Status, error) { + c, err := m.config() + if err != nil { + return Status{}, err + } + r := c.redactor() + s := Status{Running: m.procs(clientComm), Config: c, Problems: []string{}, StartedBy: m.autostart(entryName)} + if s.Running == nil { + s.Running = []Proc{} + } + if v, err := m.installed(packageFor); err == nil { + s.Installed = v + } + for ai := range s.Config.Accounts { + for fi := range s.Config.Accounts[ai].Folders { + f := &s.Config.Accounts[ai].Folders[fi] + if p := m.syncLogFor(f.LocalPath); p != "" { + if run, err := m.lastRun(p, r); err == nil { + f.LastSync = run + } + } + } + } + // The two newest files: the log rotates every two hours, and may just have. + logs := m.clientLogs() + if len(logs) > 2 { + logs = logs[:2] + } + for i := len(logs) - 1; i >= 0; i-- { + if lines, err := tailLines(logs[i], 1<<20); err == nil { + for _, l := range lines { + if isProblem(l) { + s.Problems = append(s.Problems, r.clean(l)) + } + } + } + } + if len(s.Problems) > 10 { + s.Problems = s.Problems[len(s.Problems)-10:] + } + return s, nil +} + +// RestartAnswer is what nextcloud_restart answers. +type RestartAnswer struct { + Ended []int `json:"ended"` + Killed []int `json:"killed,omitempty"` + Running []Proc `json:"running"` + Session Session `json:"session"` + Unit string `json:"unit"` +} + +// Restart ends the running client and starts it again in the operator's session, under the account's +// service manager, as its autostart entry does (--background: to the tray, no window). +func (m *Machine) Restart() (RestartAnswer, error) { + s, err := m.session() + if err != nil { + return RestartAnswer{}, err + } + a := RestartAnswer{Session: s, Unit: restartAs + ".service"} + a.Ended, a.Killed = m.stop(6*time.Second, clientComm) + if err := m.detach(s, restartAs, clientBin, "--background"); err != nil { + return a, err + } + a.Running = m.waitFor(clientComm, 4*time.Second) + if len(a.Running) == 0 { + return a, fmt.Errorf("the client was started as %s but no %s process appeared within 4 s: "+ + "see `journalctl --user -u %s`", a.Unit, clientComm, a.Unit) + } + return a, nil +} + +// CheckAnswer is what nextcloud_check answers. +type CheckAnswer struct { + OK bool `json:"ok"` + Findings []Finding `json:"findings"` + Starts []string `json:"starts"` +} + +// Check verifies the module's promises: the package, one start (the client's own autostart entry, +// which the session's dex runs), the client running once in a session, and its folders present. +func (m *Machine) Check() (CheckAnswer, error) { + a := CheckAnswer{Findings: []Finding{}, Starts: []string{}} + add := func(what, do string) { a.Findings = append(a.Findings, Finding{what, do}) } + v, err := m.installed(packageFor) + if err != nil { + return a, err + } + if v == "" { + add("the package "+packageFor+" is not installed", "push the module to the node") + } + c, err := m.config() + if err != nil { + return a, err + } + entry := m.autostart(entryName) + if entry.Starts { + a.Starts = append(a.Starts, "XDG autostart: "+entry.From) + if !strings.Contains(entry.Exec, clientComm) { + add("the autostart entry runs "+entry.Exec+", not the client", "untick and tick again 'Launch on system startup' in the client's settings") + } + } else { + add("the client does not start with the session ("+entry.Because+")", + "tick 'Launch on system startup' in the client's General settings: the client writes its own entry") + } + if c.LaunchAtStartup != nil && !*c.LaunchAtStartup && entry.Starts { + add("the client's setting says not to launch at startup, but its autostart entry is there", "tick and untick the setting, or remove ~/.config/autostart/"+entryName) + } + if o := m.cmd(0, nil, "dex", "--version"); o.Err != nil { + add("dex, which runs the XDG autostart entries at login, is not installed", "assign the i3 module, which installs it and runs it") + } + for _, l := range m.i3Starts(clientComm) { + a.Starts = append(a.Starts, "window manager: "+l) + add("a second start: "+l, "remove the line; the autostart entry is the client's one start") + } + if o := m.cmd(0, m.bus(), "systemctl", "--user", "is-enabled", userUnit); o.Err == nil && strings.TrimSpace(o.Stdout) == "enabled" { + a.Starts = append(a.Starts, "user unit: "+userUnit) + add("a second start: the packaged user unit "+userUnit+" is enabled", "systemctl --user disable "+userUnit) + } + running := m.procs(clientComm) + if _, err := m.session(); err == nil { + switch { + case len(running) == 0: + add("no client runs in the desktop session", "nextcloud_restart") + case len(running) > 1: + add(fmt.Sprintf("%d clients run", len(running)), "nextcloud_restart ends them all and starts one") + } + } + if !c.Found { + add("the client has no settings yet (~/"+configFile+")", "open the client and add the account: its configuration is the operator's") + } else if len(c.Accounts) == 0 { + add("the client has no account", "add the account in the client") + } + for _, acc := range c.Accounts { + for _, f := range acc.Folders { + if !exists(filepath.Join(m.Root, f.LocalPath)) { + add("the sync folder "+m.tilde(f.LocalPath)+" of "+acc.Name+" does not exist", "recreate it, or remove the folder from the client") + } else if f.Journal != "" && !f.JournalFound { + add("the sync folder "+m.tilde(f.LocalPath)+" has no sync journal yet", "it is written at the first sync") + } + if f.Paused { + add("the sync folder "+m.tilde(f.LocalPath)+" is paused", "resume it in the client") + } + } + } + a.OK = len(a.Findings) == 0 + return a, nil +} diff --git a/modules/nextcloud-client/cmd/nextcloud-client-tools/nextcloud_test.go b/modules/nextcloud-client/cmd/nextcloud-client-tools/nextcloud_test.go new file mode 100644 index 0000000..449c6f6 --- /dev/null +++ b/modules/nextcloud-client/cmd/nextcloud-client-tools/nextcloud_test.go @@ -0,0 +1,269 @@ +package main + +import ( + "bytes" + "compress/gzip" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +// A client's settings as the client writes them, with a certificate, a server and user ids that no +// answer may carry. +const cfg = `[General] +clientVersion=34.0.4daily +launchOnSystemStartup=true + +[Accounts] +0\Folders\1\journalPath=.sync_abc.db +0\Folders\1\localPath=/home/operator/Nextcloud/ +0\Folders\1\paused=false +0\Folders\1\targetPath=/ +0\Folders\1\virtualFilesMode=off +0\Folders\2\localPath=/home/operator/Photos/ +0\Folders\2\paused=true +0\Folders\2\targetPath=/Photos +0\General\CaCertificates="@ByteArray(-----BEGIN CERTIFICATE-----\nMIIE6zCC\n-----END CERTIFICATE-----)" +0\authType=webflow +0\dav_user=kc-1234-uid +0\displayName=The Operator +0\url=https://cloud.example.test:8443 +0\user=kc-1234-uid@cloud.example.test +0\webflow_user=kc-1234-uid +version=13 +` + +const syncLog = `/home/operator/Nextcloud/ +# timestamp | duration | file | instruction | dir | modtime | etag | size | fileId | status | errorString | http result code | other size | other modtime | X-Request-ID +#=#=#=# Syncrun started 2026-10-05T07:27:35Z +||old.md|2|2|1|e|1|i|13||0|1|1|| +#=#=#=# Syncrun finished 2026-10-05T07:27:35Z (last step: 16 msec, total: 170 msec) +#=#=#=# Syncrun started 2026-10-05T09:27:40Z +#=#=#=#=# Propagation starts 2026-10-05T09:27:40Z (last step: 131 msec, total: 131 msec) +13:58:53||Notes/a.md|8|1|1|e|1|i|13||201|0|0|r| +13:58:54||Notes/b.md|8|1|1|e|1|i|13|"/Notes/b.md" is locked|423|0|0|r| +13:58:55||kc-1234-uid/c.md|8|1|1|e|1|i|13|File has changed since discovery|200|0|0|r| +#=#=#=# Syncrun finished 2026-10-05T09:27:40Z (last step: 17 msec, total: 148 msec) +` + +func clientLine(level, text string) string { + return "2026-10-05 11:27:41:151 [ " + level + " nextcloud.gui.folder /src/folder.cpp:1 ]:\t" + text +} + +func newClient(t *testing.T) *fake { + f := newFake(t) + f.write(testHome+"/"+configFile, cfg) + f.write(testHome+"/Nextcloud/.sync_abc.db", "") + f.write(testHome+"/.local/share/Nextcloud/Nextcloud_sync.log", syncLog) + // An older log of the same folder, in the old place: the newer one wins. + f.write(testHome+"/.config/Nextcloud/Nextcloud_sync.log", "/home/operator/Nextcloud/\n#=#=#=# Syncrun started 2023-11-17T00:00:00Z\n") + old := filepath.Join(f.Root, testHome, ".config/Nextcloud/Nextcloud_sync.log") + _ = os.Chtimes(old, time.Unix(1_700_000_000, 0), time.Unix(1_700_000_000, 0)) + var gz bytes.Buffer + z := gzip.NewWriter(&gz) + _, _ = z.Write([]byte(clientLine("info", "older") + "\n" + clientLine("warning", "Network error on https://cloud.example.test:8443/x Authorization: Bearer abc.def") + "\n")) + _ = z.Close() + f.write(testHome+"/"+logDir+"/20261005_0927_nextcloud.log.0.gz", gz.String()) + f.write(testHome+"/"+logDir+"/20261005_1127_permanent_delete.log.0", "not the client's log\n") + f.write(testHome+"/"+logDir+"/20261005_1127_nextcloud.log.0", + clientLine("info", "Sync finished for folder of account [kc-1234-uid@cloud.example.test]")+"\n"+clientLine("critical", "Could not read journal")+"\n") + gzPath := filepath.Join(f.Root, testHome, logDir, "20261005_0927_nextcloud.log.0.gz") + _ = os.Chtimes(gzPath, time.Now().Add(-time.Hour), time.Now().Add(-time.Hour)) + f.write(testHome+"/.config/autostart/Nextcloud.desktop", "[Desktop Entry]\nName=Nextcloud\nExec=\"/usr/bin/nextcloud\" --background\nX-GNOME-Autostart-enabled=true\n") + f.answer = func(name string, args []string) Output { + switch { + case name == "pacman": + return Output{Stdout: "nextcloud-client 2:34.0.4-1\n"} + case name == "systemctl" && len(args) > 1 && args[1] == "is-enabled": + return Output{Stdout: "disabled\n", Code: 1} + } + return Output{} + } + return f +} + +func noSecrets(t *testing.T, v any) string { + t.Helper() + raw, err := json.Marshal(v) + if err != nil { + t.Fatal(err) + } + s := string(raw) + for _, never := range []string{"example.test", "kc-1234", "CERTIFICATE", "abc.def", "/home/operator"} { + if strings.Contains(s, never) { + t.Errorf("the answer carries %q: %s", never, s) + } + } + return s +} + +func TestStatusNamesAccountsAndFoldersWithTheirLastSyncAndNothingSecret(t *testing.T) { + f := newClient(t) + f.proc(3865, 1000, clientComm, []string{"/usr/bin/nextcloud", "--background"}, "session-c1.scope") + s, err := f.Status() + if err != nil { + t.Fatal(err) + } + noSecrets(t, s) + if s.Installed != "2:34.0.4-1" || len(s.Running) != 1 || s.Running[0].StartedIn != "session-c1.scope" { + t.Fatalf("%+v", s) + } + if !s.StartedBy.Starts || s.StartedBy.From != "~/.config/autostart/Nextcloud.desktop" { + t.Fatalf("%+v", s.StartedBy) + } + if s.Config.ClientVersion != "34.0.4daily" || s.Config.LaunchAtStartup == nil || !*s.Config.LaunchAtStartup { + t.Fatalf("%+v", s.Config) + } + if len(s.Config.Accounts) != 1 { + t.Fatalf("%+v", s.Config.Accounts) + } + a := s.Config.Accounts[0] + if a.Name != "The Operator" || a.Auth != "webflow" || len(a.Folders) != 2 { + t.Fatalf("%+v", a) + } + main, photos := a.Folders[0], a.Folders[1] + if main.Local != "~/Nextcloud/" || main.RemotePath != "/" || main.Paused || !main.JournalFound || main.VirtualFiles != "off" { + t.Fatalf("%+v", main) + } + run := main.LastSync + if run == nil || run.State != "finished" || run.Started != "2026-10-05T09:27:40Z" || run.Finished != "2026-10-05T09:27:40Z" || + run.Changes != 3 || run.Errors != 2 || run.Log != "~/.local/share/Nextcloud/Nextcloud_sync.log" { + t.Fatalf("%+v", run) + } + if run.Problems[0].File != "Notes/b.md" || run.Problems[0].HTTP != "423" || run.Problems[1].File != "/c.md" { + t.Fatalf("%+v", run.Problems) + } + if !photos.Paused || photos.RemotePath != "/Photos" || photos.LastSync != nil { + t.Fatalf("%+v", photos) + } + // Warnings and worse of the two newest client logs, oldest first, hidden. + if len(s.Problems) != 2 || !strings.Contains(s.Problems[0], "/x Authorization: ") || + !strings.Contains(s.Problems[1], "Could not read journal") { + t.Fatalf("%q", s.Problems) + } +} + +func TestARunWithoutItsEndIsRunningAndALogWithoutRunsIsNever(t *testing.T) { + f := newClient(t) + f.write(testHome+"/.local/share/Nextcloud/Nextcloud_sync.log", "/home/operator/Nextcloud/\n#=#=#=# Syncrun started 2026-10-05T10:00:00Z\n||x|1|1|1|e|1|i|13||200|0|0||\n") + s, _ := f.Status() + if r := s.Config.Accounts[0].Folders[0].LastSync; r.State != "running" || r.Finished != "" || r.Changes != 1 { + t.Fatalf("%+v", r) + } + f.write(testHome+"/.local/share/Nextcloud/Nextcloud_sync.log", "/home/operator/Nextcloud/\n") + s, _ = f.Status() + if r := s.Config.Accounts[0].Folders[0].LastSync; r.State != "never" { + t.Fatalf("%+v", r) + } +} + +func TestNoSettingsIsNoAccountNotAnError(t *testing.T) { + f := newFake(t) + f.answer = func(string, []string) Output { return Output{Code: 1} } + s, err := f.Status() + if err != nil || s.Config.Found || len(s.Config.Accounts) != 0 || s.Installed != "" { + t.Fatalf("%+v %v", s, err) + } +} + +func TestTheLogIsBoundedHiddenAndReadAcrossRotations(t *testing.T) { + f := newClient(t) + l, err := f.Log("client", 3, false) + if err != nil { + t.Fatal(err) + } + noSecrets(t, l) + if len(l.Lines) != 3 || !strings.Contains(l.Lines[0], "Authorization: ") || !strings.Contains(l.Lines[1], "[]") || + len(l.Files) != 2 || !strings.HasSuffix(l.Files[0], "_1127_nextcloud.log.0") { + t.Fatalf("%+v", l) + } + l, _ = f.Log("client", 1, false) + if len(l.Lines) != 1 || len(l.Files) != 1 || !strings.Contains(l.Lines[0], "Could not read journal") { + t.Fatalf("%+v", l) + } + l, _ = f.Log("client", 50, true) + if len(l.Lines) != 2 { + t.Fatalf("%+v", l) + } + l, _ = f.Log("sync", 2, false) + noSecrets(t, l) + if len(l.Lines) != 2 || !strings.HasPrefix(l.Lines[1], "#=#=#=# Syncrun finished") || l.Files[0] != "~/.local/share/Nextcloud/Nextcloud_sync.log" { + t.Fatalf("%+v", l) + } + if _, err := f.Log("server", 2, false); err == nil { + t.Fatal("an unknown source is refused") + } + empty := newFake(t) + if l, err := empty.Log("client", 5, false); err != nil || l.Note == "" || l.Lines == nil { + t.Fatalf("%+v %v", l, err) + } +} + +func TestRestartEndsTheClientAndStartsOneInTheSession(t *testing.T) { + f := newClient(t) + if _, err := f.Restart(); err == nil || !strings.Contains(err.Error(), "no graphical session") { + t.Fatalf("without a desktop: %v", err) + } + f.desktopSession() + f.proc(3865, 1000, clientComm, []string{"nextcloud"}, "session-4.scope") + f.onStart = func(argv []string) { + f.proc(9000, 1000, clientComm, argv, "app.slice/"+restartAs+".service") + } + a, err := f.Restart() + if err != nil { + t.Fatal(err) + } + if len(a.Ended) != 1 || a.Ended[0] != 3865 || len(a.Running) != 1 || a.Running[0].PID != 9000 || + a.Running[0].StartedIn != restartAs+".service" || a.Running[0].Command != "/usr/bin/nextcloud --background" { + t.Fatalf("%+v", a) + } + f.onStart = nil + if _, err := f.Restart(); err == nil || !strings.Contains(err.Error(), "journalctl --user -u "+restartAs) { + t.Fatalf("a start that shows no process: %v", err) + } +} + +func TestCheckPassesOneStartAndNamesEveryOther(t *testing.T) { + f := newClient(t) + f.desktopSession() + f.write(testHome+"/Photos/.keep", "") + f.proc(3865, 1000, clientComm, []string{"nextcloud"}, "session-c1.scope") + c, err := f.Check() + if err != nil { + t.Fatal(err) + } + // The one finding is the paused folder. + if c.OK || len(c.Findings) != 1 || !strings.Contains(c.Findings[0].What, "~/Photos/ is paused") || len(c.Starts) != 1 { + t.Fatalf("%+v", c) + } + noSecrets(t, c) + + f.write(testHome+"/.config/i3/config", "exec --no-startup-id nextcloud\n") + f.answer = func(name string, args []string) Output { + switch name { + case "pacman": + return Output{Code: 1, Stderr: "error: package 'nextcloud-client' was not found"} + case "systemctl": + return Output{Stdout: "enabled\n"} + case "dex": + return Output{Code: 127, Err: ErrNotInstalled} + } + return Output{} + } + f.proc(3866, 1000, clientComm, []string{"nextcloud"}, "session-c1.scope") + f.write(testHome+"/.config/autostart/Nextcloud.desktop", "[Desktop Entry]\nExec=nextcloud\nHidden=true\n") + c, _ = f.Check() + all := noSecrets(t, c) + for _, want := range []string{"not installed", "does not start with the session (Hidden=true)", "dex", "a second start: ~/.config/i3/config:1", + "packaged user unit", "2 clients run"} { + if !strings.Contains(all, want) { + t.Errorf("no finding %q in %s", want, all) + } + } + if len(c.Starts) != 2 { + t.Fatalf("%q", c.Starts) + } +} diff --git a/modules/nextcloud-client/go.mod b/modules/nextcloud-client/go.mod new file mode 100644 index 0000000..a97f7a6 --- /dev/null +++ b/modules/nextcloud-client/go.mod @@ -0,0 +1,5 @@ +module nextcloud-client + +go 1.22 + +require git.novox.be/novox/mesh-sdk/go v0.1.7 diff --git a/modules/nextcloud-client/go.sum b/modules/nextcloud-client/go.sum new file mode 100644 index 0000000..b474419 --- /dev/null +++ b/modules/nextcloud-client/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/nextcloud-client/module.json b/modules/nextcloud-client/module.json new file mode 100644 index 0000000..af71acb --- /dev/null +++ b/modules/nextcloud-client/module.json @@ -0,0 +1,38 @@ +{ + "module": "nextcloud-client", + "version": "1", + "capabilities": [ + "package-manager" + ], + "requires": [ + "x11-display" + ], + "tools": [ + "nextcloud_status", + "nextcloud_log", + "nextcloud_restart", + "nextcloud_check" + ], + "resources": [ + { + "id": "package", + "type": "package", + "package": "nextcloud-client" + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/nextcloud-client-tools", + "binary": "nextcloud-client-tools", + "loads": [ + "nextcloud-client-tools" + ] + } + ] + } +}