Mining/pkg/mining/service.go
Claude 273169264e
ax(mining): rename db-prefixed fields to full databaseEnabled/databaseRetention
AX Principle 1 — predictable names over short names. The Manager struct
fields dbEnabled and dbRetention used the db abbreviation which requires
context to decode. Renamed to databaseEnabled and databaseRetention across
manager.go and the single service.go callsite. No behaviour change.

Co-Authored-By: Charon <charon@lethean.io>
2026-04-02 07:26:59 +01:00

1415 lines
44 KiB
Go

package mining
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"net"
"net/http"
"net/url"
"os"
"path/filepath"
"runtime"
"strings"
"sync/atomic"
"time"
"github.com/Masterminds/semver/v3"
"forge.lthn.ai/Snider/Mining/docs"
"forge.lthn.ai/Snider/Mining/pkg/logging"
"github.com/adrg/xdg"
ginmcp "github.com/ckanthony/gin-mcp"
"github.com/gin-contrib/cors"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
"github.com/shirou/gopsutil/v4/mem"
"github.com/swaggo/swag"
swaggerFiles "github.com/swaggo/files"
ginSwagger "github.com/swaggo/gin-swagger"
)
// Service encapsulates the gin-gonic router and the mining manager.
type Service struct {
Manager ManagerInterface
ProfileManager *ProfileManager
NodeService *NodeService
EventHub *EventHub
Router *gin.Engine
Server *http.Server
DisplayAddr string
SwaggerInstanceName string
APIBasePath string
SwaggerUIPath string
rateLimiter *RateLimiter
auth *DigestAuth
mcpServer *ginmcp.GinMCP
}
// APIError represents a structured error response for the API
type APIError struct {
Code string `json:"code"` // Machine-readable error code
Message string `json:"message"` // Human-readable message
Details string `json:"details,omitempty"` // Technical details (for debugging)
Suggestion string `json:"suggestion,omitempty"` // What to do next
Retryable bool `json:"retryable"` // Can the client retry?
}
// debugErrorsEnabled controls whether internal error details are exposed in API responses.
// In production, this should be false to prevent information disclosure.
var debugErrorsEnabled = os.Getenv("DEBUG_ERRORS") == "true" || os.Getenv("GIN_MODE") != "release"
// sanitizeErrorDetails filters potentially sensitive information from error details.
// In production mode (debugErrorsEnabled=false), returns empty string.
func sanitizeErrorDetails(details string) string {
if debugErrorsEnabled {
return details
}
// In production, don't expose internal error details
return ""
}
// Error codes are defined in errors.go
// respondWithError sends a structured error response
func respondWithError(c *gin.Context, status int, code string, message string, details string) {
apiErr := APIError{
Code: code,
Message: message,
Details: sanitizeErrorDetails(details),
Retryable: isRetryableError(status),
}
// Add suggestions based on error code
switch code {
case ErrCodeMinerNotFound:
apiErr.Suggestion = "Check the miner name or install the miner first"
case ErrCodeProfileNotFound:
apiErr.Suggestion = "Create a new profile or check the profile ID"
case ErrCodeInstallFailed:
apiErr.Suggestion = "Check your internet connection and try again"
case ErrCodeStartFailed:
apiErr.Suggestion = "Check the miner configuration and logs"
case ErrCodeInvalidInput:
apiErr.Suggestion = "Verify the request body matches the expected format"
case ErrCodeServiceUnavailable:
apiErr.Suggestion = "The service is temporarily unavailable, try again later"
apiErr.Retryable = true
}
c.JSON(status, apiErr)
}
// respondWithMiningError sends a structured error response from a MiningError.
// This allows using pre-built error constructors from errors.go.
func respondWithMiningError(c *gin.Context, err *MiningError) {
details := ""
if err.Cause != nil {
details = err.Cause.Error()
}
if err.Details != "" {
if details != "" {
details += "; "
}
details += err.Details
}
apiErr := APIError{
Code: err.Code,
Message: err.Message,
Details: sanitizeErrorDetails(details),
Suggestion: err.Suggestion,
Retryable: err.Retryable,
}
c.JSON(err.StatusCode(), apiErr)
}
// isRetryableError determines if an error status code is retryable
func isRetryableError(status int) bool {
return status == http.StatusServiceUnavailable ||
status == http.StatusTooManyRequests ||
status == http.StatusGatewayTimeout
}
// securityHeadersMiddleware adds security headers to all responses.
// This helps protect against common web vulnerabilities.
func securityHeadersMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
// Prevent MIME type sniffing
c.Header("X-Content-Type-Options", "nosniff")
// Prevent clickjacking
c.Header("X-Frame-Options", "DENY")
// Enable XSS filter in older browsers
c.Header("X-XSS-Protection", "1; mode=block")
// Restrict referrer information
c.Header("Referrer-Policy", "strict-origin-when-cross-origin")
// Content Security Policy for API responses
c.Header("Content-Security-Policy", "default-src 'none'; frame-ancestors 'none'")
c.Next()
}
}
// contentTypeValidationMiddleware ensures POST/PUT requests have proper Content-Type.
func contentTypeValidationMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
method := c.Request.Method
if method != http.MethodPost && method != http.MethodPut && method != http.MethodPatch {
c.Next()
return
}
// Skip if no body expected
if c.Request.ContentLength == 0 {
c.Next()
return
}
contentType := c.GetHeader("Content-Type")
// Allow JSON and form data
if strings.HasPrefix(contentType, "application/json") ||
strings.HasPrefix(contentType, "application/x-www-form-urlencoded") ||
strings.HasPrefix(contentType, "multipart/form-data") {
c.Next()
return
}
respondWithError(c, http.StatusUnsupportedMediaType, ErrCodeInvalidInput,
"Unsupported Content-Type",
"Use application/json for API requests")
c.Abort()
}
}
// requestIDMiddleware adds a unique request ID to each request for tracing
func requestIDMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
// Use existing request ID from header if provided, otherwise generate one
requestID := c.GetHeader("X-Request-ID")
if requestID == "" {
requestID = generateRequestID()
}
// Set in context for use by handlers
c.Set("requestID", requestID)
// Set in response header
c.Header("X-Request-ID", requestID)
c.Next()
}
}
// generateRequestID creates a unique request ID using timestamp and random bytes
func generateRequestID() string {
b := make([]byte, 8)
_, _ = base64.StdEncoding.Decode(b, []byte(fmt.Sprintf("%d", time.Now().UnixNano())))
return fmt.Sprintf("%d-%x", time.Now().UnixMilli(), b[:4])
}
// getRequestID extracts the request ID from gin context
func getRequestID(c *gin.Context) string {
if id, exists := c.Get("requestID"); exists {
if s, ok := id.(string); ok {
return s
}
}
return ""
}
// logWithRequestID logs a message with request ID correlation
func logWithRequestID(c *gin.Context, level string, message string, fields logging.Fields) {
if fields == nil {
fields = logging.Fields{}
}
if reqID := getRequestID(c); reqID != "" {
fields["request_id"] = reqID
}
switch level {
case "error":
logging.Error(message, fields)
case "warn":
logging.Warn(message, fields)
case "info":
logging.Info(message, fields)
default:
logging.Debug(message, fields)
}
}
// csrfMiddleware protects against CSRF attacks for browser-based requests.
// For state-changing methods (POST, PUT, DELETE), it requires one of:
// - Authorization header (API clients)
// - X-Requested-With header (AJAX clients)
// - Origin header matching allowed origins (already handled by CORS)
// GET requests are always allowed as they should be idempotent.
func csrfMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
// Only check state-changing methods
method := c.Request.Method
if method == http.MethodGet || method == http.MethodHead || method == http.MethodOptions {
c.Next()
return
}
// Allow if Authorization header present (API client)
if c.GetHeader("Authorization") != "" {
c.Next()
return
}
// Allow if X-Requested-With header present (AJAX/XHR request)
if c.GetHeader("X-Requested-With") != "" {
c.Next()
return
}
// Allow if Content-Type is application/json (not sent by HTML forms)
contentType := c.GetHeader("Content-Type")
if strings.HasPrefix(contentType, "application/json") {
c.Next()
return
}
// Reject the request as potential CSRF
respondWithError(c, http.StatusForbidden, "CSRF_PROTECTION",
"Request blocked by CSRF protection",
"Include X-Requested-With header or use application/json content type")
c.Abort()
}
}
// DefaultRequestTimeout is the default timeout for API requests.
const DefaultRequestTimeout = 30 * time.Second
// Cache-Control header constants
const (
CacheNoStore = "no-store"
CacheNoCache = "no-cache"
CachePublic1Min = "public, max-age=60"
CachePublic5Min = "public, max-age=300"
)
// cacheMiddleware adds Cache-Control headers based on the endpoint.
func cacheMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
// Only cache GET requests
if c.Request.Method != http.MethodGet {
c.Header("Cache-Control", CacheNoStore)
c.Next()
return
}
path := c.Request.URL.Path
// Static-ish resources that can be cached briefly
switch {
case strings.HasSuffix(path, "/available"):
// Available miners list - can be cached for 5 minutes
c.Header("Cache-Control", CachePublic5Min)
case strings.HasSuffix(path, "/info"):
// System info - can be cached for 1 minute
c.Header("Cache-Control", CachePublic1Min)
case strings.Contains(path, "/swagger"):
// Swagger docs - can be cached
c.Header("Cache-Control", CachePublic5Min)
default:
// Dynamic data (stats, miners, profiles) - don't cache
c.Header("Cache-Control", CacheNoCache)
}
c.Next()
}
}
// requestTimeoutMiddleware adds a timeout to request handling.
// This prevents slow requests from consuming resources indefinitely.
func requestTimeoutMiddleware(timeout time.Duration) gin.HandlerFunc {
return func(c *gin.Context) {
// Skip timeout for WebSocket upgrades and streaming endpoints
if c.GetHeader("Upgrade") == "websocket" {
c.Next()
return
}
if strings.HasSuffix(c.Request.URL.Path, "/events") {
c.Next()
return
}
// Create context with timeout
ctx, cancel := context.WithTimeout(c.Request.Context(), timeout)
defer cancel()
// Replace request context
c.Request = c.Request.WithContext(ctx)
// Use atomic flag to prevent race condition between handler and timeout response
// Only one of them should write to the response
var responded int32
// Channel to signal completion
done := make(chan struct{})
go func() {
c.Next()
// Mark that the handler has completed (and likely written a response)
atomic.StoreInt32(&responded, 1)
close(done)
}()
select {
case <-done:
// Request completed normally
case <-ctx.Done():
// Timeout occurred - only respond if handler hasn't already
if atomic.CompareAndSwapInt32(&responded, 0, 1) {
c.Abort()
respondWithError(c, http.StatusGatewayTimeout, ErrCodeTimeout,
"Request timed out", fmt.Sprintf("Request exceeded %s timeout", timeout))
}
}
}
}
// WebSocket upgrader for the events endpoint
var wsUpgrader = websocket.Upgrader{
ReadBufferSize: 1024,
WriteBufferSize: 1024,
CheckOrigin: func(r *http.Request) bool {
// Allow connections from localhost origins only
origin := r.Header.Get("Origin")
if origin == "" {
return true // No origin header (non-browser clients)
}
// Parse the origin URL properly to prevent bypass attacks
u, err := url.Parse(origin)
if err != nil {
return false
}
host := u.Hostname()
// Only allow exact localhost matches
return host == "localhost" || host == "127.0.0.1" || host == "::1" ||
host == "wails.localhost"
},
}
// NewService creates a new mining service
func NewService(manager ManagerInterface, listenAddr string, displayAddr string, swaggerNamespace string) (*Service, error) {
apiBasePath := "/" + strings.Trim(swaggerNamespace, "/")
swaggerUIPath := apiBasePath + "/swagger"
docs.SwaggerInfo.Title = "Mining Module API"
docs.SwaggerInfo.Version = "1.0"
docs.SwaggerInfo.Host = displayAddr
docs.SwaggerInfo.BasePath = apiBasePath
instanceName := "swagger_" + strings.ReplaceAll(strings.Trim(swaggerNamespace, "/"), "/", "_")
swag.Register(instanceName, docs.SwaggerInfo)
profileManager, err := NewProfileManager()
if err != nil {
logging.Warn("failed to initialize profile manager", logging.Fields{"error": err})
// Continue without profile manager - profile features will be degraded
// Create a minimal in-memory profile manager as fallback
profileManager = &ProfileManager{
profiles: make(map[string]*MiningProfile),
}
}
// Initialize node service (optional - only fails if XDG paths are broken)
nodeService, err := NewNodeService()
if err != nil {
logging.Warn("failed to initialize node service", logging.Fields{"error": err})
// Continue without node service - P2P features will be unavailable
}
// Initialize event hub for WebSocket real-time updates
eventHub := NewEventHub()
go eventHub.Run()
// Wire up event hub to manager for miner events
if mgr, ok := manager.(*Manager); ok {
mgr.SetEventHub(eventHub)
}
// Set up state provider for WebSocket state sync on reconnect
eventHub.SetStateProvider(func() interface{} {
miners := manager.ListMiners()
if len(miners) == 0 {
return nil
}
// Return current state of all miners
state := make([]map[string]interface{}, 0, len(miners))
for _, miner := range miners {
stats, _ := miner.GetStats(context.Background())
minerState := map[string]interface{}{
"name": miner.GetName(),
"status": "running",
}
if stats != nil {
minerState["hashrate"] = stats.Hashrate
minerState["shares"] = stats.Shares
minerState["rejected"] = stats.Rejected
minerState["uptime"] = stats.Uptime
}
state = append(state, minerState)
}
return map[string]interface{}{
"miners": state,
}
})
// Initialize authentication from environment
authConfig := AuthConfigFromEnv()
var auth *DigestAuth
if authConfig.Enabled {
auth = NewDigestAuth(authConfig)
logging.Info("API authentication enabled", logging.Fields{"realm": authConfig.Realm})
}
return &Service{
Manager: manager,
ProfileManager: profileManager,
NodeService: nodeService,
EventHub: eventHub,
Server: &http.Server{
Addr: listenAddr,
ReadTimeout: 30 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 60 * time.Second,
ReadHeaderTimeout: 10 * time.Second,
},
DisplayAddr: displayAddr,
SwaggerInstanceName: instanceName,
APIBasePath: apiBasePath,
SwaggerUIPath: swaggerUIPath,
auth: auth,
}, nil
}
// InitRouter initializes the Gin router and sets up all routes without starting an HTTP server.
// Use this when embedding the mining service in another application (e.g., Wails).
// After calling InitRouter, you can use the Router field directly as an http.Handler.
func (s *Service) InitRouter() {
s.Router = gin.Default()
// Extract port safely from server address for CORS
serverPort := "9090" // default fallback
if s.Server.Addr != "" {
if _, port, err := net.SplitHostPort(s.Server.Addr); err == nil && port != "" {
serverPort = port
}
}
// Configure CORS to only allow local origins
corsConfig := cors.Config{
AllowOrigins: []string{
"http://localhost:4200", // Angular dev server
"http://127.0.0.1:4200",
"http://localhost:9090", // Default API port
"http://127.0.0.1:9090",
"http://localhost:" + serverPort,
"http://127.0.0.1:" + serverPort,
"http://wails.localhost", // Wails desktop app (uses localhost origin)
},
AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
AllowHeaders: []string{"Origin", "Content-Type", "Accept", "Authorization", "X-Request-ID", "X-Requested-With"},
ExposeHeaders: []string{"Content-Length", "X-Request-ID"},
AllowCredentials: true,
MaxAge: 12 * time.Hour,
}
s.Router.Use(cors.New(corsConfig))
// Add security headers (SEC-LOW-4)
s.Router.Use(securityHeadersMiddleware())
// Add Content-Type validation for POST/PUT (API-MED-8)
s.Router.Use(contentTypeValidationMiddleware())
// Add request body size limit middleware (1MB max)
s.Router.Use(func(c *gin.Context) {
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 1<<20) // 1MB
c.Next()
})
// Add CSRF protection for browser requests (SEC-MED-3)
// Requires X-Requested-With or Authorization header for state-changing methods
s.Router.Use(csrfMiddleware())
// Add request timeout middleware (RESIL-MED-8)
s.Router.Use(requestTimeoutMiddleware(DefaultRequestTimeout))
// Add cache headers middleware (API-MED-7)
s.Router.Use(cacheMiddleware())
// Add X-Request-ID middleware for request tracing
s.Router.Use(requestIDMiddleware())
// Add rate limiting (10 requests/second with burst of 20)
s.rateLimiter = NewRateLimiter(10, 20)
s.Router.Use(s.rateLimiter.Middleware())
s.SetupRoutes()
}
// Stop gracefully stops the service and cleans up resources
func (s *Service) Stop() {
if s.rateLimiter != nil {
s.rateLimiter.Stop()
}
if s.EventHub != nil {
s.EventHub.Stop()
}
if s.auth != nil {
s.auth.Stop()
}
if s.NodeService != nil {
if err := s.NodeService.StopTransport(); err != nil {
logging.Warn("failed to stop node service transport", logging.Fields{"error": err})
}
}
}
// ServiceStartup initializes the router and starts the HTTP server.
// For embedding without a standalone server, use InitRouter() instead.
func (s *Service) ServiceStartup(ctx context.Context) error {
s.InitRouter()
s.Server.Handler = s.Router
// Channel to capture server startup errors
errChan := make(chan error, 1)
go func() {
if err := s.Server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
logging.Error("server error", logging.Fields{"addr": s.Server.Addr, "error": err})
errChan <- err
}
close(errChan) // Prevent goroutine leak
}()
go func() {
<-ctx.Done()
s.Stop() // Clean up service resources (auth, event hub, node service)
s.Manager.Stop()
ctxShutdown, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := s.Server.Shutdown(ctxShutdown); err != nil {
logging.Error("server shutdown error", logging.Fields{"error": err})
}
}()
// Verify server is actually listening by attempting to connect
maxRetries := 50 // 50 * 100ms = 5 seconds max
for i := 0; i < maxRetries; i++ {
select {
case err := <-errChan:
if err != nil {
return fmt.Errorf("failed to start server: %w", err)
}
return nil // Channel closed without error means server shut down
default:
// Try to connect to verify server is listening
conn, err := net.DialTimeout("tcp", s.Server.Addr, 50*time.Millisecond)
if err == nil {
conn.Close()
return nil // Server is ready
}
time.Sleep(100 * time.Millisecond)
}
}
return fmt.Errorf("server failed to start listening on %s within timeout", s.Server.Addr)
}
// SetupRoutes configures all API routes on the Gin router.
// This is called automatically by ServiceStartup, but can also be called
// manually after InitRouter for embedding in other applications.
func (s *Service) SetupRoutes() {
apiGroup := s.Router.Group(s.APIBasePath)
// Health endpoints (no auth required for orchestration/monitoring)
apiGroup.GET("/health", s.handleHealth)
apiGroup.GET("/ready", s.handleReady)
// Apply authentication middleware if enabled
if s.auth != nil {
apiGroup.Use(s.auth.Middleware())
}
{
apiGroup.GET("/info", s.handleGetInfo)
apiGroup.GET("/metrics", s.handleMetrics)
apiGroup.POST("/doctor", s.handleDoctor)
apiGroup.POST("/update", s.handleUpdateCheck)
minersGroup := apiGroup.Group("/miners")
{
minersGroup.GET("", s.handleListMiners)
minersGroup.GET("/available", s.handleListAvailableMiners)
minersGroup.POST("/:miner_name/install", s.handleInstallMiner)
minersGroup.DELETE("/:miner_name/uninstall", s.handleUninstallMiner)
minersGroup.DELETE("/:miner_name", s.handleStopMiner)
minersGroup.GET("/:miner_name/stats", s.handleGetMinerStats)
minersGroup.GET("/:miner_name/hashrate-history", s.handleGetMinerHashrateHistory)
minersGroup.GET("/:miner_name/logs", s.handleGetMinerLogs)
minersGroup.POST("/:miner_name/stdin", s.handleMinerStdin)
}
// Historical data endpoints (database-backed)
historyGroup := apiGroup.Group("/history")
{
historyGroup.GET("/status", s.handleHistoryStatus)
historyGroup.GET("/miners", s.handleAllMinersHistoricalStats)
historyGroup.GET("/miners/:miner_name", s.handleMinerHistoricalStats)
historyGroup.GET("/miners/:miner_name/hashrate", s.handleMinerHistoricalHashrate)
}
profilesGroup := apiGroup.Group("/profiles")
{
profilesGroup.GET("", s.handleListProfiles)
profilesGroup.POST("", s.handleCreateProfile)
profilesGroup.GET("/:id", s.handleGetProfile)
profilesGroup.PUT("/:id", s.handleUpdateProfile)
profilesGroup.DELETE("/:id", s.handleDeleteProfile)
profilesGroup.POST("/:id/start", s.handleStartMinerWithProfile)
}
// WebSocket endpoint for real-time events
wsGroup := apiGroup.Group("/ws")
{
wsGroup.GET("/events", s.handleWebSocketEvents)
}
// Add P2P node endpoints if node service is available
if s.NodeService != nil {
s.NodeService.SetupRoutes(apiGroup)
}
}
// Serve the embedded web component
componentFS, err := GetComponentFS()
if err == nil {
s.Router.StaticFS("/component", componentFS)
}
swaggerURL := ginSwagger.URL(fmt.Sprintf("http://%s%s/doc.json", s.DisplayAddr, s.SwaggerUIPath))
s.Router.GET(s.SwaggerUIPath+"/*any", ginSwagger.WrapHandler(swaggerFiles.Handler, swaggerURL, ginSwagger.InstanceName(s.SwaggerInstanceName)))
// Initialize MCP server for AI assistant integration
// This exposes API endpoints as MCP tools for Claude, Cursor, etc.
s.mcpServer = ginmcp.New(s.Router, &ginmcp.Config{
Name: "Mining API",
Description: "Mining dashboard API exposed via Model Context Protocol (MCP)",
BaseURL: fmt.Sprintf("http://%s", s.DisplayAddr),
})
s.mcpServer.Mount(s.APIBasePath + "/mcp")
logging.Info("MCP server enabled", logging.Fields{"endpoint": s.APIBasePath + "/mcp"})
}
// HealthResponse represents the health check response
type HealthResponse struct {
Status string `json:"status"`
Components map[string]string `json:"components,omitempty"`
}
// handleHealth godoc
// @Summary Health check endpoint
// @Description Returns service health status. Used for liveness probes.
// @Tags system
// @Produce json
// @Success 200 {object} HealthResponse
// @Router /health [get]
func (s *Service) handleHealth(c *gin.Context) {
c.JSON(http.StatusOK, HealthResponse{
Status: "healthy",
})
}
// handleReady godoc
// @Summary Readiness check endpoint
// @Description Returns service readiness with component status. Used for readiness probes.
// @Tags system
// @Produce json
// @Success 200 {object} HealthResponse
// @Success 503 {object} HealthResponse
// @Router /ready [get]
func (s *Service) handleReady(c *gin.Context) {
components := make(map[string]string)
allReady := true
// Check manager
if s.Manager != nil {
components["manager"] = "ready"
} else {
components["manager"] = "not initialized"
allReady = false
}
// Check profile manager
if s.ProfileManager != nil {
components["profiles"] = "ready"
} else {
components["profiles"] = "degraded"
// Don't fail readiness for degraded profile manager
}
// Check event hub
if s.EventHub != nil {
components["events"] = "ready"
} else {
components["events"] = "not initialized"
allReady = false
}
// Check node service (optional)
if s.NodeService != nil {
components["p2p"] = "ready"
} else {
components["p2p"] = "disabled"
}
status := "ready"
httpStatus := http.StatusOK
if !allReady {
status = "not ready"
httpStatus = http.StatusServiceUnavailable
}
c.JSON(httpStatus, HealthResponse{
Status: status,
Components: components,
})
}
// handleGetInfo godoc
// @Summary Get live miner installation information
// @Description Retrieves live installation details for all miners, along with system information.
// @Tags system
// @Produce json
// @Success 200 {object} SystemInfo
// @Failure 500 {object} map[string]string "Internal server error"
// @Router /info [get]
func (s *Service) handleGetInfo(c *gin.Context) {
systemInfo, err := s.updateInstallationCache()
if err != nil {
respondWithMiningError(c, ErrInternal("failed to get system info").WithCause(err))
return
}
c.JSON(http.StatusOK, systemInfo)
}
// updateInstallationCache performs a live check and updates the cache file.
func (s *Service) updateInstallationCache() (*SystemInfo, error) {
// Always create a complete SystemInfo object
systemInfo := &SystemInfo{
Timestamp: time.Now(),
OS: runtime.GOOS,
Architecture: runtime.GOARCH,
GoVersion: runtime.Version(),
AvailableCPUCores: runtime.NumCPU(),
InstalledMinersInfo: []*InstallationDetails{}, // Initialize as empty slice
}
vMem, err := mem.VirtualMemory()
if err == nil {
systemInfo.TotalSystemRAMGB = float64(vMem.Total) / (1024 * 1024 * 1024)
}
for _, availableMiner := range s.Manager.ListAvailableMiners() {
miner, err := CreateMiner(availableMiner.Name)
if err != nil {
continue // Skip unsupported miner types
}
details, err := miner.CheckInstallation()
if err != nil {
logging.Warn("failed to check installation", logging.Fields{"miner": availableMiner.Name, "error": err})
}
systemInfo.InstalledMinersInfo = append(systemInfo.InstalledMinersInfo, details)
}
configDir, err := xdg.ConfigFile("lethean-desktop/miners")
if err != nil {
return nil, fmt.Errorf("could not get config directory: %w", err)
}
if err := os.MkdirAll(configDir, 0755); err != nil {
return nil, fmt.Errorf("could not create config directory: %w", err)
}
configPath := filepath.Join(configDir, "config.json")
data, err := json.MarshalIndent(systemInfo, "", " ")
if err != nil {
return nil, fmt.Errorf("could not marshal cache data: %w", err)
}
if err := os.WriteFile(configPath, data, 0600); err != nil {
return nil, fmt.Errorf("could not write cache file: %w", err)
}
return systemInfo, nil
}
// handleDoctor godoc
// @Summary Check miner installations
// @Description Performs a live check on all available miners to verify their installation status, version, and path.
// @Tags system
// @Produce json
// @Success 200 {object} SystemInfo
// @Router /doctor [post]
func (s *Service) handleDoctor(c *gin.Context) {
systemInfo, err := s.updateInstallationCache()
if err != nil {
respondWithMiningError(c, ErrInternal("failed to update cache").WithCause(err))
return
}
c.JSON(http.StatusOK, systemInfo)
}
// handleUpdateCheck godoc
// @Summary Check for miner updates
// @Description Checks if any installed miners have a new version available for download.
// @Tags system
// @Produce json
// @Success 200 {object} map[string]string
// @Router /update [post]
func (s *Service) handleUpdateCheck(c *gin.Context) {
updates := make(map[string]string)
for _, availableMiner := range s.Manager.ListAvailableMiners() {
miner, err := CreateMiner(availableMiner.Name)
if err != nil {
continue // Skip unsupported miner types
}
details, err := miner.CheckInstallation()
if err != nil || !details.IsInstalled {
continue
}
latestVersionStr, err := miner.GetLatestVersion()
if err != nil {
continue
}
latestVersion, err := semver.NewVersion(latestVersionStr)
if err != nil {
continue
}
installedVersion, err := semver.NewVersion(details.Version)
if err != nil {
continue
}
if latestVersion.GreaterThan(installedVersion) {
updates[miner.GetName()] = latestVersion.String()
}
}
if len(updates) == 0 {
c.JSON(http.StatusOK, gin.H{"status": "All miners are up to date."})
return
}
c.JSON(http.StatusOK, gin.H{"updates_available": updates})
}
// handleUninstallMiner godoc
// @Summary Uninstall a miner
// @Description Removes all files for a specific miner.
// @Tags miners
// @Produce json
// @Param miner_type path string true "Miner Type to uninstall"
// @Success 200 {object} map[string]string
// @Router /miners/{miner_type}/uninstall [delete]
func (s *Service) handleUninstallMiner(c *gin.Context) {
minerType := c.Param("miner_name")
if err := s.Manager.UninstallMiner(c.Request.Context(), minerType); err != nil {
respondWithMiningError(c, ErrInternal("failed to uninstall miner").WithCause(err))
return
}
if _, err := s.updateInstallationCache(); err != nil {
logging.Warn("failed to update cache after uninstall", logging.Fields{"error": err})
}
c.JSON(http.StatusOK, gin.H{"status": minerType + " uninstalled successfully."})
}
// handleListMiners godoc
// @Summary List all running miners
// @Description Get a list of all running miners
// @Tags miners
// @Produce json
// @Success 200 {array} XMRigMiner
// @Router /miners [get]
func (s *Service) handleListMiners(c *gin.Context) {
miners := s.Manager.ListMiners()
c.JSON(http.StatusOK, miners)
}
// handleListAvailableMiners godoc
// @Summary List all available miners
// @Description Get a list of all available miners
// @Tags miners
// @Produce json
// @Success 200 {array} AvailableMiner
// @Router /miners/available [get]
func (s *Service) handleListAvailableMiners(c *gin.Context) {
miners := s.Manager.ListAvailableMiners()
c.JSON(http.StatusOK, miners)
}
// handleInstallMiner godoc
// @Summary Install or update a miner
// @Description Install a new miner or update an existing one.
// @Tags miners
// @Produce json
// @Param miner_type path string true "Miner Type to install/update"
// @Success 200 {object} map[string]string
// @Router /miners/{miner_type}/install [post]
func (s *Service) handleInstallMiner(c *gin.Context) {
minerType := c.Param("miner_name")
miner, err := CreateMiner(minerType)
if err != nil {
respondWithMiningError(c, ErrUnsupportedMiner(minerType))
return
}
if err := miner.Install(); err != nil {
respondWithMiningError(c, ErrInstallFailed(minerType).WithCause(err))
return
}
if _, err := s.updateInstallationCache(); err != nil {
logging.Warn("failed to update cache after install", logging.Fields{"error": err})
}
details, err := miner.CheckInstallation()
if err != nil {
respondWithMiningError(c, ErrInternal("failed to verify installation").WithCause(err))
return
}
c.JSON(http.StatusOK, gin.H{"status": "installed", "version": details.Version, "path": details.Path})
}
// handleStartMinerWithProfile godoc
// @Summary Start a new miner using a profile
// @Description Start a new miner with the configuration from a saved profile
// @Tags profiles
// @Produce json
// @Param id path string true "Profile ID"
// @Success 200 {object} XMRigMiner
// @Router /profiles/{id}/start [post]
func (s *Service) handleStartMinerWithProfile(c *gin.Context) {
profileID := c.Param("id")
profile, exists := s.ProfileManager.GetProfile(profileID)
if !exists {
respondWithMiningError(c, ErrProfileNotFound(profileID))
return
}
var config Config
if err := json.Unmarshal(profile.Config, &config); err != nil {
respondWithMiningError(c, ErrInvalidConfig("failed to parse profile config").WithCause(err))
return
}
// Validate config from profile to prevent shell injection and other issues
if err := config.Validate(); err != nil {
respondWithMiningError(c, ErrInvalidConfig("profile config validation failed").WithCause(err))
return
}
miner, err := s.Manager.StartMiner(c.Request.Context(), profile.MinerType, &config)
if err != nil {
respondWithMiningError(c, ErrStartFailed(profile.Name).WithCause(err))
return
}
c.JSON(http.StatusOK, miner)
}
// handleStopMiner godoc
// @Summary Stop a running miner
// @Description Stop a running miner by its name
// @Tags miners
// @Produce json
// @Param miner_name path string true "Miner Name"
// @Success 200 {object} map[string]string
// @Router /miners/{miner_name} [delete]
func (s *Service) handleStopMiner(c *gin.Context) {
minerName := c.Param("miner_name")
if err := s.Manager.StopMiner(c.Request.Context(), minerName); err != nil {
respondWithMiningError(c, ErrStopFailed(minerName).WithCause(err))
return
}
c.JSON(http.StatusOK, gin.H{"status": "stopped"})
}
// handleGetMinerStats godoc
// @Summary Get miner stats
// @Description Get statistics for a running miner
// @Tags miners
// @Produce json
// @Param miner_name path string true "Miner Name"
// @Success 200 {object} PerformanceMetrics
// @Router /miners/{miner_name}/stats [get]
func (s *Service) handleGetMinerStats(c *gin.Context) {
minerName := c.Param("miner_name")
miner, err := s.Manager.GetMiner(minerName)
if err != nil {
respondWithMiningError(c, ErrMinerNotFound(minerName).WithCause(err))
return
}
stats, err := miner.GetStats(c.Request.Context())
if err != nil {
respondWithMiningError(c, ErrInternal("failed to get miner stats").WithCause(err))
return
}
c.JSON(http.StatusOK, stats)
}
// handleGetMinerHashrateHistory godoc
// @Summary Get miner hashrate history
// @Description Get historical hashrate data for a running miner
// @Tags miners
// @Produce json
// @Param miner_name path string true "Miner Name"
// @Success 200 {array} HashratePoint
// @Router /miners/{miner_name}/hashrate-history [get]
func (s *Service) handleGetMinerHashrateHistory(c *gin.Context) {
minerName := c.Param("miner_name")
history, err := s.Manager.GetMinerHashrateHistory(minerName)
if err != nil {
respondWithMiningError(c, ErrMinerNotFound(minerName).WithCause(err))
return
}
c.JSON(http.StatusOK, history)
}
// handleGetMinerLogs godoc
// @Summary Get miner log output
// @Description Get the captured stdout/stderr output from a running miner. Log lines are base64 encoded to preserve ANSI escape codes and special characters.
// @Tags miners
// @Produce json
// @Param miner_name path string true "Miner Name"
// @Success 200 {array} string "Base64 encoded log lines"
// @Router /miners/{miner_name}/logs [get]
func (s *Service) handleGetMinerLogs(c *gin.Context) {
minerName := c.Param("miner_name")
miner, err := s.Manager.GetMiner(minerName)
if err != nil {
respondWithMiningError(c, ErrMinerNotFound(minerName).WithCause(err))
return
}
logs := miner.GetLogs()
// Base64 encode each log line to preserve ANSI escape codes and special characters
encodedLogs := make([]string, len(logs))
for i, line := range logs {
encodedLogs[i] = base64.StdEncoding.EncodeToString([]byte(line))
}
c.JSON(http.StatusOK, encodedLogs)
}
// StdinInput represents input to send to miner's stdin
type StdinInput struct {
Input string `json:"input" binding:"required"`
}
// handleMinerStdin godoc
// @Summary Send input to miner stdin
// @Description Send console commands to a running miner's stdin (e.g., 'h' for hashrate, 'p' for pause)
// @Tags miners
// @Accept json
// @Produce json
// @Param miner_name path string true "Miner Name"
// @Param input body StdinInput true "Input to send"
// @Success 200 {object} map[string]string
// @Failure 400 {object} map[string]string
// @Failure 404 {object} map[string]string
// @Router /miners/{miner_name}/stdin [post]
func (s *Service) handleMinerStdin(c *gin.Context) {
minerName := c.Param("miner_name")
miner, err := s.Manager.GetMiner(minerName)
if err != nil {
respondWithError(c, http.StatusNotFound, ErrCodeMinerNotFound, "miner not found", err.Error())
return
}
var input StdinInput
if err := c.ShouldBindJSON(&input); err != nil {
respondWithMiningError(c, ErrInvalidConfig("invalid input format").WithCause(err))
return
}
if err := miner.WriteStdin(input.Input); err != nil {
respondWithMiningError(c, ErrInternal("failed to write to stdin").WithCause(err))
return
}
c.JSON(http.StatusOK, gin.H{"status": "sent", "input": input.Input})
}
// handleListProfiles godoc
// @Summary List all mining profiles
// @Description Get a list of all saved mining profiles
// @Tags profiles
// @Produce json
// @Success 200 {array} MiningProfile
// @Router /profiles [get]
func (s *Service) handleListProfiles(c *gin.Context) {
profiles := s.ProfileManager.GetAllProfiles()
c.JSON(http.StatusOK, profiles)
}
// handleCreateProfile godoc
// @Summary Create a new mining profile
// @Description Create and save a new mining profile
// @Tags profiles
// @Accept json
// @Produce json
// @Param profile body MiningProfile true "Mining Profile"
// @Success 201 {object} MiningProfile
// @Failure 400 {object} APIError "Invalid profile data"
// @Router /profiles [post]
func (s *Service) handleCreateProfile(c *gin.Context) {
var profile MiningProfile
if err := c.ShouldBindJSON(&profile); err != nil {
respondWithError(c, http.StatusBadRequest, ErrCodeInvalidInput, "invalid profile data", err.Error())
return
}
// Validate required fields
if profile.Name == "" {
respondWithError(c, http.StatusBadRequest, ErrCodeInvalidInput, "profile name is required", "")
return
}
if profile.MinerType == "" {
respondWithError(c, http.StatusBadRequest, ErrCodeInvalidInput, "miner type is required", "")
return
}
createdProfile, err := s.ProfileManager.CreateProfile(&profile)
if err != nil {
respondWithError(c, http.StatusInternalServerError, ErrCodeInternal, "failed to create profile", err.Error())
return
}
c.JSON(http.StatusCreated, createdProfile)
}
// handleGetProfile godoc
// @Summary Get a specific mining profile
// @Description Get a mining profile by its ID
// @Tags profiles
// @Produce json
// @Param id path string true "Profile ID"
// @Success 200 {object} MiningProfile
// @Router /profiles/{id} [get]
func (s *Service) handleGetProfile(c *gin.Context) {
profileID := c.Param("id")
profile, exists := s.ProfileManager.GetProfile(profileID)
if !exists {
respondWithError(c, http.StatusNotFound, ErrCodeProfileNotFound, "profile not found", "")
return
}
c.JSON(http.StatusOK, profile)
}
// handleUpdateProfile godoc
// @Summary Update a mining profile
// @Description Update an existing mining profile
// @Tags profiles
// @Accept json
// @Produce json
// @Param id path string true "Profile ID"
// @Param profile body MiningProfile true "Updated Mining Profile"
// @Success 200 {object} MiningProfile
// @Failure 404 {object} APIError "Profile not found"
// @Router /profiles/{id} [put]
func (s *Service) handleUpdateProfile(c *gin.Context) {
profileID := c.Param("id")
var profile MiningProfile
if err := c.ShouldBindJSON(&profile); err != nil {
respondWithError(c, http.StatusBadRequest, ErrCodeInvalidInput, "invalid profile data", err.Error())
return
}
profile.ID = profileID
if err := s.ProfileManager.UpdateProfile(&profile); err != nil {
// Check if error is "not found"
if strings.Contains(err.Error(), "not found") {
respondWithError(c, http.StatusNotFound, ErrCodeProfileNotFound, "profile not found", err.Error())
return
}
respondWithError(c, http.StatusInternalServerError, ErrCodeInternal, "failed to update profile", err.Error())
return
}
c.JSON(http.StatusOK, profile)
}
// handleDeleteProfile godoc
// @Summary Delete a mining profile
// @Description Delete a mining profile by its ID. Idempotent - returns success even if profile doesn't exist.
// @Tags profiles
// @Produce json
// @Param id path string true "Profile ID"
// @Success 200 {object} map[string]string
// @Router /profiles/{id} [delete]
func (s *Service) handleDeleteProfile(c *gin.Context) {
profileID := c.Param("id")
if err := s.ProfileManager.DeleteProfile(profileID); err != nil {
// Make DELETE idempotent - if profile doesn't exist, still return success
if strings.Contains(err.Error(), "not found") {
c.JSON(http.StatusOK, gin.H{"status": "profile deleted"})
return
}
respondWithError(c, http.StatusInternalServerError, ErrCodeInternal, "failed to delete profile", err.Error())
return
}
c.JSON(http.StatusOK, gin.H{"status": "profile deleted"})
}
// handleHistoryStatus godoc
// @Summary Get database history status
// @Description Get the status of database persistence for historical data
// @Tags history
// @Produce json
// @Success 200 {object} map[string]interface{}
// @Router /history/status [get]
func (s *Service) handleHistoryStatus(c *gin.Context) {
if manager, ok := s.Manager.(*Manager); ok {
c.JSON(http.StatusOK, gin.H{
"enabled": manager.IsDatabaseEnabled(),
"retentionDays": manager.databaseRetention,
})
return
}
c.JSON(http.StatusOK, gin.H{"enabled": false, "error": "manager type not supported"})
}
// handleAllMinersHistoricalStats godoc
// @Summary Get historical stats for all miners
// @Description Get aggregated historical statistics for all miners from the database
// @Tags history
// @Produce json
// @Success 200 {array} database.HashrateStats
// @Router /history/miners [get]
func (s *Service) handleAllMinersHistoricalStats(c *gin.Context) {
manager, ok := s.Manager.(*Manager)
if !ok {
respondWithMiningError(c, ErrInternal("manager type not supported"))
return
}
stats, err := manager.GetAllMinerHistoricalStats()
if err != nil {
respondWithMiningError(c, ErrDatabaseError("get historical stats").WithCause(err))
return
}
c.JSON(http.StatusOK, stats)
}
// handleMinerHistoricalStats godoc
// @Summary Get historical stats for a specific miner
// @Description Get aggregated historical statistics for a specific miner from the database
// @Tags history
// @Produce json
// @Param miner_name path string true "Miner Name"
// @Success 200 {object} database.HashrateStats
// @Router /history/miners/{miner_name} [get]
func (s *Service) handleMinerHistoricalStats(c *gin.Context) {
minerName := c.Param("miner_name")
manager, ok := s.Manager.(*Manager)
if !ok {
respondWithMiningError(c, ErrInternal("manager type not supported"))
return
}
stats, err := manager.GetMinerHistoricalStats(minerName)
if err != nil {
respondWithMiningError(c, ErrDatabaseError("get miner stats").WithCause(err))
return
}
if stats == nil {
respondWithMiningError(c, ErrMinerNotFound(minerName).WithDetails("no historical data found"))
return
}
c.JSON(http.StatusOK, stats)
}
// handleMinerHistoricalHashrate godoc
// @Summary Get historical hashrate data for a specific miner
// @Description Get detailed historical hashrate data for a specific miner from the database
// @Tags history
// @Produce json
// @Param miner_name path string true "Miner Name"
// @Param since query string false "Start time (RFC3339 format)"
// @Param until query string false "End time (RFC3339 format)"
// @Success 200 {array} HashratePoint
// @Router /history/miners/{miner_name}/hashrate [get]
func (s *Service) handleMinerHistoricalHashrate(c *gin.Context) {
minerName := c.Param("miner_name")
manager, ok := s.Manager.(*Manager)
if !ok {
respondWithMiningError(c, ErrInternal("manager type not supported"))
return
}
// Parse time range from query params, default to last 24 hours
until := time.Now()
since := until.Add(-24 * time.Hour)
if sinceStr := c.Query("since"); sinceStr != "" {
if t, err := time.Parse(time.RFC3339, sinceStr); err == nil {
since = t
}
}
if untilStr := c.Query("until"); untilStr != "" {
if t, err := time.Parse(time.RFC3339, untilStr); err == nil {
until = t
}
}
history, err := manager.GetMinerHistoricalHashrate(minerName, since, until)
if err != nil {
respondWithMiningError(c, ErrDatabaseError("get hashrate history").WithCause(err))
return
}
c.JSON(http.StatusOK, history)
}
// handleWebSocketEvents godoc
// @Summary WebSocket endpoint for real-time mining events
// @Description Upgrade to WebSocket for real-time mining stats and events.
// @Description Events include: miner.starting, miner.started, miner.stopping, miner.stopped, miner.stats, miner.error
// @Tags websocket
// @Success 101 {string} string "Switching Protocols"
// @Router /ws/events [get]
func (s *Service) handleWebSocketEvents(c *gin.Context) {
conn, err := wsUpgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
logging.Error("failed to upgrade WebSocket connection", logging.Fields{"error": err})
return
}
logging.Info("new WebSocket connection", logging.Fields{"remote": c.Request.RemoteAddr})
// Only record connection after successful registration to avoid metrics race
if s.EventHub.ServeWs(conn) {
RecordWSConnection(true)
} else {
logging.Warn("WebSocket connection rejected", logging.Fields{"remote": c.Request.RemoteAddr, "reason": "limit reached"})
}
}
// handleMetrics godoc
// @Summary Get internal metrics
// @Description Returns internal metrics for monitoring and debugging
// @Tags system
// @Produce json
// @Success 200 {object} map[string]interface{}
// @Router /metrics [get]
func (s *Service) handleMetrics(c *gin.Context) {
c.JSON(http.StatusOK, GetMetricsSnapshot())
}