Ship live alerts, pool status, AI monitor, remote agent commands, build manager, and uninstall flow; wire Calibrate settings (WS ping, pool traffic log, retention limits) at runtime and exclude server/data from git.
344 lines
9.8 KiB
Go
344 lines
9.8 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"time"
|
|
|
|
"crypto-miner-server/internal/alerts"
|
|
"crypto-miner-server/internal/api"
|
|
"crypto-miner-server/internal/builder"
|
|
"crypto-miner-server/internal/db"
|
|
"crypto-miner-server/internal/maintenance"
|
|
"crypto-miner-server/internal/pool"
|
|
)
|
|
|
|
func main() {
|
|
log.SetFlags(log.LstdFlags | log.Lshortfile)
|
|
log.Println("Crypto Miner Control Server starting...")
|
|
|
|
// Load configuration
|
|
cfg := LoadConfig()
|
|
log.Printf("Configuration loaded: port=%d, dataDir=%s", cfg.Port, cfg.DataDir)
|
|
|
|
// Ensure data directories exist
|
|
dirs := []string{
|
|
cfg.DataDir,
|
|
filepath.Join(cfg.DataDir, "builds"),
|
|
filepath.Join(cfg.DataDir, "preps"),
|
|
filepath.Join(cfg.DataDir, "logs"),
|
|
}
|
|
for _, dir := range dirs {
|
|
if err := os.MkdirAll(dir, 0755); err != nil {
|
|
log.Fatalf("Failed to create directory %s: %v", dir, err)
|
|
}
|
|
}
|
|
|
|
// Initialize database
|
|
database, err := db.New(cfg.DataDir)
|
|
if err != nil {
|
|
log.Fatalf("Failed to initialize database: %v", err)
|
|
}
|
|
defer database.Close()
|
|
log.Println("Database initialized")
|
|
|
|
// Initialize AI autonomy handler (Ollama)
|
|
aiHandler := api.NewAIHandler(database)
|
|
log.Println("AI handler initialized")
|
|
|
|
// Initialize WebSocket hub
|
|
wsHub := api.NewWSHub(database)
|
|
wsHub.SetAIHandler(aiHandler)
|
|
aiHandler.SetEventBroadcaster(func(entry api.AIActivityEntry) {
|
|
wsHub.BroadcastAIActivity(entry)
|
|
})
|
|
log.Println("WebSocket hub initialized")
|
|
|
|
// Initialize builder handler
|
|
// The agent source is expected at ../agent relative to the server directory
|
|
agentSrcDir := findAgentSourceDir()
|
|
projectRoot := findProjectRoot()
|
|
builderHandler := builder.NewHandler(database, cfg.DataDir, agentSrcDir, projectRoot)
|
|
log.Printf("Builder handler initialized (agent source: %s)", agentSrcDir)
|
|
|
|
defaultPoolCfg := pool.Config{
|
|
Host: cfg.Pool.Host,
|
|
Port: cfg.Pool.Port,
|
|
UseTLS: cfg.Pool.UseTLS,
|
|
Wallet: cfg.Wallet.Address,
|
|
Password: cfg.Pool.Password,
|
|
}
|
|
|
|
// Initialize Stratum pool manager (connections keyed by forged pool + wallet)
|
|
poolManager := pool.NewManager(
|
|
func(job *pool.Job) {
|
|
log.Printf("[Pool] New job received: ID=%s, Height=%d", job.ID, job.Height)
|
|
payload, _ := json.Marshal(job)
|
|
wsHub.BroadcastToAgents(api.Message{
|
|
Type: "new_job",
|
|
Payload: payload,
|
|
})
|
|
},
|
|
func(err error) {
|
|
log.Printf("[Pool] Error: %v", err)
|
|
},
|
|
)
|
|
wsHub.SetPoolManager(poolManager, defaultPoolCfg)
|
|
|
|
applyRuntimeConfig(cfg, wsHub, poolManager, builderHandler)
|
|
|
|
configProvider := &serverConfigProvider{
|
|
config: cfg,
|
|
onSaved: func(c *Config) {
|
|
applyRuntimeConfig(c, wsHub, poolManager, builderHandler)
|
|
},
|
|
}
|
|
configHandler := api.NewConfigHandler(database, configProvider)
|
|
log.Println("Config handler initialized")
|
|
|
|
maintenance.StartRetentionJobs(database, cfg.DataDir, cfg.Server.StatsRetentionHours, cfg.Server.BuildRetentionDays)
|
|
|
|
// Pre-connect default upstream pool from server config (Forge defaults seed from here)
|
|
go func() {
|
|
if _, err := poolManager.EnsurePool(&defaultPoolCfg); err != nil {
|
|
log.Printf("[Pool] Failed to connect default pool (will retry on agent auth): %v", err)
|
|
}
|
|
}()
|
|
|
|
// Fleet alert evaluator (thresholds from Calibrate → alerts config)
|
|
alertEvaluator := alerts.NewEvaluator(database, func() alerts.Thresholds {
|
|
return alerts.Thresholds{
|
|
OfflineMinutes: cfg.Alerts.OfflineThresholdMinutes,
|
|
HashrateDropPct: cfg.Alerts.HashrateDropThresholdPct,
|
|
RejectionRatePct: cfg.Alerts.RejectionRateThresholdPct,
|
|
}
|
|
}, alerts.NotifyConfig{
|
|
TelegramBotToken: cfg.Alerts.TelegramBotToken,
|
|
TelegramChatID: cfg.Alerts.TelegramChatID,
|
|
EmailEnabled: cfg.Alerts.EmailEnabled,
|
|
SMTPHost: cfg.Alerts.SMTPHost,
|
|
SMTPPort: cfg.Alerts.SMTPPort,
|
|
SMTPUser: cfg.Alerts.SMTPUser,
|
|
SMTPPassword: cfg.Alerts.SMTPPassword,
|
|
EmailTo: cfg.Alerts.EmailTo,
|
|
EmailFrom: cfg.Alerts.EmailFrom,
|
|
}, func(ev alerts.AlertEvent) {
|
|
wsHub.BroadcastFleetAlert(ev)
|
|
})
|
|
alertEvaluator.Start(30 * time.Second)
|
|
log.Println("Fleet alert evaluator started")
|
|
|
|
// Pool status broadcast to dashboard
|
|
go func() {
|
|
ticker := time.NewTicker(15 * time.Second)
|
|
defer ticker.Stop()
|
|
for range ticker.C {
|
|
wsHub.BroadcastPoolStatus(poolManager.ListStatus())
|
|
}
|
|
}()
|
|
|
|
fleetHandler := api.NewFleetHandler(database, wsHub, aiHandler, poolManager, alertEvaluator, defaultPoolCfg)
|
|
|
|
// Initialize blueprint handler (config presets)
|
|
blueprintHandler := api.NewBlueprintHandler(cfg.DataDir)
|
|
log.Println("Blueprint handler initialized")
|
|
|
|
// Find web root for frontend
|
|
webRoot := findWebRoot()
|
|
log.Printf("Web root: %s", webRoot)
|
|
|
|
// Initialize router
|
|
router := api.NewRouter(database, wsHub, configHandler, builderHandler, blueprintHandler, aiHandler, fleetHandler, webRoot, func() string {
|
|
return configProvider.PublicURL()
|
|
})
|
|
log.Println("Router initialized")
|
|
|
|
// Start server
|
|
addr := fmt.Sprintf(":%d", cfg.Port)
|
|
log.Printf("Server listening on %s", addr)
|
|
log.Printf("Open http://localhost:%d in your browser", cfg.Port)
|
|
|
|
if err := http.ListenAndServe(addr, router); err != nil {
|
|
log.Fatalf("Server failed: %v", err)
|
|
}
|
|
}
|
|
|
|
func applyRuntimeConfig(cfg *Config, wsHub *api.WSHub, poolManager *pool.Manager, builderHandler *builder.Handler) {
|
|
if wsHub != nil {
|
|
wsHub.SetPingInterval(cfg.Server.WebSocketPingSeconds)
|
|
wsHub.SetServerPolicy(api.ServerPolicy{
|
|
MaxAgents: cfg.Server.MaxAgents,
|
|
LogAgentConnections: cfg.Server.LogAgentConnections,
|
|
LogShareSubmissions: cfg.Server.LogShareSubmissions,
|
|
LogPoolTraffic: cfg.Server.LogPoolTraffic,
|
|
StrictWalletValidation: cfg.Server.StrictWalletValidation,
|
|
MaxBuildSizeMB: cfg.Server.MaxBuildSizeMB,
|
|
PoolReconnectSeconds: cfg.Server.PoolReconnectSeconds,
|
|
})
|
|
}
|
|
if poolManager != nil {
|
|
poolManager.SetReconnectDelay(cfg.Server.PoolReconnectSeconds)
|
|
poolManager.SetVerboseTraffic(cfg.Server.LogPoolTraffic)
|
|
}
|
|
if builderHandler != nil {
|
|
builderHandler.SetBuildPolicy(builder.BuildPolicy{
|
|
StrictWalletValidation: cfg.Server.StrictWalletValidation,
|
|
MaxBuildSizeMB: cfg.Server.MaxBuildSizeMB,
|
|
})
|
|
}
|
|
}
|
|
|
|
// serverConfigProvider wraps the Config to implement api.ConfigProvider interface
|
|
type serverConfigProvider struct {
|
|
config *Config
|
|
onSaved func(*Config)
|
|
}
|
|
|
|
func (p *serverConfigProvider) PublicURL() string {
|
|
return p.config.Server.PublicURL
|
|
}
|
|
|
|
func (p *serverConfigProvider) GetConfigJSON() json.RawMessage {
|
|
data, _ := json.Marshal(p.config)
|
|
return data
|
|
}
|
|
|
|
func (p *serverConfigProvider) UpdateConfigFromJSON(data json.RawMessage) error {
|
|
var incoming Config
|
|
if err := json.Unmarshal(data, &incoming); err != nil {
|
|
return fmt.Errorf("invalid config: %w", err)
|
|
}
|
|
|
|
// Merge incoming config over current config
|
|
mergeConfig(p.config, &incoming)
|
|
|
|
// Save to disk
|
|
if err := p.config.Save(); err != nil {
|
|
return fmt.Errorf("failed to save config: %w", err)
|
|
}
|
|
|
|
if p.onSaved != nil {
|
|
p.onSaved(p.config)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// findAgentSourceDir locates the agent source code directory
|
|
// It searches relative to the server binary location and the current working directory
|
|
func findAgentSourceDir() string {
|
|
projectRoot := findProjectRoot()
|
|
candidates := []string{
|
|
filepath.Join(projectRoot, "agent"),
|
|
"../agent",
|
|
"./agent",
|
|
}
|
|
|
|
if cwd, err := os.Getwd(); err == nil {
|
|
candidates = append(candidates,
|
|
filepath.Join(cwd, "agent"),
|
|
filepath.Join(filepath.Dir(cwd), "agent"),
|
|
)
|
|
}
|
|
|
|
if exe, err := os.Executable(); err == nil {
|
|
exeDir := filepath.Dir(exe)
|
|
candidates = append(candidates,
|
|
filepath.Join(exeDir, "agent"),
|
|
filepath.Join(exeDir, "..", "agent"),
|
|
filepath.Join(exeDir, "..", "..", "agent"),
|
|
)
|
|
}
|
|
|
|
seen := map[string]bool{}
|
|
for _, candidate := range candidates {
|
|
absPath, err := filepath.Abs(candidate)
|
|
if err != nil || seen[absPath] {
|
|
continue
|
|
}
|
|
seen[absPath] = true
|
|
goModPath := filepath.Join(absPath, "go.mod")
|
|
if _, err := os.Stat(goModPath); err == nil {
|
|
return absPath
|
|
}
|
|
}
|
|
|
|
return filepath.Join(projectRoot, "agent")
|
|
}
|
|
|
|
func findProjectRoot() string {
|
|
if cwd, err := os.Getwd(); err == nil {
|
|
if _, err := os.Stat(filepath.Join(cwd, "run.bat")); err == nil {
|
|
return cwd
|
|
}
|
|
if _, err := os.Stat(filepath.Join(filepath.Dir(cwd), "run.bat")); err == nil {
|
|
return filepath.Dir(cwd)
|
|
}
|
|
}
|
|
if exe, err := os.Executable(); err == nil {
|
|
exeDir := filepath.Dir(exe)
|
|
candidates := []string{
|
|
exeDir,
|
|
filepath.Join(exeDir, ".."),
|
|
filepath.Join(exeDir, "..", ".."),
|
|
}
|
|
for _, candidate := range candidates {
|
|
if _, err := os.Stat(filepath.Join(candidate, "run.bat")); err == nil {
|
|
abs, _ := filepath.Abs(candidate)
|
|
return abs
|
|
}
|
|
}
|
|
}
|
|
if cwd, err := os.Getwd(); err == nil {
|
|
return cwd
|
|
}
|
|
return "."
|
|
}
|
|
|
|
// findWebRoot locates the frontend build output directory
|
|
func findWebRoot() string {
|
|
candidates := []string{
|
|
"webroot", // Copied by run.bat
|
|
"web/dist", // Vite build output relative to server/
|
|
filepath.Join("..", "server", "web", "dist"), // Relative to project root
|
|
filepath.Join("server", "web", "dist"), // From project root
|
|
}
|
|
|
|
if cwd, err := os.Getwd(); err == nil {
|
|
candidates = append(candidates,
|
|
filepath.Join(cwd, "webroot"),
|
|
filepath.Join(cwd, "web", "dist"),
|
|
filepath.Join(filepath.Dir(cwd), "server", "webroot"),
|
|
filepath.Join(filepath.Dir(cwd), "server", "web", "dist"),
|
|
)
|
|
}
|
|
|
|
if exe, err := os.Executable(); err == nil {
|
|
exeDir := filepath.Dir(exe)
|
|
candidates = append(candidates,
|
|
filepath.Join(exeDir, "..", "webroot"),
|
|
filepath.Join(exeDir, "..", "web", "dist"),
|
|
filepath.Join(exeDir, "..", "..", "server", "webroot"),
|
|
filepath.Join(exeDir, "..", "..", "server", "web", "dist"),
|
|
)
|
|
}
|
|
|
|
for _, candidate := range candidates {
|
|
absPath, err := filepath.Abs(candidate)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
// Check if it has index.html
|
|
indexPath := filepath.Join(absPath, "index.html")
|
|
if _, err := os.Stat(indexPath); err == nil {
|
|
return absPath
|
|
}
|
|
}
|
|
|
|
return ""
|
|
}
|