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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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