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