package api import ( "crypto/rand" "crypto/subtle" "encoding/hex" "encoding/json" "fmt" "log" "net/http" "os" "path/filepath" "strings" "sync" "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" ) var ( // authUsers is populated from data/users.json on startup. On the very first // run (no users.json) a random password is generated, saved, and printed to // the console — no hard-coded credentials anywhere in the binary. authUsers = map[string]string{} usersFilePath string usersMu sync.RWMutex // 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 loadUsers(dataDir string) { usersFilePath = filepath.Join(dataDir, "users.json") 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 { // Migration: re-hash any plain-text entries left from an older version. 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 if migrated { d, _ := json.MarshalIndent(authUsers, "", " ") _ = os.WriteFile(usersFilePath, d, 0600) } return } } // First run — no users.json (or empty). Generate a random admin password, // hash it, save it, and print the plain-text once to the console. pw := generateRandomPassword() hashed, herr := hashPassword(pw) if herr != nil { hashed = pw // extremely unlikely; degrade gracefully log.Printf("[Auth] WARNING: bcrypt failed, storing plain-text password: %v", herr) } authUsers = map[string]string{"admin": hashed} 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) } } banner := fmt.Sprintf(` ╔══════════════════════════════════════════════════╗ ║ AetherForge — First Run ║ ║ ║ ║ Dashboard login ║ ║ Username : admin ║ ║ Password : %-34s║ ║ ║ ║ Save this — it will not be shown again. ║ ║ Change it later in Calibrate → Users. ║ ╚══════════════════════════════════════════════════╝`, pw) log.Print(banner) } // generateRandomPassword returns a 20-character hex string suitable for use // as an initial admin password. func generateRandomPassword() string { b := make([]byte, 10) if _, err := rand.Read(b); err != nil { return "CHANGE-ME-NOW-12345" } 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() defer usersMu.Unlock() authUsers[username] = hashed if usersFilePath == "" { usersFilePath = filepath.Join("data", "users.json") } if err := os.MkdirAll(filepath.Dir(usersFilePath), 0755); err != nil { return err } data, _ := json.MarshalIndent(authUsers, "", " ") return os.WriteFile(usersFilePath, data, 0600) } 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 download endpoints are always open. if path == "/api/v1/health" || path == "/get" || path == "/install.sh" || path == "/install.ps1" || (strings.HasPrefix(path, "/api/v1/builds/") && (strings.HasSuffix(path, "/download") || strings.Contains(path, "/artifact/"))) { 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. if strings.HasPrefix(path, "/api/v1/agent/") { fleetSecretForAgentPathsMu.RLock() secret := fleetSecretForAgentPaths fleetSecretForAgentPathsMu.RUnlock() if secret != "" { provided := r.Header.Get("X-Fleet-Secret") if subtle.ConstantTimeCompare([]byte(provided), []byte(secret)) != 1 { http.Error(w, "Forbidden", http.StatusForbidden) return } } // Secret is empty (first run before config save) or matched — allow through. next.ServeHTTP(w, r) return } user, pass, ok := r.BasicAuth() if !ok { w.Header().Set("WWW-Authenticate", `Basic realm="AetherForge Control Deck"`) http.Error(w, "Unauthorized", http.StatusUnauthorized) return } usersMu.RLock() storedHash, exists := authUsers[user] usersMu.RUnlock() if !exists || !checkPassword(storedHash, pass) { w.Header().Set("WWW-Authenticate", `Basic realm="AetherForge Control Deck"`) http.Error(w, "Unauthorized", http.StatusUnauthorized) return } next.ServeHTTP(w, r) }) } func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler, builderHandler *builder.Handler, blueprintHandler *BlueprintHandler, aiHandler *AIHandler, fleetHandler *FleetHandler, dropperHandler *DropperHandler, webRoot string, dataDir string, publicURLOverride func() string) http.Handler { loadUsers(dataDir) 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"}, 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.Get("/server/info", func(w http.ResponseWriter, r *http.Request) { override := "" if publicURLOverride != nil { override = publicURLOverride() } GetServerInfo(w, r, override) }) // Dashboard r.Get("/dashboard/stats", h.GetDashboardStats) // 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.Get("/agents/{id}/log", fleetHandler.GetAgentLog) r.Put("/agents/{id}/meta", fleetHandler.PutAgentMeta) r.Post("/agents/bulk-command", fleetHandler.PostBulkCommand) } // Fleet ops if fleetHandler != nil { r.Get("/alerts", fleetHandler.GetAlerts) r.Get("/pools/status", fleetHandler.GetPoolStatus) r.Get("/ai/activity", fleetHandler.GetAIActivity) r.Get("/earnings/estimate", fleetHandler.GetEarningsEstimate) } // Shares r.Get("/shares", h.GetRecentShares) // Builds r.Get("/builds", h.ListBuilds) 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) 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 } writeJSON(w, map[string]interface{}{"ok": true, "hint": newSecret[:8] + "..."}) }) // 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 || payload.Username == "" || payload.Password == "" { http.Error(w, "Invalid username or password", http.StatusBadRequest) 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}) }) // AI Autonomy (Ollama) r.Post("/agent/decide", aiHandler.HandleDecide) r.Post("/agent/report", aiHandler.HandleReport) r.Post("/agent/heartbeat", aiHandler.HandleHeartbeat) }) // 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) } // 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(`Crypto Miner

Crypto Miner Control Server

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(`Crypto Miner

Crypto Miner Control Server

Server is running. No frontend configured.

API: /api/v1/health

`)) }) } return r }