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
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
+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
PREFIX ?= /usr/local/bin
+2
View File
@@ -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
+14
View File
@@ -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
+68 -11
View File
@@ -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
+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
// 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
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)
}
+56 -37
View File
@@ -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"`
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"`
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
}
_ = json.Unmarshal([]byte(out), &lt)
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}
return 0
}
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"`
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
}
_ = json.Unmarshal([]byte(files), &uf)
enabled := map[string]string{}
for _, f := range uf {
enabled[f.UnitFile] = f.State
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]
}
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 {
x.Next = t.Next / 1000
if t.Unit != "" {
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 })
return res
+5
View File
@@ -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)
}
+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.
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) {
+9 -2
View File
@@ -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"`
+49 -1
View File
@@ -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")
+6 -1
View File
@@ -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)
+1 -1
View File
@@ -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 (
+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>
<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>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>}
+1 -1
View File
@@ -75,7 +75,7 @@ export default function Updates() {
<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">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>
+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
// 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')
}
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) {
let msg = r.statusText
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.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()
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.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'))
+1
View File
@@ -14,6 +14,7 @@ export function getRefreshMs(): 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))
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
// terminals to repaint. Cheap enough to call on every load before first render.
export function applyTheme(id: string) {
const t = themeById(id)
const t = themeById(id) // unknown ids fall back to the default
const root = document.documentElement
for (const [k, v] of Object.entries(t.vars)) root.style.setProperty(k, v)
root.dataset.theme = t.id