Multi-host phase 1: API tokens, auto TLS with pinned fingerprints, hub-side node manager + reverse proxy (WebSockets included), machine switcher in the rail, Machines page

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Mitchell-Johnson
2026-09-18 11:35:29 -07:00
co-authored by Claude Fable 5.1
parent fc42675e9f
commit 7dcdb5dc98
15 changed files with 960 additions and 56 deletions
+17 -4
View File
@@ -176,10 +176,23 @@ something long in one, switch away and back, refresh — it's all still there.
the daemon's sampling clock; the chart window is always 300 samples, so 30 s at 100 ms the daemon's sampling clock; the chart window is always 300 samples, so 30 s at 100 ms
and 10 min at 2 s. Still to do: persistent and 10 min at 2 s. Still to do: persistent
history (SQLite ring, e.g. 10 s × 7 days), SMART (`smartctl -j`), longer time ranges. history (SQLite ring, e.g. 10 s × 7 days), SMART (`smartctl -j`), longer time ranges.
- **Multi-host** — same binary as a headless *agent* on `grid` / Mac mini / - **Multi-host** — **Phase 1 shipped 2026-09-18** (`internal/nodes`, `internal/tlsauto`,
`wyse-5070-1`, `core`'s instance is the UI and fans out over mTLS. The rail grows a `MachineSwitcher.tsx`, `Machines.tsx`). One binary, hub and spoke: a host whose
host level at the top, exactly like Proxmox's datacenter → node tree. Design the `config.json` lists `nodes` is a hub; any other is a standalone node with its full UI.
MVP URL space so `host` can be inserted later without breaking clients. Node side: `"tls": "auto"` makes a self-signed cert in the data dir and serves HTTPS;
`POST /api/tokens {password,name}` trades the login password for a long-lived bearer
token (SHA-256 stored in `tokens`), accepted by the auth middleware alongside the
cookie; `DELETE /api/tokens/{name}` revokes. Hub side: `POST /api/nodes {name,url,
password}` peeks the node's certificate (trust on first use, fingerprint pinned),
obtains a token named `hub:<hostname>`, stores `{name,url,fingerprint,token}`; a
15 s prober keeps each node's status; `/api/nodes/{name}/…` is a reverse proxy to the
node's `/api/…` (bearer swapped in, cookie and Origin stripped, streaming flush) that
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.
- **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
+9
View File
@@ -24,5 +24,14 @@ sudo systemctl start hamlab # listens on 127.0.0.1:8100 by default; set "lis
Terminals run as the user the service runs as, with that user's login shell. Terminals run as the user the service runs as, with that user's login shell.
## Managing several machines
Install Ham Lab on each host. On the ones the hub should reach, add `"tls": "auto"` and a
LAN `listen` address to `config.json` (a self-signed certificate is generated on first
start). Then on the hub, open **Machines**, enter a name, the address
(`https://host:8100`) and that machine's Ham Lab password once. The hub pins the
certificate, keeps an access token, and every tab works against the chosen machine
through the switcher at the top of the rail.
- 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
@@ -24,11 +24,13 @@ import (
"hamlab/internal/cron" "hamlab/internal/cron"
"hamlab/internal/files" "hamlab/internal/files"
"hamlab/internal/metrics" "hamlab/internal/metrics"
"hamlab/internal/nodes"
"hamlab/internal/services" "hamlab/internal/services"
"hamlab/internal/storage" "hamlab/internal/storage"
"hamlab/internal/store" "hamlab/internal/store"
"hamlab/internal/tasks" "hamlab/internal/tasks"
hterm "hamlab/internal/term" hterm "hamlab/internal/term"
"hamlab/internal/tlsauto"
"hamlab/internal/updates" "hamlab/internal/updates"
"hamlab/internal/web" "hamlab/internal/web"
) )
@@ -160,7 +162,19 @@ func serve(cfg *config.Config) error {
if svc.Sudo() && self != "" { if svc.Sudo() && self != "" {
sudoFS = files.Sudo{Self: self} sudoFS = files.Sudo{Self: self}
} }
srv := api.New(cfg, sessions, mgr, col, st, svc, cr, tm, up, sto, files.Local{}, sudoFS, web.FS(), version) host, _ := os.Hostname()
nm := nodes.New(cfg, host)
go nm.Run(context.Background())
certPath, keyPath, fingerprint := "", "", ""
if cfg.TLS == "auto" {
var err error
certPath, keyPath, fingerprint, err = tlsauto.Ensure(cfg.DataDir())
if err != nil {
return fmt.Errorf("tls: %w", err)
}
}
srv := api.New(cfg, sessions, mgr, col, st, svc, cr, tm, up, sto, files.Local{}, sudoFS, nm, fingerprint, web.FS(), version)
hs := &http.Server{ hs := &http.Server{
Addr: cfg.Listen, Addr: cfg.Listen,
Handler: srv.Handler(), Handler: srv.Handler(),
@@ -176,6 +190,13 @@ func serve(cfg *config.Config) error {
_ = hs.Shutdown(ctx) _ = hs.Shutdown(ctx)
mgr.Shutdown() mgr.Shutdown()
}() }()
if certPath != "" {
log.Printf("hamlab %s listening on https://%s as %s (shell %s) cert %s", version, cfg.Listen, u.Username, shell, fingerprint)
if err := hs.ListenAndServeTLS(certPath, keyPath); !errors.Is(err, http.ErrServerClosed) {
return err
}
return nil
}
log.Printf("hamlab %s listening on %s as %s (shell %s)", version, cfg.Listen, u.Username, shell) log.Printf("hamlab %s listening on %s as %s (shell %s)", version, cfg.Listen, u.Username, shell)
if err := hs.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) { if err := hs.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {
return err return err
+169 -26
View File
@@ -26,6 +26,7 @@ import (
"hamlab/internal/cron" "hamlab/internal/cron"
"hamlab/internal/files" "hamlab/internal/files"
"hamlab/internal/metrics" "hamlab/internal/metrics"
"hamlab/internal/nodes"
"hamlab/internal/services" "hamlab/internal/services"
"hamlab/internal/storage" "hamlab/internal/storage"
"hamlab/internal/store" "hamlab/internal/store"
@@ -37,30 +38,32 @@ import (
const cookieName = "hamlab_session" const cookieName = "hamlab_session"
type Server struct { type Server struct {
cfg *config.Config cfg *config.Config
sessions *auth.Sessions sessions *auth.Sessions
limiter auth.Limiter limiter auth.Limiter
mgr *term.Manager mgr *term.Manager
metrics *metrics.Collector metrics *metrics.Collector
store *store.Store store *store.Store
svc *services.Manager svc *services.Manager
cron *cron.Manager cron *cron.Manager
tasks *tasks.Manager tasks *tasks.Manager
updates *updates.Manager updates *updates.Manager
storage *storage.Manager storage *storage.Manager
fsLocal files.FS fsLocal files.FS
fsSudo files.FS // nil without passwordless sudo fsSudo files.FS // nil without passwordless sudo
version string nodes *nodes.Manager
started time.Time fingerprint string
user string version string
host string started time.Time
ui fs.FS user string
mux *http.ServeMux host string
ui fs.FS
mux *http.ServeMux
} }
func New(cfg *config.Config, sessions *auth.Sessions, mgr *term.Manager, col *metrics.Collector, st *store.Store, svc *services.Manager, cr *cron.Manager, tm *tasks.Manager, up *updates.Manager, sto *storage.Manager, fsLocal, fsSudo files.FS, ui fs.FS, version string) *Server { func New(cfg *config.Config, sessions *auth.Sessions, mgr *term.Manager, col *metrics.Collector, st *store.Store, svc *services.Manager, cr *cron.Manager, tm *tasks.Manager, up *updates.Manager, sto *storage.Manager, fsLocal, fsSudo files.FS, nm *nodes.Manager, fingerprint string, ui fs.FS, version string) *Server {
host, _ := os.Hostname() host, _ := os.Hostname()
s := &Server{cfg: cfg, sessions: sessions, mgr: mgr, metrics: col, store: st, svc: svc, cron: cr, tasks: tm, updates: up, storage: sto, fsLocal: fsLocal, fsSudo: fsSudo, ui: ui, version: version, s := &Server{cfg: cfg, sessions: sessions, mgr: mgr, metrics: col, store: st, svc: svc, cron: cr, tasks: tm, updates: up, storage: sto, fsLocal: fsLocal, fsSudo: fsSudo, nodes: nm, fingerprint: fingerprint, ui: ui, version: version,
started: time.Now(), host: host, user: os.Getenv("USER")} started: time.Now(), host: host, user: os.Getenv("USER")}
if u, err := user.Current(); err == nil && u.Username != "" { if u, err := user.Current(); err == nil && u.Username != "" {
s.user = u.Username s.user = u.Username
@@ -76,6 +79,13 @@ func New(cfg *config.Config, sessions *auth.Sessions, mgr *term.Manager, col *me
m := http.NewServeMux() m := http.NewServeMux()
m.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(204) }) m.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(204) })
m.HandleFunc("POST /api/login", s.login) m.HandleFunc("POST /api/login", s.login)
m.HandleFunc("POST /api/tokens", s.issueToken)
m.HandleFunc("GET /api/tokens", s.auth(s.listTokens))
m.HandleFunc("DELETE /api/tokens/{name}", s.auth(s.revokeToken))
m.HandleFunc("GET /api/nodes", s.auth(s.listNodes))
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/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))
@@ -161,17 +171,150 @@ func (s *Server) setCookie(w http.ResponseWriter, r *http.Request, tok string, m
}) })
} }
// auth accepts the browser session cookie or a bearer API token issued to a hub.
func (s *Server) auth(next http.HandlerFunc) http.HandlerFunc { func (s *Server) auth(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
c, err := r.Cookie(cookieName) if c, err := r.Cookie(cookieName); err == nil && s.sessions.Valid(c.Value) {
if err != nil || !s.sessions.Valid(c.Value) { next(w, r)
jsonErr(w, 401, "unauthenticated")
return return
} }
next(w, r) if h := r.Header.Get("Authorization"); strings.HasPrefix(h, "Bearer ") {
var hashes []string
s.cfg.View(func(c *config.Config) {
for _, t := range c.Tokens {
hashes = append(hashes, t.Hash)
}
})
if auth.TokenMatches(strings.TrimPrefix(h, "Bearer "), hashes) {
next(w, r)
return
}
}
jsonErr(w, 401, "unauthenticated")
} }
} }
// ---- API tokens: how a hub authenticates to this node ----
// issueToken trades the password for a long-lived token, named by the caller (a hub).
// Rate-limited like login; an existing token with the same name is replaced.
func (s *Server) issueToken(w http.ResponseWriter, r *http.Request) {
if err := s.limiter.Allow(); err != nil {
jsonErr(w, 429, "too many attempts, try again later")
return
}
var body struct {
Password string `json:"password"`
Name string `json:"name"`
}
if err := json.NewDecoder(io.LimitReader(r.Body, 4096)).Decode(&body); err != nil || strings.TrimSpace(body.Name) == "" {
jsonErr(w, 400, "password and name required")
return
}
if !auth.VerifyPassword(s.cfg.PasswordHash, body.Password) {
s.limiter.Fail()
log.Printf("token request failed from %s", clientIP(r))
jsonErr(w, 401, "wrong password")
return
}
s.limiter.Reset()
tok, hash, err := auth.NewToken()
if err != nil {
jsonErr(w, 500, "token error")
return
}
name := strings.TrimSpace(body.Name)
if err := s.cfg.Update(func(c *config.Config) {
kept := c.Tokens[:0]
for _, t := range c.Tokens {
if t.Name != name {
kept = append(kept, t)
}
}
c.Tokens = append(kept, config.Token{Name: name, Hash: hash, Created: time.Now()})
}); err != nil {
jsonErr(w, 500, err.Error())
return
}
log.Printf("API token %q issued to %s", name, clientIP(r))
jsonOK(w, 200, map[string]string{"token": tok, "name": name})
}
func (s *Server) listTokens(w http.ResponseWriter, _ *http.Request) {
var out []map[string]any
s.cfg.View(func(c *config.Config) {
for _, t := range c.Tokens {
out = append(out, map[string]any{"name": t.Name, "created": t.Created})
}
})
if out == nil {
out = []map[string]any{}
}
jsonOK(w, 200, out)
}
func (s *Server) revokeToken(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("name")
found := false
_ = s.cfg.Update(func(c *config.Config) {
kept := c.Tokens[:0]
for _, t := range c.Tokens {
if t.Name == name {
found = true
continue
}
kept = append(kept, t)
}
c.Tokens = kept
})
if !found {
jsonErr(w, 404, "no such token")
return
}
log.Printf("API token %q revoked (from %s)", name, clientIP(r))
w.WriteHeader(204)
}
// ---- nodes: the machines this hub manages ----
func (s *Server) listNodes(w http.ResponseWriter, _ *http.Request) {
jsonOK(w, 200, map[string]any{"nodes": s.nodes.List(), "host": s.host, "fingerprint": s.fingerprint})
}
func (s *Server) addNode(w http.ResponseWriter, r *http.Request) {
var body struct{ Name, URL, Password string }
if err := json.NewDecoder(io.LimitReader(r.Body, 4096)).Decode(&body); err != nil {
jsonErr(w, 400, "bad request")
return
}
st, err := s.nodes.Add(r.Context(), strings.TrimSpace(body.Name), body.URL, body.Password)
if err != nil {
jsonErr(w, 400, err.Error())
return
}
log.Printf("nodes: added %s (%s) from %s", st.Name, st.URL, clientIP(r))
jsonOK(w, 201, st)
}
func (s *Server) removeNode(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("name")
if err := s.nodes.Remove(r.Context(), name); err != nil {
jsonErr(w, 404, err.Error())
return
}
log.Printf("nodes: removed %s (from %s)", name, clientIP(r))
w.WriteHeader(204)
}
func (s *Server) proxyNode(w http.ResponseWriter, r *http.Request) {
p, ok := s.nodes.Proxy(r.PathValue("name"))
if !ok {
jsonErr(w, 404, "no such machine")
return
}
p.ServeHTTP(w, r)
}
// ---- auth ---- // ---- auth ----
func (s *Server) login(w http.ResponseWriter, r *http.Request) { func (s *Server) login(w http.ResponseWriter, r *http.Request) {
@@ -238,7 +381,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, "reboot_needed": rebootErr == nil, "fingerprint": s.fingerprint,
}) })
} }
+30
View File
@@ -3,6 +3,7 @@ package auth
import ( import (
"crypto/rand" "crypto/rand"
"crypto/sha256"
"crypto/subtle" "crypto/subtle"
"encoding/base64" "encoding/base64"
"encoding/hex" "encoding/hex"
@@ -59,6 +60,35 @@ func VerifyPassword(hash, pw string) bool {
return subtle.ConstantTimeCompare(got, want) == 1 return subtle.ConstantTimeCompare(got, want) == 1
} }
// ---- API tokens (hub → node) ----
// NewToken returns a random bearer token and its SHA-256 hex, which is what gets stored.
func NewToken() (token, hash string, err error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", "", err
}
token = base64.RawURLEncoding.EncodeToString(b)
return token, HashToken(token), nil
}
func HashToken(token string) string {
sum := sha256.Sum256([]byte(token))
return hex.EncodeToString(sum[:])
}
// TokenMatches compares a presented token against stored hashes in constant time.
func TokenMatches(token string, hashes []string) bool {
h := []byte(HashToken(token))
ok := false
for _, want := range hashes {
if subtle.ConstantTimeCompare(h, []byte(want)) == 1 {
ok = true
}
}
return ok
}
// Sessions is a token store backed by the state file. Expiry is sliding. // Sessions is a token store backed by the state file. Expiry is sliding.
type Sessions struct { type Sessions struct {
st *store.Store st *store.Store
+47 -2
View File
@@ -7,13 +7,37 @@ import (
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
"sync"
"time"
) )
// Token is an API token another Ham Lab (a hub) uses to manage this host. Only the
// SHA-256 of the token is kept.
type Token struct {
Name string `json:"name"`
Hash string `json:"hash"`
Created time.Time `json:"created"`
}
// Node is a remote Ham Lab this host (as hub) manages. The token is the one that
// node issued to us; the fingerprint pins its self-signed certificate.
type Node struct {
Name string `json:"name"`
URL string `json:"url"`
Fingerprint string `json:"fingerprint,omitempty"`
Token string `json:"token"`
Added time.Time `json:"added"`
}
type Config struct { type Config struct {
Listen string `json:"listen"` Listen string `json:"listen"`
PasswordHash string `json:"password_hash"` PasswordHash string `json:"password_hash"`
TLS string `json:"tls,omitempty"` // "auto" → self-signed cert in the data dir; "" → plain HTTP
Tokens []Token `json:"tokens,omitempty"`
Nodes []Node `json:"nodes,omitempty"`
path string path string
mu sync.Mutex
} }
func Default(path string) *Config { func Default(path string) *Config {
@@ -40,6 +64,12 @@ func (c *Config) Path() string { return c.path }
func (c *Config) DataDir() string { return filepath.Dir(c.path) } func (c *Config) DataDir() string { return filepath.Dir(c.path) }
func (c *Config) Save() error { func (c *Config) Save() error {
c.mu.Lock()
defer c.mu.Unlock()
return c.save()
}
func (c *Config) save() error {
if err := os.MkdirAll(c.DataDir(), 0o700); err != nil { if err := os.MkdirAll(c.DataDir(), 0o700); err != nil {
return err return err
} }
@@ -53,3 +83,18 @@ func (c *Config) Save() error {
} }
return os.Rename(tmp, c.path) return os.Rename(tmp, c.path)
} }
// Update applies fn under the lock and saves.
func (c *Config) Update(fn func(*Config)) error {
c.mu.Lock()
defer c.mu.Unlock()
fn(c)
return c.save()
}
// View reads under the lock.
func (c *Config) View(fn func(*Config)) {
c.mu.Lock()
defer c.mu.Unlock()
fn(c)
}
+323
View File
@@ -0,0 +1,323 @@
// Package nodes is the hub side of multi-host: the list of managed Ham Labs, their
// health, adding one (pair with its password, receive a token, pin its certificate),
// removing one (revoke the token), and the reverse proxy that turns
// /api/nodes/<name>/… into /api/… on that node, WebSockets included.
package nodes
import (
"bytes"
"context"
"crypto/tls"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/http/httputil"
"net/url"
"regexp"
"sort"
"strings"
"sync"
"time"
"hamlab/internal/config"
"hamlab/internal/tlsauto"
)
var nameRe = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,31}$`)
type Status struct {
Name string `json:"name"`
URL string `json:"url"`
Fingerprint string `json:"fingerprint,omitempty"`
Added time.Time `json:"added"`
Online bool `json:"online"`
Host string `json:"host,omitempty"`
Version string `json:"version,omitempty"`
Load string `json:"load,omitempty"`
Uptime float64 `json:"uptime,omitempty"`
RebootNeed bool `json:"reboot_needed,omitempty"`
LastSeen time.Time `json:"last_seen,omitempty"`
Error string `json:"error,omitempty"`
}
type Manager struct {
cfg *config.Config
hubName string
mu sync.Mutex
status map[string]*Status
proxies map[string]*httputil.ReverseProxy
clients map[string]*http.Client
}
func New(cfg *config.Config, hubName string) *Manager {
return &Manager{cfg: cfg, hubName: hubName, status: map[string]*Status{}, proxies: map[string]*httputil.ReverseProxy{}, clients: map[string]*http.Client{}}
}
func transportFor(n config.Node) *http.Transport {
t := &http.Transport{
DialContext: (&net.Dialer{Timeout: 5 * time.Second}).DialContext,
TLSHandshakeTimeout: 5 * time.Second,
ResponseHeaderTimeout: 60 * time.Second,
MaxIdleConnsPerHost: 8,
}
if strings.HasPrefix(n.URL, "https://") {
if n.Fingerprint != "" {
t.TLSClientConfig = tlsauto.PinnedConfig(n.Fingerprint)
} else {
t.TLSClientConfig = &tls.Config{InsecureSkipVerify: true}
}
}
return t
}
func (m *Manager) client(n config.Node) *http.Client {
m.mu.Lock()
defer m.mu.Unlock()
if c, ok := m.clients[n.Name]; ok {
return c
}
c := &http.Client{Transport: transportFor(n), Timeout: 15 * time.Second}
m.clients[n.Name] = c
return c
}
func (m *Manager) node(name string) (config.Node, bool) {
var found config.Node
ok := false
m.cfg.View(func(c *config.Config) {
for _, n := range c.Nodes {
if n.Name == name {
found, ok = n, true
}
}
})
return found, ok
}
// ---- probing ----
func (m *Manager) probe(ctx context.Context, n config.Node) *Status {
st := &Status{Name: n.Name, URL: n.URL, Fingerprint: n.Fingerprint, Added: n.Added}
req, _ := http.NewRequestWithContext(ctx, "GET", strings.TrimRight(n.URL, "/")+"/api/me", nil)
req.Header.Set("Authorization", "Bearer "+n.Token)
resp, err := m.client(n).Do(req)
if err != nil {
st.Error = shortErr(err)
return st
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
st.Error = fmt.Sprintf("HTTP %d", resp.StatusCode)
if resp.StatusCode == 401 {
st.Error = "token rejected (re-add the machine)"
}
return st
}
var me struct {
Host, Version, Load string
Uptime float64
RebootNeeded bool `json:"reboot_needed"`
}
if json.NewDecoder(io.LimitReader(resp.Body, 64<<10)).Decode(&me) != nil {
st.Error = "bad reply"
return st
}
st.Online, st.Host, st.Version, st.Load, st.Uptime, st.RebootNeed, st.LastSeen = true, me.Host, me.Version, me.Load, me.Uptime, me.RebootNeeded, time.Now()
return st
}
func shortErr(err error) string {
s := err.Error()
if i := strings.LastIndex(s, ": "); i >= 0 {
s = s[i+2:]
}
return s
}
// Run refreshes every node's status periodically.
func (m *Manager) Run(ctx context.Context) {
m.refresh(ctx)
t := time.NewTicker(15 * time.Second)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
m.refresh(ctx)
}
}
}
func (m *Manager) refresh(ctx context.Context) {
var list []config.Node
m.cfg.View(func(c *config.Config) { list = append(list, c.Nodes...) })
var wg sync.WaitGroup
for _, n := range list {
wg.Add(1)
go func(n config.Node) {
defer wg.Done()
pctx, cancel := context.WithTimeout(ctx, 8*time.Second)
defer cancel()
st := m.probe(pctx, n)
m.mu.Lock()
if prev, ok := m.status[n.Name]; ok && !st.Online && prev.Online {
st.LastSeen = prev.LastSeen // keep the last good sighting
}
m.status[n.Name] = st
m.mu.Unlock()
}(n)
}
wg.Wait()
}
// List returns every managed node with its last known status.
func (m *Manager) List() []Status {
var list []config.Node
m.cfg.View(func(c *config.Config) { list = append(list, c.Nodes...) })
m.mu.Lock()
defer m.mu.Unlock()
out := make([]Status, 0, len(list))
for _, n := range list {
if st, ok := m.status[n.Name]; ok {
out = append(out, *st)
} else {
out = append(out, Status{Name: n.Name, URL: n.URL, Fingerprint: n.Fingerprint, Added: n.Added, Error: "not probed yet"})
}
}
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out
}
// ---- add / remove ----
// Add pairs with a node: pin its certificate (trust on first use), trade the node's
// password for a token named after this hub, and store the node. The password is
// used once and never kept.
func (m *Manager) Add(ctx context.Context, name, rawURL, password string) (Status, error) {
if !nameRe.MatchString(name) {
return Status{}, errors.New("name: lowercase letters, digits and dashes only")
}
if _, exists := m.node(name); exists {
return Status{}, errors.New("a machine with that name already exists")
}
u, err := url.Parse(strings.TrimSpace(rawURL))
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
return Status{}, errors.New("address must be http(s)://host:port")
}
n := config.Node{Name: name, URL: u.Scheme + "://" + u.Host, Added: time.Now()}
if u.Scheme == "https" {
host := u.Host
if _, _, err := net.SplitHostPort(host); err != nil {
host += ":443"
}
fp, err := tlsauto.Peek(host)
if err != nil {
return Status{}, fmt.Errorf("connect: %s", shortErr(err))
}
n.Fingerprint = fp
}
body, _ := json.Marshal(map[string]string{"password": password, "name": "hub:" + m.hubName})
req, _ := http.NewRequestWithContext(ctx, "POST", n.URL+"/api/tokens", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, err := (&http.Client{Transport: transportFor(n), Timeout: 15 * time.Second}).Do(req)
if err != nil {
return Status{}, fmt.Errorf("connect: %s", shortErr(err))
}
defer resp.Body.Close()
var reply struct {
Token string `json:"token"`
Error string `json:"error"`
}
_ = json.NewDecoder(io.LimitReader(resp.Body, 64<<10)).Decode(&reply)
if resp.StatusCode != 200 || reply.Token == "" {
if reply.Error == "" {
reply.Error = fmt.Sprintf("HTTP %d", resp.StatusCode)
}
return Status{}, errors.New("pairing refused: " + reply.Error)
}
n.Token = reply.Token
if err := m.cfg.Update(func(c *config.Config) { c.Nodes = append(c.Nodes, n) }); err != nil {
return Status{}, err
}
st := m.probe(ctx, n)
m.mu.Lock()
m.status[name] = st
m.mu.Unlock()
return *st, nil
}
// Remove revokes our token on the node (best effort) and forgets it.
func (m *Manager) Remove(ctx context.Context, name string) error {
n, ok := m.node(name)
if !ok {
return errors.New("no such machine")
}
req, _ := http.NewRequestWithContext(ctx, "DELETE", n.URL+"/api/tokens/"+url.PathEscape("hub:"+m.hubName), nil)
req.Header.Set("Authorization", "Bearer "+n.Token)
if resp, err := m.client(n).Do(req); err == nil {
resp.Body.Close()
}
err := m.cfg.Update(func(c *config.Config) {
kept := c.Nodes[:0]
for _, x := range c.Nodes {
if x.Name != name {
kept = append(kept, x)
}
}
c.Nodes = kept
})
m.mu.Lock()
delete(m.status, name)
delete(m.proxies, name)
delete(m.clients, name)
m.mu.Unlock()
return err
}
// ---- proxy ----
// Proxy returns the handler for /api/nodes/<name>/<rest>: it rewrites to <node>/api/<rest>,
// swaps the hub's cookie for the node's bearer token, and drops Origin so the node's
// WebSocket origin check (meant for browsers) doesn't reject the hub.
func (m *Manager) Proxy(name string) (http.Handler, bool) {
n, ok := m.node(name)
if !ok {
return nil, false
}
m.mu.Lock()
if p, ok := m.proxies[name]; ok {
m.mu.Unlock()
return p, true
}
m.mu.Unlock()
target, _ := url.Parse(n.URL)
prefix := "/api/nodes/" + name
p := &httputil.ReverseProxy{
Director: func(r *http.Request) {
r.URL.Scheme, r.URL.Host = target.Scheme, target.Host
r.URL.Path = "/api" + strings.TrimPrefix(r.URL.Path, prefix)
r.URL.RawPath = ""
r.Host = target.Host
r.Header.Set("Authorization", "Bearer "+n.Token)
r.Header.Del("Cookie")
r.Header.Del("Origin")
r.Header.Del("X-Forwarded-For")
r.Header.Del("X-Forwarded-Proto")
},
Transport: transportFor(n),
FlushInterval: -1, // stream task output / log tails immediately
ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadGateway)
_ = json.NewEncoder(w).Encode(map[string]string{"error": name + " unreachable: " + shortErr(err)})
},
}
m.mu.Lock()
m.proxies[name] = p
m.mu.Unlock()
return p, true
}
+125
View File
@@ -0,0 +1,125 @@
// Package tlsauto makes a self-signed certificate for a node so hub↔node traffic is
// encrypted without any CA. The hub pins the certificate's fingerprint.
package tlsauto
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/sha256"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/hex"
"encoding/pem"
"errors"
"math/big"
"net"
"os"
"path/filepath"
"strings"
"time"
)
// Fingerprint formats a certificate's SHA-256 the way the UI shows it.
func Fingerprint(der []byte) string {
sum := sha256.Sum256(der)
h := hex.EncodeToString(sum[:])
var b strings.Builder
for i := 0; i < len(h); i += 2 {
if i > 0 {
b.WriteByte(':')
}
b.WriteString(strings.ToUpper(h[i : i+2]))
}
return b.String()
}
// Ensure returns the cert/key paths and fingerprint, generating them on first use.
func Ensure(dir string) (certPath, keyPath, fingerprint string, err error) {
certPath, keyPath = filepath.Join(dir, "cert.pem"), filepath.Join(dir, "key.pem")
if _, err := os.Stat(certPath); err == nil {
if _, err := os.Stat(keyPath); err == nil {
c, err := tls.LoadX509KeyPair(certPath, keyPath)
if err == nil && len(c.Certificate) > 0 {
return certPath, keyPath, Fingerprint(c.Certificate[0]), nil
}
}
}
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return "", "", "", err
}
serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 127))
if err != nil {
return "", "", "", err
}
host, _ := os.Hostname()
tmpl := &x509.Certificate{
SerialNumber: serial,
Subject: pkix.Name{CommonName: "hamlab@" + host, Organization: []string{"Ham Lab"}},
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().AddDate(10, 0, 0),
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
BasicConstraintsValid: true,
DNSNames: []string{host, "localhost"},
IPAddresses: []net.IP{net.ParseIP("127.0.0.1")},
}
if addrs, err := net.InterfaceAddrs(); err == nil {
for _, a := range addrs {
if ipn, ok := a.(*net.IPNet); ok && !ipn.IP.IsLoopback() && ipn.IP.To4() != nil {
tmpl.IPAddresses = append(tmpl.IPAddresses, ipn.IP)
}
}
}
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &priv.PublicKey, priv)
if err != nil {
return "", "", "", err
}
keyDER, err := x509.MarshalECPrivateKey(priv)
if err != nil {
return "", "", "", err
}
if err := os.MkdirAll(dir, 0o700); err != nil {
return "", "", "", err
}
if err := os.WriteFile(certPath, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}), 0o644); err != nil {
return "", "", "", err
}
if err := os.WriteFile(keyPath, pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}), 0o600); err != nil {
return "", "", "", err
}
return certPath, keyPath, Fingerprint(der), nil
}
// PinnedConfig returns a client TLS config that accepts exactly one certificate.
func PinnedConfig(fingerprint string) *tls.Config {
return &tls.Config{
InsecureSkipVerify: true, // we verify by fingerprint below, not by CA
VerifyPeerCertificate: func(raw [][]byte, _ [][]*x509.Certificate) error {
if len(raw) == 0 {
return errors.New("no certificate presented")
}
if Fingerprint(raw[0]) != fingerprint {
return errors.New("certificate fingerprint changed: " + Fingerprint(raw[0]))
}
return nil
},
}
}
// Peek connects and returns the fingerprint the server presents (trust on first use).
func Peek(addr string) (string, error) {
d := &net.Dialer{Timeout: 5 * time.Second}
conn, err := tls.DialWithDialer(d, "tcp", addr, &tls.Config{InsecureSkipVerify: true})
if err != nil {
return "", err
}
defer conn.Close()
certs := conn.ConnectionState().PeerCertificates
if len(certs) == 0 {
return "", errors.New("no certificate presented")
}
return Fingerprint(certs[0].Raw), nil
}
+1 -1
View File
@@ -7,7 +7,7 @@ export default function App() {
const [me, setMe] = useState<Me | null | undefined>(undefined) const [me, setMe] = useState<Me | null | undefined>(undefined)
const refresh = useCallback(async () => { const refresh = useCallback(async () => {
try { setMe(await api.me()) } catch (e) { if (e instanceof Unauthenticated) setMe(null) } try { setMe(await api.meHere()) } catch (e) { if (e instanceof Unauthenticated) setMe(null) }
}, []) }, [])
useEffect(() => { refresh() }, [refresh]) useEffect(() => { refresh() }, [refresh])
+41
View File
@@ -0,0 +1,41 @@
import { useEffect, useRef, useState } from 'react'
import { MachineStatus } from './api'
// The host picker at the top of the rail. The hub is always first; managed machines follow
// with their live status. Picking one re-points the whole console at it.
export default function MachineSwitcher({ hub, current, machines, onChange, onManage }: {
hub: string; current: string | null; machines: MachineStatus[]; onChange: (name: string | null) => void; onManage: () => void
}) {
const [open, setOpen] = useState(false)
const box = useRef<HTMLDivElement>(null)
useEffect(() => {
const h = (e: MouseEvent) => { if (box.current && !box.current.contains(e.target as Node)) setOpen(false) }
window.addEventListener('click', h)
return () => window.removeEventListener('click', h)
}, [])
const cur = current ? machines.find((m) => m.name === current) : null
const dotFor = (m?: MachineStatus | null) => (!m ? 'ok' : m.online ? 'ok' : 'bad')
return (
<div className="mswitch" ref={box}>
<button className="mswitch-btn" onClick={() => setOpen(!open)} title="switch machine">
<span className={'dot ' + (current ? dotFor(cur) : 'ok')} />
<span className="mname">{current ?? hub}</span>
<span className="muted small">{current ? (cur?.online ? 'remote' : 'offline') : 'this host'}</span>
<span className="chev">▾</span>
</button>
{open && (
<div className="mmenu">
<button className={'mitem' + (!current ? ' on' : '')} onClick={() => { setOpen(false); onChange(null) }}>
<span className="dot ok" /><span className="mname">{hub}</span><span className="muted small">this host</span>
</button>
{machines.map((m) => (
<button key={m.name} className={'mitem' + (current === m.name ? ' on' : '')} onClick={() => { setOpen(false); onChange(m.name) }} title={m.error || m.url}>
<span className={'dot ' + dotFor(m)} /><span className="mname">{m.name}</span><span className="muted small">{m.online ? m.load : 'offline'}</span>
</button>
))}
<button className="mitem manage" onClick={() => { setOpen(false); onManage() }}>+ manage machines…</button>
</div>
)}
</div>
)
}
+91
View File
@@ -0,0 +1,91 @@
import { FormEvent, useCallback, useEffect, useState } from 'react'
import { api, MachineStatus, Me } from './api'
import { Btn, Loading, toast } from './ui'
const fmtUptime = (s: number) => { const d = Math.floor(s / 86400), h = Math.floor((s % 86400) / 3600), m = Math.floor((s % 3600) / 60); return d > 0 ? `${d}d ${h}h` : h > 0 ? `${h}h ${m}m` : `${m}m` }
const ago = (iso?: string) => { if (!iso) return 'never'; const s = (Date.now() - Date.parse(iso)) / 1000; return s < 90 ? 'just now' : s < 3600 ? `${Math.round(s / 60)}m ago` : s < 86400 ? `${Math.round(s / 3600)}h ago` : `${Math.round(s / 86400)}d ago` }
const shortFp = (fp?: string) => (fp ? fp.split(':').slice(0, 6).join(':') + '…' : '')
export default function Machines({ hub, onChanged, onOpen }: { hub: Me; onChanged: () => void; onOpen: (name: string) => void }) {
const [list, setList] = useState<MachineStatus[] | null>(null)
const [fp, setFp] = useState<string | undefined>()
const [err, setErr] = useState('')
const [name, setName] = useState('')
const [url, setUrl] = useState('https://')
const [pw, setPw] = useState('')
const [adding, setAdding] = useState(false)
const [addErr, setAddErr] = useState('')
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([]) }
}, [])
useEffect(() => { load(); const t = setInterval(load, 10000); return () => clearInterval(t) }, [load])
async function add(e: FormEvent) {
e.preventDefault()
setAdding(true); setAddErr('')
try {
const st = await api.addMachine(name.trim().toLowerCase(), url.trim(), pw)
toast(`${st.name} added${st.online ? ` · ${st.host} v${st.version}` : ' (offline)'}`)
setName(''); setUrl('https://'); setPw('')
await load(); onChanged()
} catch (e) { setAddErr(e instanceof Error ? e.message : 'failed') }
finally { setAdding(false) }
}
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') }
}
if (!list) return <div className="dash">{err ? <div className="error">{err}</div> : <Loading label="Loading machines…" />}</div>
return (
<div className="dash machines">
<div className="dash-bar"><h2 className="section">This host</h2></div>
<section className="card">
<div className="sub-head"><span className="dot ok" /><span className="dname">{hub.host}</span><span className="badge">hub</span><span className="spacer" /><span className="num muted">v{hub.version.replace(/^v/, '')} · up {fmtUptime(hub.uptime)}</span></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>
<div className="dash-bar"><h2 className="section">Managed machines · {list.length}</h2></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">
{list.map((m) => (
<section className="card" key={m.name}>
<div className="sub-head">
<span className={'dot ' + (m.online ? 'ok' : 'bad')} />
<span className="dname">{m.name}</span>
<span className="muted small mono">{m.url}</span>
<span className="spacer" />
{m.reboot_needed && <span className="badge" title="reboot required">⟳</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>
{m.host && <div><span className="k">Host</span><span>{m.host} · v{(m.version ?? '').replace(/^v/, '')}</span></div>}
{m.online && <div><span className="k">Load</span><span>{m.load} · up {fmtUptime(m.uptime ?? 0)}</span></div>}
<div><span className="k">Seen</span><span>{ago(m.last_seen)}</span></div>
{m.fingerprint && <div><span className="k">Cert</span><span className="mono" title={m.fingerprint}>{shortFp(m.fingerprint)}</span></div>}
<div><span className="k">Added</span><span>{new Date(m.added).toLocaleDateString()}</span></div>
</div>
<div className="svc-actions">
<button className="btn on" disabled={!m.online} onClick={() => onOpen(m.name)}>Open</button>
<Btn className="btn danger" onClick={() => remove(m)}>Remove</Btn>
</div>
</section>
))}
</div>
<div className="dash-bar"><h2 className="section">Add a machine</h2></div>
<form className="card addform" onSubmit={add}>
<label>Name <input value={name} onChange={(e) => setName(e.target.value)} placeholder="grid" pattern="[a-z0-9][a-z0-9-]*" required /></label>
<label>Address <input value={url} onChange={(e) => setUrl(e.target.value)} placeholder="https://10.0.0.214:8100" required /></label>
<label>Its Ham Lab password <input type="password" value={pw} onChange={(e) => setPw(e.target.value)} autoComplete="off" required /></label>
{addErr && <div className="error">{addErr}</div>}
<div className="svc-actions">
<button className="btn on" disabled={adding || !name || !pw}>{adding ? 'Pairing…' : 'Add machine'}</button>
<span className="muted small">The password is used once to obtain an access token and is not stored. The machine's certificate is pinned on first contact.</span>
</div>
</form>
</div>
)
}
+3 -2
View File
@@ -7,15 +7,16 @@ import { Spinner } from './ui'
export interface Group { id: string; label: string; icon: ReactNode; soon?: boolean; page?: boolean } export interface Group { id: string; label: string; icon: ReactNode; soon?: boolean; page?: boolean }
interface Props { interface Props {
groups: Group[]; terms: Term[]; active: string | null; view: string; creating?: boolean groups: Group[]; terms: Term[]; active: string | null; view: string; creating?: boolean; header?: ReactNode
onSelect: (id: string) => void; onView: (id: string) => void; onNew: () => void onSelect: (id: string) => void; onView: (id: string) => void; onNew: () => void
onRename: (t: Term) => void; onClose: (t: Term) => void onRename: (t: Term) => void; onClose: (t: Term) => void
} }
export default function Rail({ groups, terms, active, view, creating, onSelect, onView, onNew, onRename, onClose }: Props) { export default function Rail({ groups, terms, active, view, creating, header, onSelect, onView, onNew, onRename, onClose }: Props) {
const [open, setOpen] = useState<string>('terminal') const [open, setOpen] = useState<string>('terminal')
return ( return (
<nav className="rail"> <nav className="rail">
{header}
{groups.map((g) => { {groups.map((g) => {
const expanded = !g.page && open === g.id const expanded = !g.page && open === g.id
const isPage = !!g.page && view === g.id const isPage = !!g.page && view === g.id
+45 -9
View File
@@ -10,6 +10,9 @@ import Updates from './Updates'
import Disks from './Disks' import Disks from './Disks'
import Files from './Files' import Files from './Files'
import Procs from './Procs' import Procs from './Procs'
import Machines from './Machines'
import MachineSwitcher from './MachineSwitcher'
import { MachineStatus, setMachine } from './api'
import Power from './Power' import Power from './Power'
import { Btn, Toasts, toast } from './ui' import { Btn, Toasts, toast } from './ui'
@@ -29,16 +32,24 @@ const GROUPS: Group[] = [
{ id: 'updates', label: 'Updates', icon: '⇪', page: true }, { id: 'updates', label: 'Updates', icon: '⇪', page: true },
{ id: 'disks', label: 'Disks', icon: '▤', page: true }, { id: 'disks', label: 'Disks', icon: '▤', page: true },
{ id: 'files', label: 'Files', icon: FolderIcon, page: true }, { id: 'files', label: 'Files', icon: FolderIcon, page: true },
{ id: 'machines', label: 'Machines', icon: '⌂', page: true },
] ]
type View = 'terminal' | 'dashboard' | 'services' | 'cron' | 'updates' | 'disks' | 'files' | 'procs' type View = 'terminal' | 'dashboard' | 'services' | 'cron' | 'updates' | 'disks' | 'files' | 'procs' | 'machines'
function fmtUptime(s: number): string { function fmtUptime(s: number): string {
const d = Math.floor(s / 86400), h = Math.floor((s % 86400) / 3600), m = Math.floor((s % 3600) / 60) const d = Math.floor(s / 86400), h = Math.floor((s % 86400) / 3600), m = Math.floor((s % 3600) / 60)
return d > 0 ? `${d}d ${h}h` : h > 0 ? `${h}h ${m}m` : `${m}m` return d > 0 ? `${d}d ${h}h` : h > 0 ? `${h}h ${m}m` : `${m}m`
} }
export default function Shell({ me, onLoggedOut }: { me: Me; onLoggedOut: () => void }) { export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut: () => void }) {
// Which machine we're looking at. null = the hub itself. Set before anything renders so
// every page's first request already goes to the right place.
const [machine, setMachineState] = useState<string | null>(() => { const m = sessionStorage.getItem('hamlab.machine'); setMachine(m); return m })
const [machines, setMachines] = useState<MachineStatus[]>([])
const [remoteMe, setRemoteMe] = useState<Me | null>(null)
const [remoteErr, setRemoteErr] = useState('')
const me: Me = machine ? (remoteMe ?? { ...hubMe, host: machine, load: '', version: '…', uptime: 0, reboot_needed: false }) : hubMe
const [terms, setTerms] = useState<Term[]>([]) const [terms, setTerms] = useState<Term[]>([])
const [active, setActive] = useState<string | null>(null) const [active, setActive] = useState<string | null>(null)
const [view, setView] = useState<View>('dashboard') const [view, setView] = useState<View>('dashboard')
@@ -48,13 +59,35 @@ export default function Shell({ me, onLoggedOut }: { me: Me; onLoggedOut: () =>
const handles = useRef<Record<string, TermHandle>>({}) const handles = useRef<Record<string, TermHandle>>({})
const reload = useCallback(async () => { const reload = useCallback(async () => {
const list = await api.terminals() try {
setTerms(list) const list = await api.terminals()
setActive((a) => (a && list.some((t) => t.id === a) ? a : list[0]?.id ?? null)) setTerms(list)
}, []) setActive((a) => (a && list.some((t) => t.id === a) ? a : list[0]?.id ?? null))
} catch (e) { setTerms([]); if (machine) setRemoteErr(e instanceof Error ? e.message : 'unreachable') }
}, [machine])
useEffect(() => { reload() }, [reload]) useEffect(() => { reload() }, [reload])
// Machine list for the switcher (hub-local), refreshed while the console is open.
const loadMachines = useCallback(async () => { try { setMachines((await api.machines()).nodes) } catch { /* not a hub or offline */ } }, [])
useEffect(() => { loadMachines(); const t = setInterval(loadMachines, 15000); return () => clearInterval(t) }, [loadMachines])
// Switching machines: reset everything that belongs to a host, then re-fetch its identity.
const switchMachine = (name: string | null) => {
setMachine(name); sessionStorage.setItem('hamlab.machine', name ?? ''); if (!name) sessionStorage.removeItem('hamlab.machine')
handles.current = {}; setTerms([]); setActive(null); setRemoteMe(null); setRemoteErr(''); setDrawer(false)
setMachineState(name)
if (view === 'machines') setView('dashboard')
}
useEffect(() => {
if (!machine) { setRemoteMe(null); return }
let stop = false
const tick = async () => { try { const m = await api.me(); if (!stop) { setRemoteMe(m); setRemoteErr('') } } catch (e) { if (!stop) setRemoteErr(e instanceof Error ? e.message : 'unreachable') } }
tick()
const t = setInterval(tick, 30000)
return () => { stop = true; clearInterval(t) }
}, [machine])
async function create() { async function create() {
setCreating(true) setCreating(true)
try { try {
@@ -100,6 +133,7 @@ export default function Shell({ me, onLoggedOut }: { me: Me; onLoggedOut: () =>
<button className="icon-btn menu" onClick={() => setDrawer(!drawer)} aria-label="menu">☰</button> <button className="icon-btn menu" onClick={() => setDrawer(!drawer)} aria-label="menu">☰</button>
<span className="brand">Ham Lab</span> <span className="brand">Ham Lab</span>
<span className="host">{me.user}@{me.host}</span> <span className="host">{me.user}@{me.host}</span>
{machine && remoteErr && <span className="badge bad" title={remoteErr}>offline</span>}
<span className="spacer" /> <span className="spacer" />
<span className="stat" title="load average">{me.load}</span> <span className="stat" title="load average">{me.load}</span>
<span className={'dot ' + (online ? 'ok' : 'bad')} title={online ? 'connected' : 'disconnected'} /> <span className={'dot ' + (online ? 'ok' : 'bad')} title={online ? 'connected' : 'disconnected'} />
@@ -109,12 +143,14 @@ export default function Shell({ me, onLoggedOut }: { me: Me; onLoggedOut: () =>
<div className="body"> <div className="body">
<Rail groups={GROUPS} terms={terms} active={active} view={view} creating={creating} <Rail groups={GROUPS} terms={terms} active={active} view={view} creating={creating}
header={<MachineSwitcher hub={hubMe.host} current={machine} machines={machines} onChange={switchMachine} onManage={() => { setView('machines'); setDrawer(false) }} />}
onSelect={(id) => { setActive(id); setView('terminal'); setDrawer(false) }} onSelect={(id) => { setActive(id); setView('terminal'); setDrawer(false) }}
onView={(id) => { setView(id as View); setDrawer(false) }} onView={(id) => { setView(id as View); setDrawer(false) }}
onNew={create} onRename={rename} onClose={close} /> onNew={create} onRename={rename} onClose={close} />
<div className="backdrop" onClick={() => setDrawer(false)} /> <div className="backdrop" onClick={() => setDrawer(false)} />
<main className="main"> <main className="main" key={machine ?? 'hub'}>
{view === 'machines' && <Machines hub={hubMe} onChanged={loadMachines} onOpen={(n) => { switchMachine(n); setView('dashboard') }} />}
{view === 'dashboard' && <Dashboard />} {view === 'dashboard' && <Dashboard />}
{view === 'procs' && <Procs />} {view === 'procs' && <Procs />}
{view === 'services' && <Services />} {view === 'services' && <Services />}
@@ -141,10 +177,10 @@ export default function Shell({ me, onLoggedOut }: { me: Me; onLoggedOut: () =>
setCtrl={(v) => handles.current[activeTerm.id]?.armCtrl(v)} />} setCtrl={(v) => handles.current[activeTerm.id]?.armCtrl(v)} />}
<footer className="footer"> <footer className="footer">
<span>{me.version.startsWith('v') ? me.version : 'v' + me.version}</span> <span>{machine ? `${machine} · ` : ''}{me.version.startsWith('v') ? me.version : 'v' + me.version}</span>
<span>up {fmtUptime(me.uptime)}</span> <span>up {fmtUptime(me.uptime)}</span>
<span className="spacer" /> <span className="spacer" />
<span className="muted">{view === 'dashboard' ? 'Dashboard' : view === 'services' ? 'Services' : view === 'cron' ? 'Cron' : view === 'updates' ? 'Updates' : view === 'disks' ? 'Disks' : view === 'files' ? 'Files' : view === 'procs' ? 'Processes' : activeTerm ? activeTerm.name : ''}</span> <span className="muted">{view === 'dashboard' ? 'Dashboard' : view === 'services' ? 'Services' : view === 'cron' ? 'Cron' : view === 'updates' ? 'Updates' : view === 'disks' ? 'Disks' : view === 'files' ? 'Files' : view === 'procs' ? 'Processes' : view === 'machines' ? 'Machines' : activeTerm ? activeTerm.name : ''}</span>
</footer> </footer>
</div> </div>
) )
+23 -11
View File
@@ -1,16 +1,23 @@
export class Unauthenticated extends Error {} export class Unauthenticated extends Error {}
// 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,
// the machine list itself) pass local=true and are never rewritten.
let base = ''
export function setMachine(name: string | null) { base = name ? `/api/nodes/${name}` : '' }
export const apiPath = (p: string) => (base && p.startsWith('/api/') ? base + p.slice(4) : p)
let inflight = 0 let inflight = 0
function track(delta: number) { function track(delta: number) {
inflight += delta inflight += delta
window.dispatchEvent(new CustomEvent('hamlab:inflight', { detail: inflight })) window.dispatchEvent(new CustomEvent('hamlab:inflight', { detail: inflight }))
} }
async function req<T>(method: string, path: string, body?: unknown): Promise<T> { async function req<T>(method: string, path: string, body?: unknown, local = false): Promise<T> {
track(1) track(1)
let r: Response let r: Response
try { try {
r = await fetch(path, { r = await fetch(local ? path : apiPath(path), {
method, method,
credentials: 'same-origin', credentials: 'same-origin',
headers: body !== undefined ? { 'Content-Type': 'application/json' } : undefined, headers: body !== undefined ? { 'Content-Type': 'application/json' } : undefined,
@@ -31,7 +38,8 @@ async function req<T>(method: string, path: string, body?: unknown): Promise<T>
return r.json() return r.json()
} }
export interface Me { host: string; user: string; version: string; load: string; uptime: number; reboot_needed?: boolean } export interface Me { host: string; user: string; version: string; load: string; uptime: number; reboot_needed?: boolean; fingerprint?: 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 }
export interface Temp { chip: string; label: string; c: number; max?: number; crit?: number } export interface Temp { chip: string; label: string; c: number; max?: number; crit?: number }
@@ -90,21 +98,21 @@ export const api = {
files: (path: string, sudo: boolean) => req<{ path: string; parent?: string; entries: FileEntry[] }>('GET', `/api/files?${q(path, sudo)}`), files: (path: string, sudo: boolean) => req<{ path: string; parent?: string; entries: FileEntry[] }>('GET', `/api/files?${q(path, sudo)}`),
fileStat: (path: string, sudo: boolean) => req<FileInfo>('GET', `/api/files/stat?${q(path, sudo)}`), fileStat: (path: string, sudo: boolean) => req<FileInfo>('GET', `/api/files/stat?${q(path, sudo)}`),
fileRead: async (path: string, sudo: boolean): Promise<string> => { fileRead: async (path: string, sudo: boolean): Promise<string> => {
const r = await fetch(`/api/files/read?${q(path, sudo, '&max=2097152')}`, { credentials: 'same-origin' }) const r = await fetch(apiPath(`/api/files/read?${q(path, sudo, '&max=2097152')}`), { credentials: 'same-origin' })
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) }
return r.text() return r.text()
}, },
fileReadURL: (path: string, sudo: boolean) => `/api/files/read?${q(path, sudo)}`, fileReadURL: (path: string, sudo: boolean) => apiPath(`/api/files/read?${q(path, sudo)}`),
fileDownloadURL: (path: string, sudo: boolean) => `/api/files/download?${q(path, sudo)}`, fileDownloadURL: (path: string, sudo: boolean) => apiPath(`/api/files/download?${q(path, sudo)}`),
fileWrite: async (path: string, text: string, sudo: boolean) => { fileWrite: async (path: string, text: string, sudo: boolean) => {
const r = await fetch(`/api/files/write?${q(path, sudo)}`, { method: 'PUT', credentials: 'same-origin', body: text, headers: { 'Content-Type': 'application/octet-stream' } }) const r = await fetch(apiPath(`/api/files/write?${q(path, sudo)}`), { method: 'PUT', credentials: 'same-origin', body: text, headers: { 'Content-Type': 'application/octet-stream' } })
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) => new Promise<void>((resolve, reject) => {
const x = new XMLHttpRequest() const x = new XMLHttpRequest()
x.open('PUT', `/api/files/upload?${q(dir, sudo)}&name=${encodeURIComponent(file.name)}`) x.open('PUT', apiPath(`/api/files/upload?${q(dir, sudo)}&name=${encodeURIComponent(file.name)}`))
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'))
@@ -148,9 +156,13 @@ export const api = {
saveDashPrefs: (p: DashPrefs) => req<void>('PUT', '/api/prefs/dashboard', p), saveDashPrefs: (p: DashPrefs) => req<void>('PUT', '/api/prefs/dashboard', p),
metrics: () => req<Sample>('GET', '/api/metrics'), metrics: () => req<Sample>('GET', '/api/metrics'),
metricsHistory: () => req<Series>('GET', '/api/metrics/history'), metricsHistory: () => req<Series>('GET', '/api/metrics/history'),
login: (password: string) => req<void>('POST', '/api/login', { password }), login: (password: string) => req<void>('POST', '/api/login', { password }, true),
logout: () => req<void>('POST', '/api/logout'), logout: () => req<void>('POST', '/api/logout', undefined, true),
meHere: () => req<Me>('GET', '/api/me', undefined, true),
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),
addMachine: (name: string, url: string, password: string) => req<MachineStatus>('POST', '/api/nodes', { name, url, password }, 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 }),
renameTerminal: (id: string, name: string) => req<void>('PATCH', `/api/terminals/${id}`, { name }), renameTerminal: (id: string, name: string) => req<void>('PATCH', `/api/terminals/${id}`, { name }),
@@ -159,6 +171,6 @@ export const api = {
export function wsURLPath(path: string): string { export function wsURLPath(path: string): string {
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:' const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'
return `${proto}//${location.host}${path}` return `${proto}//${location.host}${apiPath(path)}`
} }
export const wsURL = (id: string) => wsURLPath(`/api/terminals/${id}/attach`) export const wsURL = (id: string) => wsURLPath(`/api/terminals/${id}/attach`)
+14
View File
@@ -334,3 +334,17 @@ h2.section { margin: 0; font-size: 13px; font-weight: 600; color: var(--muted);
.graph { font-size: 16px; } .graph { font-size: 16px; }
.netrow { grid-template-columns: 70px 1fr; } .netrow > :nth-child(3) { display: none; } .netrow { grid-template-columns: 70px 1fr; } .netrow > :nth-child(3) { display: none; }
} }
/* machine switcher + machines page */
.mswitch { position: relative; padding: 4px 8px 8px; border-bottom: 1px solid var(--border); margin-bottom: 4px; }
.mswitch-btn { width: 100%; display: flex; align-items: center; gap: 8px; padding: 8px 10px; background: var(--bg3); border: 1px solid var(--border); border-radius: 8px; text-align: left; }
.mswitch-btn .mname { font-weight: 700; color: var(--accent); }
.mswitch-btn .chev { margin-left: auto; color: var(--muted); }
.mmenu { position: absolute; left: 8px; right: 8px; top: calc(100% - 4px); z-index: 20; background: var(--bg2); border: 1px solid var(--border); border-radius: 8px; box-shadow: 0 8px 24px rgba(0,0,0,.5); overflow: hidden; }
.mitem { width: 100%; display: flex; align-items: center; gap: 8px; padding: 9px 12px; text-align: left; }
.mitem:hover { background: var(--bg3); } .mitem.on { box-shadow: inset 3px 0 0 var(--accent); }
.mitem .mname { flex: 1; } .mitem.manage { color: var(--accent2); }
.addform { display: flex; flex-direction: column; gap: 10px; }
.addform label { display: flex; flex-direction: column; gap: 4px; font-size: 12px; color: var(--muted); }
.addform input { padding: 8px 10px; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; color: var(--fg); font-size: 15px; }
.header .badge.bad { color: var(--bad); border-color: var(--bad); }