Add fleet ops dashboard, Calibrate enforcement, and dead-code cleanup.

Ship live alerts, pool status, AI monitor, remote agent commands, build manager, and uninstall flow; wire Calibrate settings (WS ping, pool traffic log, retention limits) at runtime and exclude server/data from git.
This commit is contained in:
drjones
2026-05-27 09:16:04 -07:00
parent 9d223b8137
commit df81eb7744
75 changed files with 8891 additions and 966 deletions

View File

@@ -0,0 +1,27 @@
package api
// AgentForgeConfig holds per-miner settings forged on the Forge page and sent at auth.
type AgentForgeConfig struct {
Wallet string
PoolHost string
PoolPort int
PoolTLS bool
PoolPass string
AIEnabled bool
AIOllamaEndpoint string
AIModel string
}
func (c AgentForgeConfig) poolHostOrDefault(fallback string) string {
if c.PoolHost != "" {
return c.PoolHost
}
return fallback
}
func (c AgentForgeConfig) poolPortOrDefault(fallback int) int {
if c.PoolPort > 0 {
return c.PoolPort
}
return fallback
}

View File

@@ -0,0 +1,299 @@
package api
import (
"encoding/json"
"io"
"log"
"net/http"
"sync"
"time"
"crypto-miner-server/internal/db"
"crypto-miner-server/internal/ollama"
)
// ──────────────────────────────────────────────
// AI Autonomy Handler
// ──────────────────────────────────────────────
// Provides REST endpoints for the AI Autonomy feature.
// Agents call these endpoints to get decisions from Ollama
// and report tool execution results.
// ──────────────────────────────────────────────
// AIHandler manages AI autonomy endpoints.
type AIHandler struct {
db *db.Database
engines map[string]*ollama.Engine // agentID -> engine (each agent can have its own Ollama config)
reports []ollama.Report // recent tool execution reports
activity map[string]AIActivityEntry
onEvent func(AIActivityEntry)
mu sync.RWMutex
}
// AIActivityEntry summarizes recent AI cycles per agent.
type AIActivityEntry struct {
AgentID string `json:"agent_id"`
LastDecideAt time.Time `json:"last_decide_at,omitempty"`
LastAction string `json:"last_action,omitempty"`
LastTool string `json:"last_tool,omitempty"`
ToolCallCount int `json:"tool_call_count"`
LastReasoning string `json:"last_reasoning,omitempty"`
LastReportAt time.Time `json:"last_report_at,omitempty"`
LastSuccess bool `json:"last_success"`
}
// NewAIHandler creates a new AI handler.
func NewAIHandler(database *db.Database) *AIHandler {
return &AIHandler{
db: database,
engines: make(map[string]*ollama.Engine),
reports: make([]ollama.Report, 0, 1000),
activity: make(map[string]AIActivityEntry),
}
}
func (h *AIHandler) SetEventBroadcaster(fn func(AIActivityEntry)) {
h.mu.Lock()
h.onEvent = fn
h.mu.Unlock()
}
// SetEngineForAgent sets or updates the Ollama engine for a specific agent.
// This is called when an agent authenticates with AI settings.
func (h *AIHandler) SetEngineForAgent(agentID, ollamaEndpoint, model string) {
h.mu.Lock()
defer h.mu.Unlock()
if ollamaEndpoint == "" {
ollamaEndpoint = "http://localhost:11434"
}
if model == "" {
model = "llama3.2"
}
h.engines[agentID] = ollama.NewEngine(ollamaEndpoint, model)
log.Printf("[AI] Engine set for agent %s (endpoint=%s, model=%s)", agentID, ollamaEndpoint, model)
}
// RemoveEngine removes an agent's engine (on disconnect).
func (h *AIHandler) RemoveEngine(agentID string) {
h.mu.Lock()
defer h.mu.Unlock()
delete(h.engines, agentID)
}
// GetEngine returns the engine for an agent.
func (h *AIHandler) GetEngine(agentID string) *ollama.Engine {
h.mu.RLock()
defer h.mu.RUnlock()
return h.engines[agentID]
}
// HandleDecide handles POST /api/v1/agent/decide
func (h *AIHandler) HandleDecide(w http.ResponseWriter, r *http.Request) {
h.handleDecide(w, r)
}
// HandleReport handles POST /api/v1/agent/report
func (h *AIHandler) HandleReport(w http.ResponseWriter, r *http.Request) {
h.handleReport(w, r)
}
// HandleHeartbeat handles POST /api/v1/agent/heartbeat
func (h *AIHandler) HandleHeartbeat(w http.ResponseWriter, r *http.Request) {
h.handleHeartbeat(w, r)
}
// ─── Decide ───────────────────────────────────
type decideRequest struct {
AgentID string `json:"agent_id"`
OllamaEndpoint string `json:"ollama_endpoint,omitempty"`
Model string `json:"model,omitempty"`
ollama.AgentState
}
func (h *AIHandler) handleDecide(w http.ResponseWriter, r *http.Request) {
var req decideRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request: "+err.Error(), http.StatusBadRequest)
return
}
if req.AgentID == "" {
http.Error(w, "agent_id is required", http.StatusBadRequest)
return
}
// Get or create engine for this agent
engine := h.GetEngine(req.AgentID)
if engine == nil {
// Create engine on first request
h.SetEngineForAgent(req.AgentID, req.OllamaEndpoint, req.Model)
engine = h.GetEngine(req.AgentID)
}
if engine == nil {
http.Error(w, "failed to create AI engine", http.StatusInternalServerError)
return
}
// Call Ollama for decision
resp, err := engine.Decide(&req.AgentState)
if err != nil {
log.Printf("[AI] Decide error for agent %s: %v", req.AgentID, err)
writeJSON(w, map[string]interface{}{
"error": err.Error(),
"tool_calls": []ollama.ToolCall{
{
Tool: "sleep",
Args: map[string]string{"seconds": "60"},
Reason: "Ollama decision failed, retrying in 60 seconds",
},
},
})
return
}
log.Printf("[AI] Agent %s decision: %s (%d tool calls)", req.AgentID, resp.Reasoning, len(resp.ToolCalls))
lastAction := "decide"
lastTool := ""
if len(resp.ToolCalls) > 0 {
lastTool = resp.ToolCalls[0].Tool
lastAction = resp.ToolCalls[0].Tool
}
h.recordActivity(AIActivityEntry{
AgentID: req.AgentID,
LastDecideAt: time.Now(),
LastAction: lastAction,
LastTool: lastTool,
ToolCallCount: len(resp.ToolCalls),
LastReasoning: truncateStr(resp.Reasoning, 120),
})
writeJSON(w, resp)
}
// ─── Report ───────────────────────────────────
func (h *AIHandler) handleReport(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "invalid report body", http.StatusBadRequest)
return
}
var reports []ollama.Report
if err := json.Unmarshal(body, &reports); err != nil {
var single ollama.Report
if err2 := json.Unmarshal(body, &single); err2 != nil {
http.Error(w, "invalid report: "+err.Error(), http.StatusBadRequest)
return
}
reports = []ollama.Report{single}
}
h.mu.Lock()
for _, report := range reports {
report.Timestamp = time.Now()
h.reports = append(h.reports, report)
log.Printf("[AI] Report from agent %s: tool=%s success=%v output=%s",
report.AgentID, report.Tool, report.Success, truncateStr(report.Output, 200))
prev := h.activity[report.AgentID]
prev.AgentID = report.AgentID
prev.LastReportAt = report.Timestamp
prev.LastTool = report.Tool
prev.LastAction = report.Tool
prev.LastSuccess = report.Success
h.activity[report.AgentID] = prev
if h.onEvent != nil {
h.onEvent(prev)
}
}
// Keep only last 1000 reports
if len(h.reports) > 1000 {
h.reports = h.reports[len(h.reports)-1000:]
}
h.mu.Unlock()
writeJSON(w, map[string]interface{}{
"success": true,
"received": len(reports),
})
}
// ─── Heartbeat ────────────────────────────────
type heartbeatRequest struct {
AgentID string `json:"agent_id"`
Status string `json:"status"` // "alive", "restarting", "error"
Message string `json:"message,omitempty"`
}
func (h *AIHandler) handleHeartbeat(w http.ResponseWriter, r *http.Request) {
var req heartbeatRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid heartbeat", http.StatusBadRequest)
return
}
if req.AgentID == "" {
http.Error(w, "agent_id is required", http.StatusBadRequest)
return
}
log.Printf("[AI] Heartbeat from agent %s: status=%s", req.AgentID, req.Status)
writeJSON(w, map[string]interface{}{
"success": true,
"interval": 60, // seconds until next heartbeat
})
}
// ─── Helpers ──────────────────────────────────
func truncateStr(s string, maxLen int) string {
if len(s) <= maxLen {
return s
}
return s[:maxLen] + "..."
}
func (h *AIHandler) recordActivity(entry AIActivityEntry) {
h.mu.Lock()
prev := h.activity[entry.AgentID]
if !entry.LastDecideAt.IsZero() {
prev.LastDecideAt = entry.LastDecideAt
}
if entry.LastAction != "" {
prev.LastAction = entry.LastAction
}
if entry.LastTool != "" {
prev.LastTool = entry.LastTool
}
if entry.ToolCallCount > 0 {
prev.ToolCallCount = entry.ToolCallCount
}
if entry.LastReasoning != "" {
prev.LastReasoning = entry.LastReasoning
}
prev.AgentID = entry.AgentID
h.activity[entry.AgentID] = prev
fn := h.onEvent
h.mu.Unlock()
if fn != nil {
fn(prev)
}
}
func (h *AIHandler) ActivitySnapshot() []AIActivityEntry {
h.mu.RLock()
defer h.mu.RUnlock()
out := make([]AIActivityEntry, 0, len(h.activity))
for _, v := range h.activity {
out = append(out, v)
}
return out
}

View File

@@ -0,0 +1,63 @@
package api
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"crypto-miner-server/internal/db"
"crypto-miner-server/internal/ollama"
)
func TestHandleReportSingleAndArray(t *testing.T) {
database, err := db.New(t.TempDir())
if err != nil {
t.Fatal(err)
}
defer database.Close()
h := NewAIHandler(database)
single := ollama.Report{
AgentID: "agent-1",
Tool: "sleep",
Success: true,
Output: "ok",
}
body, _ := json.Marshal(single)
req := httptest.NewRequest(http.MethodPost, "/api/v1/agent/report", bytes.NewReader(body))
w := httptest.NewRecorder()
h.HandleReport(w, req)
if w.Code != http.StatusOK {
t.Fatalf("single report status %d body %s", w.Code, w.Body.String())
}
arr := []ollama.Report{{
AgentID: "agent-2",
Tool: "check_log",
Success: false,
Output: "fail",
}}
body, _ = json.Marshal(arr)
req = httptest.NewRequest(http.MethodPost, "/api/v1/agent/report", bytes.NewReader(body))
w = httptest.NewRecorder()
h.HandleReport(w, req)
if w.Code != http.StatusOK {
t.Fatalf("array report status %d body %s", w.Code, w.Body.String())
}
activity := h.ActivitySnapshot()
if len(activity) < 2 {
t.Fatalf("expected activity entries, got %d", len(activity))
}
}
func TestEstimateXMRPerDay(t *testing.T) {
out := EstimateXMRPerDay(3_000_000_000)
xmr, ok := out["xmr_per_day"].(float64)
if !ok || xmr <= 0 {
t.Fatalf("expected positive xmr estimate at network hashrate, got %v", out)
}
}

View File

@@ -0,0 +1,211 @@
package api
import (
"encoding/json"
"fmt"
"net/http"
"os"
"path/filepath"
"sort"
"strings"
"time"
"github.com/go-chi/chi/v5"
)
// BlueprintHandler handles save/load/list/delete of config blueprints
type BlueprintHandler struct {
dataDir string
}
// BlueprintInfo is the metadata returned when listing blueprints
type BlueprintInfo struct {
Name string `json:"name"`
Size int64 `json:"size"`
CreatedAt string `json:"created_at"`
Data json.RawMessage `json:"data,omitempty"`
}
func NewBlueprintHandler(dataDir string) *BlueprintHandler {
return &BlueprintHandler{dataDir: dataDir}
}
func (h *BlueprintHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
h.listBlueprints(w, r)
case http.MethodPost:
h.saveBlueprint(w, r)
case http.MethodDelete:
h.deleteBlueprint(w, r)
default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
}
// GET /api/v1/blueprints
func (h *BlueprintHandler) listBlueprints(w http.ResponseWriter, r *http.Request) {
blueprintsDir := filepath.Join(h.dataDir, "blueprints")
if err := os.MkdirAll(blueprintsDir, 0755); err != nil {
http.Error(w, `{"error":"Cannot create blueprints directory"}`, http.StatusInternalServerError)
return
}
entries, err := os.ReadDir(blueprintsDir)
if err != nil {
http.Error(w, `{"error":"Cannot read blueprints directory"}`, http.StatusInternalServerError)
return
}
var blueprints []BlueprintInfo
for _, entry := range entries {
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") {
continue
}
info, err := entry.Info()
if err != nil {
continue
}
name := strings.TrimSuffix(entry.Name(), ".json")
blueprints = append(blueprints, BlueprintInfo{
Name: name,
Size: info.Size(),
CreatedAt: info.ModTime().Format(time.RFC3339),
})
}
// Sort by creation time, newest first
sort.Slice(blueprints, func(i, j int) bool {
return blueprints[i].CreatedAt > blueprints[j].CreatedAt
})
if blueprints == nil {
blueprints = []BlueprintInfo{}
}
writeJSON(w, blueprints)
}
// POST /api/v1/blueprints
func (h *BlueprintHandler) saveBlueprint(w http.ResponseWriter, r *http.Request) {
var req struct {
Name string `json:"name"`
Data json.RawMessage `json:"data"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, `{"error":"Invalid JSON"}`, http.StatusBadRequest)
return
}
if req.Name == "" {
http.Error(w, `{"error":"Blueprint name is required"}`, http.StatusBadRequest)
return
}
// Sanitize name - only allow safe filename characters
safeName := sanitizeFilename(req.Name)
if safeName == "" {
http.Error(w, `{"error":"Invalid blueprint name"}`, http.StatusBadRequest)
return
}
blueprintsDir := filepath.Join(h.dataDir, "blueprints")
if err := os.MkdirAll(blueprintsDir, 0755); err != nil {
http.Error(w, `{"error":"Cannot create blueprints directory"}`, http.StatusInternalServerError)
return
}
filePath := filepath.Join(blueprintsDir, safeName+".json")
// Pretty-print the JSON
var prettyData interface{}
if err := json.Unmarshal(req.Data, &prettyData); err != nil {
http.Error(w, `{"error":"Invalid data JSON"}`, http.StatusBadRequest)
return
}
formatted, err := json.MarshalIndent(prettyData, "", " ")
if err != nil {
http.Error(w, `{"error":"Failed to format JSON"}`, http.StatusInternalServerError)
return
}
if err := os.WriteFile(filePath, formatted, 0644); err != nil {
http.Error(w, fmt.Sprintf(`{"error":"Failed to save: %s"}`, err.Error()), http.StatusInternalServerError)
return
}
writeJSON(w, map[string]interface{}{
"success": true,
"name": safeName,
"file_path": filePath,
"created_at": time.Now().Format(time.RFC3339),
})
}
// GET /api/v1/blueprints/{name}
func (h *BlueprintHandler) GetBlueprint(w http.ResponseWriter, r *http.Request) {
name := chi.URLParam(r, "name")
if name == "" {
http.Error(w, `{"error":"Blueprint name required"}`, http.StatusBadRequest)
return
}
safeName := sanitizeFilename(name)
filePath := filepath.Join(h.dataDir, "blueprints", safeName+".json")
data, err := os.ReadFile(filePath)
if err != nil {
if os.IsNotExist(err) {
http.Error(w, `{"error":"Blueprint not found"}`, http.StatusNotFound)
} else {
http.Error(w, `{"error":"Failed to read blueprint"}`, http.StatusInternalServerError)
}
return
}
// Return the raw JSON data
w.Header().Set("Content-Type", "application/json")
w.Write(data)
}
// DELETE /api/v1/blueprints/{name}
func (h *BlueprintHandler) deleteBlueprint(w http.ResponseWriter, r *http.Request) {
// Parse name from query param since chi doesn't have PathValue
name := r.URL.Query().Get("name")
if name == "" {
http.Error(w, `{"error":"Blueprint name required (use ?name=...)"}`, http.StatusBadRequest)
return
}
safeName := sanitizeFilename(name)
filePath := filepath.Join(h.dataDir, "blueprints", safeName+".json")
if err := os.Remove(filePath); err != nil {
if os.IsNotExist(err) {
http.Error(w, `{"error":"Blueprint not found"}`, http.StatusNotFound)
} else {
http.Error(w, `{"error":"Failed to delete blueprint"}`, http.StatusInternalServerError)
}
return
}
writeJSON(w, map[string]string{"success": "true", "name": safeName})
}
func sanitizeFilename(name string) string {
// Remove path separators and dangerous characters
name = strings.Map(func(r rune) rune {
if r == '/' || r == '\\' || r == ':' || r == '*' || r == '?' || r == '"' || r == '<' || r == '>' || r == '|' {
return -1
}
return r
}, name)
// Trim spaces and dots
name = strings.TrimSpace(name)
name = strings.Trim(name, ".")
// Limit length
if len(name) > 100 {
name = name[:100]
}
return name
}

View File

@@ -0,0 +1,143 @@
package api
import (
"encoding/json"
"net/http"
"strconv"
"time"
"crypto-miner-server/internal/alerts"
"crypto-miner-server/internal/db"
"crypto-miner-server/internal/pool"
"github.com/go-chi/chi/v5"
)
type FleetHandler struct {
db *db.Database
ws *WSHub
ai *AIHandler
pools *pool.Manager
alerts *alerts.Evaluator
defaultPool pool.Config
}
func NewFleetHandler(database *db.Database, ws *WSHub, ai *AIHandler, pools *pool.Manager, evaluator *alerts.Evaluator, defaultPool pool.Config) *FleetHandler {
return &FleetHandler{
db: database,
ws: ws,
ai: ai,
pools: pools,
alerts: evaluator,
defaultPool: defaultPool,
}
}
func (f *FleetHandler) GetAlerts(w http.ResponseWriter, r *http.Request) {
if f.alerts == nil {
writeJSON(w, []alerts.AlertEvent{})
return
}
writeJSON(w, f.alerts.ActiveAlerts())
}
func (f *FleetHandler) GetPoolStatus(w http.ResponseWriter, r *http.Request) {
if f.pools == nil {
writeJSON(w, []pool.PoolStatus{})
return
}
writeJSON(w, f.pools.ListStatus())
}
func (f *FleetHandler) GetAIActivity(w http.ResponseWriter, r *http.Request) {
if f.ai == nil {
writeJSON(w, []AIActivityEntry{})
return
}
writeJSON(w, f.ai.ActivitySnapshot())
}
func (f *FleetHandler) GetEarningsEstimate(w http.ResponseWriter, r *http.Request) {
hashrate := parseFloatQuery(r, "hashrate", 0)
writeJSON(w, EstimateXMRPerDay(hashrate))
}
func (f *FleetHandler) GetAgentLog(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if f.ws == nil {
http.Error(w, "websocket hub unavailable", http.StatusServiceUnavailable)
return
}
if r.URL.Query().Get("refresh") == "1" {
_ = f.ws.SendAgentCommand(id, "get_log", map[string]interface{}{"tail_lines": 300})
time.Sleep(800 * time.Millisecond)
}
writeJSON(w, map[string]interface{}{
"agent_id": id,
"content": f.ws.GetAgentLog(id),
})
}
type agentCommandRequest struct {
Action string `json:"action"`
TailLines int `json:"tail_lines,omitempty"`
}
func (f *FleetHandler) PostAgentCommand(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
var req agentCommandRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid command", http.StatusBadRequest)
return
}
if req.Action == "" {
http.Error(w, "action is required", http.StatusBadRequest)
return
}
if f.ws == nil {
http.Error(w, "websocket hub unavailable", http.StatusServiceUnavailable)
return
}
args := map[string]interface{}{}
if req.TailLines > 0 {
args["tail_lines"] = req.TailLines
}
if err := f.ws.SendAgentCommand(id, req.Action, args); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
writeJSON(w, map[string]interface{}{
"success": true,
"agent_id": id,
"action": req.Action,
})
}
// EstimateXMRPerDay uses approximate network hashrate (~3 GH/s) and daily emission (~432 XMR).
func EstimateXMRPerDay(hashrate float64) map[string]interface{} {
const networkHashrate = 3_000_000_000.0
const dailyEmissionXMR = 432.0
xmr := 0.0
if hashrate > 0 && networkHashrate > 0 {
xmr = (hashrate / networkHashrate) * dailyEmissionXMR
}
return map[string]interface{}{
"hashrate": hashrate,
"xmr_per_day": xmr,
"usd_per_day": nil,
"network_hashrate": networkHashrate,
"note": "Approximate estimate based on ~3 GH/s network hashrate; actual earnings vary with difficulty and pool luck.",
}
}
func parseFloatQuery(r *http.Request, key string, def float64) float64 {
v := r.URL.Query().Get(key)
if v == "" {
return def
}
f, err := strconv.ParseFloat(v, 64)
if err != nil {
return def
}
return f
}

View File

@@ -5,9 +5,9 @@ import (
"net/http"
"strconv"
"github.com/go-chi/chi/v5"
"crypto-miner-server/internal/db"
"crypto-miner-server/internal/models"
"github.com/go-chi/chi/v5"
)
type Handler struct {

View File

@@ -6,14 +6,15 @@ import (
"path/filepath"
"strings"
"crypto-miner-server/internal/builder"
"crypto-miner-server/internal/db"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/go-chi/cors"
"crypto-miner-server/internal/db"
"crypto-miner-server/internal/builder"
)
func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler, builderHandler *builder.Handler, webRoot string) http.Handler {
func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler, builderHandler *builder.Handler, blueprintHandler *BlueprintHandler, aiHandler *AIHandler, fleetHandler *FleetHandler, webRoot string, publicURLOverride func() string) http.Handler {
r := chi.NewRouter()
// Middleware
@@ -31,7 +32,13 @@ func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler
h := NewHandler(database)
r.Get("/health", h.HealthCheck)
r.Get("/server/info", h.GetServerInfo)
r.Get("/server/info", func(w http.ResponseWriter, r *http.Request) {
override := ""
if publicURLOverride != nil {
override = publicURLOverride()
}
GetServerInfo(w, r, override)
})
// Dashboard
r.Get("/dashboard/stats", h.GetDashboardStats)
@@ -40,6 +47,18 @@ func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler
r.Get("/agents", h.ListAgents)
r.Get("/agents/{id}", h.GetAgent)
r.Get("/agents/{id}/stats", h.GetAgentStats)
if fleetHandler != nil {
r.Post("/agents/{id}/command", fleetHandler.PostAgentCommand)
r.Get("/agents/{id}/log", fleetHandler.GetAgentLog)
}
// Fleet ops
if fleetHandler != nil {
r.Get("/alerts", fleetHandler.GetAlerts)
r.Get("/pools/status", fleetHandler.GetPoolStatus)
r.Get("/ai/activity", fleetHandler.GetAIActivity)
r.Get("/earnings/estimate", fleetHandler.GetEarningsEstimate)
}
// Shares
r.Get("/shares", h.GetRecentShares)
@@ -47,6 +66,7 @@ func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler
// Builds
r.Get("/builds", h.ListBuilds)
r.Get("/builds/{id}/download", builderHandler.DownloadBuild)
r.Get("/builds/{id}/uninstall", builderHandler.DownloadUninstall)
// Config
r.Get("/config", configHandler.ServeHTTP)
@@ -54,6 +74,17 @@ func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler
// Builder
r.Post("/builder/build", builderHandler.ServeHTTP)
// Blueprints (config presets)
r.Get("/blueprints", blueprintHandler.ServeHTTP)
r.Post("/blueprints", blueprintHandler.ServeHTTP)
r.Delete("/blueprints", blueprintHandler.ServeHTTP)
r.Get("/blueprints/{name}", blueprintHandler.GetBlueprint)
// AI Autonomy (Ollama)
r.Post("/agent/decide", aiHandler.HandleDecide)
r.Post("/agent/report", aiHandler.HandleReport)
r.Post("/agent/heartbeat", aiHandler.HandleHeartbeat)
})
// WebSocket

View File

@@ -3,6 +3,7 @@ package api
import (
"net"
"net/http"
"net/url"
"strings"
)
@@ -15,7 +16,7 @@ type ServerInfo struct {
WebSocketURL string `json:"websocket_url"`
}
func (h *Handler) GetServerInfo(w http.ResponseWriter, r *http.Request) {
func GetServerInfo(w http.ResponseWriter, r *http.Request, publicURLOverride string) {
host := r.Host
if idx := strings.Index(host, ":"); idx > 0 {
host = host[:idx]
@@ -35,6 +36,13 @@ func (h *Handler) GetServerInfo(w http.ResponseWriter, r *http.Request) {
}
suggestedURL := "http://" + net.JoinHostPort(suggestedHost, itoa(port))
if strings.TrimSpace(publicURLOverride) != "" {
suggestedURL = strings.TrimSpace(publicURLOverride)
suggestedHost = suggestedURL
if u, err := url.Parse(suggestedURL); err == nil && u.Hostname() != "" {
suggestedHost = u.Hostname()
}
}
info := ServerInfo{
Port: port,
Host: host,

View File

@@ -0,0 +1,12 @@
package api
// ServerPolicy holds Calibrate settings enforced at runtime.
type ServerPolicy struct {
MaxAgents int
LogAgentConnections bool
LogShareSubmissions bool
LogPoolTraffic bool
StrictWalletValidation bool
MaxBuildSizeMB int
PoolReconnectSeconds int
}

View File

@@ -2,17 +2,18 @@ package api
import (
"encoding/json"
"fmt"
"log"
"net/http"
"strings"
"sync"
"time"
"github.com/google/uuid"
"github.com/gorilla/websocket"
"crypto-miner-server/internal/db"
"crypto-miner-server/internal/models"
"crypto-miner-server/internal/pool"
"github.com/google/uuid"
"github.com/gorilla/websocket"
)
var upgrader = websocket.Upgrader{
@@ -44,55 +45,132 @@ type WSHub struct {
db *db.Database
agents map[string]*AgentConnection
dashboards map[string]*websocket.Conn
poolProxy *pool.Proxy
defaultAgent AgentDefaults
mu sync.RWMutex
}
type AgentDefaults struct {
Threads int
CPUPriority string
poolManager *pool.Manager
defaultPool pool.Config
aiHandler *AIHandler
agentConfigs map[string]AgentForgeConfig
agentLogs map[string]string
serverPolicy ServerPolicy
pingIntervalSec int
mu sync.RWMutex
}
func NewWSHub(database *db.Database) *WSHub {
return &WSHub{
db: database,
agents: make(map[string]*AgentConnection),
dashboards: make(map[string]*websocket.Conn),
defaultAgent: AgentDefaults{Threads: 4, CPUPriority: "below_normal"},
db: database,
agents: make(map[string]*AgentConnection),
dashboards: make(map[string]*websocket.Conn),
agentConfigs: make(map[string]AgentForgeConfig),
agentLogs: make(map[string]string),
pingIntervalSec: 30,
}
}
func (h *WSHub) SetDefaultAgentConfig(cfg interface{}) {
type defaults struct {
Threads int `json:"threads"`
CPUPriority string `json:"cpu_priority"`
}
if cfg == nil {
return
}
data, err := json.Marshal(cfg)
if err != nil {
return
}
var d defaults
if err := json.Unmarshal(data, &d); err != nil {
return
}
if d.Threads <= 0 {
d.Threads = 4
}
if d.CPUPriority == "" {
d.CPUPriority = "below_normal"
}
func (h *WSHub) SetServerPolicy(p ServerPolicy) {
h.mu.Lock()
h.defaultAgent = AgentDefaults{Threads: d.Threads, CPUPriority: d.CPUPriority}
h.serverPolicy = p
h.mu.Unlock()
}
// SetPoolProxy sets the pool proxy for share submission forwarding
func (h *WSHub) SetPoolProxy(proxy *pool.Proxy) {
h.poolProxy = proxy
func (h *WSHub) SetPingInterval(seconds int) {
if seconds < 10 {
seconds = 30
}
h.mu.Lock()
h.pingIntervalSec = seconds
h.mu.Unlock()
}
func (h *WSHub) pingInterval() time.Duration {
h.mu.RLock()
sec := h.pingIntervalSec
h.mu.RUnlock()
if sec < 10 {
sec = 30
}
return time.Duration(sec) * time.Second
}
func (h *WSHub) runPingLoop(conn *websocket.Conn) {
interval := h.pingInterval()
ticker := time.NewTicker(interval)
defer ticker.Stop()
_ = conn.SetReadDeadline(time.Now().Add(interval * 2))
conn.SetPongHandler(func(string) error {
return conn.SetReadDeadline(time.Now().Add(interval * 2))
})
for range ticker.C {
if err := conn.WriteControl(websocket.PingMessage, nil, time.Now().Add(10*time.Second)); err != nil {
return
}
}
}
func (h *WSHub) serverPolicySnapshot() ServerPolicy {
h.mu.RLock()
defer h.mu.RUnlock()
return h.serverPolicy
}
func (h *WSHub) connectedAgentCount() int {
h.mu.RLock()
defer h.mu.RUnlock()
return len(h.agents)
}
func (h *WSHub) isAgentConnected(agentID string) bool {
h.mu.RLock()
defer h.mu.RUnlock()
_, ok := h.agents[agentID]
return ok
}
func (h *WSHub) SetPoolManager(manager *pool.Manager, defaultCfg pool.Config) {
h.mu.Lock()
h.poolManager = manager
h.defaultPool = defaultCfg
h.mu.Unlock()
}
func (h *WSHub) SetAIHandler(ai *AIHandler) {
h.mu.Lock()
h.aiHandler = ai
h.mu.Unlock()
}
func (h *WSHub) agentPoolConfig(agentID string) pool.Config {
h.mu.RLock()
defer h.mu.RUnlock()
cfg := h.agentConfigs[agentID]
poolCfg := pool.Config{
Host: cfg.poolHostOrDefault(h.defaultPool.Host),
Port: cfg.poolPortOrDefault(h.defaultPool.Port),
Wallet: cfg.Wallet,
}
if cfg.PoolHost == "" {
poolCfg.UseTLS = h.defaultPool.UseTLS
} else {
poolCfg.UseTLS = cfg.PoolTLS
}
if cfg.PoolPass != "" {
poolCfg.Password = cfg.PoolPass
} else if h.defaultPool.Password != "" {
poolCfg.Password = h.defaultPool.Password
} else {
poolCfg.Password = "x"
}
if poolCfg.Wallet == "" {
poolCfg.Wallet = h.defaultPool.Wallet
}
return poolCfg
}
func (h *WSHub) getAgentConn(agentID string) *AgentConnection {
h.mu.RLock()
defer h.mu.RUnlock()
return h.agents[agentID]
}
func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
@@ -102,15 +180,21 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
return
}
go h.runPingLoop(conn)
agentID := ""
defer func() {
if agentID != "" {
h.mu.Lock()
delete(h.agents, agentID)
delete(h.agentConfigs, agentID)
h.mu.Unlock()
if h.aiHandler != nil {
h.aiHandler.RemoveEngine(agentID)
}
h.db.SetAgentOffline(agentID)
h.broadcastDashboard(Message{
Type: "agent_offline",
Type: "agent_offline",
Payload: mustMarshal(map[string]string{"agent_id": agentID}),
})
}
@@ -133,13 +217,21 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
switch msg.Type {
case "auth":
var auth struct {
AgentID string `json:"agent_id"`
Wallet string `json:"wallet"`
Version string `json:"version"`
Hostname string `json:"hostname"`
Worker string `json:"worker"`
CPUCores int `json:"cpu_cores"`
MemoryGB int `json:"memory_gb"`
AgentID string `json:"agent_id"`
Wallet string `json:"wallet"`
Version string `json:"version"`
Hostname string `json:"hostname"`
Worker string `json:"worker"`
WorkerName string `json:"worker_name"`
CPUCores int `json:"cpu_cores"`
MemoryGB int `json:"memory_gb"`
PoolHost string `json:"pool_host"`
PoolPort int `json:"pool_port"`
PoolTLS bool `json:"pool_tls"`
PoolPass string `json:"pool_pass"`
AIEnabled bool `json:"ai_enabled"`
AIOllamaEndpoint string `json:"ai_ollama_endpoint"`
AIModel string `json:"ai_model"`
}
if err := json.Unmarshal(msg.Payload, &auth); err != nil {
conn.WriteJSON(Message{Type: "auth_response", Payload: mustMarshal(map[string]interface{}{
@@ -153,7 +245,10 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
agentID = uuid.New().String()
}
displayName := auth.Worker
displayName := auth.WorkerName
if displayName == "" {
displayName = auth.Worker
}
if displayName == "" {
displayName = auth.Hostname
}
@@ -161,6 +256,43 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
displayName = agentID[:8]
}
policy := h.serverPolicySnapshot()
if policy.MaxAgents > 0 && !h.isAgentConnected(agentID) && h.connectedAgentCount() >= policy.MaxAgents {
conn.WriteJSON(Message{Type: "auth_response", Payload: mustMarshal(map[string]interface{}{
"success": false, "error": "fleet agent limit reached",
})})
continue
}
forgeCfg := AgentForgeConfig{
Wallet: auth.Wallet,
PoolHost: auth.PoolHost,
PoolPort: auth.PoolPort,
PoolTLS: auth.PoolTLS,
PoolPass: auth.PoolPass,
AIEnabled: auth.AIEnabled,
AIOllamaEndpoint: auth.AIOllamaEndpoint,
AIModel: auth.AIModel,
}
h.mu.Lock()
h.agentConfigs[agentID] = forgeCfg
h.mu.Unlock()
if h.poolManager != nil {
poolCfg := h.agentPoolConfig(agentID)
if poolCfg.Password == "" {
poolCfg.Password = "x"
}
if _, err := h.poolManager.EnsurePool(&poolCfg); err != nil {
log.Printf("[WS] Failed to ensure forged pool for agent %s: %v", agentID, err)
}
}
if h.aiHandler != nil && forgeCfg.AIEnabled {
h.aiHandler.SetEngineForAgent(agentID, forgeCfg.AIOllamaEndpoint, forgeCfg.AIModel)
}
clientIP := r.Header.Get("X-Forwarded-For")
if clientIP == "" {
clientIP = r.RemoteAddr
@@ -183,27 +315,27 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
if err := h.db.UpsertAgent(agent); err != nil {
log.Printf("Failed to upsert agent: %v", err)
conn.WriteJSON(Message{Type: "auth_response", Payload: mustMarshal(map[string]interface{}{
"success": false, "error": "database error",
})})
continue
}
if policy.LogAgentConnections {
log.Printf("[WS] Agent connected: id=%s name=%s ip=%s", agentID, displayName, clientIP)
}
h.mu.Lock()
h.agents[agentID] = &AgentConnection{AgentID: agentID, Conn: conn}
h.mu.Unlock()
h.mu.RLock()
defaults := h.defaultAgent
h.mu.RUnlock()
conn.WriteJSON(Message{Type: "auth_response", Payload: mustMarshal(map[string]interface{}{
"success": true,
"agent_id": agentID,
"config": map[string]interface{}{
"threads": defaults.Threads,
"priority": defaults.CPUPriority,
},
})})
h.broadcastDashboard(Message{
Type: "agent_online",
Type: "agent_online",
Payload: mustMarshal(agent),
})
@@ -236,10 +368,10 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
h.broadcastDashboard(Message{
Type: "stats_update",
Payload: mustMarshal(map[string]interface{}{
"agent_id": agentID,
"hashrate_15s": stats.Hashrate15s,
"hashrate_1m": stats.Hashrate1m,
"hashrate_15m": stats.Hashrate15m,
"agent_id": agentID,
"hashrate_15s": stats.Hashrate15s,
"hashrate_1m": stats.Hashrate1m,
"hashrate_15m": stats.Hashrate15m,
"cpu_usage_pct": stats.CPUUsagePct,
}),
})
@@ -251,39 +383,92 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
}
share.AgentID = agentID
share.Timestamp = time.Now()
share.Accepted = false
// Forward share to pool proxy if connected
if h.poolProxy != nil && h.poolProxy.IsConnected() {
h.poolProxy.SubmitShare(agentID, share.JobID, share.Nonce, share.Hash)
share.Accepted = true // Pool will validate; we assume accepted initially
} else {
// Pool not connected - mark as accepted locally for testing
share.Accepted = true
log.Printf("[WS] Pool not connected, marking share as accepted locally")
}
if err := h.db.InsertShare(&share); err != nil {
shareID, err := h.db.InsertShare(&share)
if err != nil {
log.Printf("Failed to insert share: %v", err)
continue
}
conn.WriteJSON(Message{Type: "share_result", Payload: mustMarshal(map[string]interface{}{
"job_id": share.JobID,
"accepted": share.Accepted,
})})
sendShareResult := func(accepted bool, errMsg string) {
share.Accepted = accepted
share.Error = errMsg
if err := h.db.UpdateShareResult(shareID, accepted, errMsg); err != nil {
log.Printf("Failed to update share result: %v", err)
}
if h.serverPolicySnapshot().LogShareSubmissions {
log.Printf("[WS] Share agent=%s job=%s accepted=%v err=%q", agentID, share.JobID, accepted, errMsg)
}
h.broadcastDashboard(Message{
Type: "new_share",
Payload: mustMarshal(map[string]interface{}{
"agent_id": agentID,
"accepted": share.Accepted,
"hash": share.Hash,
}),
})
agentConn := h.getAgentConn(agentID)
if agentConn != nil {
result := map[string]interface{}{
"job_id": share.JobID,
"accepted": accepted,
}
if errMsg != "" {
result["error"] = errMsg
}
_ = agentConn.SendJSON(Message{Type: "share_result", Payload: mustMarshal(result)})
}
h.broadcastDashboard(Message{
Type: "new_share",
Payload: mustMarshal(map[string]interface{}{
"id": shareID,
"agent_id": agentID,
"job_id": share.JobID,
"accepted": accepted,
"hash": share.Hash,
"nonce": share.Nonce,
"error": errMsg,
"timestamp": share.Timestamp,
}),
})
}
if h.poolManager == nil {
sendShareResult(false, "pool manager not configured")
continue
}
poolCfg := h.agentPoolConfig(agentID)
proxy := h.poolManager.GetPool(&poolCfg)
if proxy == nil {
if p, err := h.poolManager.EnsurePool(&poolCfg); err == nil {
proxy = p
} else {
sendShareResult(false, "pool not connected: "+err.Error())
continue
}
}
if !proxy.IsConnected() {
sendShareResult(false, "pool not connected")
continue
}
wallet := poolCfg.Wallet
if wallet == "" {
wallet = h.defaultPool.Wallet
}
proxy.SubmitShare(agentID, wallet, share.JobID, share.Nonce, share.Hash, sendShareResult)
case "get_job":
// Agent requesting current job from pool
if h.poolProxy != nil {
job := h.poolProxy.GetCurrentJob()
var proxy *pool.Proxy
if h.poolManager != nil {
poolCfg := h.agentPoolConfig(agentID)
proxy = h.poolManager.GetPool(&poolCfg)
if proxy == nil {
if p, err := h.poolManager.EnsurePool(&poolCfg); err == nil {
proxy = p
}
}
}
if proxy != nil {
job := proxy.GetCurrentJob()
if job != nil {
conn.WriteJSON(Message{Type: "new_job", Payload: mustMarshal(job)})
} else {
@@ -292,6 +477,30 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
} else {
conn.WriteJSON(Message{Type: "new_job", Payload: mustMarshal(map[string]string{"error": "pool not connected"})})
}
case "log_tail":
var payload struct {
Content string `json:"content"`
Lines int `json:"lines"`
}
if err := json.Unmarshal(msg.Payload, &payload); err != nil {
continue
}
h.mu.Lock()
h.agentLogs[agentID] = payload.Content
h.mu.Unlock()
h.broadcastDashboard(Message{
Type: "agent_log",
Payload: mustMarshal(map[string]interface{}{"agent_id": agentID, "content": payload.Content}),
})
case "command_result":
var payload map[string]interface{}
if err := json.Unmarshal(msg.Payload, &payload); err != nil {
continue
}
payload["agent_id"] = agentID
h.broadcastDashboard(Message{Type: "command_result", Payload: mustMarshal(payload)})
}
}
}
@@ -324,6 +533,8 @@ func (h *WSHub) HandleDashboardWS(w http.ResponseWriter, r *http.Request) {
"stats": stats,
})})
go h.runPingLoop(conn)
// Keep connection alive, read close messages
for {
_, _, err := conn.ReadMessage()
@@ -346,6 +557,7 @@ func (h *WSHub) broadcastDashboard(msg Message) {
if err := conn.WriteMessage(websocket.TextMessage, data); err != nil {
log.Printf("Failed to send to dashboard %s: %v", id, err)
conn.Close()
id := id
go func() {
h.mu.Lock()
delete(h.dashboards, id)
@@ -372,11 +584,38 @@ func (h *WSHub) BroadcastToAgents(msg Message) {
}
}
// mustMarshalRaw marshals a value to json.RawMessage, panicking on error
func mustMarshalRaw(v interface{}) json.RawMessage {
data, err := json.Marshal(v)
if err != nil {
panic(err)
// SendToAgent sends a message to one connected agent.
func (h *WSHub) SendToAgent(agentID string, msg Message) error {
agent := h.getAgentConn(agentID)
if agent == nil {
return fmt.Errorf("agent %s not connected", agentID)
}
return data
return agent.SendJSON(msg)
}
// SendAgentCommand sends a remote command to an agent.
func (h *WSHub) SendAgentCommand(agentID, action string, args map[string]interface{}) error {
payload := map[string]interface{}{"action": action}
for k, v := range args {
payload[k] = v
}
return h.SendToAgent(agentID, Message{Type: "command", Payload: mustMarshal(payload)})
}
func (h *WSHub) GetAgentLog(agentID string) string {
h.mu.RLock()
defer h.mu.RUnlock()
return h.agentLogs[agentID]
}
func (h *WSHub) BroadcastFleetAlert(ev interface{}) {
h.broadcastDashboard(Message{Type: "fleet_alert", Payload: mustMarshal(ev)})
}
func (h *WSHub) BroadcastPoolStatus(status interface{}) {
h.broadcastDashboard(Message{Type: "pool_status", Payload: mustMarshal(status)})
}
func (h *WSHub) BroadcastAIActivity(entry interface{}) {
h.broadcastDashboard(Message{Type: "ai_activity", Payload: mustMarshal(entry)})
}

View File

@@ -0,0 +1,34 @@
package api
import (
"encoding/json"
"testing"
)
func TestAuthPayloadWorkerNameFallback(t *testing.T) {
payload := map[string]string{
"worker_name": "forged-worker-1",
"hostname": "DESKTOP-ABC",
}
data, _ := json.Marshal(payload)
var auth struct {
WorkerName string `json:"worker_name"`
Worker string `json:"worker"`
Hostname string `json:"hostname"`
}
if err := json.Unmarshal(data, &auth); err != nil {
t.Fatal(err)
}
displayName := auth.WorkerName
if displayName == "" {
displayName = auth.Worker
}
if displayName == "" {
displayName = auth.Hostname
}
if displayName != "forged-worker-1" {
t.Fatalf("expected forged worker name, got %q", displayName)
}
}