Add nextcloud-client and blueman: the two tray apps get an owner and tools, each with one start
Both are official packages already on both workstations, started once by dex from an XDG autostart entry (the client's own, the package's). The modules declare the package, add no second start, own none of the apps' files, and give the mesh status, log, restart and check.
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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 <server> and <account>. (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() },
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 (<appdata>/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
|
||||
}
|
||||
// <id>\Folders\<n>\<key>, 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}<hidden>")
|
||||
for _, u := range r.users {
|
||||
s = strings.ReplaceAll(s, u, "<account>")
|
||||
}
|
||||
for _, h := range r.hosts {
|
||||
s = strings.ReplaceAll(s, h, "<server>")
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -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 != "<account>/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], "<server>/x Authorization: <hidden>") ||
|
||||
!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: <hidden>") || !strings.Contains(l.Lines[1], "[<account>]") ||
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user