AX: clarify mining service names and comments
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run

This commit is contained in:
Virgil 2026-04-04 05:39:20 +00:00
parent 4e8283c84f
commit a675d16ed6
5 changed files with 67 additions and 73 deletions

View file

@ -31,28 +31,28 @@ var serveCmd = &cobra.Command{
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
displayHost := host
if displayHost == "0.0.0.0" {
displayHostName := host
if displayHostName == "0.0.0.0" {
var err error
displayHost, err = getLocalIP()
displayHostName, err = getLocalIP()
if err != nil {
displayHost = "localhost"
displayHostName = "localhost"
}
}
displayAddr := fmt.Sprintf("%s:%d", displayHost, port)
listenAddr := fmt.Sprintf("%s:%d", host, port)
displayAddress := fmt.Sprintf("%s:%d", displayHostName, port)
listenAddress := fmt.Sprintf("%s:%d", host, port)
// miningManager := getServiceManager() shares the same miner lifecycle state across CLI commands.
miningManager := getServiceManager()
// manager := getServiceManager() shares the same miner lifecycle state across CLI commands.
manager := getServiceManager()
miningService, err := mining.NewService(miningManager, listenAddr, displayAddr, namespace)
service, err := mining.NewService(manager, listenAddress, displayAddress, namespace)
if err != nil {
return fmt.Errorf("failed to create new service: %w", err)
}
// miningService.ServiceStartup(ctx) starts the HTTP server while the shell keeps reading stdin.
// service.ServiceStartup(ctx) starts the HTTP server while the shell keeps reading stdin.
go func() {
if err := miningService.ServiceStartup(ctx); err != nil {
if err := service.ServiceStartup(ctx); err != nil {
fmt.Fprintf(os.Stderr, "Failed to start service: %v\n", err)
cancel()
}
@ -64,8 +64,8 @@ var serveCmd = &cobra.Command{
// go func() { fmt.Print(">> ") } // keeps the interactive shell responsive while the API serves requests.
go func() {
fmt.Printf("Mining service started on http://%s:%d\n", displayHost, port)
fmt.Printf("Swagger documentation is available at http://%s:%d%s/index.html\n", displayHost, port, miningService.SwaggerUIPath)
fmt.Printf("Mining service started on http://%s:%d\n", displayHostName, port)
fmt.Printf("Swagger documentation is available at http://%s:%d%s/index.html\n", displayHostName, port, service.SwaggerUIPath)
fmt.Println("Entering interactive shell. Type 'exit' or 'quit' to stop.")
fmt.Print(">> ")
@ -135,7 +135,7 @@ var serveCmd = &cobra.Command{
continue
}
miner, err := miningManager.StartMiner(context.Background(), minerType, config)
miner, err := manager.StartMiner(context.Background(), minerType, config)
if err != nil {
fmt.Fprintf(os.Stderr, "Error starting miner: %v\n", err)
} else {
@ -147,7 +147,7 @@ var serveCmd = &cobra.Command{
fmt.Println("Error: status command requires miner name, for example `status xmrig`")
} else {
minerName := commandArgs[0]
miner, err := miningManager.GetMiner(minerName)
miner, err := manager.GetMiner(minerName)
if err != nil {
fmt.Fprintf(os.Stderr, "Error getting miner status: %v\n", err)
} else {
@ -169,7 +169,7 @@ var serveCmd = &cobra.Command{
fmt.Println("Error: stop command requires miner name, for example `stop xmrig`")
} else {
minerName := commandArgs[0]
err := miningManager.StopMiner(context.Background(), minerName)
err := manager.StopMiner(context.Background(), minerName)
if err != nil {
fmt.Fprintf(os.Stderr, "Error stopping miner: %v\n", err)
} else {
@ -177,7 +177,7 @@ var serveCmd = &cobra.Command{
}
}
case "list":
miners := miningManager.ListMiners()
miners := manager.ListMiners()
if len(miners) == 0 {
fmt.Println("No miners currently running.")
} else {
@ -206,8 +206,8 @@ var serveCmd = &cobra.Command{
case <-ctx.Done():
}
// miningManager.Stop() stops miner goroutines and closes the shared manager before exit.
miningManager.Stop()
// manager.Stop() stops miner goroutines and closes the shared manager before exit.
manager.Stop()
fmt.Println("Mining service stopped.")
return nil

View file

@ -57,8 +57,8 @@ Available presets:
displayHost = "localhost"
}
}
displayAddr := fmt.Sprintf("%s:%d", displayHost, port)
listenAddr := fmt.Sprintf("%s:%d", host, port)
displayAddress := fmt.Sprintf("%s:%d", displayHost, port)
listenAddress := fmt.Sprintf("%s:%d", host, port)
// manager := mining.NewManagerForSimulation() // keeps simulated miners isolated from the real autostart state.
manager := mining.NewManagerForSimulation()
@ -83,7 +83,7 @@ Available presets:
}
// service, err := mining.NewService(manager, listenAddr, displayAddr, namespace) // serves the simulator on http://127.0.0.1:9090.
service, err := mining.NewService(manager, listenAddr, displayAddr, namespace)
service, err := mining.NewService(manager, listenAddress, displayAddress, namespace)
if err != nil {
return fmt.Errorf("failed to create new service: %w", err)
}

View file

@ -20,13 +20,13 @@ var startCmd = &cobra.Command{
Long: `Start a new miner with the specified configuration.`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
selectedMinerType := args[0]
minerType := args[0]
config := &mining.Config{
Pool: poolAddress,
Wallet: walletAddress,
}
miner, err := getServiceManager().StartMiner(context.Background(), selectedMinerType, config)
miner, err := getServiceManager().StartMiner(context.Background(), minerType, config)
if err != nil {
return fmt.Errorf("failed to start miner: %w", err)
}

View file

@ -17,9 +17,9 @@ var statusCmd = &cobra.Command{
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
minerName := args[0]
miningManager := getServiceManager()
manager := getServiceManager()
miner, err := miningManager.GetMiner(minerName)
miner, err := manager.GetMiner(minerName)
if err != nil {
return fmt.Errorf("failed to get miner: %w", err)
}

View file

@ -60,10 +60,10 @@ type APIError struct {
Retryable bool `json:"retryable"` // Can the client retry?
}
// if debugErrorsEnabled { /* details exposed in API responses */ } // true when DEBUG_ERRORS=true or GIN_MODE != "release"
// 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") // => "" in production, => "exec: file not found" in debug mode
// sanitizeErrorDetails("exec: file not found") returns "" in production and the full string in debug mode.
func sanitizeErrorDetails(details string) string {
if debugErrorsEnabled {
return details
@ -71,7 +71,7 @@ func sanitizeErrorDetails(details string) string {
return ""
}
// respondWithError(c, http.StatusNotFound, ErrCodeMinerNotFound, "xmrig not found", err.Error())
// 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,
@ -80,7 +80,7 @@ func respondWithError(c *gin.Context, status int, code string, message string, d
Retryable: isRetryableError(status),
}
// respondWithError(c, http.StatusServiceUnavailable, ErrCodeServiceUnavailable, "service unavailable", "database offline") // adds a retry suggestion.
// 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"
@ -125,15 +125,15 @@ func respondWithMiningError(c *gin.Context, err *MiningError) {
c.JSON(err.StatusCode(), apiError)
}
// isRetryableError(http.StatusServiceUnavailable) // => true
// isRetryableError(http.StatusNotFound) // => false
// 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()) // GET /api/v1/mining/status returns X-Content-Type-Options: nosniff and Content-Security-Policy: default-src 'none'
// 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")
@ -145,7 +145,7 @@ func securityHeadersMiddleware() gin.HandlerFunc {
}
}
// router.Use(contentTypeValidationMiddleware()) // POST /api/v1/mining/profiles with Content-Type: text/plain returns 415 Unsupported Media Type
// 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
@ -174,7 +174,7 @@ func contentTypeValidationMiddleware() gin.HandlerFunc {
}
}
// router.Use(requestIDMiddleware()) // GET /api/v1/mining/status with X-Request-ID: trace-123 keeps the same ID in the response
// 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")
@ -189,7 +189,7 @@ func requestIDMiddleware() gin.HandlerFunc {
}
}
// requestID := generateRequestID() // "1712070000123-a1b2c3d4e5f6a7b8"
// requestID := generateRequestID() // example: 1712070000123-a1b2c3d4e5f6a7b8
func generateRequestID() string {
randomBytes := make([]byte, 8)
if _, err := rand.Read(randomBytes); err != nil {
@ -198,7 +198,7 @@ func generateRequestID() string {
return strconv.FormatInt(time.Now().UnixMilli(), 10) + "-" + hex.EncodeToString(randomBytes)
}
// requestID := getRequestID(c) // "trace-123" after requestIDMiddleware stores the incoming X-Request-ID header
// requestID := getRequestID(c) returns "trace-123" after requestIDMiddleware stores the incoming header.
func getRequestID(c *gin.Context) string {
if id, exists := c.Get("requestID"); exists {
if stringValue, ok := id.(string); ok {
@ -229,41 +229,36 @@ func logWithRequestID(c *gin.Context, level string, message string, fields loggi
}
}
// 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.
// 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
// 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 if Authorization header present (API client)
// Allow requests like `Authorization: Digest username="miner-admin"` from API clients.
if c.GetHeader("Authorization") != "" {
c.Next()
return
}
// Allow if X-Requested-With header present (AJAX/XHR request)
// Allow requests like `X-Requested-With: XMLHttpRequest` from browser clients.
if c.GetHeader("X-Requested-With") != "" {
c.Next()
return
}
// Allow if Content-Type is application/json (not sent by HTML forms)
// 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 the request as potential CSRF
// 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")
@ -271,10 +266,10 @@ func csrfMiddleware() gin.HandlerFunc {
}
}
// ctx, cancel := context.WithTimeout(context.Background(), DefaultRequestTimeout)
// context.WithTimeout(context.Background(), DefaultRequestTimeout) keeps long API requests under 30 seconds.
const DefaultRequestTimeout = 30 * time.Second
// Cache-Control header constants
// CachePublic5Min matches GET /api/v1/mining/miners/available responses.
const (
CacheNoStore = "no-store"
CacheNoCache = "no-cache"
@ -282,10 +277,10 @@ const (
CachePublic5Min = "public, max-age=300"
)
// router.Use(cacheMiddleware()) // serves /miners/available with Cache-Control: 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
// Only cache GET requests like /api/v1/mining/info.
if c.Request.Method != http.MethodGet {
c.Header("Cache-Control", CacheNoStore)
c.Next()
@ -294,19 +289,18 @@ func cacheMiddleware() gin.HandlerFunc {
path := c.Request.URL.Path
// Static-ish resources that can be cached briefly
// Cache GET /api/v1/mining/miners/available for 5 minutes.
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
// Cache GET /api/v1/mining/info for 1 minute.
c.Header("Cache-Control", CachePublic1Min)
case strings.Contains(path, "/swagger"):
// Swagger docs - can be cached
// Cache GET /api/v1/mining/swagger/index.html for 5 minutes.
c.Header("Cache-Control", CachePublic5Min)
default:
// Dynamic data (stats, miners, profiles) - don't cache
// Keep dynamic requests like /api/v1/mining/miners/xmrig live.
c.Header("Cache-Control", CacheNoCache)
}
@ -314,10 +308,10 @@ func cacheMiddleware() gin.HandlerFunc {
}
}
// router.Use(requestTimeoutMiddleware(30 * time.Second)) // aborts a slow /history/miners/xmrig request after 30 seconds
// 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 and streaming endpoints
// Skip timeout for WebSocket upgrades like /ws/events.
if c.GetHeader("Upgrade") == "websocket" {
c.Next()
return
@ -327,11 +321,11 @@ func requestTimeoutMiddleware(timeout time.Duration) gin.HandlerFunc {
return
}
// Create context with timeout
// 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 request context
// Replace the incoming request context with the timed one.
c.Request = c.Request.WithContext(ctx)
var responded int32
@ -345,9 +339,9 @@ func requestTimeoutMiddleware(timeout time.Duration) gin.HandlerFunc {
select {
case <-done:
// Request completed normally
// Request completed normally, for example GET /api/v1/mining/status.
case <-ctx.Done():
// Timeout occurred - only respond if handler hasn't already
// 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,
@ -357,36 +351,36 @@ func requestTimeoutMiddleware(timeout time.Duration) gin.HandlerFunc {
}
}
// conn, err := wsUpgrader.Upgrade(c.Writer, c.Request, nil) // upgrade HTTP to WebSocket
// 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 connections from localhost origins only
// Allow browser requests like `Origin: http://localhost:4200`.
origin := r.Header.Get("Origin")
if origin == "" {
return true // No origin header (non-browser clients)
return true // No Origin header, for example from curl or another non-browser client.
}
// Parse the origin URL properly to prevent bypass attacks
// 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
// 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, listenAddr string, displayAddr string, swaggerNamespace string) (*Service, error) {
// 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 = displayAddr
docs.SwaggerInfo.Host = displayAddress
docs.SwaggerInfo.BasePath = apiBasePath
instanceName := "swagger_" + strings.ReplaceAll(strings.Trim(swaggerNamespace, "/"), "/", "_")
swag.Register(instanceName, docs.SwaggerInfo)
@ -458,13 +452,13 @@ func NewService(manager ManagerInterface, listenAddr string, displayAddr string,
NodeService: nodeService,
EventHub: eventHub,
Server: &http.Server{
Addr: listenAddr,
Addr: listenAddress,
ReadTimeout: 30 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 60 * time.Second,
ReadHeaderTimeout: 10 * time.Second,
},
DisplayAddr: displayAddr,
DisplayAddr: displayAddress,
SwaggerInstanceName: instanceName,
APIBasePath: apiBasePath,
SwaggerUIPath: swaggerUIPath,