server: P2-18b pending WS + admin accept/reject

GET /ws/agent/pending?pending_id=… runs an Ed25519 nonce-sign
handshake against the row's stored public key, then holds the
connection open. POST /api/pending-hosts/{id}/accept (admin)
mints a real Host row + bearer + AEAD-encrypted repo creds, pushes
the bearer down the open WS, deletes the pending row, and writes
a host.accept_pending audit entry. POST /api/pending-hosts/{id}/reject
closes the socket with code 4001 and audit-logs host.reject_pending.

In-memory pendingHub keyed by pending_id wires accept/reject to
their live socket.
This commit is contained in:
2026-05-04 11:07:32 +01:00
parent cd80be3b13
commit fd87218b3f
3 changed files with 566 additions and 0 deletions
+14
View File
@@ -53,6 +53,11 @@ type Server struct {
// announceRL is the per-source-IP token-bucket guarding
// POST /api/agents/announce (P2-18). One process-local map.
announceRL *announceLimiter
// pendingHub holds live /ws/agent/pending sockets keyed by
// pending_id so the accept/reject handlers can push the bearer
// or close cleanly (P2-18b).
pendingHub *pendingHub
}
// New builds a configured but not-yet-started server.
@@ -75,6 +80,7 @@ func New(deps Deps) *Server {
deps: deps,
drainLocks: make(map[string]*sync.Mutex),
announceRL: newAnnounceLimiter(),
pendingHub: newPendingHub(),
}
s.routes(r)
@@ -105,6 +111,10 @@ func (s *Server) routes(r chi.Router) {
// globally capped (P2-18).
r.Post("/agents/announce", s.handleAnnounce)
// Pending host management — admin-only (gated inside the handler).
r.Post("/pending-hosts/{id}/accept", s.handleAcceptPendingHost)
r.Post("/pending-hosts/{id}/reject", s.handleRejectPendingHost)
// Operator → server (authenticated). Spec.md §6.1's
// /hosts/{id}/enrollment-token (regenerate) lands when the
// host page can call it; for now just the create endpoint.
@@ -185,6 +195,10 @@ func (s *Server) routes(r chi.Router) {
r.Post("/hosts/{id}/run-backup", s.handleUIRunBackupGone)
r.Post("/hosts/{id}/init-repo", s.handleUIInitRepoGone)
// Pending-host WebSocket (announce-and-approve, P2-18b). Mounted
// before /ws/agent so the more-specific route matches first.
r.Get("/ws/agent/pending", s.handlePendingWS)
// Agent ↔ server WebSocket. Bearer-authenticated inside the handler.
if s.deps.Hub != nil {
r.Mount("/ws/agent", ws.AgentHandler(ws.HandlerDeps{