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" "github.com/Snider/Mining/docs" "github.com/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.dbRetention, }) 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()) }