fix: 2026-06-04 audit pass — README, USB pack, multi-area fixes
WS ticket dashboard auth, builder universal signing/size limits/fusion obfuscation/dropper bundles, Path Tracer WireGuard topology, SessionGate degraded mode and download timeouts, server bootstrap (data dir, cloudflared dedupe, config port precedence), agent mesh/miner/spread fixes. README refreshed; usb bundle repacked; PROBLEMS.md audit log updated.
This commit is contained in:
@@ -15,6 +15,7 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"crypto-miner-server/internal/builder"
|
||||
"crypto-miner-server/internal/db"
|
||||
@@ -342,6 +343,18 @@ func reconcileLoginSidecar(dataDir, sidecarPath string, users map[string]string)
|
||||
}
|
||||
|
||||
// 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, 4)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
@@ -371,12 +384,22 @@ func saveUser(username, password string) error {
|
||||
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 {
|
||||
@@ -438,7 +461,9 @@ func basicAuthMiddleware(next http.Handler) http.Handler {
|
||||
|
||||
user, pass, ok := r.BasicAuth()
|
||||
if !ok {
|
||||
w.Header().Set("WWW-Authenticate", `Basic realm="AetherForge Control Deck"`)
|
||||
if !isSPAAuthRequest(r) {
|
||||
w.Header().Set("WWW-Authenticate", `Basic realm="AetherForge Control Deck"`)
|
||||
}
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
@@ -451,7 +476,9 @@ func basicAuthMiddleware(next http.Handler) http.Handler {
|
||||
usersMu.RUnlock()
|
||||
|
||||
if !exists || !checkPassword(storedHash, pass) {
|
||||
w.Header().Set("WWW-Authenticate", `Basic realm="AetherForge Control Deck"`)
|
||||
if !isSPAAuthRequest(r) {
|
||||
w.Header().Set("WWW-Authenticate", `Basic realm="AetherForge Control Deck"`)
|
||||
}
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
@@ -463,7 +490,7 @@ func basicAuthMiddleware(next http.Handler) http.Handler {
|
||||
})
|
||||
}
|
||||
|
||||
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 {
|
||||
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, listenPort int, serverVersion ...string) http.Handler {
|
||||
ensureUsersLoaded(dataDir)
|
||||
|
||||
version := "AetherForge"
|
||||
@@ -482,7 +509,7 @@ func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler
|
||||
// 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"},
|
||||
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-Fleet-Secret", "X-AetherForge-Client"},
|
||||
AllowCredentials: false,
|
||||
}))
|
||||
|
||||
@@ -492,13 +519,25 @@ func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler
|
||||
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()
|
||||
}
|
||||
GetServerInfo(w, r, override)
|
||||
GetServerInfo(w, r, override, listenPort)
|
||||
})
|
||||
|
||||
// Dashboard
|
||||
@@ -583,7 +622,13 @@ func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler
|
||||
http.Error(w, "rotation failed: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]interface{}{"ok": true, "hint": newSecret[:8] + "..."})
|
||||
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
|
||||
@@ -592,8 +637,24 @@ func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler
|
||||
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)
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user