Bug bash fixes: special-file reads, per-client lockout, prefs validation, security headers, kernel-thread guard, login feedback, error text; plus journal-quiet sudoers + journal groups, upload overwrite guard, machines pattern, dnf freshness, cron/services speed, clean version

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Mitchell-Johnson
2026-09-18 17:41:52 -07:00
co-authored by Claude Fable 5.1
parent 57c58ebffb
commit 27f9665606
18 changed files with 306 additions and 85 deletions
+7 -7
View File
@@ -7,24 +7,24 @@ with hostile inputs, and a code read. Ordered by severity within each group.
## Security / robustness ## 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, 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 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 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 viewer would hang the tab. Needs: refuse non-regular files for read/edit, and cap the
copy with `io.LimitReader` regardless of `stat`. 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). 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`, 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. 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 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 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). 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'`, send `X-Frame-Options: DENY`, `Content-Security-Policy: frame-ancestors 'none'`,
`Referrer-Policy: no-referrer`, `X-Content-Type-Options: nosniff` on every response. `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 returns 204. Harmless in practice, but the guard should refuse PIDs with an empty
cmdline (kernel threads) and our own PID. cmdline (kernel threads) and our own PID.
6. **Journal spam from the daemon's own sudo calls.** Every sudo'd `journalctl`, `smartctl` 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 ## 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 with an empty message, and the login form only renders `err` when non-empty. The
button just re-enables. Reproduced on phone and desktop. button just re-enables. Reproduced on phone and desktop.
10. **Switching machines while on Settings or Machines keeps showing the hub's page.** 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 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 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. 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. file or directory". Fine for a lab, ugly for a product; strip the syscall prefix.
## Mobile / UX ## Mobile / UX
+2 -1
View File
@@ -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 GO ?= go
PREFIX ?= /usr/local/bin PREFIX ?= /usr/local/bin
+2
View File
@@ -6,6 +6,8 @@ After=network.target
Type=simple Type=simple
User=@USER@ User=@USER@
Group=@GROUP@ Group=@GROUP@
# read the system journal without sudo (keeps the daemon's own log clean)
SupplementaryGroups=systemd-journal adm
WorkingDirectory=@HOME@ WorkingDirectory=@HOME@
Environment=HAMLAB_CONFIG=@HOME@/apps/hamlab/config.json Environment=HAMLAB_CONFIG=@HOME@/apps/hamlab/config.json
ExecStart=@PREFIX@/hamlab serve ExecStart=@PREFIX@/hamlab serve
+14
View File
@@ -25,6 +25,20 @@ for alt in bin/hamlab-linux-*; do
done done
sudo install -m 0644 "$unit" /etc/systemd/system/hamlab.service sudo install -m 0644 "$unit" /etc/systemd/system/hamlab.service
rm -f "$unit" 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 daemon-reload
sudo systemctl enable hamlab.service >/dev/null sudo systemctl enable hamlab.service >/dev/null
if [ ! -f "$HOME_DIR/apps/hamlab/config.json" ]; then if [ ! -f "$HOME_DIR/apps/hamlab/config.json" ]; then
+68 -11
View File
@@ -9,6 +9,7 @@ import (
"io/fs" "io/fs"
"log" "log"
"mime" "mime"
"net"
"net/http" "net/http"
"net/url" "net/url"
"os" "os"
@@ -154,7 +155,17 @@ func New(cfg *config.Config, sessions *auth.Sessions, mgr *term.Manager, col *me
return s 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 ---- // ---- 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). // 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. // Rate-limited like login; an existing token with the same name is replaced.
func (s *Server) issueToken(w http.ResponseWriter, r *http.Request) { 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") jsonErr(w, 429, "too many attempts, try again later")
return return
} }
@@ -220,12 +231,12 @@ func (s *Server) issueToken(w http.ResponseWriter, r *http.Request) {
return return
} }
if !auth.VerifyPassword(s.cfg.PasswordHash, body.Password) { 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)) log.Printf("token request failed from %s", clientIP(r))
jsonErr(w, 401, "wrong password") jsonErr(w, 401, "wrong password")
return return
} }
s.limiter.Reset() s.limiter.Reset(clientKey(r))
tok, hash, err := auth.NewToken() tok, hash, err := auth.NewToken()
if err != nil { if err != nil {
jsonErr(w, 500, "token error") jsonErr(w, 500, "token error")
@@ -415,7 +426,7 @@ func (s *Server) proxyNode(w http.ResponseWriter, r *http.Request) {
// ---- auth ---- // ---- auth ----
func (s *Server) login(w http.ResponseWriter, r *http.Request) { 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") jsonErr(w, 429, "too many attempts, try again later")
return return
} }
@@ -427,12 +438,12 @@ func (s *Server) login(w http.ResponseWriter, r *http.Request) {
return return
} }
if !auth.VerifyPassword(s.cfg.PasswordHash, body.Password) { if !auth.VerifyPassword(s.cfg.PasswordHash, body.Password) {
s.limiter.Fail() s.limiter.Fail(clientKey(r))
log.Printf("login failed from %s", clientIP(r)) log.Printf("login failed from %s", clientIP(r))
jsonErr(w, 401, "wrong password") jsonErr(w, 401, "wrong password")
return return
} }
s.limiter.Reset() s.limiter.Reset(clientKey(r))
tok, err := s.sessions.Create() tok, err := s.sessions.Create()
if err != nil { if err != nil {
jsonErr(w, 500, "session error") jsonErr(w, 500, "session error")
@@ -451,6 +462,19 @@ func (s *Server) logout(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(204) 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 { func clientIP(r *http.Request) string {
if xff := r.Header.Get("X-Forwarded-For"); xff != "" { if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
return strings.TrimSpace(strings.Split(xff, ",")[0]) + " via " + r.RemoteAddr return strings.TrimSpace(strings.Split(xff, ",")[0]) + " via " + r.RemoteAddr
@@ -880,8 +904,13 @@ func fsErr(w http.ResponseWriter, err error) {
code = 403 code = 403
} }
msg := err.Error() 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() 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) jsonErr(w, code, msg)
} }
@@ -948,7 +977,12 @@ func (s *Server) filesRead(w http.ResponseWriter, r *http.Request) {
jsonErr(w, 400, "is a directory") jsonErr(w, 400, "is a directory")
return 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") jsonErr(w, 413, "file too large")
return return
} }
@@ -958,6 +992,10 @@ func (s *Server) filesRead(w http.ResponseWriter, r *http.Request) {
return return
} }
defer f.Close() 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) name := filepath.Base(p)
ct := mime.TypeByExtension(filepath.Ext(name)) ct := mime.TypeByExtension(filepath.Ext(name))
if ct == "" { if ct == "" {
@@ -975,10 +1013,10 @@ func (s *Server) filesRead(w http.ResponseWriter, r *http.Request) {
} else { } else {
w.Header().Set("Content-Disposition", "inline; filename*=UTF-8''"+url.PathEscape(name)) 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)) 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) { 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 return
} }
dst := filepath.Join(dir, name) 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 { if err := fsys.Write(r.Context(), dst, r.Body); err != nil {
fsErr(w, err) fsErr(w, err)
return return
@@ -1241,6 +1285,19 @@ func (s *Server) putUIPrefs(w http.ResponseWriter, r *http.Request) {
jsonErr(w, 400, "expected a JSON object") jsonErr(w, 400, "expected a JSON object")
return 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 { if err := s.store.Update(func(st *store.State) { st.UI = json.RawMessage(b) }); err != nil {
jsonErr(w, 500, err.Error()) jsonErr(w, 500, err.Error())
return return
+63 -10
View File
@@ -152,39 +152,92 @@ func (s *Sessions) Delete(tok string) {
// Limiter is a single global backoff: after 5 failures, each further failure // 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. // 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 { type Limiter struct {
mu sync.Mutex mu sync.Mutex
clients map[string]*bucket
global bucket
}
type bucket struct {
failures int failures int
lockUntil time.Time lockUntil time.Time
last time.Time
} }
var ErrLocked = errors.New("too many failed logins") 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() l.mu.Lock()
defer l.mu.Unlock() 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 ErrLocked
} }
return nil return nil
} }
func (l *Limiter) Fail() { func (l *Limiter) Fail(key string) {
l.mu.Lock() l.mu.Lock()
defer l.mu.Unlock() defer l.mu.Unlock()
l.failures++ now := time.Now()
if l.failures >= 5 { b := l.get(key)
d := 30 * time.Second << uint(l.failures-5) b.failures++
b.last = now
if b.failures >= perClientLock {
d := 30 * time.Second << uint(b.failures-perClientLock)
if d > 10*time.Minute { if d > 10*time.Minute {
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() l.mu.Lock()
defer l.mu.Unlock() defer l.mu.Unlock()
l.failures = 0 delete(l.clients, key)
l.lockUntil = time.Time{}
} }
+56 -37
View File
@@ -82,6 +82,7 @@ type Manager struct {
mu sync.Mutex mu sync.Mutex
lastRuns map[string]int64 // user\x00command -> unix ms lastRuns map[string]int64 // user\x00command -> unix ms
lastAt time.Time lastAt time.Time
lastCursor string // journal cursor of the newest entry folded into lastRuns
logging bool logging bool
runs map[string]*Run runs map[string]*Run
} }
@@ -388,32 +389,48 @@ func (m *Manager) Overview(ctx context.Context) Overview {
var cronLogRe = regexp.MustCompile(`^\((\S+)\) CMD \((.*)\)$`) var cronLogRe = regexp.MustCompile(`^\((\S+)\) CMD \((.*)\)$`)
// lastRunsFromJournal maps user+command to the most recent "(user) CMD (command)" line. // 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 { func (m *Manager) lastRunsFromJournal(ctx context.Context) map[string]int64 {
m.mu.Lock() 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 r := m.lastRuns
m.mu.Unlock() m.mu.Unlock()
return r return r
} }
cursor := m.lastCursor
res := map[string]int64{}
for k, v := range m.lastRuns {
res[k] = v
}
m.mu.Unlock() 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 { if m.journalSudo {
args = append([]string{"sudo", "-n"}, args...) args = append([]string{"sudo", "-n"}, args...)
} }
out, _ := run(ctx, "", args...) out, _ := run(ctx, "", args...)
res := map[string]int64{} seen := m.logging
seen := false
sc := bufio.NewScanner(strings.NewReader(out)) sc := bufio.NewScanner(strings.NewReader(out))
sc.Buffer(make([]byte, 0, 64<<10), 4<<20) sc.Buffer(make([]byte, 0, 64<<10), 4<<20)
for sc.Scan() { for sc.Scan() {
var raw struct { var raw struct {
Msg any `json:"MESSAGE"` Msg any `json:"MESSAGE"`
TS string `json:"__REALTIME_TIMESTAMP"` TS string `json:"__REALTIME_TIMESTAMP"`
Cursor string `json:"__CURSOR"`
} }
if json.Unmarshal(sc.Bytes(), &raw) != nil { if json.Unmarshal(sc.Bytes(), &raw) != nil {
continue continue
} }
seen = true seen = true
if raw.Cursor != "" {
cursor = raw.Cursor
}
msg, _ := raw.Msg.(string) msg, _ := raw.Msg.(string)
mm := cronLogRe.FindStringSubmatch(msg) mm := cronLogRe.FindStringSubmatch(msg)
if mm == nil { if mm == nil {
@@ -426,7 +443,7 @@ func (m *Manager) lastRunsFromJournal(ctx context.Context) map[string]int64 {
} }
} }
m.mu.Lock() 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() m.mu.Unlock()
return res return res
} }
@@ -480,48 +497,50 @@ func (m *Manager) OutputTail(ctx context.Context, j Job, n int) (string, error)
// ---- timers ---- // ---- timers ----
// timers: one `systemctl show` over every loaded timer instead of three list commands.
func (m *Manager) timers(ctx context.Context) []Timer { 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 { if err != nil {
return []Timer{} return []Timer{}
} }
var lt []struct { unix := func(v string) int64 { // "@1789711405" (seconds) or "n/a"
Next int64 `json:"next"` if strings.HasPrefix(v, "@") {
Last int64 `json:"last"` s, _ := strconv.ParseInt(strings.TrimPrefix(v, "@"), 10, 64)
Unit string `json:"unit"` return s * 1000
Activates string `json:"activates"`
} }
_ = json.Unmarshal([]byte(out), &lt) return 0
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") res := []Timer{}
var uf []struct { for _, block := range strings.Split(out, "\n\n") {
UnitFile string `json:"unit_file"` t := Timer{}
State string `json:"state"` for _, line := range strings.Split(block, "\n") {
k, v, ok := strings.Cut(line, "=")
if !ok {
continue
} }
_ = json.Unmarshal([]byte(files), &uf) switch k {
enabled := map[string]string{} case "Id":
for _, f := range uf { t.Unit = v
enabled[f.UnitFile] = f.State 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]
} }
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
} }
if t.Next > 0 { if t.Unit != "" {
x.Next = t.Next / 1000 res = append(res, t)
} }
if t.Last > 0 {
x.Last = t.Last / 1000
}
res = append(res, x)
} }
sort.Slice(res, func(i, j int) bool { return res[i].Unit < res[j].Unit }) sort.Slice(res, func(i, j int) bool { return res[i].Unit < res[j].Unit })
return res return res
+5
View File
@@ -206,6 +206,8 @@ func looksText(b []byte) bool {
return ctrl*20 < len(b) 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) { func (Local) Open(_ context.Context, path string) (io.ReadCloser, error) {
st, err := os.Stat(path) st, err := os.Stat(path)
if err != nil { if err != nil {
@@ -214,6 +216,9 @@ func (Local) Open(_ context.Context, path string) (io.ReadCloser, error) {
if st.IsDir() { if st.IsDir() {
return nil, errors.New("is a directory") 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) return os.Open(path)
} }
+6 -1
View File
@@ -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. // 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 { 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)) 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 { if err := syscall.Kill(pid, sig); err == nil {
return nil return nil
} else if !errors.Is(err, syscall.EPERM) { } else if !errors.Is(err, syscall.EPERM) {
+9 -2
View File
@@ -114,7 +114,15 @@ func (m *Manager) List(ctx context.Context) ([]Unit, error) {
} }
m.mu.Unlock() 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 { if err != nil {
return nil, err 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 { if err := json.Unmarshal([]byte(out), &units); err != nil {
return nil, fmt.Errorf("parse list-units: %w", err) 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 { var uf []struct {
UnitFile string `json:"unit_file"` UnitFile string `json:"unit_file"`
State string `json:"state"` State string `json:"state"`
+49 -1
View File
@@ -12,6 +12,7 @@ import (
"errors" "errors"
"os" "os"
"os/exec" "os/exec"
"path/filepath"
"regexp" "regexp"
"sort" "sort"
"strings" "strings"
@@ -83,6 +84,52 @@ func out(ctx context.Context, args ...string) (string, error) {
return string(b), nil 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 { func mtimeMs(p string) int64 {
st, err := os.Stat(p) st, err := os.Stat(p)
if err != nil { if err != nil {
@@ -113,7 +160,8 @@ func (m *Manager) Status(ctx context.Context) Status {
s.AutoUpgrades = aptAutoUpgrades(ctx) s.AutoUpgrades = aptAutoUpgrades(ctx)
case "dnf": case "dnf":
s.Packages = dnfUpgradable(ctx) 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": case "pacman":
s.Packages = pacmanUpgradable(ctx) s.Packages = pacmanUpgradable(ctx)
s.LastRefresh = mtimeMs("/var/lib/pacman/sync") s.LastRefresh = mtimeMs("/var/lib/pacman/sync")
+6 -1
View File
@@ -157,9 +157,14 @@ export default function Files({ onOpenInTerminal }: { onOpenInTerminal?: (cmd: s
const list = Array.from(files) const list = Array.from(files)
for (let i = 0; i < list.length; i++) { for (let i = 0; i < list.length; i++) {
const f = list[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 }) setUpload({ name: f.name, pct: 0, n: i + 1, of: list.length })
try { 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 } } catch (e) { toast(`upload ${f.name} — ${e instanceof Error ? e.message : 'failed'}`, 'err'); break }
} }
setUpload(null) setUpload(null)
+1 -1
View File
@@ -11,7 +11,7 @@ export default function Login({ onLogin }: { onLogin: () => void }) {
e.preventDefault() e.preventDefault()
setBusy(true); setErr('') setBusy(true); setErr('')
try { await api.login(pw); onLogin() } 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 ( return (
+1 -1
View File
@@ -96,7 +96,7 @@ export default function Machines({ hub, onChanged, onOpen }: { hub: Me; onChange
<div className="dash-bar"><h2 className="section">Add a machine</h2></div> <div className="dash-bar"><h2 className="section">Add a machine</h2></div>
<form className="card addform" onSubmit={add}> <form className="card addform" onSubmit={add}>
<label>Name <input value={name} onChange={(e) => setName(e.target.value)} placeholder="grid" pattern="[a-z0-9][a-z0-9-]*" required /></label> <label>Name <input value={name} onChange={(e) => setName(e.target.value)} placeholder="grid" pattern="[a-z0-9][a-z0-9\\-]*" required /></label>
<label>Address <input value={url} onChange={(e) => setUrl(e.target.value)} placeholder="https://10.0.0.214:8100" required /></label> <label>Address <input value={url} onChange={(e) => setUrl(e.target.value)} placeholder="https://10.0.0.214:8100" required /></label>
<label>Its Ham Lab password <input type="password" value={pw} onChange={(e) => setPw(e.target.value)} autoComplete="off" required /></label> <label>Its Ham Lab password <input type="password" value={pw} onChange={(e) => setPw(e.target.value)} autoComplete="off" required /></label>
{addErr && <div className="error">{addErr}</div>} {addErr && <div className="error">{addErr}</div>}
+1 -1
View File
@@ -75,7 +75,7 @@ export default function Updates() {
<div className="kpis"> <div className="kpis">
<div className="tile"><div className="label">Upgradable</div><div className="value">{st.packages.length}</div><div className="sub">{st.security_count ? `${st.security_count} security` : 'no security updates'}</div></div> <div className="tile"><div className="label">Upgradable</div><div className="value">{st.packages.length}</div><div className="sub">{st.security_count ? `${st.security_count} security` : 'no security updates'}</div></div>
<div className="tile"><div className="label">Index refreshed</div><div className="value">{ago(st.last_refresh)}</div><div className="sub">{st.manager}</div></div> <div className="tile"><div className="label">Index refreshed</div><div className="value">{ago(st.last_refresh)}</div><div className="sub">{st.manager}</div></div>
<div className="tile"><div className="label">Last upgrade</div><div className="value">{ago(st.last_upgrade)}</div><div className="sub">from apt history</div></div> <div className="tile"><div className="label">Last upgrade</div><div className="value">{ago(st.last_upgrade)}</div><div className="sub">from {st.manager} history</div></div>
<div className="tile"><div className="label">Unattended</div><div className="value">{st.auto_upgrades ? 'on' : 'off'}</div><div className="sub">automatic upgrades</div></div> <div className="tile"><div className="label">Unattended</div><div className="value">{st.auto_upgrades ? 'on' : 'off'}</div><div className="sub">automatic upgrades</div></div>
</div> </div>
+8 -4
View File
@@ -1,4 +1,4 @@
export class Unauthenticated extends Error {} export class Unauthenticated extends Error { constructor(msg = 'unauthenticated') { super(msg) } }
// Which machine the console is looking at. '' is this host; otherwise every /api/… call // Which machine the console is looking at. '' is this host; otherwise every /api/… call
// is rewritten to /api/nodes/<name>/… and the hub proxies it. Hub-only calls (login, // is rewritten to /api/nodes/<name>/… and the hub proxies it. Hub-only calls (login,
@@ -28,7 +28,11 @@ async function req<T>(method: string, path: string, body?: unknown, local = fals
throw new Error('network error') throw new Error('network error')
} }
track(-1) track(-1)
if (r.status === 401) throw new Unauthenticated() if (r.status === 401) {
let msg = 'unauthenticated'
try { msg = (await r.json()).error ?? msg } catch { /* ignore */ }
throw new Unauthenticated(msg)
}
if (!r.ok) { if (!r.ok) {
let msg = r.statusText let msg = r.statusText
try { msg = (await r.json()).error ?? msg } catch { /* ignore */ } try { msg = (await r.json()).error ?? msg } catch { /* ignore */ }
@@ -111,9 +115,9 @@ export const api = {
if (r.status === 401) throw new Unauthenticated() if (r.status === 401) throw new Unauthenticated()
if (!r.ok) { let m = r.statusText; try { m = (await r.json()).error ?? m } catch { /* */ } throw new Error(m) } if (!r.ok) { let m = r.statusText; try { m = (await r.json()).error ?? m } catch { /* */ } throw new Error(m) }
}, },
fileUpload: (dir: string, file: File, sudo: boolean, onProgress: (pct: number) => void) => new Promise<void>((resolve, reject) => { fileUpload: (dir: string, file: File, sudo: boolean, onProgress: (pct: number) => void, overwrite = false) => new Promise<void>((resolve, reject) => {
const x = new XMLHttpRequest() const x = new XMLHttpRequest()
x.open('PUT', apiPath(`/api/files/upload?${q(dir, sudo)}&name=${encodeURIComponent(file.name)}`)) x.open('PUT', apiPath(`/api/files/upload?${q(dir, sudo)}&name=${encodeURIComponent(file.name)}${overwrite ? '&overwrite=1' : ''}`))
x.upload.onprogress = (e) => { if (e.lengthComputable) onProgress(Math.round((e.loaded / e.total) * 100)) } x.upload.onprogress = (e) => { if (e.lengthComputable) onProgress(Math.round((e.loaded / e.total) * 100)) }
x.onload = () => { if (x.status >= 200 && x.status < 300) resolve(); else { let m = x.statusText; try { m = JSON.parse(x.responseText).error ?? m } catch { /* */ } reject(new Error(m)) } } x.onload = () => { if (x.status >= 200 && x.status < 300) resolve(); else { let m = x.statusText; try { m = JSON.parse(x.responseText).error ?? m } catch { /* */ } reject(new Error(m)) } }
x.onerror = () => reject(new Error('network error')) x.onerror = () => reject(new Error('network error'))
+1
View File
@@ -14,6 +14,7 @@ export function getRefreshMs(): number {
} }
export function setRefreshMs(ms: number) { export function setRefreshMs(ms: number) {
if (!(ms >= 100 && ms <= 60000)) return // never accept a nonsense rate, wherever it came from
localStorage.setItem(KEY, String(ms)) localStorage.setItem(KEY, String(ms))
window.dispatchEvent(new CustomEvent('hamlab:refresh', { detail: ms })) window.dispatchEvent(new CustomEvent('hamlab:refresh', { detail: ms }))
} }
+1 -1
View File
@@ -73,7 +73,7 @@ export function currentThemeId(): string {
// applyTheme sets the variables on <html>, remembers the choice, and tells open // applyTheme sets the variables on <html>, remembers the choice, and tells open
// terminals to repaint. Cheap enough to call on every load before first render. // terminals to repaint. Cheap enough to call on every load before first render.
export function applyTheme(id: string) { export function applyTheme(id: string) {
const t = themeById(id) const t = themeById(id) // unknown ids fall back to the default
const root = document.documentElement const root = document.documentElement
for (const [k, v] of Object.entries(t.vars)) root.style.setProperty(k, v) for (const [k, v] of Object.entries(t.vars)) root.style.setProperty(k, v)
root.dataset.theme = t.id root.dataset.theme = t.id