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:
co-authored by
Claude Fable 5.1
parent
fc42675e9f
commit
7dcdb5dc98
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
+150
-7
@@ -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"
|
||||||
@@ -50,6 +51,8 @@ type Server struct {
|
|||||||
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
|
||||||
|
nodes *nodes.Manager
|
||||||
|
fingerprint string
|
||||||
version string
|
version string
|
||||||
started time.Time
|
started time.Time
|
||||||
user string
|
user string
|
||||||
@@ -58,9 +61,9 @@ type Server struct {
|
|||||||
mux *http.ServeMux
|
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,15 +171,148 @@ 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 ----
|
||||||
@@ -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,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
@@ -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])
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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
@@ -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
|
||||||
|
|||||||
+42
-6
@@ -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 () => {
|
||||||
|
try {
|
||||||
const list = await api.terminals()
|
const list = await api.terminals()
|
||||||
setTerms(list)
|
setTerms(list)
|
||||||
setActive((a) => (a && list.some((t) => t.id === a) ? a : list[0]?.id ?? null))
|
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
@@ -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`)
|
||||||
|
|||||||
@@ -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); }
|
||||||
|
|||||||
Reference in New Issue
Block a user