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
+169
-26
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user