Refine AX naming and usage comments
This commit is contained in:
parent
1f9624279a
commit
e3986fb064
4 changed files with 115 additions and 121 deletions
|
|
@ -15,16 +15,13 @@ import (
|
|||
|
||||
const signpostFilename = ".installed-miners"
|
||||
|
||||
// validateConfigPath validates that a config path is within the expected XDG config directory
|
||||
// This prevents path traversal attacks via manipulated signpost files
|
||||
// validateConfigPath("/home/alice/.config/lethean-desktop/miners/config.json") // nil
|
||||
// validateConfigPath("/tmp/config.json") // error
|
||||
func validateConfigPath(configPath string) error {
|
||||
// Get the expected XDG config base directory
|
||||
expectedBase := filepath.Join(xdg.ConfigHome, "lethean-desktop")
|
||||
|
||||
// Clean and resolve the config path
|
||||
cleanPath := filepath.Clean(configPath)
|
||||
|
||||
// Check if the path is within the expected directory
|
||||
if !strings.HasPrefix(cleanPath, expectedBase+string(os.PathSeparator)) && cleanPath != expectedBase {
|
||||
return fmt.Errorf("invalid config path: must be within %s", expectedBase)
|
||||
}
|
||||
|
|
@ -45,7 +42,7 @@ var doctorCmd = &cobra.Command{
|
|||
if err := updateDoctorCache(); err != nil {
|
||||
return fmt.Errorf("failed to run doctor check: %w", err)
|
||||
}
|
||||
// After updating the cache, display the fresh results
|
||||
// loadAndDisplayCache() // prints the refreshed miner summary after updateDoctorCache()
|
||||
_, err := loadAndDisplayCache()
|
||||
return err
|
||||
},
|
||||
|
|
@ -69,7 +66,7 @@ func loadAndDisplayCache() (bool, error) {
|
|||
}
|
||||
configPath := strings.TrimSpace(string(configPathBytes))
|
||||
|
||||
// Security: Validate that the config path is within the expected directory
|
||||
// validateConfigPath("/home/alice/.config/lethean-desktop/miners/config.json") // blocks path traversal
|
||||
if err := validateConfigPath(configPath); err != nil {
|
||||
return false, fmt.Errorf("security error: %w", err)
|
||||
}
|
||||
|
|
@ -93,7 +90,7 @@ func loadAndDisplayCache() (bool, error) {
|
|||
fmt.Println()
|
||||
|
||||
for _, details := range systemInfo.InstalledMinersInfo {
|
||||
// Infer miner name from path for display purposes
|
||||
// details.Path = "/home/alice/.local/share/lethean-desktop/miners/xmrig" -> "XMRig"
|
||||
var minerName string
|
||||
if details.Path != "" {
|
||||
if strings.Contains(details.Path, "xmrig") {
|
||||
|
|
|
|||
|
|
@ -13,20 +13,20 @@ import (
|
|||
"forge.lthn.ai/Snider/Mining/pkg/logging"
|
||||
)
|
||||
|
||||
// equalFold("xmrig", "XMRig") == true
|
||||
// equalFold("tt-miner", "TT-Miner") == true
|
||||
// equalFold("xmrig", "XMRig") // true
|
||||
// equalFold("tt-miner", "TT-Miner") // true
|
||||
func equalFold(left, right string) bool {
|
||||
return bytes.EqualFold([]byte(left), []byte(right))
|
||||
}
|
||||
|
||||
// hasPrefix("xmrig-rx0", "xmrig") == true
|
||||
// hasPrefix("ttminer-rtx", "xmrig") == false
|
||||
// hasPrefix("xmrig-rx0", "xmrig") // true
|
||||
// hasPrefix("ttminer-rtx", "xmrig") // false
|
||||
func hasPrefix(input, prefix string) bool {
|
||||
return len(input) >= len(prefix) && input[:len(prefix)] == prefix
|
||||
}
|
||||
|
||||
// containsStr("peer not found", "not found") == true
|
||||
// containsStr("connection ok", "not found") == false
|
||||
// containsStr("peer not found", "not found") // true
|
||||
// containsStr("connection ok", "not found") // false
|
||||
func containsStr(haystack, needle string) bool {
|
||||
if len(needle) == 0 {
|
||||
return true
|
||||
|
|
@ -42,7 +42,7 @@ func containsStr(haystack, needle string) bool {
|
|||
return false
|
||||
}
|
||||
|
||||
// safe := instanceNameRegex.ReplaceAllString("my algo!", "_") // => "my_algo_"
|
||||
// safeName := instanceNameRegex.ReplaceAllString("my algo!", "_") // "my_algo_"
|
||||
var instanceNameRegex = regexp.MustCompile(`[^a-zA-Z0-9_/-]`)
|
||||
|
||||
// var managerInterface ManagerInterface = mining.NewManager()
|
||||
|
|
@ -96,7 +96,7 @@ func (m *Manager) emitEvent(eventType EventType, data interface{}) {
|
|||
var _ ManagerInterface = (*Manager)(nil)
|
||||
|
||||
// manager := mining.NewManager()
|
||||
// defer manager.Stop()
|
||||
// defer manager.Stop() // stops miner goroutines and the hourly database cleanup loop
|
||||
func NewManager() *Manager {
|
||||
manager := &Manager{
|
||||
miners: make(map[string]Miner),
|
||||
|
|
@ -111,7 +111,7 @@ func NewManager() *Manager {
|
|||
}
|
||||
|
||||
// manager := mining.NewManagerForSimulation()
|
||||
// manager.StartMiner(ctx, "xmrig", &mining.Config{Algo: "rx/0"})
|
||||
// manager.StartMiner(ctx, "xmrig", &Config{Algo: "rx/0"})
|
||||
func NewManagerForSimulation() *Manager {
|
||||
manager := &Manager{
|
||||
miners: make(map[string]Miner),
|
||||
|
|
@ -122,7 +122,7 @@ func NewManagerForSimulation() *Manager {
|
|||
return manager
|
||||
}
|
||||
|
||||
// m.initDatabase() // called in NewManager(); enables persistence if MinersConfig.Database.Enabled == true
|
||||
// m.initDatabase() // NewManager() calls this after loading miners.json, for example with Database.Enabled = true
|
||||
func (m *Manager) initDatabase() {
|
||||
minersConfiguration, err := LoadMinersConfig()
|
||||
if err != nil {
|
||||
|
|
@ -154,11 +154,11 @@ func (m *Manager) initDatabase() {
|
|||
|
||||
logging.Info("database persistence enabled", logging.Fields{"retention_days": m.databaseRetention})
|
||||
|
||||
// Start periodic cleanup
|
||||
// m.startDBCleanup() // keeps database.Cleanup(30) running after persistence is enabled
|
||||
m.startDBCleanup()
|
||||
}
|
||||
|
||||
// m.startDBCleanup() // called after initDatabase(); purges rows older than m.databaseRetention days once per hour
|
||||
// m.startDBCleanup() // runs database.Cleanup(30) once an hour after persistence is enabled
|
||||
func (m *Manager) startDBCleanup() {
|
||||
m.waitGroup.Add(1)
|
||||
go func() {
|
||||
|
|
@ -168,11 +168,11 @@ func (m *Manager) startDBCleanup() {
|
|||
logging.Error("panic in database cleanup goroutine", logging.Fields{"panic": r})
|
||||
}
|
||||
}()
|
||||
// Run cleanup once per hour
|
||||
// ticker := time.NewTicker(time.Hour) // checks for expired rows every 60 minutes
|
||||
ticker := time.NewTicker(time.Hour)
|
||||
defer ticker.Stop()
|
||||
|
||||
// Run initial cleanup
|
||||
// database.Cleanup(30) // removes rows older than 30 days during startup
|
||||
if err := database.Cleanup(m.databaseRetention); err != nil {
|
||||
logging.Warn("database cleanup failed", logging.Fields{"error": err})
|
||||
}
|
||||
|
|
@ -190,8 +190,7 @@ func (m *Manager) startDBCleanup() {
|
|||
}()
|
||||
}
|
||||
|
||||
// m.syncMinersConfig() // called on startup; adds {MinerType: "xmrig", Autostart: false} for any
|
||||
// registered miner not yet present in miners.json — existing entries are left unchanged.
|
||||
// m.syncMinersConfig() // when miners.json only contains tt-miner, this adds xmrig with Autostart=false
|
||||
func (m *Manager) syncMinersConfig() {
|
||||
minersConfiguration, err := LoadMinersConfig()
|
||||
if err != nil {
|
||||
|
|
@ -214,7 +213,7 @@ func (m *Manager) syncMinersConfig() {
|
|||
minersConfiguration.Miners = append(minersConfiguration.Miners, MinerAutostartConfig{
|
||||
MinerType: availableMiner.Name,
|
||||
Autostart: false,
|
||||
Config: nil, // No default config
|
||||
Config: nil, // keep the new miner disabled until the user saves a profile
|
||||
})
|
||||
configUpdated = true
|
||||
logging.Info("added default config for missing miner", logging.Fields{"miner": availableMiner.Name})
|
||||
|
|
@ -228,7 +227,7 @@ func (m *Manager) syncMinersConfig() {
|
|||
}
|
||||
}
|
||||
|
||||
// m.autostartMiners() // called in NewManager(); reads miners.json and starts any entry with Autostart == true
|
||||
// m.autostartMiners() // NewManager() uses this to start xmrig when miners.json contains Autostart=true
|
||||
func (m *Manager) autostartMiners() {
|
||||
minersConfiguration, err := LoadMinersConfig()
|
||||
if err != nil {
|
||||
|
|
@ -262,7 +261,7 @@ func findAvailablePort() (int, error) {
|
|||
return listener.Addr().(*net.TCPAddr).Port, nil
|
||||
}
|
||||
|
||||
// miner, err := manager.StartMiner(ctx, "xmrig", &mining.Config{Algo: "rx/0"})
|
||||
// miner, err := manager.StartMiner(ctx, "xmrig", &Config{Algo: "rx/0"})
|
||||
func (m *Manager) StartMiner(ctx context.Context, minerType string, config *Config) (Miner, error) {
|
||||
// Check for cancellation before acquiring lock
|
||||
select {
|
||||
|
|
@ -324,13 +323,13 @@ func (m *Manager) StartMiner(ctx context.Context, minerType string, config *Conf
|
|||
}
|
||||
}
|
||||
|
||||
// Emit starting event before actually starting
|
||||
// m.emitEvent(EventMinerStarting, MinerEventData{Name: "xmrig-rx_0"}) // fires before miner.Start(config)
|
||||
m.emitEvent(EventMinerStarting, MinerEventData{
|
||||
Name: instanceName,
|
||||
})
|
||||
|
||||
if err := miner.Start(config); err != nil {
|
||||
// Emit error event
|
||||
// m.emitEvent(EventMinerError, MinerEventData{Name: "xmrig-rx_0", Error: err.Error()})
|
||||
m.emitEvent(EventMinerError, MinerEventData{
|
||||
Name: instanceName,
|
||||
Error: err.Error(),
|
||||
|
|
@ -414,7 +413,7 @@ func (m *Manager) UninstallMiner(ctx context.Context, minerType string) error {
|
|||
})
|
||||
}
|
||||
|
||||
// m.updateMinerConfig("xmrig", true, config) // persists autostart=true and last-used config to miners.json
|
||||
// m.updateMinerConfig("xmrig", true, config) // saves Autostart=true and the last-used config back to miners.json
|
||||
func (m *Manager) updateMinerConfig(minerType string, autostart bool, config *Config) error {
|
||||
return UpdateMinersConfig(func(configuration *MinersConfig) error {
|
||||
found := false
|
||||
|
|
@ -498,7 +497,7 @@ func (m *Manager) StopMiner(ctx context.Context, name string) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
// miner, err := m.GetMiner("xmrig-randomx")
|
||||
// miner, err := m.GetMiner("xmrig-randomx") // returns ErrMinerNotFound when the name is missing
|
||||
// if err != nil { /* miner not found */ }
|
||||
func (m *Manager) GetMiner(name string) (Miner, error) {
|
||||
m.mutex.RLock()
|
||||
|
|
@ -522,8 +521,8 @@ func (m *Manager) ListMiners() []Miner {
|
|||
return miners
|
||||
}
|
||||
|
||||
// sim := NewSimulatedMiner(SimulatedMinerConfig{Name: "sim-rx0"})
|
||||
// if err := manager.RegisterMiner(sim); err != nil { return err }
|
||||
// simulatedMiner := NewSimulatedMiner(SimulatedMinerConfig{Name: "sim-rx0"})
|
||||
// if err := manager.RegisterMiner(simulatedMiner); err != nil { return err }
|
||||
func (m *Manager) RegisterMiner(miner Miner) error {
|
||||
name := miner.GetName()
|
||||
|
||||
|
|
@ -545,7 +544,7 @@ func (m *Manager) RegisterMiner(miner Miner) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
// for _, available := range manager.ListAvailableMiners() { logging.Info(available.Name, nil) }
|
||||
// for _, availableMiner := range manager.ListAvailableMiners() { logging.Info(availableMiner.Name, nil) }
|
||||
func (m *Manager) ListAvailableMiners() []AvailableMiner {
|
||||
return []AvailableMiner{
|
||||
{
|
||||
|
|
@ -559,7 +558,7 @@ func (m *Manager) ListAvailableMiners() []AvailableMiner {
|
|||
}
|
||||
}
|
||||
|
||||
// m.startStatsCollection() // called in NewManager(); polls all active miners every HighResolutionInterval
|
||||
// m.startStatsCollection() // NewManager() uses this to poll each running miner every HighResolutionInterval
|
||||
func (m *Manager) startStatsCollection() {
|
||||
m.waitGroup.Add(1)
|
||||
go func() {
|
||||
|
|
@ -586,7 +585,7 @@ func (m *Manager) startStatsCollection() {
|
|||
// ctx, cancel := context.WithTimeout(ctx, statsCollectionTimeout)
|
||||
const statsCollectionTimeout = 5 * time.Second
|
||||
|
||||
// m.collectMinerStats() // called by startStatsCollection ticker; gathers stats from all active miners in parallel
|
||||
// m.collectMinerStats() // the stats ticker calls this to poll all running miners in parallel
|
||||
func (m *Manager) collectMinerStats() {
|
||||
// Take a snapshot of miners under read lock - minimize lock duration
|
||||
m.mutex.RLock()
|
||||
|
|
@ -610,11 +609,11 @@ func (m *Manager) collectMinerStats() {
|
|||
now := time.Now()
|
||||
|
||||
// Collect stats from all miners in parallel
|
||||
var waitGroup sync.WaitGroup
|
||||
var collectionWaitGroup sync.WaitGroup
|
||||
for _, entry := range miners {
|
||||
waitGroup.Add(1)
|
||||
collectionWaitGroup.Add(1)
|
||||
go func(miner Miner, minerType string) {
|
||||
defer waitGroup.Done()
|
||||
defer collectionWaitGroup.Done()
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
logging.Error("panic in single miner stats collection", logging.Fields{
|
||||
|
|
@ -626,7 +625,7 @@ func (m *Manager) collectMinerStats() {
|
|||
m.collectSingleMinerStats(miner, minerType, now, databaseEnabled)
|
||||
}(entry.miner, entry.minerType)
|
||||
}
|
||||
waitGroup.Wait()
|
||||
collectionWaitGroup.Wait()
|
||||
}
|
||||
|
||||
// for attempt := 0; attempt <= statsRetryCount; attempt++ { ... }
|
||||
|
|
@ -693,9 +692,9 @@ func (m *Manager) collectSingleMinerStats(miner Miner, minerType string, now tim
|
|||
Timestamp: point.Timestamp,
|
||||
Hashrate: point.Hashrate,
|
||||
}
|
||||
// Create a new context for DB writes (original context is from retry loop)
|
||||
databaseContext, databaseCancel := context.WithTimeout(context.Background(), statsCollectionTimeout)
|
||||
if err := database.InsertHashratePoint(databaseContext, minerName, minerType, databasePoint, database.ResolutionHigh); err != nil {
|
||||
// database.InsertHashratePoint(ctx, "xmrig-rx_0", databasePoint, database.ResolutionHigh) // persists a single sample
|
||||
databaseWriteContext, databaseCancel := context.WithTimeout(context.Background(), statsCollectionTimeout)
|
||||
if err := database.InsertHashratePoint(databaseWriteContext, minerName, minerType, databasePoint, database.ResolutionHigh); err != nil {
|
||||
logging.Warn("failed to persist hashrate", logging.Fields{"miner": minerName, "error": err})
|
||||
}
|
||||
databaseCancel()
|
||||
|
|
@ -728,7 +727,7 @@ func (m *Manager) GetMinerHashrateHistory(name string) ([]HashratePoint, error)
|
|||
// ctx, cancel := context.WithTimeout(context.Background(), ShutdownTimeout)
|
||||
const ShutdownTimeout = 10 * time.Second
|
||||
|
||||
// defer manager.Stop() // safe in main() or test cleanup; subsequent calls are no-ops
|
||||
// defer manager.Stop() // stops miners, waits for goroutines, and closes the database during shutdown
|
||||
func (m *Manager) Stop() {
|
||||
m.stopOnce.Do(func() {
|
||||
// Stop all running miners first
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import (
|
|||
"sync"
|
||||
)
|
||||
|
||||
// var repo Repository[MinersConfig] = NewFileRepository[MinersConfig](path)
|
||||
// repo := NewFileRepository[MinersConfig]("/home/alice/.config/lethean-desktop/miners.json")
|
||||
// data, err := repo.Load()
|
||||
type Repository[T any] interface {
|
||||
// data, err := repo.Load()
|
||||
|
|
@ -20,7 +20,7 @@ type Repository[T any] interface {
|
|||
Update(modifier func(*T) error) error
|
||||
}
|
||||
|
||||
// repo := NewFileRepository[MinersConfig](path, WithDefaults(defaultMinersConfig))
|
||||
// repo := NewFileRepository[MinersConfig]("/home/alice/.config/lethean-desktop/miners.json", WithDefaults(defaultMinersConfig))
|
||||
// data, err := repo.Load()
|
||||
type FileRepository[T any] struct {
|
||||
mutex sync.RWMutex
|
||||
|
|
@ -28,17 +28,17 @@ type FileRepository[T any] struct {
|
|||
defaults func() T
|
||||
}
|
||||
|
||||
// repo := NewFileRepository[MinersConfig](path, WithDefaults(defaultMinersConfig), myOption)
|
||||
// repo := NewFileRepository[MinersConfig]("/home/alice/.config/lethean-desktop/miners.json", WithDefaults(defaultMinersConfig), myOption)
|
||||
type FileRepositoryOption[T any] func(*FileRepository[T])
|
||||
|
||||
// repo := NewFileRepository[MinersConfig](path, WithDefaults(defaultMinersConfig))
|
||||
// repo := NewFileRepository[MinersConfig]("/home/alice/.config/lethean-desktop/miners.json", WithDefaults(defaultMinersConfig))
|
||||
func WithDefaults[T any](defaultsProvider func() T) FileRepositoryOption[T] {
|
||||
return func(repo *FileRepository[T]) {
|
||||
repo.defaults = defaultsProvider
|
||||
}
|
||||
}
|
||||
|
||||
// repo := NewFileRepository[MinersConfig](path, WithDefaults(defaultMinersConfig))
|
||||
// repo := NewFileRepository[MinersConfig]("/home/alice/.config/lethean-desktop/miners.json", WithDefaults(defaultMinersConfig))
|
||||
func NewFileRepository[T any](path string, options ...FileRepositoryOption[T]) *FileRepository[T] {
|
||||
repo := &FileRepository[T]{
|
||||
path: path,
|
||||
|
|
@ -83,7 +83,7 @@ func (repository *FileRepository[T]) Save(data T) error {
|
|||
return repository.saveUnlocked(data)
|
||||
}
|
||||
|
||||
// return repository.saveUnlocked(data) // called by Save and Update while mutex is held
|
||||
// repository.saveUnlocked(updatedConfig) // used by Save() and Update() while the mutex is already held
|
||||
func (repository *FileRepository[T]) saveUnlocked(data T) error {
|
||||
dir := filepath.Dir(repository.path)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
|
|
@ -132,7 +132,7 @@ func (repository *FileRepository[T]) Update(modifier func(*T) error) error {
|
|||
return repository.saveUnlocked(data)
|
||||
}
|
||||
|
||||
// path := repo.Path() // => "/home/user/.config/lethean-desktop/miners.json"
|
||||
// path := repo.Path() // path == "/home/alice/.config/lethean-desktop/miners.json"
|
||||
func (repository *FileRepository[T]) Path() string {
|
||||
return repository.path
|
||||
}
|
||||
|
|
|
|||
|
|
@ -133,7 +133,7 @@ func isRetryableError(status int) bool {
|
|||
status == http.StatusGatewayTimeout
|
||||
}
|
||||
|
||||
// router.Use(securityHeadersMiddleware()) // X-Content-Type-Options, X-Frame-Options, CSP on every response
|
||||
// router.Use(securityHeadersMiddleware()) // sets X-Content-Type-Options=nosniff and CSP=default-src 'none' on every response
|
||||
func securityHeadersMiddleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
// Prevent MIME type sniffing
|
||||
|
|
@ -288,7 +288,7 @@ const (
|
|||
CachePublic5Min = "public, max-age=300"
|
||||
)
|
||||
|
||||
// router.Use(cacheMiddleware()) // sets Cache-Control: no-store on mutating routes, max-age=30 on read-only routes
|
||||
// router.Use(cacheMiddleware()) // serves /miners/available with Cache-Control: public, max-age=300
|
||||
func cacheMiddleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
// Only cache GET requests
|
||||
|
|
@ -320,7 +320,7 @@ func cacheMiddleware() gin.HandlerFunc {
|
|||
}
|
||||
}
|
||||
|
||||
// router.Use(requestTimeoutMiddleware(30 * time.Second))
|
||||
// router.Use(requestTimeoutMiddleware(30 * time.Second)) // aborts a slow /history/miners/xmrig request after 30 seconds
|
||||
func requestTimeoutMiddleware(timeout time.Duration) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
// Skip timeout for WebSocket upgrades and streaming endpoints
|
||||
|
|
@ -407,29 +407,29 @@ func NewService(manager ManagerInterface, listenAddr string, displayAddr string,
|
|||
}
|
||||
}
|
||||
|
||||
// Initialize node service (optional - only fails if XDG paths are broken)
|
||||
// NewNodeService() // falls back to miner-only API mode when XDG paths are unavailable
|
||||
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
|
||||
// NewEventHub() // broadcasts miner events to /ws/events clients
|
||||
eventHub := NewEventHub()
|
||||
go eventHub.Run()
|
||||
|
||||
// Wire up event hub to manager for miner events
|
||||
// concreteManager.SetEventHub(eventHub) // lets Manager broadcast start/stop/stat events
|
||||
if concreteManager, ok := manager.(*Manager); ok {
|
||||
concreteManager.SetEventHub(eventHub)
|
||||
}
|
||||
|
||||
// Set up state provider for WebSocket state sync on reconnect
|
||||
// eventHub.SetStateProvider(...) // returns running miner state after a reconnect
|
||||
eventHub.SetStateProvider(func() interface{} {
|
||||
miners := manager.ListMiners()
|
||||
if len(miners) == 0 {
|
||||
return nil
|
||||
}
|
||||
// Return current state of all miners
|
||||
// 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())
|
||||
|
|
@ -450,7 +450,7 @@ func NewService(manager ManagerInterface, listenAddr string, displayAddr string,
|
|||
}
|
||||
})
|
||||
|
||||
// Initialize authentication from environment
|
||||
// AuthConfigFromEnv() // enables digest auth when MINING_API_USERNAME and MINING_API_PASSWORD are set
|
||||
authConfig := AuthConfigFromEnv()
|
||||
var auth *DigestAuth
|
||||
if authConfig.Enabled {
|
||||
|
|
@ -479,11 +479,11 @@ func NewService(manager ManagerInterface, listenAddr string, displayAddr string,
|
|||
}
|
||||
|
||||
// service.InitRouter()
|
||||
// http.Handle("/", service.Router) // embed as handler in Wails or another HTTP server
|
||||
// http.Handle("/", service.Router) // embeds the API under a parent HTTP server in Wails
|
||||
func (s *Service) InitRouter() {
|
||||
s.Router = gin.Default()
|
||||
|
||||
// Extract port safely from server address for CORS
|
||||
// s.Server.Addr = ":9090" -> serverPort = "9090" for local CORS origins
|
||||
serverPort := "9090" // default fallback
|
||||
if s.Server.Addr != "" {
|
||||
if _, port, err := net.SplitHostPort(s.Server.Addr); err == nil && port != "" {
|
||||
|
|
@ -510,39 +510,38 @@ func (s *Service) InitRouter() {
|
|||
}
|
||||
s.Router.Use(cors.New(corsConfig))
|
||||
|
||||
// Add security headers (SEC-LOW-4)
|
||||
// s.Router.Use(securityHeadersMiddleware()) // sets security headers on every API response
|
||||
s.Router.Use(securityHeadersMiddleware())
|
||||
|
||||
// Add Content-Type validation for POST/PUT (API-MED-8)
|
||||
// s.Router.Use(contentTypeValidationMiddleware()) // rejects POST /miners/xmrig/install without application/json
|
||||
s.Router.Use(contentTypeValidationMiddleware())
|
||||
|
||||
// Add request body size limit middleware (1MB max)
|
||||
// c.Request.Body = http.MaxBytesReader(..., 1<<20) // caps request bodies at 1 MiB
|
||||
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()) // allows API clients with Authorization or X-Requested-With
|
||||
s.Router.Use(csrfMiddleware())
|
||||
|
||||
// Add request timeout middleware (RESIL-MED-8)
|
||||
// s.Router.Use(requestTimeoutMiddleware(DefaultRequestTimeout)) // aborts stalled requests after 30 seconds
|
||||
s.Router.Use(requestTimeoutMiddleware(DefaultRequestTimeout))
|
||||
|
||||
// Add cache headers middleware (API-MED-7)
|
||||
// s.Router.Use(cacheMiddleware()) // returns Cache-Control: public, max-age=300 for /miners/available
|
||||
s.Router.Use(cacheMiddleware())
|
||||
|
||||
// Add X-Request-ID middleware for request tracing
|
||||
// s.Router.Use(requestIDMiddleware()) // preserves the incoming X-Request-ID or creates a new one
|
||||
s.Router.Use(requestIDMiddleware())
|
||||
|
||||
// Add rate limiting (10 requests/second with burst of 20)
|
||||
// NewRateLimiter(10, 20) // allows bursts of 20 with a 10 requests/second refill rate
|
||||
s.rateLimiter = NewRateLimiter(10, 20)
|
||||
s.Router.Use(s.rateLimiter.Middleware())
|
||||
|
||||
s.SetupRoutes()
|
||||
}
|
||||
|
||||
// service.Stop() // called by ServiceStartup on ctx.Done(); also safe to call directly for embedded use
|
||||
// service.Stop() // stops rate limiting, auth, event hub, and node transport during shutdown
|
||||
func (s *Service) Stop() {
|
||||
if s.rateLimiter != nil {
|
||||
s.rateLimiter.Stop()
|
||||
|
|
@ -560,29 +559,29 @@ func (s *Service) Stop() {
|
|||
}
|
||||
}
|
||||
|
||||
// service.ServiceStartup(ctx) // blocks until ctx is cancelled; use InitRouter() to embed without a server
|
||||
// service.ServiceStartup(ctx) // starts the HTTP server and stops it when ctx.Done() fires
|
||||
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)
|
||||
// serverErrorChannel captures ListenAndServe failures without blocking startup
|
||||
serverErrorChannel := 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
|
||||
serverErrorChannel <- err
|
||||
}
|
||||
close(errChan) // Prevent goroutine leak
|
||||
close(serverErrorChannel) // 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)
|
||||
shutdownContext, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
if err := s.Server.Shutdown(ctxShutdown); err != nil {
|
||||
if err := s.Server.Shutdown(shutdownContext); err != nil {
|
||||
logging.Error("server shutdown error", logging.Fields{"error": err})
|
||||
}
|
||||
}()
|
||||
|
|
@ -591,7 +590,7 @@ func (s *Service) ServiceStartup(ctx context.Context) error {
|
|||
maxRetries := 50 // 50 * 100ms = 5 seconds max
|
||||
for i := 0; i < maxRetries; i++ {
|
||||
select {
|
||||
case err := <-errChan:
|
||||
case err := <-serverErrorChannel:
|
||||
if err != nil {
|
||||
return ErrInternal("failed to start server").WithCause(err)
|
||||
}
|
||||
|
|
@ -613,64 +612,64 @@ func (s *Service) ServiceStartup(ctx context.Context) error {
|
|||
// service.InitRouter()
|
||||
// service.SetupRoutes() // re-call after adding middleware manually
|
||||
func (s *Service) SetupRoutes() {
|
||||
apiGroup := s.Router.Group(s.APIBasePath)
|
||||
apiRouterGroup := s.Router.Group(s.APIBasePath)
|
||||
|
||||
// Health endpoints (no auth required for orchestration/monitoring)
|
||||
apiGroup.GET("/health", s.handleHealth)
|
||||
apiGroup.GET("/ready", s.handleReady)
|
||||
apiRouterGroup.GET("/health", s.handleHealth)
|
||||
apiRouterGroup.GET("/ready", s.handleReady)
|
||||
|
||||
// Apply authentication middleware if enabled
|
||||
if s.auth != nil {
|
||||
apiGroup.Use(s.auth.Middleware())
|
||||
apiRouterGroup.Use(s.auth.Middleware())
|
||||
}
|
||||
|
||||
{
|
||||
apiGroup.GET("/info", s.handleGetInfo)
|
||||
apiGroup.GET("/metrics", s.handleMetrics)
|
||||
apiGroup.POST("/doctor", s.handleDoctor)
|
||||
apiGroup.POST("/update", s.handleUpdateCheck)
|
||||
apiRouterGroup.GET("/info", s.handleGetInfo)
|
||||
apiRouterGroup.GET("/metrics", s.handleMetrics)
|
||||
apiRouterGroup.POST("/doctor", s.handleDoctor)
|
||||
apiRouterGroup.POST("/update", s.handleUpdateCheck)
|
||||
|
||||
minersGroup := apiGroup.Group("/miners")
|
||||
minersRouterGroup := apiRouterGroup.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)
|
||||
minersRouterGroup.GET("", s.handleListMiners)
|
||||
minersRouterGroup.GET("/available", s.handleListAvailableMiners)
|
||||
minersRouterGroup.POST("/:miner_name/install", s.handleInstallMiner)
|
||||
minersRouterGroup.DELETE("/:miner_name/uninstall", s.handleUninstallMiner)
|
||||
minersRouterGroup.DELETE("/:miner_name", s.handleStopMiner)
|
||||
minersRouterGroup.GET("/:miner_name/stats", s.handleGetMinerStats)
|
||||
minersRouterGroup.GET("/:miner_name/hashrate-history", s.handleGetMinerHashrateHistory)
|
||||
minersRouterGroup.GET("/:miner_name/logs", s.handleGetMinerLogs)
|
||||
minersRouterGroup.POST("/:miner_name/stdin", s.handleMinerStdin)
|
||||
}
|
||||
|
||||
// Historical data endpoints (database-backed)
|
||||
historyGroup := apiGroup.Group("/history")
|
||||
historyRouterGroup := apiRouterGroup.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)
|
||||
historyRouterGroup.GET("/status", s.handleHistoryStatus)
|
||||
historyRouterGroup.GET("/miners", s.handleAllMinersHistoricalStats)
|
||||
historyRouterGroup.GET("/miners/:miner_name", s.handleMinerHistoricalStats)
|
||||
historyRouterGroup.GET("/miners/:miner_name/hashrate", s.handleMinerHistoricalHashrate)
|
||||
}
|
||||
|
||||
profilesGroup := apiGroup.Group("/profiles")
|
||||
profilesRouterGroup := apiRouterGroup.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)
|
||||
profilesRouterGroup.GET("", s.handleListProfiles)
|
||||
profilesRouterGroup.POST("", s.handleCreateProfile)
|
||||
profilesRouterGroup.GET("/:id", s.handleGetProfile)
|
||||
profilesRouterGroup.PUT("/:id", s.handleUpdateProfile)
|
||||
profilesRouterGroup.DELETE("/:id", s.handleDeleteProfile)
|
||||
profilesRouterGroup.POST("/:id/start", s.handleStartMinerWithProfile)
|
||||
}
|
||||
|
||||
// WebSocket endpoint for real-time events
|
||||
wsGroup := apiGroup.Group("/ws")
|
||||
websocketRouterGroup := apiRouterGroup.Group("/ws")
|
||||
{
|
||||
wsGroup.GET("/events", s.handleWebSocketEvents)
|
||||
websocketRouterGroup.GET("/events", s.handleWebSocketEvents)
|
||||
}
|
||||
|
||||
// Add P2P node endpoints if node service is available
|
||||
if s.NodeService != nil {
|
||||
s.NodeService.SetupRoutes(apiGroup)
|
||||
s.NodeService.SetupRoutes(apiRouterGroup)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -683,8 +682,7 @@ func (s *Service) SetupRoutes() {
|
|||
swaggerURL := ginSwagger.URL("http://" + s.DisplayAddr + s.SwaggerUIPath + "/doc.json")
|
||||
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.
|
||||
// ginmcp.New(s.Router, ...) // exposes the API as MCP tools for Claude or Cursor
|
||||
s.mcpServer = ginmcp.New(s.Router, &ginmcp.Config{
|
||||
Name: "Mining API",
|
||||
Description: "Mining dashboard API exposed via Model Context Protocol (MCP)",
|
||||
|
|
@ -725,7 +723,7 @@ func (s *Service) handleReady(c *gin.Context) {
|
|||
components := make(map[string]string)
|
||||
allReady := true
|
||||
|
||||
// Check manager
|
||||
// s.Manager != nil -> "manager": "ready"
|
||||
if s.Manager != nil {
|
||||
components["manager"] = "ready"
|
||||
} else {
|
||||
|
|
@ -733,15 +731,15 @@ func (s *Service) handleReady(c *gin.Context) {
|
|||
allReady = false
|
||||
}
|
||||
|
||||
// Check profile manager
|
||||
// s.ProfileManager != nil -> "profiles": "ready"
|
||||
if s.ProfileManager != nil {
|
||||
components["profiles"] = "ready"
|
||||
} else {
|
||||
components["profiles"] = "degraded"
|
||||
// Don't fail readiness for degraded profile manager
|
||||
// keep readiness green when only profile loading is degraded
|
||||
}
|
||||
|
||||
// Check event hub
|
||||
// s.EventHub != nil -> "events": "ready"
|
||||
if s.EventHub != nil {
|
||||
components["events"] = "ready"
|
||||
} else {
|
||||
|
|
@ -749,7 +747,7 @@ func (s *Service) handleReady(c *gin.Context) {
|
|||
allReady = false
|
||||
}
|
||||
|
||||
// Check node service (optional)
|
||||
// s.NodeService != nil -> "p2p": "ready"
|
||||
if s.NodeService != nil {
|
||||
components["p2p"] = "ready"
|
||||
} else {
|
||||
|
|
@ -796,7 +794,7 @@ func (s *Service) updateInstallationCache() (*SystemInfo, error) {
|
|||
Architecture: runtime.GOARCH,
|
||||
GoVersion: runtime.Version(),
|
||||
AvailableCPUCores: runtime.NumCPU(),
|
||||
InstalledMinersInfo: []*InstallationDetails{}, // Initialize as empty slice
|
||||
InstalledMinersInfo: []*InstallationDetails{}, // keep /info responses stable when no miners are installed
|
||||
}
|
||||
|
||||
vMem, err := mem.VirtualMemory()
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue