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:
co-authored by
Claude Fable 5.1
parent
e9be94e32b
commit
254e0cdd58
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
+22
-1
@@ -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)
|
||||
|
||||
+95
-1
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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/<name>/<rest>: it rewrites to <node>/api/<rest>,
|
||||
|
||||
+18
-1
@@ -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
|
||||
<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>
|
||||
|
||||
<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>}
|
||||
{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">
|
||||
@@ -58,6 +73,7 @@ export default function Machines({ hub, onChanged, onOpen }: { hub: Me; onChange
|
||||
<span className="muted small mono">{m.url}</span>
|
||||
<span className="spacer" />
|
||||
{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 className="props">
|
||||
<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 className="svc-actions">
|
||||
<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>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
+4
-1
@@ -38,7 +38,8 @@ async function req<T>(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<Me>('GET', '/api/me'),
|
||||
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),
|
||||
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),
|
||||
terminals: () => req<Term[]>('GET', '/api/terminals'),
|
||||
createTerminal: (name?: string) => req<{ id: string; name: string }>('POST', '/api/terminals', { name }),
|
||||
|
||||
Reference in New Issue
Block a user