diff --git a/BUGS.md b/BUGS.md index 4679882..1740657 100644 --- a/BUGS.md +++ b/BUGS.md @@ -7,24 +7,24 @@ with hostile inputs, and a code read. Ordered by severity within each group. ## Security / robustness -1. **Reading a special file hangs the request forever.** `GET /api/files/read?path=/dev/zero` +1. ✅ **RESOLVED 2026-09-18** — only regular files are readable; the byte cap applies to what is sent, not to `stat`. `/dev/zero`, `/dev/null` and FIFOs answer 400 in 30 ms; `/proc/cpuinfo` still reads. **Reading a special file hangs the request forever.** `GET /api/files/read?path=/dev/zero` never ends: the `max` cap compares against `stat` size, which is 0 for device files, so the cap never triggers and the handler streams `/dev/zero` until the client gives up. Same for FIFOs (blocks on open) and `/proc/self/mem`. Opening one in the Files viewer would hang the tab. Needs: refuse non-regular files for read/edit, and cap the copy with `io.LimitReader` regardless of `stat`. -2. **Login lockout is global, not per client.** Five bad passwords from anyone on the +2. ✅ **RESOLVED** — per-client buckets (first X-Forwarded-For hop or the peer) with the same 5-strike schedule, plus a global backstop of 100 failures per 5 min → 1 min. Verified: core's public IP locked while grid logged in fine. **Login lockout is global, not per client.** Five bad passwords from anyone on the internet lock the real user out for up to 10 minutes (`auth.Limiter` is one bucket). The bash locked the hub for the full 10 minutes twice. Also locks `POST /api/tokens`, so hub pairing fails during a lockout. Needs per-IP buckets with a modest global cap. -3. **UI prefs from the server are not validated.** A stored `refreshMs` of `-5` made the +3. ✅ **RESOLVED** — server rejects refreshMs outside 100–60000 and non-identifier themes (400); client ignores out-of-range rates from any source. **UI prefs from the server are not validated.** A stored `refreshMs` of `-5` made the client call `setInterval(-5)` on every live view: a request storm that took the browser to `ERR_INSUFFICIENT_RESOURCES` and hammered the daemon. `theme` is validated only by falling back. Needs range checks on load (and on save server-side). -4. **No anti-framing / CSP headers.** Only HSTS (from NPM). A root-capable console should +4. ✅ **RESOLVED** — every response carries X-Frame-Options DENY, CSP frame-ancestors none, nosniff, no-referrer. **No anti-framing / CSP headers.** Only HSTS (from NPM). A root-capable console should send `X-Frame-Options: DENY`, `Content-Security-Policy: frame-ancestors 'none'`, `Referrer-Policy: no-referrer`, `X-Content-Type-Options: nosniff` on every response. -5. **Signals can be sent to kernel threads.** `POST /api/procs/2/signal` (kthreadd) +5. ✅ **RESOLVED** — kernel threads (empty cmdline) and the daemon's own PID are refused. **Signals can be sent to kernel threads.** `POST /api/procs/2/signal` (kthreadd) returns 204. Harmless in practice, but the guard should refuse PIDs with an empty cmdline (kernel threads) and our own PID. 6. **Journal spam from the daemon's own sudo calls.** Every sudo'd `journalctl`, `smartctl` @@ -41,7 +41,7 @@ with hostile inputs, and a code read. Ordered by severity within each group. ## Functional bugs -9. **Wrong password gives no feedback at all.** The 401 is thrown as `Unauthenticated` +9. ✅ **RESOLVED** — 401 carries the server message; the form shows "wrong password" (verified headless). **Wrong password gives no feedback at all.** The 401 is thrown as `Unauthenticated` with an empty message, and the login form only renders `err` when non-empty. The button just re-enables. Reproduced on phone and desktop. 10. **Switching machines while on Settings or Machines keeps showing the hub's page.** @@ -78,7 +78,7 @@ with hostile inputs, and a code read. Ordered by severity within each group. 20. **Self-update restarts without warning running work.** A push to a node kills its open terminals and any running task (upgrade in progress). The hub should refuse or warn when the node reports a running task or attached terminals. -21. **Error text leaks raw Go errors.** e.g. rename shows "rename /tmp/x /tmp/y: no such +21. ✅ **RESOLVED** — PathError and LinkError are unwrapped to the bare reason. **Error text leaks raw Go errors.** e.g. rename shows "rename /tmp/x /tmp/y: no such file or directory". Fine for a lab, ugly for a product; strip the syscall prefix. ## Mobile / UX diff --git a/Makefile b/Makefile index ebb7b33..c21cd1c 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,5 @@ -VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev) +# Commit first, then deploy: the version is the commit the binary was built from. +VERSION ?= $(shell git describe --tags --always 2>/dev/null || echo dev) GO ?= go PREFIX ?= /usr/local/bin diff --git a/deploy/hamlab.service b/deploy/hamlab.service index 103d89f..1a2a7f3 100644 --- a/deploy/hamlab.service +++ b/deploy/hamlab.service @@ -6,6 +6,8 @@ After=network.target Type=simple User=@USER@ Group=@GROUP@ +# read the system journal without sudo (keeps the daemon's own log clean) +SupplementaryGroups=systemd-journal adm WorkingDirectory=@HOME@ Environment=HAMLAB_CONFIG=@HOME@/apps/hamlab/config.json ExecStart=@PREFIX@/hamlab serve diff --git a/deploy/install.sh b/deploy/install.sh index d0c5ebd..62c97ba 100755 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -25,6 +25,20 @@ for alt in bin/hamlab-linux-*; do done sudo install -m 0644 "$unit" /etc/systemd/system/hamlab.service rm -f "$unit" +# Keep the daemon's read-only sudo polling (SMART, UPS, journal) out of the journal: with +# 250 ms refresh rates the sudo session lines would otherwise dominate the log. +quiet=$(mktemp) +cat > "$quiet" <<'SUDOERS' +# Ham Lab: don't log the daemon's read-only polling commands (installed by deploy/install.sh) +Cmnd_Alias HAMLAB_QUIET = /usr/sbin/smartctl, /usr/bin/smartctl, /usr/sbin/pwrstat, /usr/bin/journalctl, /usr/bin/tail, /usr/bin/cat, /usr/bin/ls, /usr/bin/crontab +Defaults!HAMLAB_QUIET !syslog, !pam_session, !pam_setcred +SUDOERS +if sudo visudo -cf "$quiet" >/dev/null 2>&1; then + sudo install -m 0440 -o root -g root "$quiet" /etc/sudoers.d/hamlab-quiet +else + echo "warning: sudoers snippet failed validation; skipped" >&2 +fi +rm -f "$quiet" sudo systemctl daemon-reload sudo systemctl enable hamlab.service >/dev/null if [ ! -f "$HOME_DIR/apps/hamlab/config.json" ]; then diff --git a/internal/api/server.go b/internal/api/server.go index 673d6ab..14437dc 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -9,6 +9,7 @@ import ( "io/fs" "log" "mime" + "net" "net/http" "net/url" "os" @@ -154,7 +155,17 @@ func New(cfg *config.Config, sessions *auth.Sessions, mgr *term.Manager, col *me return s } -func (s *Server) Handler() http.Handler { return s.mux } +// Handler wraps the mux with headers a root-capable console should always send. +func (s *Server) Handler() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + h := w.Header() + h.Set("X-Frame-Options", "DENY") + h.Set("Content-Security-Policy", "frame-ancestors 'none'") + h.Set("X-Content-Type-Options", "nosniff") + h.Set("Referrer-Policy", "no-referrer") + s.mux.ServeHTTP(w, r) + }) +} // ---- helpers ---- @@ -207,7 +218,7 @@ func (s *Server) auth(next http.HandlerFunc) http.HandlerFunc { // issueToken trades the password for a long-lived token, named by the caller (a hub). // Rate-limited like login; an existing token with the same name is replaced. func (s *Server) issueToken(w http.ResponseWriter, r *http.Request) { - if err := s.limiter.Allow(); err != nil { + if err := s.limiter.Allow(clientKey(r)); err != nil { jsonErr(w, 429, "too many attempts, try again later") return } @@ -220,12 +231,12 @@ func (s *Server) issueToken(w http.ResponseWriter, r *http.Request) { return } if !auth.VerifyPassword(s.cfg.PasswordHash, body.Password) { - s.limiter.Fail() + s.limiter.Fail(clientKey(r)) log.Printf("token request failed from %s", clientIP(r)) jsonErr(w, 401, "wrong password") return } - s.limiter.Reset() + s.limiter.Reset(clientKey(r)) tok, hash, err := auth.NewToken() if err != nil { jsonErr(w, 500, "token error") @@ -415,7 +426,7 @@ func (s *Server) proxyNode(w http.ResponseWriter, r *http.Request) { // ---- auth ---- func (s *Server) login(w http.ResponseWriter, r *http.Request) { - if err := s.limiter.Allow(); err != nil { + if err := s.limiter.Allow(clientKey(r)); err != nil { jsonErr(w, 429, "too many attempts, try again later") return } @@ -427,12 +438,12 @@ func (s *Server) login(w http.ResponseWriter, r *http.Request) { return } if !auth.VerifyPassword(s.cfg.PasswordHash, body.Password) { - s.limiter.Fail() + s.limiter.Fail(clientKey(r)) log.Printf("login failed from %s", clientIP(r)) jsonErr(w, 401, "wrong password") return } - s.limiter.Reset() + s.limiter.Reset(clientKey(r)) tok, err := s.sessions.Create() if err != nil { jsonErr(w, 500, "session error") @@ -451,6 +462,19 @@ func (s *Server) logout(w http.ResponseWriter, r *http.Request) { w.WriteHeader(204) } +// clientKey is the address the limiter buckets by: the first X-Forwarded-For hop when a +// reverse proxy set it, else the socket peer. +func clientKey(r *http.Request) string { + if xff := r.Header.Get("X-Forwarded-For"); xff != "" { + return strings.TrimSpace(strings.Split(xff, ",")[0]) + } + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + return r.RemoteAddr + } + return host +} + func clientIP(r *http.Request) string { if xff := r.Header.Get("X-Forwarded-For"); xff != "" { return strings.TrimSpace(strings.Split(xff, ",")[0]) + " via " + r.RemoteAddr @@ -880,8 +904,13 @@ func fsErr(w http.ResponseWriter, err error) { code = 403 } msg := err.Error() - if pe, ok := err.(*os.PathError); ok { // "open /x: permission denied" -> "permission denied" + var pe *os.PathError + var le *os.LinkError + switch { + case errors.As(err, &pe): // "open /x: permission denied" -> "permission denied" msg = pe.Err.Error() + case errors.As(err, &le): // "rename /a /b: no such file" -> "no such file or directory" + msg = le.Err.Error() } jsonErr(w, code, msg) } @@ -948,7 +977,12 @@ func (s *Server) filesRead(w http.ResponseWriter, r *http.Request) { jsonErr(w, 400, "is a directory") return } - if mx, _ := strconv.ParseInt(r.URL.Query().Get("max"), 10, 64); mx > 0 && in.Size > mx { + if in.Type == "other" { + jsonErr(w, 400, "not a regular file (device, pipe or socket)") + return + } + mx, _ := strconv.ParseInt(r.URL.Query().Get("max"), 10, 64) + if mx > 0 && in.Size > mx { jsonErr(w, 413, "file too large") return } @@ -958,6 +992,10 @@ func (s *Server) filesRead(w http.ResponseWriter, r *http.Request) { return } defer f.Close() + var body io.Reader = f + if mx > 0 { // stat can lie (size 0 on /proc); the cap must hold on the bytes actually sent + body = io.LimitReader(f, mx+1) + } name := filepath.Base(p) ct := mime.TypeByExtension(filepath.Ext(name)) if ct == "" { @@ -975,10 +1013,10 @@ func (s *Server) filesRead(w http.ResponseWriter, r *http.Request) { } else { w.Header().Set("Content-Disposition", "inline; filename*=UTF-8''"+url.PathEscape(name)) } - if in.Size > 0 { + if in.Size > 0 && mx == 0 { w.Header().Set("Content-Length", strconv.FormatInt(in.Size, 10)) } - _, _ = io.Copy(w, f) + _, _ = io.Copy(w, body) } func (s *Server) filesWrite(w http.ResponseWriter, r *http.Request) { @@ -1014,6 +1052,12 @@ func (s *Server) filesUpload(w http.ResponseWriter, r *http.Request) { return } dst := filepath.Join(dir, name) + if r.URL.Query().Get("overwrite") != "1" { + if _, err := fsys.Stat(r.Context(), dst); err == nil { + jsonErr(w, 409, "a file with that name already exists") + return + } + } if err := fsys.Write(r.Context(), dst, r.Body); err != nil { fsErr(w, err) return @@ -1241,6 +1285,19 @@ func (s *Server) putUIPrefs(w http.ResponseWriter, r *http.Request) { jsonErr(w, 400, "expected a JSON object") return } + var p struct { + Theme *string `json:"theme"` + RefreshMs *float64 `json:"refreshMs"` + } + _ = json.Unmarshal(b, &p) + if p.RefreshMs != nil && (*p.RefreshMs < 100 || *p.RefreshMs > 60000) { + jsonErr(w, 400, "refreshMs must be between 100 and 60000") + return + } + if p.Theme != nil && (len(*p.Theme) > 32 || strings.ContainsAny(*p.Theme, "<>\"'")) { + jsonErr(w, 400, "theme must be a short identifier") + return + } if err := s.store.Update(func(st *store.State) { st.UI = json.RawMessage(b) }); err != nil { jsonErr(w, 500, err.Error()) return diff --git a/internal/auth/auth.go b/internal/auth/auth.go index f9354a8..80a5bd1 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -152,39 +152,92 @@ func (s *Sessions) Delete(tok string) { // Limiter is a single global backoff: after 5 failures, each further failure // doubles a lockout starting at 30s, capped at 10 minutes. Single-user box. +// Limiter locks out a client after repeated failed logins, per client address, with a +// global backstop so a botnet can't burn CPU on argon2 indefinitely. A lockout for one +// address never blocks another. type Limiter struct { - mu sync.Mutex + mu sync.Mutex + clients map[string]*bucket + global bucket +} + +type bucket struct { failures int lockUntil time.Time + last time.Time } var ErrLocked = errors.New("too many failed logins") -func (l *Limiter) Allow() error { +const ( + perClientLock = 5 // failures before a client is locked + globalLock = 100 // failures (all clients) in the window before everyone waits a minute + globalWindow = 5 * time.Minute +) + +func (l *Limiter) get(key string) *bucket { + if l.clients == nil { + l.clients = map[string]*bucket{} + } + b, ok := l.clients[key] + if !ok { + b = &bucket{} + l.clients[key] = b + } + // forget clients that have been quiet for an hour + if len(l.clients) > 256 { + for k, x := range l.clients { + if time.Since(x.last) > time.Hour && time.Now().After(x.lockUntil) { + delete(l.clients, k) + } + } + } + return b +} + +func (l *Limiter) Allow(key string) error { l.mu.Lock() defer l.mu.Unlock() - if time.Now().Before(l.lockUntil) { + now := time.Now() + if now.Before(l.global.lockUntil) { + return ErrLocked + } + if now.Sub(l.global.last) > globalWindow { + l.global.failures = 0 + } + if b := l.get(key); now.Before(b.lockUntil) { return ErrLocked } return nil } -func (l *Limiter) Fail() { +func (l *Limiter) Fail(key string) { l.mu.Lock() defer l.mu.Unlock() - l.failures++ - if l.failures >= 5 { - d := 30 * time.Second << uint(l.failures-5) + now := time.Now() + b := l.get(key) + b.failures++ + b.last = now + if b.failures >= perClientLock { + d := 30 * time.Second << uint(b.failures-perClientLock) if d > 10*time.Minute { d = 10 * time.Minute } - l.lockUntil = time.Now().Add(d) + b.lockUntil = now.Add(d) + } + if now.Sub(l.global.last) > globalWindow { + l.global.failures = 0 + } + l.global.failures++ + l.global.last = now + if l.global.failures >= globalLock { + l.global.lockUntil = now.Add(time.Minute) + l.global.failures = 0 } } -func (l *Limiter) Reset() { +func (l *Limiter) Reset(key string) { l.mu.Lock() defer l.mu.Unlock() - l.failures = 0 - l.lockUntil = time.Time{} + delete(l.clients, key) } diff --git a/internal/cron/cron.go b/internal/cron/cron.go index 2c16e87..b19d547 100644 --- a/internal/cron/cron.go +++ b/internal/cron/cron.go @@ -82,6 +82,7 @@ type Manager struct { mu sync.Mutex lastRuns map[string]int64 // user\x00command -> unix ms lastAt time.Time + lastCursor string // journal cursor of the newest entry folded into lastRuns logging bool runs map[string]*Run } @@ -388,32 +389,48 @@ func (m *Manager) Overview(ctx context.Context) Overview { var cronLogRe = regexp.MustCompile(`^\((\S+)\) CMD \((.*)\)$`) // lastRunsFromJournal maps user+command to the most recent "(user) CMD (command)" line. +// The first call scans 30 days; later calls only read entries after the last cursor, so +// the cost per refresh is proportional to new cron activity, not to history. func (m *Manager) lastRunsFromJournal(ctx context.Context) map[string]int64 { m.mu.Lock() - if time.Since(m.lastAt) < 5*time.Second && m.lastRuns != nil { + if time.Since(m.lastAt) < 2*time.Second && m.lastRuns != nil { r := m.lastRuns m.mu.Unlock() return r } + cursor := m.lastCursor + res := map[string]int64{} + for k, v := range m.lastRuns { + res[k] = v + } m.mu.Unlock() - args := []string{"journalctl", "-t", "CRON", "-t", "cron", "-t", "crond", "-o", "json", "--no-pager", "-q", "--since", "-30d", "-n", "20000"} + + args := []string{"journalctl", "-t", "CRON", "-t", "cron", "-t", "crond", "-o", "json", "--no-pager", "-q"} + if cursor != "" { + args = append(args, "--after-cursor="+cursor) + } else { + args = append(args, "--since", "-30d", "-n", "20000") + } if m.journalSudo { args = append([]string{"sudo", "-n"}, args...) } out, _ := run(ctx, "", args...) - res := map[string]int64{} - seen := false + seen := m.logging sc := bufio.NewScanner(strings.NewReader(out)) sc.Buffer(make([]byte, 0, 64<<10), 4<<20) for sc.Scan() { var raw struct { - Msg any `json:"MESSAGE"` - TS string `json:"__REALTIME_TIMESTAMP"` + Msg any `json:"MESSAGE"` + TS string `json:"__REALTIME_TIMESTAMP"` + Cursor string `json:"__CURSOR"` } if json.Unmarshal(sc.Bytes(), &raw) != nil { continue } seen = true + if raw.Cursor != "" { + cursor = raw.Cursor + } msg, _ := raw.Msg.(string) mm := cronLogRe.FindStringSubmatch(msg) if mm == nil { @@ -426,7 +443,7 @@ func (m *Manager) lastRunsFromJournal(ctx context.Context) map[string]int64 { } } m.mu.Lock() - m.lastRuns, m.lastAt, m.logging = res, time.Now(), seen + m.lastRuns, m.lastAt, m.lastCursor, m.logging = res, time.Now(), cursor, seen m.mu.Unlock() return res } @@ -480,48 +497,50 @@ func (m *Manager) OutputTail(ctx context.Context, j Job, n int) (string, error) // ---- timers ---- +// timers: one `systemctl show` over every loaded timer instead of three list commands. func (m *Manager) timers(ctx context.Context) []Timer { - out, err := run(ctx, "", "systemctl", "list-timers", "--all", "--output=json", "--no-pager") + out, err := run(ctx, "", "systemctl", "show", "*.timer", "--timestamp=unix", "--no-pager", + "-p", "Id,Description,ActiveState,SubState,UnitFileState,NextElapseUSecRealtime,LastTriggerUSec,Triggers") if err != nil { return []Timer{} } - var lt []struct { - Next int64 `json:"next"` - Last int64 `json:"last"` - Unit string `json:"unit"` - Activates string `json:"activates"` - } - _ = json.Unmarshal([]byte(out), <) - units, _ := run(ctx, "", "systemctl", "list-units", "--type=timer", "--all", "--output=json", "--no-pager", "--plain") - var lu []struct{ Unit, Active, Sub, Description string } - _ = json.Unmarshal([]byte(units), &lu) - meta := map[string]struct{ Active, Sub, Description string }{} - for _, u := range lu { - meta[u.Unit] = struct{ Active, Sub, Description string }{u.Active, u.Sub, u.Description} - } - files, _ := run(ctx, "", "systemctl", "list-unit-files", "--type=timer", "--output=json", "--no-pager") - var uf []struct { - UnitFile string `json:"unit_file"` - State string `json:"state"` - } - _ = json.Unmarshal([]byte(files), &uf) - enabled := map[string]string{} - for _, f := range uf { - enabled[f.UnitFile] = f.State - } - res := make([]Timer, 0, len(lt)) - for _, t := range lt { - x := Timer{Unit: t.Unit, Activates: t.Activates, Enabled: enabled[t.Unit]} - if mt, ok := meta[t.Unit]; ok { - x.Active, x.Sub, x.Description = mt.Active, mt.Sub, mt.Description + unix := func(v string) int64 { // "@1789711405" (seconds) or "n/a" + if strings.HasPrefix(v, "@") { + s, _ := strconv.ParseInt(strings.TrimPrefix(v, "@"), 10, 64) + return s * 1000 } - if t.Next > 0 { - x.Next = t.Next / 1000 + return 0 + } + res := []Timer{} + for _, block := range strings.Split(out, "\n\n") { + t := Timer{} + for _, line := range strings.Split(block, "\n") { + k, v, ok := strings.Cut(line, "=") + if !ok { + continue + } + switch k { + case "Id": + t.Unit = v + case "Description": + t.Description = v + case "ActiveState": + t.Active = v + case "SubState": + t.Sub = v + case "UnitFileState": + t.Enabled = v + case "NextElapseUSecRealtime": + t.Next = unix(v) + case "LastTriggerUSec": + t.Last = unix(v) + case "Triggers": + t.Activates = strings.Fields(v + " ")[0:1][0] + } } - if t.Last > 0 { - x.Last = t.Last / 1000 + if t.Unit != "" { + res = append(res, t) } - res = append(res, x) } sort.Slice(res, func(i, j int) bool { return res[i].Unit < res[j].Unit }) return res diff --git a/internal/files/files.go b/internal/files/files.go index c84fc61..3bd0d58 100644 --- a/internal/files/files.go +++ b/internal/files/files.go @@ -206,6 +206,8 @@ func looksText(b []byte) bool { return ctrl*20 < len(b) } +// Open refuses anything that isn't a regular file: devices stream forever (/dev/zero), +// FIFOs block on open, sockets can't be read. /proc files are regular and still work. func (Local) Open(_ context.Context, path string) (io.ReadCloser, error) { st, err := os.Stat(path) if err != nil { @@ -214,6 +216,9 @@ func (Local) Open(_ context.Context, path string) (io.ReadCloser, error) { if st.IsDir() { return nil, errors.New("is a directory") } + if !st.Mode().IsRegular() { + return nil, errors.New("not a regular file (device, pipe or socket)") + } return os.Open(path) } diff --git a/internal/metrics/metrics.go b/internal/metrics/metrics.go index 5bbe2cd..479e961 100644 --- a/internal/metrics/metrics.go +++ b/internal/metrics/metrics.go @@ -1093,9 +1093,14 @@ func (c *Collector) Processes() ProcTable { // Signal sends a signal to a pid; falls back to sudo when the process isn't ours. func Signal(pid int, sig syscall.Signal) error { - if pid <= 1 { + if pid <= 1 || pid == os.Getpid() { return errors.New("refusing to signal pid " + strconv.Itoa(pid)) } + if strings.TrimRight(readStr("/proc/"+strconv.Itoa(pid)+"/cmdline"), "\x00") == "" { + if _, err := os.Stat("/proc/" + strconv.Itoa(pid)); err == nil { + return errors.New("refusing to signal a kernel thread") + } + } if err := syscall.Kill(pid, sig); err == nil { return nil } else if !errors.Is(err, syscall.EPERM) { diff --git a/internal/services/services.go b/internal/services/services.go index 99be81f..d11aca7 100644 --- a/internal/services/services.go +++ b/internal/services/services.go @@ -114,7 +114,15 @@ func (m *Manager) List(ctx context.Context) ([]Unit, error) { } m.mu.Unlock() - out, err := run(ctx, "systemctl", "list-units", "--type=service", "--all", "--output=json", "--no-pager", "--plain") + var out, files string + var err error + done := make(chan struct{}) + go func() { + files, _ = run(ctx, "systemctl", "list-unit-files", "--type=service", "--output=json", "--no-pager") + close(done) + }() + out, err = run(ctx, "systemctl", "list-units", "--type=service", "--all", "--output=json", "--no-pager", "--plain") + <-done if err != nil { return nil, err } @@ -122,7 +130,6 @@ func (m *Manager) List(ctx context.Context) ([]Unit, error) { if err := json.Unmarshal([]byte(out), &units); err != nil { return nil, fmt.Errorf("parse list-units: %w", err) } - files, _ := run(ctx, "systemctl", "list-unit-files", "--type=service", "--output=json", "--no-pager") var uf []struct { UnitFile string `json:"unit_file"` State string `json:"state"` diff --git a/internal/updates/updates.go b/internal/updates/updates.go index 99fafb0..9c7dadd 100644 --- a/internal/updates/updates.go +++ b/internal/updates/updates.go @@ -12,6 +12,7 @@ import ( "errors" "os" "os/exec" + "path/filepath" "regexp" "sort" "strings" @@ -83,6 +84,52 @@ func out(ctx context.Context, args ...string) (string, error) { return string(b), nil } +// newestMtime: the newest mtime among the paths matching any of the globs. +func newestMtime(globs ...string) int64 { + var best int64 + for _, g := range globs { + matches, _ := filepath.Glob(g) + for _, m := range matches { + if t := mtimeMs(m); t > best { + best = t + } + } + } + return best +} + +// dnfLastUpgrade: newest transaction in `dnf history` whose action includes an upgrade, +// else the newest transaction of any kind. +func dnfLastUpgrade(ctx context.Context) int64 { + text, err := out(ctx, "dnf", "history", "list") + if err != nil { + return 0 + } + re := regexp.MustCompile(`(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})\s+(\S+)`) + var newest, upgrade int64 + for _, line := range strings.Split(text, "\n") { + m := re.FindStringSubmatch(line) + if m == nil { + continue + } + t, err := time.ParseInLocation("2006-01-02 15:04:05", m[1], time.Local) + if err != nil { + continue + } + ms := t.UnixMilli() + if ms > newest { + newest = ms + } + if strings.Contains(m[2], "U") && ms > upgrade { + upgrade = ms + } + } + if upgrade > 0 { + return upgrade + } + return newest +} + func mtimeMs(p string) int64 { st, err := os.Stat(p) if err != nil { @@ -113,7 +160,8 @@ func (m *Manager) Status(ctx context.Context) Status { s.AutoUpgrades = aptAutoUpgrades(ctx) case "dnf": s.Packages = dnfUpgradable(ctx) - s.LastRefresh = mtimeMs("/var/cache/dnf") + s.LastRefresh = newestMtime("/var/cache/libdnf5/*/repodata", "/var/cache/dnf/*/repodata", "/var/cache/libdnf5", "/var/cache/dnf") + s.LastUpgrade = dnfLastUpgrade(ctx) case "pacman": s.Packages = pacmanUpgradable(ctx) s.LastRefresh = mtimeMs("/var/lib/pacman/sync") diff --git a/web/src/Files.tsx b/web/src/Files.tsx index 4c81c94..7cecf43 100644 --- a/web/src/Files.tsx +++ b/web/src/Files.tsx @@ -157,9 +157,14 @@ export default function Files({ onOpenInTerminal }: { onOpenInTerminal?: (cmd: s const list = Array.from(files) for (let i = 0; i < list.length; i++) { const f = list[i] + let overwrite = false + if ((entries ?? []).some((e) => e.name === f.name)) { + if (!window.confirm(`"${f.name}" already exists here. Replace it?`)) continue + overwrite = true + } setUpload({ name: f.name, pct: 0, n: i + 1, of: list.length }) try { - await api.fileUpload(path, f, sudo, (pct) => setUpload({ name: f.name, pct, n: i + 1, of: list.length })) + await api.fileUpload(path, f, sudo, (pct) => setUpload({ name: f.name, pct, n: i + 1, of: list.length }), overwrite) } catch (e) { toast(`upload ${f.name} — ${e instanceof Error ? e.message : 'failed'}`, 'err'); break } } setUpload(null) diff --git a/web/src/Login.tsx b/web/src/Login.tsx index cd06feb..6b4026a 100644 --- a/web/src/Login.tsx +++ b/web/src/Login.tsx @@ -11,7 +11,7 @@ export default function Login({ onLogin }: { onLogin: () => void }) { e.preventDefault() setBusy(true); setErr('') try { await api.login(pw); onLogin() } - catch (e) { setErr(e instanceof Error ? e.message : 'login failed'); setBusy(false) } + catch (e) { setErr((e instanceof Error && e.message) || 'login failed'); setBusy(false) } } return ( diff --git a/web/src/Machines.tsx b/web/src/Machines.tsx index 8d8923b..46638f7 100644 --- a/web/src/Machines.tsx +++ b/web/src/Machines.tsx @@ -96,7 +96,7 @@ export default function Machines({ hub, onChanged, onOpen }: { hub: Me; onChange