diff --git a/Makefile b/Makefile index 47be63d..08e0887 100644 --- a/Makefile +++ b/Makefile @@ -25,6 +25,11 @@ dev: deploy: build ./deploy/install.sh + @sleep 1; $(PREFIX)/hamlab push-nodes || true + +# push the installed binary to every managed machine (what deploy does last) +push-nodes: + $(PREFIX)/hamlab push-nodes clean: rm -rf bin internal/web/dist web/dist diff --git a/PLAN.md b/PLAN.md index 8cff475..e3351b3 100644 --- a/PLAN.md +++ b/PLAN.md @@ -190,9 +190,17 @@ something long in one, switch away and back, refresh — it's all still there. carries WebSockets, so terminals, log tails and task output work remotely. Frontend: `setMachine()` rewrites every `/api/…` path; the rail has a machine switcher at the top and a Machines page to add/remove/open. Pages remount on switch; - terminals live on the node. First node: `grid`. Still to do: Phase 2 Datacenter - overview; Phase 3 `make deploy-remote` and update-from-hub; optional firewall rule - limiting a node's port to the hub. + terminals live on the node. Nodes: `grid`, `wyse`. **Phase 3 (update from hub) + shipped 2026-09-18**: node `POST /api/self-update` takes a binary, checks it answers + `version`, keeps `hamlab.prev` in the data dir, installs over the executable via + `sudo install`, replies, then restarts the service; the hub pushes its own executable + with `POST /api/machines/{name}/update` or `/api/machines-update-all` (architecture + checked first via `/api/me.arch`), the Machines page shows an **outdated** badge and + Update buttons, and `hamlab push-nodes` does the same from the CLI — `make deploy` runs + it last, so one deploy on the hub updates every machine. First install on a new host + is still by hand (scp binary + `install.sh`, `hamlab passwd`, `tls auto`). Still to + do: Phase 2 Datacenter overview; optional firewall rule limiting a node's port to + the hub; cross-architecture builds for the Mac mini. - **Auth hardening** — WebAuthn passkeys (phone Face ID), TOTP fallback, session list + revoke, optional NPM access list restricting to WireGuard/LAN. - **Files tab** — **shipped 2026-09-18** (`internal/files`, `Files.tsx`): browse from diff --git a/README.md b/README.md index 99f3b5a..c698517 100644 --- a/README.md +++ b/README.md @@ -33,5 +33,9 @@ start). Then on the hub, open **Machines**, enter a name, the address certificate, keeps an access token, and every tab works against the chosen machine through the switcher at the top of the rail. +Updating: `make deploy` on the hub installs the new build there and then pushes it to +every managed machine (`hamlab push-nodes`), which install it and restart. The Machines +page flags machines running an older version and offers the same push per machine. + - On `core` it is public at `https://hamlab.bigham.dev` behind Nginx Proxy Manager. - Installable as a PWA. diff --git a/cmd/hamlab/main.go b/cmd/hamlab/main.go index 9551c8e..23968aa 100644 --- a/cmd/hamlab/main.go +++ b/cmd/hamlab/main.go @@ -12,6 +12,7 @@ import ( "os/signal" "os/user" "path/filepath" + "runtime" "strings" "syscall" "time" @@ -45,7 +46,7 @@ func main() { } cfgPath := flag.String("config", defaultCfg, "path to config.json") flag.Usage = func() { - fmt.Fprintf(os.Stderr, "usage: hamlab [-config path] [serve|passwd|version|fs …]\n") + fmt.Fprintf(os.Stderr, "usage: hamlab [-config path] [serve|passwd|version|push-nodes|fs …]\n") flag.PrintDefaults() } flag.Parse() @@ -68,6 +69,26 @@ func main() { if err := passwd(cfg); err != nil { log.Fatal(err) } + case "push-nodes": // hub CLI: send this binary to every managed machine + exe, _ := os.Executable() + host, _ := os.Hostname() + nm := nodes.New(cfg, host) + results := nm.PushAll(context.Background(), exe, runtime.GOARCH) + if len(results) == 0 { + fmt.Println("no managed machines in", cfg.Path()) + } + failed := 0 + for _, r := range results { + if r.OK { + fmt.Printf("%-12s updated to %s\n", r.Name, r.Version) + } else { + failed++ + fmt.Printf("%-12s FAILED: %s\n", r.Name, r.Error) + } + } + if failed > 0 { + os.Exit(1) + } case "serve": if err := serve(cfg); err != nil { log.Fatal(err) diff --git a/internal/api/server.go b/internal/api/server.go index a137ad8..20a12c6 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -12,8 +12,10 @@ import ( "net/http" "net/url" "os" + "os/exec" "os/user" "path/filepath" + "runtime" "strconv" "strings" "syscall" @@ -86,6 +88,9 @@ func New(cfg *config.Config, sessions *auth.Sessions, mgr *term.Manager, col *me m.HandleFunc("POST /api/nodes", s.auth(s.addNode)) m.HandleFunc("DELETE /api/nodes/{name}", s.auth(s.removeNode)) m.HandleFunc("/api/nodes/{name}/{rest...}", s.auth(s.proxyNode)) + m.HandleFunc("POST /api/self-update", s.auth(s.selfUpdate)) + m.HandleFunc("POST /api/machines/{name}/update", s.auth(s.updateMachine)) + m.HandleFunc("POST /api/machines-update-all", s.auth(s.updateAllMachines)) m.HandleFunc("POST /api/logout", s.auth(s.logout)) m.HandleFunc("GET /api/me", s.auth(s.me)) m.HandleFunc("GET /api/terminals", s.auth(s.listTerminals)) @@ -306,6 +311,95 @@ func (s *Server) removeNode(w http.ResponseWriter, r *http.Request) { w.WriteHeader(204) } +// selfUpdate receives a new binary from a hub, checks it runs, installs it over the +// current executable (keeping a .prev copy) and restarts the service. +func (s *Server) selfUpdate(w http.ResponseWriter, r *http.Request) { + exe, err := os.Executable() + if err != nil { + jsonErr(w, 500, err.Error()) + return + } + exe, _ = filepath.EvalSymlinks(exe) + tmp := filepath.Join(s.cfg.DataDir(), "hamlab.new") + f, err := os.OpenFile(tmp, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o755) + if err != nil { + jsonErr(w, 500, err.Error()) + return + } + n, err := io.Copy(f, io.LimitReader(r.Body, 512<<20)) + f.Close() + if err != nil || n < 1<<20 { + os.Remove(tmp) + jsonErr(w, 400, "upload failed or too small to be hamlab") + return + } + // It must at least run and identify itself before it replaces us. + ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second) + defer cancel() + out, err := exec.CommandContext(ctx, tmp, "version").Output() + if err != nil || !strings.HasPrefix(string(out), "hamlab ") { + os.Remove(tmp) + jsonErr(w, 400, "uploaded file is not a working hamlab binary") + return + } + newVersion := strings.TrimSpace(strings.TrimPrefix(string(out), "hamlab ")) + prev := filepath.Join(s.cfg.DataDir(), "hamlab.prev") + _ = copyFile(exe, prev) + install := exec.CommandContext(ctx, "sudo", "-n", "install", "-m", "0755", tmp, exe) + if errb, err := install.CombinedOutput(); err != nil { + if err2 := os.Rename(tmp, exe); err2 != nil { // no sudo: try in place + jsonErr(w, 500, "install failed: "+strings.TrimSpace(string(errb))) + return + } + } + os.Remove(tmp) + log.Printf("self-update: installed %s (was %s) from %s; restarting", newVersion, s.version, clientIP(r)) + jsonOK(w, 200, map[string]string{"version": newVersion, "previous": s.version}) + go func() { + time.Sleep(800 * time.Millisecond) + if exec.Command("sudo", "-n", "systemctl", "restart", "hamlab").Run() != nil { + os.Exit(0) // Restart=always brings the new binary up + } + }() +} + +func copyFile(src, dst string) error { + in, err := os.Open(src) + if err != nil { + return err + } + defer in.Close() + out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o755) + if err != nil { + return err + } + if _, err := io.Copy(out, in); err != nil { + out.Close() + return err + } + return out.Close() +} + +func (s *Server) updateMachine(w http.ResponseWriter, r *http.Request) { + exe, _ := os.Executable() + res := s.nodes.Push(r.Context(), r.PathValue("name"), exe, runtime.GOARCH) + log.Printf("machines: update %s -> ok=%v %s %s (from %s)", res.Name, res.OK, res.Version, res.Error, clientIP(r)) + if !res.OK { + jsonErr(w, 400, res.Error) + return + } + jsonOK(w, 200, res) +} + +func (s *Server) updateAllMachines(w http.ResponseWriter, r *http.Request) { + exe, _ := os.Executable() + res := s.nodes.PushAll(r.Context(), exe, runtime.GOARCH) + for _, x := range res { + log.Printf("machines: update %s -> ok=%v %s %s (from %s)", x.Name, x.OK, x.Version, x.Error, clientIP(r)) + } + jsonOK(w, 200, res) +} + func (s *Server) proxyNode(w http.ResponseWriter, r *http.Request) { p, ok := s.nodes.Proxy(r.PathValue("name")) if !ok { @@ -381,7 +475,7 @@ func (s *Server) me(w http.ResponseWriter, _ *http.Request) { jsonOK(w, 200, map[string]any{ "host": s.host, "user": s.user, "version": s.version, "load": load, "uptime": uptime, "service_started": s.started, - "reboot_needed": rebootErr == nil, "fingerprint": s.fingerprint, + "reboot_needed": rebootErr == nil, "fingerprint": s.fingerprint, "arch": runtime.GOARCH, }) } diff --git a/internal/nodes/nodes.go b/internal/nodes/nodes.go index 3213f9d..3867e75 100644 --- a/internal/nodes/nodes.go +++ b/internal/nodes/nodes.go @@ -16,6 +16,7 @@ import ( "net/http" "net/http/httputil" "net/url" + "os" "regexp" "sort" "strings" @@ -278,6 +279,94 @@ func (m *Manager) Remove(ctx context.Context, name string) error { return err } +// ---- update from hub ---- + +type PushResult struct { + Name string `json:"name"` + OK bool `json:"ok"` + Version string `json:"version,omitempty"` + Error string `json:"error,omitempty"` +} + +// Push uploads binPath (normally the hub's own executable) to the node's self-update +// endpoint. The node verifies it, installs it and restarts itself. Refused when the +// node's architecture differs from ours. +func (m *Manager) Push(ctx context.Context, name, binPath, ourArch string) PushResult { + res := PushResult{Name: name} + n, ok := m.node(name) + if !ok { + res.Error = "no such machine" + return res + } + // arch check via /api/me + req, _ := http.NewRequestWithContext(ctx, "GET", n.URL+"/api/me", nil) + req.Header.Set("Authorization", "Bearer "+n.Token) + resp, err := m.client(n).Do(req) + if err != nil { + res.Error = "unreachable: " + shortErr(err) + return res + } + var me struct { + Arch string `json:"arch"` + Version string `json:"version"` + } + _ = json.NewDecoder(io.LimitReader(resp.Body, 64<<10)).Decode(&me) + resp.Body.Close() + if me.Arch != "" && ourArch != "" && me.Arch != ourArch { + res.Error = fmt.Sprintf("architecture mismatch: node is %s, this binary is %s — build for it and deploy by hand", me.Arch, ourArch) + return res + } + f, err := os.Open(binPath) + if err != nil { + res.Error = err.Error() + return res + } + defer f.Close() + st, _ := f.Stat() + up, _ := http.NewRequestWithContext(ctx, "POST", n.URL+"/api/self-update", f) + up.ContentLength = st.Size() + up.Header.Set("Authorization", "Bearer "+n.Token) + up.Header.Set("Content-Type", "application/octet-stream") + c := &http.Client{Transport: transportFor(n), Timeout: 5 * time.Minute} + r2, err := c.Do(up) + if err != nil { + res.Error = "upload: " + shortErr(err) + return res + } + defer r2.Body.Close() + var reply struct { + Version string `json:"version"` + Error string `json:"error"` + } + _ = json.NewDecoder(io.LimitReader(r2.Body, 64<<10)).Decode(&reply) + if r2.StatusCode != 200 { + if reply.Error == "" { + reply.Error = fmt.Sprintf("HTTP %d", r2.StatusCode) + } + res.Error = reply.Error + return res + } + res.OK, res.Version = true, reply.Version + return res +} + +// PushAll updates every node, in parallel. +func (m *Manager) PushAll(ctx context.Context, binPath, ourArch string) []PushResult { + var list []config.Node + m.cfg.View(func(c *config.Config) { list = append(list, c.Nodes...) }) + out := make([]PushResult, len(list)) + var wg sync.WaitGroup + for i, n := range list { + wg.Add(1) + go func(i int, name string) { + defer wg.Done() + out[i] = m.Push(ctx, name, binPath, ourArch) + }(i, n.Name) + } + wg.Wait() + return out +} + // ---- proxy ---- // Proxy returns the handler for /api/nodes//: it rewrites to /api/, diff --git a/web/src/Machines.tsx b/web/src/Machines.tsx index fe5fd6b..14f74f9 100644 --- a/web/src/Machines.tsx +++ b/web/src/Machines.tsx @@ -15,6 +15,8 @@ export default function Machines({ hub, onChanged, onOpen }: { hub: Me; onChange const [pw, setPw] = useState('') const [adding, setAdding] = useState(false) const [addErr, setAddErr] = useState('') + const norm = (v?: string) => (v ?? '').replace(/^v/, '').replace(/-dirty$/, '') + const outdated = (m: MachineStatus) => m.online && norm(m.version) !== norm(hub.version) const load = useCallback(async () => { try { const r = await api.machines(); setList(r.nodes); setFp(r.fingerprint); setErr('') } catch (e) { setErr(e instanceof Error ? e.message : 'failed'); setList([]) } @@ -32,6 +34,19 @@ export default function Machines({ hub, onChanged, onOpen }: { hub: Me; onChange } catch (e) { setAddErr(e instanceof Error ? e.message : 'failed') } finally { setAdding(false) } } + async function update(m: MachineStatus) { + if (!window.confirm(`Update ${m.name} to ${norm(hub.version)}? Its Ham Lab restarts; open terminals there are lost.`)) return + try { const r = await api.updateMachine(m.name); toast(`${m.name} updated to ${r.version}, restarting`); setTimeout(load, 4000) } catch (e) { toast(`${m.name}: ${e instanceof Error ? e.message : 'failed'}`, 'err') } + } + async function updateAll() { + const n = list?.filter(outdated).length ?? 0 + if (!window.confirm(`Update ${n} machine${n === 1 ? '' : 's'} to ${norm(hub.version)}?`)) return + try { + const res = await api.updateAllMachines() + for (const r of res) toast(r.ok ? `${r.name} → ${r.version}` : `${r.name}: ${r.error}`, r.ok ? 'ok' : 'err') + setTimeout(load, 4000) + } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') } + } async function remove(m: MachineStatus) { if (!window.confirm(`Remove ${m.name}? Its access token will be revoked. Nothing on ${m.name} itself is changed.`)) return try { await api.removeMachine(m.name); toast(`${m.name} removed`); await load(); onChanged() } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') } @@ -46,7 +61,7 @@ export default function Machines({ hub, onChanged, onOpen }: { hub: Me; onChange
{fp ? <>Certificate {fp} : 'Serving plain HTTP (behind a reverse proxy). Set "tls": "auto" in config.json for a self-signed certificate if another hub should manage this host.'}
-

Managed machines · {list.length}

+

Managed machines · {list.length}

{list.some(outdated) && Update {list.filter(outdated).length} outdated}
{err &&
{err}
} {list.length === 0 &&
No machines yet. Install Ham Lab on another host, set its password, then add it below.
}
@@ -58,6 +73,7 @@ export default function Machines({ hub, onChanged, onOpen }: { hub: Me; onChange {m.url} {m.reboot_needed && ⟳} + {outdated(m) && outdated}
Status{m.online ? 'online' : m.error || 'offline'}
@@ -69,6 +85,7 @@ export default function Machines({ hub, onChanged, onOpen }: { hub: Me; onChange
+ {outdated(m) && update(m)}>Update} remove(m)}>Remove
diff --git a/web/src/api.ts b/web/src/api.ts index 6dab538..abcc225 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -38,7 +38,8 @@ async function req(method: string, path: string, body?: unknown, local = fals return r.json() } -export interface Me { host: string; user: string; version: string; load: string; uptime: number; reboot_needed?: boolean; fingerprint?: string } +export interface Me { host: string; user: string; version: string; load: string; uptime: number; reboot_needed?: boolean; fingerprint?: string; arch?: string } +export interface PushResult { name: string; ok: boolean; version?: string; error?: string } export interface MachineStatus { name: string; url: string; fingerprint?: string; added: string; online: boolean; host?: string; version?: string; load?: string; uptime?: number; reboot_needed?: boolean; last_seen?: string; error?: string } export interface Term { id: string; name: string; running: boolean; exit_code: number; created: string; clients: number } @@ -162,6 +163,8 @@ export const api = { me: () => req('GET', '/api/me'), machines: () => req<{ nodes: MachineStatus[]; host: string; fingerprint?: string }>('GET', '/api/nodes', undefined, true), addMachine: (name: string, url: string, password: string) => req('POST', '/api/nodes', { name, url, password }, true), + updateMachine: (name: string) => req('POST', `/api/machines/${encodeURIComponent(name)}/update`, undefined, true), + updateAllMachines: () => req('POST', '/api/machines-update-all', undefined, true), removeMachine: (name: string) => req('DELETE', `/api/nodes/${encodeURIComponent(name)}`, undefined, true), terminals: () => req('GET', '/api/terminals'), createTerminal: (name?: string) => req<{ id: string; name: string }>('POST', '/api/terminals', { name }),