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 }