From 7dcdb5dc98d7323cc0bd10c2db17751f8af77a00 Mon Sep 17 00:00:00 2001 From: Mitchell-Johnson Date: Fri, 18 Sep 2026 11:35:29 -0700 Subject: [PATCH] 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 --- PLAN.md | 21 ++- README.md | 9 + cmd/hamlab/main.go | 23 ++- internal/api/server.go | 195 +++++++++++++++++++--- internal/auth/auth.go | 30 ++++ internal/config/config.go | 49 +++++- internal/nodes/nodes.go | 323 ++++++++++++++++++++++++++++++++++++ internal/tlsauto/tlsauto.go | 125 ++++++++++++++ web/src/App.tsx | 2 +- web/src/MachineSwitcher.tsx | 41 +++++ web/src/Machines.tsx | 91 ++++++++++ web/src/Rail.tsx | 5 +- web/src/Shell.tsx | 54 +++++- web/src/api.ts | 34 ++-- web/src/styles.css | 14 ++ 15 files changed, 960 insertions(+), 56 deletions(-) create mode 100644 internal/nodes/nodes.go create mode 100644 internal/tlsauto/tlsauto.go create mode 100644 web/src/MachineSwitcher.tsx create mode 100644 web/src/Machines.tsx diff --git a/PLAN.md b/PLAN.md index 2494dad..8cff475 100644 --- a/PLAN.md +++ b/PLAN.md @@ -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 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. -- **Multi-host** — same binary as a headless *agent* on `grid` / Mac mini / - `wyse-5070-1`, `core`'s instance is the UI and fans out over mTLS. The rail grows a - host level at the top, exactly like Proxmox's datacenter → node tree. Design the - MVP URL space so `host` can be inserted later without breaking clients. +- **Multi-host** — **Phase 1 shipped 2026-09-18** (`internal/nodes`, `internal/tlsauto`, + `MachineSwitcher.tsx`, `Machines.tsx`). One binary, hub and spoke: a host whose + `config.json` lists `nodes` is a hub; any other is a standalone node with its full UI. + 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:`, 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 list + revoke, optional NPM access list restricting to WireGuard/LAN. - **Files tab** — **shipped 2026-09-18** (`internal/files`, `Files.tsx`): browse from diff --git a/README.md b/README.md index ea743ec..99f3b5a 100644 --- a/README.md +++ b/README.md @@ -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. +## 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. - Installable as a PWA. diff --git a/cmd/hamlab/main.go b/cmd/hamlab/main.go index 9f4d289..9551c8e 100644 --- a/cmd/hamlab/main.go +++ b/cmd/hamlab/main.go @@ -24,11 +24,13 @@ import ( "hamlab/internal/cron" "hamlab/internal/files" "hamlab/internal/metrics" + "hamlab/internal/nodes" "hamlab/internal/services" "hamlab/internal/storage" "hamlab/internal/store" "hamlab/internal/tasks" hterm "hamlab/internal/term" + "hamlab/internal/tlsauto" "hamlab/internal/updates" "hamlab/internal/web" ) @@ -160,7 +162,19 @@ func serve(cfg *config.Config) error { if svc.Sudo() && 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{ Addr: cfg.Listen, Handler: srv.Handler(), @@ -176,6 +190,13 @@ func serve(cfg *config.Config) error { _ = hs.Shutdown(ctx) 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) if err := hs.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) { return err diff --git a/internal/api/server.go b/internal/api/server.go index c920f7c..a137ad8 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -26,6 +26,7 @@ import ( "hamlab/internal/cron" "hamlab/internal/files" "hamlab/internal/metrics" + "hamlab/internal/nodes" "hamlab/internal/services" "hamlab/internal/storage" "hamlab/internal/store" @@ -37,30 +38,32 @@ import ( const cookieName = "hamlab_session" type Server struct { - cfg *config.Config - sessions *auth.Sessions - limiter auth.Limiter - mgr *term.Manager - metrics *metrics.Collector - store *store.Store - svc *services.Manager - cron *cron.Manager - tasks *tasks.Manager - updates *updates.Manager - storage *storage.Manager - fsLocal files.FS - fsSudo files.FS // nil without passwordless sudo - version string - started time.Time - user string - host string - ui fs.FS - mux *http.ServeMux + cfg *config.Config + sessions *auth.Sessions + limiter auth.Limiter + mgr *term.Manager + metrics *metrics.Collector + store *store.Store + svc *services.Manager + cron *cron.Manager + tasks *tasks.Manager + updates *updates.Manager + storage *storage.Manager + fsLocal files.FS + fsSudo files.FS // nil without passwordless sudo + nodes *nodes.Manager + fingerprint string + version string + started time.Time + user string + 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() - 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")} if u, err := user.Current(); err == nil && 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.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(204) }) 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("GET /api/me", s.auth(s.me)) 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 { return func(w http.ResponseWriter, r *http.Request) { - c, err := r.Cookie(cookieName) - if err != nil || !s.sessions.Valid(c.Value) { - jsonErr(w, 401, "unauthenticated") + if c, err := r.Cookie(cookieName); err == nil && s.sessions.Valid(c.Value) { + next(w, r) 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 ---- 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{ "host": s.host, "user": s.user, "version": s.version, "load": load, "uptime": uptime, "service_started": s.started, - "reboot_needed": rebootErr == nil, + "reboot_needed": rebootErr == nil, "fingerprint": s.fingerprint, }) } diff --git a/internal/auth/auth.go b/internal/auth/auth.go index af6cdf9..f9354a8 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -3,6 +3,7 @@ package auth import ( "crypto/rand" + "crypto/sha256" "crypto/subtle" "encoding/base64" "encoding/hex" @@ -59,6 +60,35 @@ func VerifyPassword(hash, pw string) bool { 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. type Sessions struct { st *store.Store diff --git a/internal/config/config.go b/internal/config/config.go index ec9e131..02fd93d 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -7,13 +7,37 @@ import ( "fmt" "os" "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 { - Listen string `json:"listen"` - PasswordHash string `json:"password_hash"` + Listen string `json:"listen"` + 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 + mu sync.Mutex } 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) 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 { return err } @@ -53,3 +83,18 @@ func (c *Config) Save() error { } 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) +} diff --git a/internal/nodes/nodes.go b/internal/nodes/nodes.go new file mode 100644 index 0000000..3213f9d --- /dev/null +++ b/internal/nodes/nodes.go @@ -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//… 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//: it rewrites to /api/, +// 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 +} diff --git a/internal/tlsauto/tlsauto.go b/internal/tlsauto/tlsauto.go new file mode 100644 index 0000000..2b77ce7 --- /dev/null +++ b/internal/tlsauto/tlsauto.go @@ -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 +} diff --git a/web/src/App.tsx b/web/src/App.tsx index 6dfe9eb..1df3df8 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -7,7 +7,7 @@ export default function App() { const [me, setMe] = useState(undefined) 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]) diff --git a/web/src/MachineSwitcher.tsx b/web/src/MachineSwitcher.tsx new file mode 100644 index 0000000..8966317 --- /dev/null +++ b/web/src/MachineSwitcher.tsx @@ -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(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 ( +
+ + {open && ( +
+ + {machines.map((m) => ( + + ))} + +
+ )} +
+ ) +} diff --git a/web/src/Machines.tsx b/web/src/Machines.tsx new file mode 100644 index 0000000..fe5fd6b --- /dev/null +++ b/web/src/Machines.tsx @@ -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(null) + const [fp, setFp] = useState() + 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
{err ?
{err}
: }
+ return ( +
+

This host

+
+
{hub.host}hubv{hub.version.replace(/^v/, '')} · up {fmtUptime(hub.uptime)}
+
{fp ? <>Certificate {fp} : 'Serving plain HTTP (behind a reverse proxy). Set "tls": "auto" in config.json for a self-signed certificate if another hub should manage this host.'}
+
+ +

Managed machines · {list.length}

+ {err &&
{err}
} + {list.length === 0 &&
No machines yet. Install Ham Lab on another host, set its password, then add it below.
} +
+ {list.map((m) => ( +
+
+ + {m.name} + {m.url} + + {m.reboot_needed && ⟳} +
+
+
Status{m.online ? 'online' : m.error || 'offline'}
+ {m.host &&
Host{m.host} · v{(m.version ?? '').replace(/^v/, '')}
} + {m.online &&
Load{m.load} · up {fmtUptime(m.uptime ?? 0)}
} +
Seen{ago(m.last_seen)}
+ {m.fingerprint &&
Cert{shortFp(m.fingerprint)}
} +
Added{new Date(m.added).toLocaleDateString()}
+
+
+ + remove(m)}>Remove +
+
+ ))} +
+ +

Add a machine

+
+ + + + {addErr &&
{addErr}
} +
+ + The password is used once to obtain an access token and is not stored. The machine's certificate is pinned on first contact. +
+
+
+ ) +} diff --git a/web/src/Rail.tsx b/web/src/Rail.tsx index e160b23..e439ce5 100644 --- a/web/src/Rail.tsx +++ b/web/src/Rail.tsx @@ -7,15 +7,16 @@ import { Spinner } from './ui' export interface Group { id: string; label: string; icon: ReactNode; soon?: boolean; page?: boolean } 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 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('terminal') return (