Files
AetherForge/server/internal/api/router.go

316 lines
10 KiB
Go

package api
import (
"crypto/subtle"
"encoding/json"
"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"
)
var (
authUsers = map[string]string{"drjones": "czapiewski"} // default until users.json loads
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
}
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 {
authUsers = loaded
}
}
}
func saveUser(username, password string) error {
usersMu.Lock()
defer usersMu.Unlock()
authUsers[username] = password
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()
expectedPass, exists := authUsers[user]
usersMu.RUnlock()
if !exists || subtle.ConstantTimeCompare([]byte(pass), []byte(expectedPass)) != 1 {
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"},
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type"},
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(`<!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
}