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 66–72 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` L51–53 | + +| 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. +
+ + + + + + + + + +
TechniqueStatusWhy
Silent browser RCE (visit → shell)DeadChromium sandboxes, site isolation, removed plugins
Auto-run from DownloadsDeadSmartScreen, MoTW, user-gesture requirements
Flash/Java plugin drive-byDeadPlugins removed or click-to-play extinct
Unauthenticated curl | bash on cautious adminsHardPipe-to-shell fingerprinting; inspect-before-run mitigations
CRX sideload via downloadDeadDownloadRestrictions; store policy blocks casual sideload
+ +

AetherForge stack — has vs needs

+ + + + + + + + + + + + + + + +
CapabilityStatus
GET /get, /install.sh, /install.ps1 with ?pin= + ?c=Working
Spread Kit ZIP export + static lander at /spread/Working
Emberwake campaign builder + War Room funnelWorking
WordPress plugin + npm helper export wizardsWorking
Fusion media ZIP bundlesWorking
USB perpetual propagation (forge flag)Working
LAN autospread / share spreadWorking
SocGholish fake-update branded landerStub
JS fingerprint / TDS gateNeeds
OAuth redirect helperNeeds
Public npm/PyPI typosquat publishOut of scope
+

Source matrix (markdown): SPREAD_TECHNIQUES.md

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ + + diff --git a/server/web/public/docs/SPREAD_TECHNIQUES.md b/server/web/public/docs/SPREAD_TECHNIQUES.md index 084a29c..396575b 100644 --- a/server/web/public/docs/SPREAD_TECHNIQUES.md +++ b/server/web/public/docs/SPREAD_TECHNIQUES.md @@ -1,5 +1,7 @@ # Web-Mediated Spread Techniques (Research Summary) +> **Operator playbook (tabbed HTML):** [SPREAD_TECHNIQUES.html](SPREAD_TECHNIQUES.html) — step-by-step Emberwake how-tos. This file is the research matrix. + > **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. | @@ -80,12 +82,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/server/web/public/docs/index.html b/server/web/public/docs/index.html index 7ab0acb..9966069 100644 --- a/server/web/public/docs/index.html +++ b/server/web/public/docs/index.html @@ -28,18 +28,25 @@ @@ -182,6 +189,52 @@ bin\miner-server.exe -port 8989 -data .\data
[Screenshot: Dashboard fleet health + contribution map]
+

Command deck route guide

+ + + + + + + + + + + + + + + +
RouteNav labelPrimary use
/dashboardCommand DeckFleet health, hashrate, topology map, install funnel, audit strip
/agentsFleet RosterPer-machine detail, remote actions, groups, protocol tunnels
/crucibleCrucibleBatch remote terminal, expanded ops, file manager, gold rain overlay
/forgeForgeFull builder — preflight, fusion, blueprints, operation modes
/mission-deckMission DeckFast path — preset loadout → one-click forge + export + clipboard links
/buildsBuildsDownload, pin, public toggle, dropper one-liners, re-forge
/emberwakeEmberwakeCampaign War Room, spread-kit export, supply-chain wizards
/settingsCalibratePool, alerts, users, fleet policy, staged modules, tunnels
/pathtracerPath TracerMulti-hop WireGuard chain builder + QR config
/docs/Field docsThis wiki — searchable; HelpTips link here
/spread/Static spread kitPublic 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. +

+ +

Overview vs Advanced mode

+ + + + + + +
ModePurposeExtra panels
OverviewFast status scanCore health, key metrics, roster, topology, share pulse
AdvancedDeep operator sessionAI 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)

+ +

Dashboard operating rhythm

+
    +
  1. Open Dashboard first and check Fleet Health, Nodes Live, and Accept.
  2. +
  3. If health is amber/red, inspect pool status and the underperformer list before touching config.
  4. +
  5. Open Agents only after the dashboard tells you which machines need attention.
  6. +
  7. Use Builds to verify what is currently pinned before forging anything new.
  8. +
  9. Use Calibrate for durable defaults; use Forge only for build-specific overrides.
  10. +
+ + + +
+

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. +

+ +

Mining & lifecycle

+ + + + + + + + + + +
CommandPurposePlatformsStatus
pause / resume / restartMiner controlAllWorking
stop / killTerminate agent processAllWorking
uninstallRemove persistence + binaryAllWorking
upgradeDownload + replace from build URLAllWorking
fetch_moduleStage signed runtime packAllWorking
bof_executeIn-memory BOFDisabled — always errors
+ +

System & power

+ + + + + + + + + +
CommandPurposePlatformsStatus
reboot_machine / shutdown_machinePower controlAllWorking
reboot / shutdownLegacy aliasesAllWorking
Wake-on-LANPOST /api/v1/agents/{id}/wol — UDP magic packetServer → agent MACWorking (offline OK)
exec / powershellShell (hidden window)Win / Unix shWorking
get_logTail agent logAllWorking
+ +

Recon & posture

+ + + + + + + + + + + + + + + + +
CommandPurposeStatus
sysinfoHostname, OS, CPU, RAM, uptimeWorking
full_sys_checkAV, firewall, disk, DNS, ports, CISA KEV exposureWorking — KEV block Windows-focused
ps / netstat / users / softwareProcess / network / user inventoryWorking
listen_ports / patch_statusOpen ports + patch levelWorking
postureFirewall + AV summaryWorking
connectivity_probeDNS + TCP to C2 and poolWorking
arp_neighborsARP cache IPs (spread targeting)Working
persistence_auditRun keys / tasks / systemd / launchd JSONWorking
screenshotDesktop JPEG (live view polls 3s)Working
camera_list / camera_snapshotUSB camera capturePartial — macOS stub; needs ffmpeg
ipconfig / wifi / clipboardNetwork / Wi‑Fi / clipboardWorking
kill_processKill PID (command field)Working
+ +

Files

+ + + + + + + + + +
CommandPurposeGuards
list_dir / read_fileRemote browse + read (512 KB cap)System-root guards
upload / downloadTransfer filesAuth via dashboard
push_desktopDeploy to @desktop/
delete_path / move_pathFile opsNo dirs / system roots
secure_wipeOverwrite-then-delete folderConfirm in UI; aggressive
+ +

Network, spread & tunnels

+ + + + + + + + + + + + + + + +
CommandPurposeStatus
spread_nowTrigger LAN spread sweepWindows/Linux — SMB WinRM / SSH
spread_statusLast sweep in-memory JSONWorking
subnet_scanActive subnet discoveryWorking
smb_sharesARP/subnet → net view JSONWindows-only
hole_punch*UPnP IGD port mapWorking — needs hole_punch forge flag
tunnel_cloudflaredOutbound Cloudflare tunnelWindows agent — server launcher Win-only
tunnel_ssh_forwardSSH local forward matrixWindows
tunnel_wireguard / wg_*WireGuard setup (Path Tracer)Windows — Linux/macOS agent stub
tunnel_status / tunnel_stopQuery / stop tunnelsWorking
tunnel_streamTCP reverse relay over WSNot implemented
mesh_statusP2P peer countStub without -tags p2p
+ +

Firewall, persistence & registry (aggressive)

+ + + + + + + + + + + + +
CommandNotesStatus
firewall_punch / firewall_off / firewall_onnetsh / ufw / iptablesWorking — macOS firewall stub
firewall_profiles / firewall_removeProfile toggles + rule cleanupWorking
defender_offWindows Defender disable attemptWindows-only
bits_persist / host_binary_persistBITS job / host-binary hijackWindows-only
registry_read / write / deleteAllowlisted hivesWindows-only
credential_vault_listCredential Manager / Keychain / secret-tool namesWorking — names only
get_wifi_passwordsSaved Wi‑Fi profilesWindows-focused
encrypt_path / sys_cryptPath encryption opsWorking — confirm in UI
@@ -249,6 +416,53 @@ bin\miner-server.exe -port 8989 -data .\data +

Builder workflow from blank form to archived artifact

+
    +
  1. Choose the target profile. Start with one OS/arch unless you specifically need a multi-platform ZIP.
  2. +
  3. Set the runtime identity. Worker name, server URL, and output directory are the fields that shape how the build is tracked later.
  4. +
  5. Review defaults from Calibrate. The builder inherits server-side defaults; only override fields that truly differ for this build.
  6. +
  7. Run preflight mentally before compile. Confirm output path, signing configuration, and any packaging choices.
  8. +
  9. Compile once, then archive the result. Every successful build is copied into data/builds/{build-id}/ so the Build Manager becomes the source of truth.
  10. +
+ +

Builder field families

+ + + + + + + + + + +
FamilyWhat it controlsWhere it shows up later
IdentityWorker name, build naming, archive labelsBuild Manager, agent cards, audit log, install funnel
TargetOS, architecture, output typeArtifact file names, launcher scripts, compatibility checks
ConnectivityServer URL and fallback URLsInstall instructions, QR codes, download links, runtime connection path
PackagingUniversal ZIP, spread-kit export, fusion packagingArtifact archive, download endpoints, size estimates
SigningThumbprint, timestamp URL, signing tool pathPost-build artifact treatment and estimate notes
BlueprintsSaved form presetsRe-forge flow, repeatable operator workflows
+ +

Blueprint discipline

+

+ 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

+ +

Forge simple mode — spread profile chips