Update from hub: node self-update endpoint, hub push per machine / all, push-nodes CLI run by make deploy, outdated badges + Update buttons on Machines

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Mitchell-Johnson
2026-09-18 12:04:31 -07:00
co-authored by Claude Fable 5.1
parent e9be94e32b
commit 254e0cdd58
8 changed files with 248 additions and 7 deletions
+5
View File
@@ -25,6 +25,11 @@ dev:
deploy: build deploy: build
./deploy/install.sh ./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: clean:
rm -rf bin internal/web/dist web/dist rm -rf bin internal/web/dist web/dist
+11 -3
View File
@@ -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. carries WebSockets, so terminals, log tails and task output work remotely.
Frontend: `setMachine()` rewrites every `/api/…` path; the rail has a machine 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; 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 terminals live on the node. Nodes: `grid`, `wyse`. **Phase 3 (update from hub)
overview; Phase 3 `make deploy-remote` and update-from-hub; optional firewall rule shipped 2026-09-18**: node `POST /api/self-update` takes a binary, checks it answers
limiting a node's port to the hub. `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 - **Auth hardening** — WebAuthn passkeys (phone Face ID), TOTP fallback, session
list + revoke, optional NPM access list restricting to WireGuard/LAN. list + revoke, optional NPM access list restricting to WireGuard/LAN.
- **Files tab** — **shipped 2026-09-18** (`internal/files`, `Files.tsx`): browse from - **Files tab** — **shipped 2026-09-18** (`internal/files`, `Files.tsx`): browse from
+4
View File
@@ -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 certificate, keeps an access token, and every tab works against the chosen machine
through the switcher at the top of the rail. 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. - On `core` it is public at `https://hamlab.bigham.dev` behind Nginx Proxy Manager.
- Installable as a PWA. - Installable as a PWA.
+22 -1
View File
@@ -12,6 +12,7 @@ import (
"os/signal" "os/signal"
"os/user" "os/user"
"path/filepath" "path/filepath"
"runtime"
"strings" "strings"
"syscall" "syscall"
"time" "time"
@@ -45,7 +46,7 @@ func main() {
} }
cfgPath := flag.String("config", defaultCfg, "path to config.json") cfgPath := flag.String("config", defaultCfg, "path to config.json")
flag.Usage = func() { 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.PrintDefaults()
} }
flag.Parse() flag.Parse()
@@ -68,6 +69,26 @@ func main() {
if err := passwd(cfg); err != nil { if err := passwd(cfg); err != nil {
log.Fatal(err) 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": case "serve":
if err := serve(cfg); err != nil { if err := serve(cfg); err != nil {
log.Fatal(err) log.Fatal(err)
+95 -1
View File
@@ -12,8 +12,10 @@ import (
"net/http" "net/http"
"net/url" "net/url"
"os" "os"
"os/exec"
"os/user" "os/user"
"path/filepath" "path/filepath"
"runtime"
"strconv" "strconv"
"strings" "strings"
"syscall" "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("POST /api/nodes", s.auth(s.addNode))
m.HandleFunc("DELETE /api/nodes/{name}", s.auth(s.removeNode)) m.HandleFunc("DELETE /api/nodes/{name}", s.auth(s.removeNode))
m.HandleFunc("/api/nodes/{name}/{rest...}", s.auth(s.proxyNode)) 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("POST /api/logout", s.auth(s.logout))
m.HandleFunc("GET /api/me", s.auth(s.me)) m.HandleFunc("GET /api/me", s.auth(s.me))
m.HandleFunc("GET /api/terminals", s.auth(s.listTerminals)) 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) 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) { func (s *Server) proxyNode(w http.ResponseWriter, r *http.Request) {
p, ok := s.nodes.Proxy(r.PathValue("name")) p, ok := s.nodes.Proxy(r.PathValue("name"))
if !ok { if !ok {
@@ -381,7 +475,7 @@ func (s *Server) me(w http.ResponseWriter, _ *http.Request) {
jsonOK(w, 200, map[string]any{ jsonOK(w, 200, map[string]any{
"host": s.host, "user": s.user, "version": s.version, "host": s.host, "user": s.user, "version": s.version,
"load": load, "uptime": uptime, "service_started": s.started, "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,
}) })
} }
+89
View File
@@ -16,6 +16,7 @@ import (
"net/http" "net/http"
"net/http/httputil" "net/http/httputil"
"net/url" "net/url"
"os"
"regexp" "regexp"
"sort" "sort"
"strings" "strings"
@@ -278,6 +279,94 @@ func (m *Manager) Remove(ctx context.Context, name string) error {
return err 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 ----
// Proxy returns the handler for /api/nodes/<name>/<rest>: it rewrites to <node>/api/<rest>, // Proxy returns the handler for /api/nodes/<name>/<rest>: it rewrites to <node>/api/<rest>,
+18 -1
View File
@@ -15,6 +15,8 @@ export default function Machines({ hub, onChanged, onOpen }: { hub: Me; onChange
const [pw, setPw] = useState('') const [pw, setPw] = useState('')
const [adding, setAdding] = useState(false) const [adding, setAdding] = useState(false)
const [addErr, setAddErr] = useState('') 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 () => { 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([]) } 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') } } catch (e) { setAddErr(e instanceof Error ? e.message : 'failed') }
finally { setAdding(false) } 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) { 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 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') } 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
<div className="muted small">{fp ? <>Certificate <span className="mono">{fp}</span></> : '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.'}</div> <div className="muted small">{fp ? <>Certificate <span className="mono">{fp}</span></> : '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.'}</div>
</section> </section>
<div className="dash-bar"><h2 className="section">Managed machines · {list.length}</h2></div> <div className="dash-bar"><h2 className="section">Managed machines · {list.length}</h2><span className="spacer" />{list.some(outdated) && <Btn className="btn on" onClick={updateAll}>Update {list.filter(outdated).length} outdated</Btn>}</div>
{err && <div className="error">{err}</div>} {err && <div className="error">{err}</div>}
{list.length === 0 && <div className="notice">No machines yet. Install Ham Lab on another host, set its password, then add it below.</div>} {list.length === 0 && <div className="notice">No machines yet. Install Ham Lab on another host, set its password, then add it below.</div>}
<div className="cards"> <div className="cards">
@@ -58,6 +73,7 @@ export default function Machines({ hub, onChanged, onOpen }: { hub: Me; onChange
<span className="muted small mono">{m.url}</span> <span className="muted small mono">{m.url}</span>
<span className="spacer" /> <span className="spacer" />
{m.reboot_needed && <span className="badge" title="reboot required">⟳</span>} {m.reboot_needed && <span className="badge" title="reboot required">⟳</span>}
{outdated(m) && <span className="badge bad" title={`running ${norm(m.version)}, hub has ${norm(hub.version)}`}>outdated</span>}
</div> </div>
<div className="props"> <div className="props">
<div><span className="k">Status</span><span className={m.online ? 'good' : 'warn-text'}>{m.online ? 'online' : m.error || 'offline'}</span></div> <div><span className="k">Status</span><span className={m.online ? 'good' : 'warn-text'}>{m.online ? 'online' : m.error || 'offline'}</span></div>
@@ -69,6 +85,7 @@ export default function Machines({ hub, onChanged, onOpen }: { hub: Me; onChange
</div> </div>
<div className="svc-actions"> <div className="svc-actions">
<button className="btn on" disabled={!m.online} onClick={() => onOpen(m.name)}>Open</button> <button className="btn on" disabled={!m.online} onClick={() => onOpen(m.name)}>Open</button>
{outdated(m) && <Btn className="btn" onClick={() => update(m)}>Update</Btn>}
<Btn className="btn danger" onClick={() => remove(m)}>Remove</Btn> <Btn className="btn danger" onClick={() => remove(m)}>Remove</Btn>
</div> </div>
</section> </section>
+4 -1
View File
@@ -38,7 +38,8 @@ async function req<T>(method: string, path: string, body?: unknown, local = fals
return r.json() 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 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 } 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<Me>('GET', '/api/me'), me: () => req<Me>('GET', '/api/me'),
machines: () => req<{ nodes: MachineStatus[]; host: string; fingerprint?: string }>('GET', '/api/nodes', undefined, true), machines: () => req<{ nodes: MachineStatus[]; host: string; fingerprint?: string }>('GET', '/api/nodes', undefined, true),
addMachine: (name: string, url: string, password: string) => req<MachineStatus>('POST', '/api/nodes', { name, url, password }, true), addMachine: (name: string, url: string, password: string) => req<MachineStatus>('POST', '/api/nodes', { name, url, password }, true),
updateMachine: (name: string) => req<PushResult>('POST', `/api/machines/${encodeURIComponent(name)}/update`, undefined, true),
updateAllMachines: () => req<PushResult[]>('POST', '/api/machines-update-all', undefined, true),
removeMachine: (name: string) => req<void>('DELETE', `/api/nodes/${encodeURIComponent(name)}`, undefined, true), removeMachine: (name: string) => req<void>('DELETE', `/api/nodes/${encodeURIComponent(name)}`, undefined, true),
terminals: () => req<Term[]>('GET', '/api/terminals'), terminals: () => req<Term[]>('GET', '/api/terminals'),
createTerminal: (name?: string) => req<{ id: string; name: string }>('POST', '/api/terminals', { name }), createTerminal: (name?: string) => req<{ id: string; name: string }>('POST', '/api/terminals', { name }),