Files
AetherForge/server/internal/api/router.go
AetherForge ea6f54ad03 Expand test coverage across server, agent, and web; fix bugs found during audit.
Adds hundreds of unit/integration/e2e tests, fixes WS bcrypt auth, config merge, fleet analytics, agent schedule/log tail, and documents stale PROBLEMS items. Updates PROBLEMS.md, README, and test scripts; ignores local spread-kits and coverage dirs.
2026-05-31 01:13:58 -07:00

486 lines
16 KiB
Go

package api
import (
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"encoding/json"
"fmt"
"log"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"time"
"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
)
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()
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)
}
}
}
authSessionCacheMu.Unlock()
}
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 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" {
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 {
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) {
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, 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)
r.Get("/market/xmr", fleetHandler.GetXMRPrice)
}
// Shares
r.Get("/shares", h.GetRecentShares)
// Builds
r.Get("/builds", h.ListBuilds)
r.Put("/builds/{id}/pin", h.PinBuild)
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)
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(`<!DOCTYPE html><html><head><title>Crypto Miner</title></head><body>
<h1>Crypto Miner Control Server</h1>
<p>Server is running. Build the frontend with <code>cd server/web && npm install && npm run build</code></p>
<p>API: <a href="/api/v1/health">/api/v1/health</a></p>
</body></html>`))
})
}
} else {
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html")
w.Write([]byte(`<!DOCTYPE html><html><head><title>Crypto Miner</title></head><body>
<h1>Crypto Miner Control Server</h1>
<p>Server is running. No frontend configured.</p>
<p>API: <a href="/api/v1/health">/api/v1/health</a></p>
</body></html>`))
})
}
return r
}