Files
AetherForge/server/main.go

433 lines
13 KiB
Go

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)
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)
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)
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
// <repo>/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 ""
}