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:
co-authored by
Claude Fable 5.1
parent
57c58ebffb
commit
27f9665606
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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), <)
|
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
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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 (
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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'))
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user