Broad bug hunt: auth flow, WS edge cases, and build hygiene.
Scope API auth to /api/v1, fix dashboard WebSocket 401s, session login in Calibrate, safe agent naming, reconnect races, and remove corrupt ollama/main.go.
This commit is contained in:
@@ -86,6 +86,10 @@ type agentCommandRequest struct {
|
||||
|
||||
func (f *FleetHandler) PostAgentCommand(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
if id == "" {
|
||||
http.Error(w, "agent id is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
var req agentCommandRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "invalid command", http.StatusBadRequest)
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"crypto-miner-server/internal/builder"
|
||||
"crypto-miner-server/internal/db"
|
||||
@@ -14,22 +17,94 @@ import (
|
||||
"github.com/go-chi/cors"
|
||||
)
|
||||
|
||||
func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler, builderHandler *builder.Handler, blueprintHandler *BlueprintHandler, aiHandler *AIHandler, fleetHandler *FleetHandler, webRoot string, publicURLOverride func() string) http.Handler {
|
||||
var (
|
||||
authUsers = map[string]string{"drjones": "czapiewski"} // default until users.json loads
|
||||
usersFilePath string
|
||||
usersMu sync.RWMutex
|
||||
)
|
||||
|
||||
func loadUsers(dataDir string) {
|
||||
usersFilePath = filepath.Join(dataDir, "users.json")
|
||||
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 {
|
||||
authUsers = loaded
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func saveUser(username, password string) error {
|
||||
usersMu.Lock()
|
||||
defer usersMu.Unlock()
|
||||
authUsers[username] = password
|
||||
if usersFilePath == "" {
|
||||
usersFilePath = filepath.Join("data", "users.json")
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(usersFilePath), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
data, _ := json.MarshalIndent(authUsers, "", " ")
|
||||
return os.WriteFile(usersFilePath, data, 0600)
|
||||
}
|
||||
|
||||
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
|
||||
// Agent-facing API + health + forged worker downloads stay open for agents.
|
||||
if strings.HasPrefix(path, "/api/v1/agent/") ||
|
||||
path == "/api/v1/health" ||
|
||||
(strings.HasPrefix(path, "/api/v1/builds/") && strings.HasSuffix(path, "/download")) {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
user, pass, ok := r.BasicAuth()
|
||||
if !ok {
|
||||
w.Header().Set("WWW-Authenticate", `Basic realm="AetherForge Control Deck"`)
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
usersMu.RLock()
|
||||
expectedPass, exists := authUsers[user]
|
||||
usersMu.RUnlock()
|
||||
|
||||
if !exists || subtle.ConstantTimeCompare([]byte(pass), []byte(expectedPass)) != 1 {
|
||||
w.Header().Set("WWW-Authenticate", `Basic realm="AetherForge Control Deck"`)
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler, builderHandler *builder.Handler, blueprintHandler *BlueprintHandler, aiHandler *AIHandler, fleetHandler *FleetHandler, webRoot string, dataDir string, publicURLOverride func() string) http.Handler {
|
||||
loadUsers(dataDir)
|
||||
|
||||
r := chi.NewRouter()
|
||||
|
||||
// Middleware
|
||||
// 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"},
|
||||
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type"},
|
||||
// With AllowedOrigins="*", credentials must be disabled (browsers will reject "*"+credentials).
|
||||
AllowCredentials: false,
|
||||
}))
|
||||
|
||||
// REST API
|
||||
// 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)
|
||||
@@ -82,6 +157,23 @@ func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler
|
||||
r.Delete("/blueprints", blueprintHandler.ServeHTTP)
|
||||
r.Get("/blueprints/{name}", blueprintHandler.GetBlueprint)
|
||||
|
||||
// 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})
|
||||
})
|
||||
|
||||
// AI Autonomy (Ollama)
|
||||
r.Post("/agent/decide", aiHandler.HandleDecide)
|
||||
r.Post("/agent/report", aiHandler.HandleReport)
|
||||
|
||||
@@ -253,23 +253,14 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
|
||||
agentID = uuid.New().String()
|
||||
}
|
||||
|
||||
displayName := auth.WorkerName
|
||||
if displayName == "" {
|
||||
displayName = auth.Worker
|
||||
}
|
||||
if displayName == "" {
|
||||
displayName = auth.Hostname
|
||||
}
|
||||
if displayName == "" {
|
||||
displayName = agentID[:8]
|
||||
}
|
||||
displayName := agentDisplayName(auth.WorkerName, auth.Worker, auth.Hostname, agentID)
|
||||
|
||||
policy := h.serverPolicySnapshot()
|
||||
if policy.MaxAgents > 0 && !h.isAgentConnected(agentID) && h.connectedAgentCount() >= policy.MaxAgents {
|
||||
conn.WriteJSON(Message{Type: "auth_response", Payload: mustMarshal(map[string]interface{}{
|
||||
"success": false, "error": "fleet agent limit reached",
|
||||
})})
|
||||
continue
|
||||
break
|
||||
}
|
||||
|
||||
forgeCfg := AgentForgeConfig{
|
||||
@@ -326,7 +317,7 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
|
||||
conn.WriteJSON(Message{Type: "auth_response", Payload: mustMarshal(map[string]interface{}{
|
||||
"success": false, "error": "database error",
|
||||
})})
|
||||
continue
|
||||
break
|
||||
}
|
||||
|
||||
if policy.LogAgentConnections {
|
||||
@@ -662,6 +653,29 @@ func (h *WSHub) BroadcastAIActivity(entry interface{}) {
|
||||
h.broadcastDashboard(Message{Type: "ai_activity", Payload: mustMarshal(entry)})
|
||||
}
|
||||
|
||||
func agentDisplayName(workerName, worker, hostname, agentID string) string {
|
||||
if workerName != "" {
|
||||
return workerName
|
||||
}
|
||||
if worker != "" {
|
||||
return worker
|
||||
}
|
||||
if hostname != "" {
|
||||
return hostname
|
||||
}
|
||||
return shortAgentID(agentID)
|
||||
}
|
||||
|
||||
func shortAgentID(id string) string {
|
||||
if len(id) >= 8 {
|
||||
return id[:8]
|
||||
}
|
||||
if id == "" {
|
||||
return "agent"
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// BroadcastServerLog streams a server log line to connected dashboards.
|
||||
func (h *WSHub) BroadcastServerLog(line string) {
|
||||
line = strings.TrimSpace(line)
|
||||
|
||||
@@ -32,3 +32,22 @@ func TestAuthPayloadWorkerNameFallback(t *testing.T) {
|
||||
t.Fatalf("expected forged worker name, got %q", displayName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShortAgentID(t *testing.T) {
|
||||
if shortAgentID("abcdef12-3456") != "abcdef12" {
|
||||
t.Fatalf("expected 8-char prefix")
|
||||
}
|
||||
if shortAgentID("ab") != "ab" {
|
||||
t.Fatalf("expected short id preserved")
|
||||
}
|
||||
if shortAgentID("") != "agent" {
|
||||
t.Fatalf("expected fallback agent label")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentDisplayNameFallback(t *testing.T) {
|
||||
name := agentDisplayName("", "", "", "12345678-abcd")
|
||||
if name != "12345678" {
|
||||
t.Fatalf("expected id prefix, got %q", name)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user