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.
486 lines
16 KiB
Go
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
|
|
}
|