feat: fleet secret auth - zero-setup agent authentication, dashboard WS token, remove credential hints

This commit is contained in:
drjones
2026-05-30 10:22:06 -07:00
parent 7c5c7dfceb
commit d073dcd7df
11 changed files with 104 additions and 3 deletions

View File

@@ -1,6 +1,8 @@
package api
import (
"crypto/subtle"
"encoding/base64"
"encoding/json"
"fmt"
"log"
@@ -17,6 +19,33 @@ import (
"github.com/gorilla/websocket"
)
// secureStringEqual compares two strings in constant time to prevent timing attacks.
func secureStringEqual(a, b string) bool {
return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1
}
// checkDashboardWSToken validates the ?token= query param on dashboard WS upgrade.
// The browser passes btoa("user:pass") — the same value stored in sessionStorage.
func checkDashboardWSToken(r *http.Request) bool {
token := r.URL.Query().Get("token")
if token == "" {
return false
}
decoded, err := base64.StdEncoding.DecodeString(token)
if err != nil {
return false
}
parts := strings.SplitN(string(decoded), ":", 2)
if len(parts) != 2 {
return false
}
user, pass := parts[0], parts[1]
usersMu.RLock()
expectedPass, exists := authUsers[user]
usersMu.RUnlock()
return exists && secureStringEqual(pass, expectedPass)
}
var upgrader = websocket.Upgrader{
ReadBufferSize: 4096,
WriteBufferSize: 4096,
@@ -79,6 +108,7 @@ type WSHub struct {
agentLogs map[string]string
serverPolicy ServerPolicy
pingIntervalSec int
fleetSecret string // baked into forged agents; verified on WS connect
mu sync.RWMutex
}
@@ -109,6 +139,14 @@ func (h *WSHub) SetPingInterval(seconds int) {
h.mu.Unlock()
}
// SetFleetSecret stores the shared secret that all forged agents must present.
// Called once at startup from main.go after config is loaded.
func (h *WSHub) SetFleetSecret(secret string) {
h.mu.Lock()
h.fleetSecret = secret
h.mu.Unlock()
}
func (h *WSHub) pingInterval() time.Duration {
h.mu.RLock()
sec := h.pingIntervalSec
@@ -270,6 +308,7 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
case "auth":
var auth struct {
AgentID string `json:"agent_id"`
FleetSecret string `json:"fleet_secret"`
Wallet string `json:"wallet"`
Version string `json:"version"`
Hostname string `json:"hostname"`
@@ -300,6 +339,18 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
continue
}
// Verify fleet secret. If the server has one configured, the agent must match.
h.mu.RLock()
requiredSecret := h.fleetSecret
h.mu.RUnlock()
if requiredSecret != "" && !secureStringEqual(auth.FleetSecret, requiredSecret) {
conn.WriteJSON(Message{Type: "auth_response", Payload: mustMarshal(map[string]interface{}{
"success": false, "error": "invalid fleet secret — re-forge this agent",
})})
log.Printf("[auth] Agent rejected: bad fleet secret (host=%s id=%s)", auth.Hostname, auth.AgentID)
return
}
agentID = auth.AgentID
if agentID == "" {
agentID = uuid.New().String()
@@ -603,6 +654,15 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
}
func (h *WSHub) HandleDashboardWS(w http.ResponseWriter, r *http.Request) {
// Verify dashboard session. The SPA sends its stored Basic-auth token as
// ?token=<base64> because the WS upgrade can't carry Authorization headers.
// We decode it and check against the same in-memory user map as the REST API.
if !checkDashboardWSToken(r) {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
log.Printf("[auth] Dashboard WS rejected: bad or missing token from %s", r.RemoteAddr)
return
}
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
log.Printf("Dashboard WebSocket upgrade error: %v", err)