1388 lines
46 KiB
Go
1388 lines
46 KiB
Go
package mining
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/base64"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strconv"
|
|
"strings"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"forge.lthn.ai/Snider/Mining/docs"
|
|
"forge.lthn.ai/Snider/Mining/pkg/logging"
|
|
"github.com/Masterminds/semver/v3"
|
|
"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, err := mining.NewService(manager, "127.0.0.1:9090", "localhost:9090", "/api/v1/mining")
|
|
// if err != nil { return err }
|
|
// service.ServiceStartup(context.Background()) starts the REST API on 127.0.0.1:9090.
|
|
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{Code: "miner_not_found", Message: "xmrig not running", Retryable: false}
|
|
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 is true when DEBUG_ERRORS=true or GIN_MODE != "release".
|
|
var debugErrorsEnabled = os.Getenv("DEBUG_ERRORS") == "true" || os.Getenv("GIN_MODE") != "release"
|
|
|
|
// sanitizeErrorDetails("exec: file not found") returns "" in production and the full string in debug mode.
|
|
func sanitizeErrorDetails(details string) string {
|
|
if debugErrorsEnabled {
|
|
return details
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// respondWithError(c, http.StatusNotFound, ErrCodeMinerNotFound, "xmrig not found", "process exited with code 1")
|
|
func respondWithError(c *gin.Context, status int, code string, message string, details string) {
|
|
apiError := APIError{
|
|
Code: code,
|
|
Message: message,
|
|
Details: sanitizeErrorDetails(details),
|
|
Retryable: isRetryableError(status),
|
|
}
|
|
|
|
// respondWithError(c, http.StatusServiceUnavailable, ErrCodeServiceUnavailable, "service unavailable", "database offline") adds a retry suggestion.
|
|
switch code {
|
|
case ErrCodeMinerNotFound:
|
|
apiError.Suggestion = "Check the miner name or install the miner first"
|
|
case ErrCodeProfileNotFound:
|
|
apiError.Suggestion = "Create a new profile or check the profile ID"
|
|
case ErrCodeInstallFailed:
|
|
apiError.Suggestion = "Check your internet connection and try again"
|
|
case ErrCodeStartFailed:
|
|
apiError.Suggestion = "Check the miner configuration and logs"
|
|
case ErrCodeInvalidInput:
|
|
apiError.Suggestion = "Verify the request body matches the expected format"
|
|
case ErrCodeServiceUnavailable:
|
|
apiError.Suggestion = "The service is temporarily unavailable, try again later"
|
|
apiError.Retryable = true
|
|
}
|
|
|
|
c.JSON(status, apiError)
|
|
}
|
|
|
|
// respondWithMiningError(c, ErrMinerNotFound("xmrig"))
|
|
// respondWithMiningError(c, ErrInternal("failed to read config").WithCause(err))
|
|
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
|
|
}
|
|
|
|
apiError := APIError{
|
|
Code: err.Code,
|
|
Message: err.Message,
|
|
Details: sanitizeErrorDetails(details),
|
|
Suggestion: err.Suggestion,
|
|
Retryable: err.Retryable,
|
|
}
|
|
|
|
c.JSON(err.StatusCode(), apiError)
|
|
}
|
|
|
|
// isRetryableError(http.StatusServiceUnavailable) returns true.
|
|
// isRetryableError(http.StatusNotFound) returns false.
|
|
func isRetryableError(status int) bool {
|
|
return status == http.StatusServiceUnavailable ||
|
|
status == http.StatusTooManyRequests ||
|
|
status == http.StatusGatewayTimeout
|
|
}
|
|
|
|
// router.Use(securityHeadersMiddleware()) adds X-Content-Type-Options: nosniff and Content-Security-Policy: default-src 'none' to GET /api/v1/mining/status.
|
|
func securityHeadersMiddleware() gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
c.Header("X-Content-Type-Options", "nosniff")
|
|
c.Header("X-Frame-Options", "DENY")
|
|
c.Header("X-XSS-Protection", "1; mode=block")
|
|
c.Header("Referrer-Policy", "strict-origin-when-cross-origin")
|
|
c.Header("Content-Security-Policy", "default-src 'none'; frame-ancestors 'none'")
|
|
c.Next()
|
|
}
|
|
}
|
|
|
|
// router.Use(contentTypeValidationMiddleware()) returns 415 for POST /api/v1/mining/profiles when Content-Type is text/plain.
|
|
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
|
|
}
|
|
|
|
if c.Request.ContentLength == 0 {
|
|
c.Next()
|
|
return
|
|
}
|
|
|
|
contentType := c.GetHeader("Content-Type")
|
|
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()
|
|
}
|
|
}
|
|
|
|
// router.Use(requestIDMiddleware()) keeps X-Request-ID: trace-123 stable across the request and response.
|
|
func requestIDMiddleware() gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
requestID := c.GetHeader("X-Request-ID")
|
|
if requestID == "" {
|
|
requestID = generateRequestID()
|
|
}
|
|
|
|
c.Set("requestID", requestID)
|
|
c.Header("X-Request-ID", requestID)
|
|
|
|
c.Next()
|
|
}
|
|
}
|
|
|
|
// requestID := generateRequestID() // example: 1712070000123-a1b2c3d4e5f6a7b8
|
|
func generateRequestID() string {
|
|
randomBytes := make([]byte, 8)
|
|
if _, err := rand.Read(randomBytes); err != nil {
|
|
return strconv.FormatInt(time.Now().UnixMilli(), 10) + "-fallback"
|
|
}
|
|
return strconv.FormatInt(time.Now().UnixMilli(), 10) + "-" + hex.EncodeToString(randomBytes)
|
|
}
|
|
|
|
// requestID := requestIDFromContext(c) returns "trace-123" after requestIDMiddleware stores the header.
|
|
func requestIDFromContext(c *gin.Context) string {
|
|
if requestIDValue, exists := c.Get("requestID"); exists {
|
|
if stringValue, ok := requestIDValue.(string); ok {
|
|
return stringValue
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// logWithRequestContext(c, "error", "miner failed to start", logging.Fields{"type": "xmrig", "name": "xmrig-main"})
|
|
// logWithRequestContext(c, "info", "miner started", logging.Fields{"name": "xmrig-1", "request_id": "trace-123"})
|
|
func logWithRequestContext(c *gin.Context, level string, message string, fields logging.Fields) {
|
|
if fields == nil {
|
|
fields = logging.Fields{}
|
|
}
|
|
if requestID := requestIDFromContext(c); requestID != "" {
|
|
fields["request_id"] = requestID
|
|
}
|
|
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() allows POST /api/v1/mining/profiles when the request includes Authorization or X-Requested-With.
|
|
func csrfMiddleware() gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
// Only check state-changing methods such as POST /api/v1/mining/profiles.
|
|
method := c.Request.Method
|
|
if method == http.MethodGet || method == http.MethodHead || method == http.MethodOptions {
|
|
c.Next()
|
|
return
|
|
}
|
|
|
|
// Allow requests like `Authorization: Digest username="miner-admin"` from API clients.
|
|
if c.GetHeader("Authorization") != "" {
|
|
c.Next()
|
|
return
|
|
}
|
|
|
|
// Allow requests like `X-Requested-With: XMLHttpRequest` from browser clients.
|
|
if c.GetHeader("X-Requested-With") != "" {
|
|
c.Next()
|
|
return
|
|
}
|
|
|
|
// Allow requests like `Content-Type: application/json` from API clients.
|
|
contentType := c.GetHeader("Content-Type")
|
|
if strings.HasPrefix(contentType, "application/json") {
|
|
c.Next()
|
|
return
|
|
}
|
|
|
|
// Reject requests like `POST /api/v1/mining/profiles` from a plain HTML form.
|
|
respondWithError(c, http.StatusForbidden, "CSRF_PROTECTION",
|
|
"Request blocked by CSRF protection",
|
|
"Include X-Requested-With header or use application/json content type")
|
|
c.Abort()
|
|
}
|
|
}
|
|
|
|
// context.WithTimeout(context.Background(), DefaultRequestTimeout) keeps long API requests under 30 seconds.
|
|
const DefaultRequestTimeout = 30 * time.Second
|
|
|
|
// CachePublic5Min matches GET /api/v1/mining/miners/available responses.
|
|
const (
|
|
CacheNoStore = "no-store"
|
|
CacheNoCache = "no-cache"
|
|
CachePublic1Min = "public, max-age=60"
|
|
CachePublic5Min = "public, max-age=300"
|
|
)
|
|
|
|
// router.Use(cacheMiddleware()) serves GET /miners/available with Cache-Control: public, max-age=300.
|
|
func cacheMiddleware() gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
// Only cache GET requests like /api/v1/mining/info.
|
|
if c.Request.Method != http.MethodGet {
|
|
c.Header("Cache-Control", CacheNoStore)
|
|
c.Next()
|
|
return
|
|
}
|
|
|
|
path := c.Request.URL.Path
|
|
|
|
// Cache GET /api/v1/mining/miners/available for 5 minutes.
|
|
switch {
|
|
case strings.HasSuffix(path, "/available"):
|
|
c.Header("Cache-Control", CachePublic5Min)
|
|
case strings.HasSuffix(path, "/info"):
|
|
// Cache GET /api/v1/mining/info for 1 minute.
|
|
c.Header("Cache-Control", CachePublic1Min)
|
|
case strings.Contains(path, "/swagger"):
|
|
// Cache GET /api/v1/mining/swagger/index.html for 5 minutes.
|
|
c.Header("Cache-Control", CachePublic5Min)
|
|
default:
|
|
// Keep dynamic requests like /api/v1/mining/miners/xmrig live.
|
|
c.Header("Cache-Control", CacheNoCache)
|
|
}
|
|
|
|
c.Next()
|
|
}
|
|
}
|
|
|
|
// router.Use(requestTimeoutMiddleware(30 * time.Second)) aborts GET /history/miners/xmrig after 30 seconds.
|
|
func requestTimeoutMiddleware(timeout time.Duration) gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
// Skip timeout for WebSocket upgrades like /ws/events.
|
|
if c.GetHeader("Upgrade") == "websocket" {
|
|
c.Next()
|
|
return
|
|
}
|
|
if strings.HasSuffix(c.Request.URL.Path, "/events") {
|
|
c.Next()
|
|
return
|
|
}
|
|
|
|
// Create a request-scoped timeout for requests like GET /api/v1/mining/history/xmrig.
|
|
ctx, cancel := context.WithTimeout(c.Request.Context(), timeout)
|
|
defer cancel()
|
|
|
|
// Replace the incoming request context with the timed one.
|
|
c.Request = c.Request.WithContext(ctx)
|
|
|
|
var responded int32
|
|
done := make(chan struct{})
|
|
|
|
go func() {
|
|
c.Next()
|
|
atomic.StoreInt32(&responded, 1)
|
|
close(done)
|
|
}()
|
|
|
|
select {
|
|
case <-done:
|
|
// Request completed normally, for example GET /api/v1/mining/status.
|
|
case <-ctx.Done():
|
|
// Timeout occurred; only respond if the handler has not already written a response.
|
|
if atomic.CompareAndSwapInt32(&responded, 0, 1) {
|
|
c.Abort()
|
|
respondWithError(c, http.StatusGatewayTimeout, ErrCodeTimeout,
|
|
"Request timed out", "Request exceeded "+timeout.String()+" timeout")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// conn, err := wsUpgrader.Upgrade(c.Writer, c.Request, nil) upgrades GET /ws/events to WebSocket.
|
|
var wsUpgrader = websocket.Upgrader{
|
|
ReadBufferSize: 1024,
|
|
WriteBufferSize: 1024,
|
|
CheckOrigin: func(r *http.Request) bool {
|
|
// Allow browser requests like `Origin: http://localhost:4200`.
|
|
origin := r.Header.Get("Origin")
|
|
if origin == "" {
|
|
return true // No Origin header, for example from curl or another non-browser client.
|
|
}
|
|
// parsedOrigin, err := url.Parse("http://localhost:4200") keeps browser-origin checks exact.
|
|
parsedOrigin, err := url.Parse(origin)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
host := parsedOrigin.Hostname()
|
|
// Allow exact localhost matches like `http://127.0.0.1:4200`.
|
|
return host == "localhost" || host == "127.0.0.1" || host == "::1" ||
|
|
host == "wails.localhost"
|
|
},
|
|
}
|
|
|
|
// service, err := mining.NewService(manager, ":9090", "localhost:9090", "/api/v1/mining")
|
|
func NewService(manager ManagerInterface, listenAddress string, displayAddress 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 = displayAddress
|
|
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})
|
|
// profileManager = &ProfileManager{profiles: map[string]*MiningProfile{}} keeps POST /api/v1/mining/profiles working even without XDG storage.
|
|
profileManager = &ProfileManager{
|
|
profiles: make(map[string]*MiningProfile),
|
|
}
|
|
}
|
|
|
|
// nodeService, err := NewNodeService() keeps GET /api/v1/mining/status available even when peer transport is unavailable.
|
|
nodeService, err := NewNodeService()
|
|
if err != nil {
|
|
logging.Warn("failed to initialize node service", logging.Fields{"error": err})
|
|
}
|
|
|
|
// NewEventHub() // broadcasts miner events to /ws/events clients
|
|
eventHub := NewEventHub()
|
|
go eventHub.Run()
|
|
|
|
// concreteManager.SetEventHub(eventHub) // lets Manager broadcast start/stop/stat events
|
|
if concreteManager, ok := manager.(*Manager); ok {
|
|
concreteManager.SetEventHub(eventHub)
|
|
}
|
|
|
|
// eventHub.SetStateProvider(...) returns running miner state after a reconnect to GET /ws/events.
|
|
eventHub.SetStateProvider(func() interface{} {
|
|
miners := manager.ListMiners()
|
|
if len(miners) == 0 {
|
|
return nil
|
|
}
|
|
// state := []map[string]interface{}{{"name": "xmrig-rx_0", "status": "running"}}
|
|
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,
|
|
}
|
|
})
|
|
|
|
// AuthConfigFromEnv() enables digest auth for requests like GET /api/v1/mining/status when credentials are present.
|
|
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: listenAddress,
|
|
ReadTimeout: 30 * time.Second,
|
|
WriteTimeout: 30 * time.Second,
|
|
IdleTimeout: 60 * time.Second,
|
|
ReadHeaderTimeout: 10 * time.Second,
|
|
},
|
|
DisplayAddr: displayAddress,
|
|
SwaggerInstanceName: instanceName,
|
|
APIBasePath: apiBasePath,
|
|
SwaggerUIPath: swaggerUIPath,
|
|
auth: auth,
|
|
}, nil
|
|
}
|
|
|
|
// service.InitRouter()
|
|
// http.Handle("/", service.Router) // embeds GET /api/v1/mining/status under a parent HTTP server in Wails
|
|
func (service *Service) InitRouter() {
|
|
service.Router = gin.Default()
|
|
|
|
// service.Server.Addr = ":9090" -> serverPort = "9090" for local CORS origins such as http://localhost:9090.
|
|
serverPort := "9090" // default fallback
|
|
if service.Server.Addr != "" {
|
|
if _, port, err := net.SplitHostPort(service.Server.Addr); err == nil && port != "" {
|
|
serverPort = port
|
|
}
|
|
}
|
|
|
|
// Configure CORS to only allow local origins like http://localhost:4200 and http://wails.localhost.
|
|
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,
|
|
}
|
|
service.Router.Use(cors.New(corsConfig))
|
|
|
|
// service.Router.Use(securityHeadersMiddleware()) // sets security headers on every GET /api/v1/mining/status response.
|
|
service.Router.Use(securityHeadersMiddleware())
|
|
|
|
// service.Router.Use(contentTypeValidationMiddleware()) // rejects POST /api/v1/mining/profiles without application/json.
|
|
service.Router.Use(contentTypeValidationMiddleware())
|
|
|
|
// c.Request.Body = http.MaxBytesReader(..., 1<<20) // caps bodies for requests like POST /api/v1/mining/profiles.
|
|
service.Router.Use(func(c *gin.Context) {
|
|
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 1<<20) // 1MB
|
|
c.Next()
|
|
})
|
|
|
|
// service.Router.Use(csrfMiddleware()) // allows API clients with Authorization or X-Requested-With headers.
|
|
service.Router.Use(csrfMiddleware())
|
|
|
|
// service.Router.Use(requestTimeoutMiddleware(DefaultRequestTimeout)) // aborts stalled requests like GET /api/v1/mining/history/xmrig.
|
|
service.Router.Use(requestTimeoutMiddleware(DefaultRequestTimeout))
|
|
|
|
// service.Router.Use(cacheMiddleware()) // returns Cache-Control: public, max-age=300 for GET /api/v1/mining/miners/available.
|
|
service.Router.Use(cacheMiddleware())
|
|
|
|
// service.Router.Use(requestIDMiddleware()) // preserves the incoming X-Request-ID or creates a new one
|
|
service.Router.Use(requestIDMiddleware())
|
|
|
|
// NewRateLimiter(10, 20) // allows bursts of 20 with a 10 requests/second refill rate
|
|
service.rateLimiter = NewRateLimiter(10, 20)
|
|
service.Router.Use(service.rateLimiter.Middleware())
|
|
|
|
service.SetupRoutes()
|
|
}
|
|
|
|
// service.Stop() // stops rate limiting, auth, event hub, and node transport during shutdown
|
|
func (service *Service) Stop() {
|
|
if service.rateLimiter != nil {
|
|
service.rateLimiter.Stop()
|
|
}
|
|
if service.EventHub != nil {
|
|
service.EventHub.Stop()
|
|
}
|
|
if service.auth != nil {
|
|
service.auth.Stop()
|
|
}
|
|
if service.NodeService != nil {
|
|
if err := service.NodeService.StopTransport(); err != nil {
|
|
logging.Warn("failed to stop node service transport", logging.Fields{"error": err})
|
|
}
|
|
}
|
|
}
|
|
|
|
// service.ServiceStartup(ctx) // starts the HTTP server and stops it when ctx.Done() fires
|
|
func (service *Service) ServiceStartup(ctx context.Context) error {
|
|
service.InitRouter()
|
|
service.Server.Handler = service.Router
|
|
|
|
// serverErrors captures ListenAndServe failures without blocking startup.
|
|
serverErrors := make(chan error, 1)
|
|
|
|
go func() {
|
|
if err := service.Server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
|
logging.Error("server error", logging.Fields{"addr": service.Server.Addr, "error": err})
|
|
serverErrors <- err
|
|
}
|
|
close(serverErrors) // prevent goroutine leak
|
|
}()
|
|
|
|
go func() {
|
|
<-ctx.Done()
|
|
service.Stop() // Clean up service resources (auth, event hub, node service)
|
|
service.Manager.Stop()
|
|
shutdownContext, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
if err := service.Server.Shutdown(shutdownContext); 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 := <-serverErrors:
|
|
if err != nil {
|
|
return ErrInternal("failed to start server").WithCause(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", service.Server.Addr, 50*time.Millisecond)
|
|
if err == nil {
|
|
conn.Close()
|
|
return nil // Server is ready
|
|
}
|
|
time.Sleep(100 * time.Millisecond)
|
|
}
|
|
}
|
|
|
|
return ErrInternal("server failed to start listening on " + service.Server.Addr + " within timeout")
|
|
}
|
|
|
|
// service.InitRouter()
|
|
// service.SetupRoutes() // re-call after adding middleware manually
|
|
func (service *Service) SetupRoutes() {
|
|
apiRoutes := service.Router.Group(service.APIBasePath)
|
|
|
|
// Health endpoints (no auth required for orchestration/monitoring)
|
|
apiRoutes.GET("/health", service.handleHealth)
|
|
apiRoutes.GET("/ready", service.handleReady)
|
|
|
|
// Apply authentication middleware if enabled
|
|
if service.auth != nil {
|
|
apiRoutes.Use(service.auth.Middleware())
|
|
}
|
|
|
|
{
|
|
apiRoutes.GET("/info", service.handleGetInfo)
|
|
apiRoutes.GET("/metrics", service.handleMetrics)
|
|
apiRoutes.POST("/doctor", service.handleDoctor)
|
|
apiRoutes.POST("/update", service.handleUpdateCheck)
|
|
|
|
minersRoutes := apiRoutes.Group("/miners")
|
|
{
|
|
minersRoutes.GET("", service.handleListMiners)
|
|
minersRoutes.GET("/available", service.handleListAvailableMiners)
|
|
minersRoutes.POST("/:miner_name/install", service.handleInstallMiner)
|
|
minersRoutes.DELETE("/:miner_name/uninstall", service.handleUninstallMiner)
|
|
minersRoutes.DELETE("/:miner_name", service.handleStopMiner)
|
|
minersRoutes.GET("/:miner_name/stats", service.handleGetMinerStats)
|
|
minersRoutes.GET("/:miner_name/hashrate-history", service.handleGetMinerHashrateHistory)
|
|
minersRoutes.GET("/:miner_name/logs", service.handleGetMinerLogs)
|
|
minersRoutes.POST("/:miner_name/stdin", service.handleMinerStdin)
|
|
}
|
|
|
|
// Historical data endpoints (database-backed)
|
|
historyRoutes := apiRoutes.Group("/history")
|
|
{
|
|
historyRoutes.GET("/status", service.handleHistoryStatus)
|
|
historyRoutes.GET("/miners", service.handleAllMinersHistoricalStats)
|
|
historyRoutes.GET("/miners/:miner_name", service.handleMinerHistoricalStats)
|
|
historyRoutes.GET("/miners/:miner_name/hashrate", service.handleMinerHistoricalHashrate)
|
|
}
|
|
|
|
profilesRoutes := apiRoutes.Group("/profiles")
|
|
{
|
|
profilesRoutes.GET("", service.handleListProfiles)
|
|
profilesRoutes.POST("", service.handleCreateProfile)
|
|
profilesRoutes.GET("/:id", service.handleGetProfile)
|
|
profilesRoutes.PUT("/:id", service.handleUpdateProfile)
|
|
profilesRoutes.DELETE("/:id", service.handleDeleteProfile)
|
|
profilesRoutes.POST("/:id/start", service.handleStartMinerWithProfile)
|
|
}
|
|
|
|
// WebSocket endpoint for real-time events
|
|
websocketRoutes := apiRoutes.Group("/ws")
|
|
{
|
|
websocketRoutes.GET("/events", service.handleWebSocketEvents)
|
|
}
|
|
|
|
// Add P2P node endpoints if node service is available
|
|
if service.NodeService != nil {
|
|
service.NodeService.SetupRoutes(apiRoutes)
|
|
}
|
|
}
|
|
|
|
// service.Router.StaticFS("/component", componentFS) // serves the embedded dashboard component bundle returned by GetComponentFS()
|
|
componentFS, err := GetComponentFS()
|
|
if err == nil {
|
|
service.Router.StaticFS("/component", componentFS)
|
|
}
|
|
|
|
swaggerURL := ginSwagger.URL("http://" + service.DisplayAddr + service.SwaggerUIPath + "/doc.json")
|
|
service.Router.GET(service.SwaggerUIPath+"/*any", ginSwagger.WrapHandler(swaggerFiles.Handler, swaggerURL, ginSwagger.InstanceName(service.SwaggerInstanceName)))
|
|
|
|
// ginmcp.New(service.Router, ...) // exposes the API as MCP tools for Claude or Cursor
|
|
service.mcpServer = ginmcp.New(service.Router, &ginmcp.Config{
|
|
Name: "Mining API",
|
|
Description: "Mining dashboard API exposed via Model Context Protocol (MCP)",
|
|
BaseURL: "http://" + service.DisplayAddr,
|
|
})
|
|
service.mcpServer.Mount(service.APIBasePath + "/mcp")
|
|
logging.Info("MCP server enabled", logging.Fields{"endpoint": service.APIBasePath + "/mcp"})
|
|
}
|
|
|
|
// c.JSON(http.StatusOK, HealthResponse{Status: "healthy", Components: map[string]string{"db": "ok"}})
|
|
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 (service *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 (service *Service) handleReady(c *gin.Context) {
|
|
components := make(map[string]string)
|
|
allReady := true
|
|
|
|
// service.Manager != nil -> "manager": "ready"
|
|
if service.Manager != nil {
|
|
components["manager"] = "ready"
|
|
} else {
|
|
components["manager"] = "not initialized"
|
|
allReady = false
|
|
}
|
|
|
|
// service.ProfileManager != nil -> "profiles": "ready"
|
|
if service.ProfileManager != nil {
|
|
components["profiles"] = "ready"
|
|
} else {
|
|
components["profiles"] = "degraded"
|
|
// keep readiness green when only profile loading is degraded
|
|
}
|
|
|
|
// service.EventHub != nil -> "events": "ready"
|
|
if service.EventHub != nil {
|
|
components["events"] = "ready"
|
|
} else {
|
|
components["events"] = "not initialized"
|
|
allReady = false
|
|
}
|
|
|
|
// service.NodeService != nil -> "p2p": "ready"
|
|
if service.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 (service *Service) handleGetInfo(c *gin.Context) {
|
|
systemInfo, err := service.updateInstallationCache()
|
|
if err != nil {
|
|
respondWithMiningError(c, ErrInternal("failed to get system info").WithCause(err))
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, systemInfo)
|
|
}
|
|
|
|
// systemInfo, err := service.updateInstallationCache()
|
|
// if err != nil { return ErrInternal("cache update failed").WithCause(err) }
|
|
func (service *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{}, // keep /info responses stable when no miners are installed
|
|
}
|
|
|
|
vMem, err := mem.VirtualMemory()
|
|
if err == nil {
|
|
systemInfo.TotalSystemRAMGB = float64(vMem.Total) / (1024 * 1024 * 1024)
|
|
}
|
|
|
|
for _, availableMiner := range service.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, ErrInternal("could not get config directory").WithCause(err)
|
|
}
|
|
if err := os.MkdirAll(configDir, 0755); err != nil {
|
|
return nil, ErrInternal("could not create config directory").WithCause(err)
|
|
}
|
|
configPath := filepath.Join(configDir, "config.json")
|
|
|
|
data, err := json.MarshalIndent(systemInfo, "", " ")
|
|
if err != nil {
|
|
return nil, ErrInternal("could not marshal cache data").WithCause(err)
|
|
}
|
|
|
|
if err := os.WriteFile(configPath, data, 0600); err != nil {
|
|
return nil, ErrInternal("could not write cache file").WithCause(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 (service *Service) handleDoctor(c *gin.Context) {
|
|
systemInfo, err := service.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 (service *Service) handleUpdateCheck(c *gin.Context) {
|
|
updates := make(map[string]string)
|
|
for _, availableMiner := range service.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 (service *Service) handleUninstallMiner(c *gin.Context) {
|
|
minerType := c.Param("miner_name")
|
|
if err := service.Manager.UninstallMiner(c.Request.Context(), minerType); err != nil {
|
|
respondWithMiningError(c, ErrInternal("failed to uninstall miner").WithCause(err))
|
|
return
|
|
}
|
|
if _, err := service.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 (service *Service) handleListMiners(c *gin.Context) {
|
|
miners := service.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 (service *Service) handleListAvailableMiners(c *gin.Context) {
|
|
miners := service.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 (service *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 := service.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 (service *Service) handleStartMinerWithProfile(c *gin.Context) {
|
|
profileID := c.Param("id")
|
|
profile, exists := service.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 := service.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 (service *Service) handleStopMiner(c *gin.Context) {
|
|
minerName := c.Param("miner_name")
|
|
if err := service.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 (service *Service) handleGetMinerStats(c *gin.Context) {
|
|
minerName := c.Param("miner_name")
|
|
miner, err := service.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 (service *Service) handleGetMinerHashrateHistory(c *gin.Context) {
|
|
minerName := c.Param("miner_name")
|
|
history, err := service.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 (service *Service) handleGetMinerLogs(c *gin.Context) {
|
|
minerName := c.Param("miner_name")
|
|
miner, err := service.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)
|
|
}
|
|
|
|
// c.ShouldBindJSON(&StdinInput{Input: "h"}) // `h` prints hash rate and `p` pauses mining.
|
|
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. Use `h` to print hash rate and `p` to pause mining.
|
|
// @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 (service *Service) handleMinerStdin(c *gin.Context) {
|
|
minerName := c.Param("miner_name")
|
|
miner, err := service.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 (service *Service) handleListProfiles(c *gin.Context) {
|
|
profiles := service.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 (service *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 := service.ProfileManager.CreateProfile(&profile)
|
|
if err != nil {
|
|
respondWithError(c, http.StatusInternalServerError, ErrCodeInternalError, "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 (service *Service) handleGetProfile(c *gin.Context) {
|
|
profileID := c.Param("id")
|
|
profile, exists := service.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 (service *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 := service.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, ErrCodeInternalError, "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 (service *Service) handleDeleteProfile(c *gin.Context) {
|
|
profileID := c.Param("id")
|
|
if err := service.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, ErrCodeInternalError, "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 (service *Service) handleHistoryStatus(c *gin.Context) {
|
|
if manager, ok := service.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 (service *Service) handleAllMinersHistoricalStats(c *gin.Context) {
|
|
manager, ok := service.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 (service *Service) handleMinerHistoricalStats(c *gin.Context) {
|
|
minerName := c.Param("miner_name")
|
|
manager, ok := service.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 (service *Service) handleMinerHistoricalHashrate(c *gin.Context) {
|
|
minerName := c.Param("miner_name")
|
|
manager, ok := service.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 (service *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 service.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 (service *Service) handleMetrics(c *gin.Context) {
|
|
c.JSON(http.StatusOK, GetMetricsSnapshot())
|
|
}
|