423 lines
13 KiB
Go
423 lines
13 KiB
Go
package config
|
|
|
|
import (
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
|
|
"github.com/cedar2025/xboard-node/internal/nlog"
|
|
"golang.org/x/term"
|
|
"gopkg.in/yaml.v3"
|
|
)
|
|
|
|
type Config struct {
|
|
Panel PanelConfig `yaml:"panel"`
|
|
Node NodeConfig `yaml:"node"`
|
|
Kernel KernelConfig `yaml:"kernel"`
|
|
Cert CertConfig `yaml:"cert"`
|
|
Log LogConfig `yaml:"log"`
|
|
Runtime RuntimeConfig `yaml:"runtime"`
|
|
WS WSConfig `yaml:"ws"`
|
|
// Standalone enables a local-only node that never contacts the panel.
|
|
Standalone *StandaloneConfig `yaml:"standalone,omitempty"`
|
|
// HealthPort enables a lightweight HTTP health-check endpoint on the
|
|
// given port (e.g. 65530). 0 = disabled (default).
|
|
HealthPort int `yaml:"health_port"`
|
|
// Nodes enables multi-node mode. When set, Panel.NodeID is ignored and
|
|
// one service instance is started per entry. All entries share the same
|
|
// panel URL/token, kernel type, log settings and runtime tuning.
|
|
Nodes []NodeEntry `yaml:"nodes,omitempty"`
|
|
}
|
|
|
|
// NodeEntry describes a single node in multi-node mode.
|
|
type NodeEntry struct {
|
|
NodeID int `yaml:"node_id"`
|
|
NodeType string `yaml:"node_type,omitempty"`
|
|
// Kernel allows per-node overrides (e.g. config_dir). nil = inherit global.
|
|
Kernel *KernelOverride `yaml:"kernel,omitempty"`
|
|
// Cert allows per-node certificate overrides. nil = inherit global.
|
|
Cert *CertConfig `yaml:"cert,omitempty"`
|
|
}
|
|
|
|
// KernelOverride holds the subset of KernelConfig that is useful to override
|
|
// per-node. Only non-zero fields replace the global value.
|
|
type KernelOverride struct {
|
|
ConfigDir string `yaml:"config_dir,omitempty"`
|
|
GeoDataDir string `yaml:"geo_data_dir,omitempty"`
|
|
LogLevel string `yaml:"log_level,omitempty"`
|
|
CustomConfig string `yaml:"custom_config,omitempty"`
|
|
}
|
|
|
|
// RuntimeConfig tunes Go runtime memory behaviour.
|
|
// These knobs let operators trade CPU against memory on constrained machines
|
|
// without recompiling.
|
|
//
|
|
// Example (config.yml):
|
|
//
|
|
// runtime:
|
|
// gomemlimit: "30MiB" # soft RSS cap; GC becomes more aggressive above this
|
|
// gogc: 50 # halve GC target → lower peak RSS, slightly more CPU
|
|
type RuntimeConfig struct {
|
|
// GoMemLimit is a human-readable soft memory limit passed to runtime/debug.SetMemoryLimit.
|
|
// Valid suffixes: B, KiB, MiB, GiB, TiB. Empty = no limit (default).
|
|
// Recommended starting point: set to ~80% of the machine's available RAM.
|
|
GoMemLimit string `yaml:"gomemlimit"`
|
|
|
|
// GoGCPercent overrides GOGC (default 100).
|
|
// Lower values (e.g. 50) trigger GC more often → lower memory, slightly higher CPU.
|
|
// 0 means "use the default (100)".
|
|
GoGCPercent int `yaml:"gogc"`
|
|
}
|
|
|
|
type PanelConfig struct {
|
|
URL string `yaml:"url"`
|
|
Token string `yaml:"token"`
|
|
NodeID int `yaml:"node_id"`
|
|
NodeType string `yaml:"node_type"`
|
|
}
|
|
|
|
type NodeConfig struct {
|
|
PushInterval int `yaml:"push_interval"`
|
|
PullInterval int `yaml:"pull_interval"`
|
|
TrackInterval int `yaml:"track_interval"` // sec, default 10
|
|
DeviceReportInterval int `yaml:"device_report_interval"` // sec, default 30
|
|
}
|
|
|
|
// WSConfig holds WebSocket client tuning options.
|
|
type WSConfig struct {
|
|
StatusInterval int `yaml:"status_interval"` // node.status interval (sec), default 10
|
|
HandshakeTimeout int `yaml:"handshake_timeout"` // WS handshake timeout (sec), default 15
|
|
BackoffInitial int `yaml:"backoff_initial"` // initial reconnect delay (sec), default 1
|
|
BackoffMax int `yaml:"backoff_max"` // max reconnect delay (sec), default 60
|
|
DiscoveryInterval int `yaml:"discovery_interval"` // WS discovery interval (sec), default 300
|
|
}
|
|
|
|
type KernelConfig struct {
|
|
Type string `yaml:"type"` // "singbox" or "xray"
|
|
ConfigDir string `yaml:"config_dir"`
|
|
LogLevel string `yaml:"log_level"`
|
|
|
|
// GeoDataDir is the directory that contains GeoIP/GeoSite database files.
|
|
// For sing-box: geoip.db and geosite.db (geoip2-format).
|
|
// For xray: geoip.dat and geosite.dat.
|
|
// Defaults to config_dir when empty. You only need to set this if your
|
|
// geo database files live somewhere other than config_dir.
|
|
GeoDataDir string `yaml:"geo_data_dir"`
|
|
|
|
// CustomOutbound adds outbound entries to the generated kernel config.
|
|
// Each item is a raw kernel-native outbound object (sing-box or xray format).
|
|
CustomOutbound []map[string]any `yaml:"custom_outbound"`
|
|
|
|
// CustomRoute adds route rules to the generated kernel config.
|
|
// Each item is a raw kernel-native route rule object.
|
|
CustomRoute []map[string]any `yaml:"custom_route"`
|
|
|
|
// CustomConfig is the path to a kernel-native config file (JSON or YAML)
|
|
// that is deep-merged into the auto-generated config. This enables full
|
|
// customization of dns, outbounds, endpoints, route, experimental, etc.
|
|
// Compatible with V2bX OriginalPath format.
|
|
CustomConfig string `yaml:"custom_config"`
|
|
}
|
|
|
|
type CertConfig struct {
|
|
AutoTLS bool `yaml:"auto_tls"`
|
|
Domain string `yaml:"domain"`
|
|
Email string `yaml:"email"`
|
|
CertFile string `yaml:"cert_file"`
|
|
KeyFile string `yaml:"key_file"`
|
|
CertDir string `yaml:"cert_dir"`
|
|
HTTPPort int `yaml:"http_port"` // port for HTTP-01 challenge (default: 80)
|
|
|
|
// CertMode selects the TLS certificate strategy:
|
|
// "" - auto-detect: if CertFile is set → file; if AutoTLS → http; else none
|
|
// "http" - ACME HTTP-01 challenge (requires port 80)
|
|
// "dns" - ACME DNS-01 challenge (requires DNSProvider + DNSEnv)
|
|
// "self" - generate a self-signed certificate (valid 10 years)
|
|
// "file" - use manually provided CertFile/KeyFile paths
|
|
// "content"- cert/key PEM provided directly in CertContent/KeyContent
|
|
// "none" - no TLS
|
|
CertMode string `yaml:"cert_mode"`
|
|
|
|
// DNSProvider specifies the DNS provider for DNS-01 challenge.
|
|
// Supported: "cloudflare", "alidns"
|
|
DNSProvider string `yaml:"dns_provider"`
|
|
|
|
// DNSEnv passes credentials to the DNS provider as key=value pairs.
|
|
// Example for cloudflare: {"CF_API_TOKEN": "xxxx"}
|
|
DNSEnv map[string]string `yaml:"dns_env"`
|
|
|
|
// CertContent / KeyContent hold PEM-encoded certificate and private key.
|
|
// Used when CertMode == "content" (e.g. panel pushes a cert directly).
|
|
// The values are written to CertDir and then referenced as files by the kernel.
|
|
CertContent string `yaml:"cert_content,omitempty"`
|
|
KeyContent string `yaml:"key_content,omitempty"`
|
|
}
|
|
|
|
type LogConfig struct {
|
|
Level string `yaml:"level"`
|
|
Output string `yaml:"output"`
|
|
}
|
|
|
|
// Load reads configuration from a YAML file, then applies environment variable
|
|
// overrides. If the config file does not exist, a config is built entirely from
|
|
// environment variables (useful for Docker deployment with -e flags).
|
|
func Load(path string) (*Config, error) {
|
|
cfg := &Config{}
|
|
|
|
data, err := os.ReadFile(path)
|
|
if err == nil {
|
|
if err := yaml.Unmarshal(data, cfg); err != nil {
|
|
return nil, fmt.Errorf("parse config: %w", err)
|
|
}
|
|
} else if !os.IsNotExist(err) {
|
|
return nil, fmt.Errorf("read config file: %w", err)
|
|
}
|
|
|
|
cfg.applyEnvOverrides()
|
|
cfg.setDefaults()
|
|
|
|
if err := cfg.validate(); err != nil {
|
|
return nil, fmt.Errorf("invalid config: %w", err)
|
|
}
|
|
|
|
return cfg, nil
|
|
}
|
|
|
|
// envFirst returns the first non-empty value among the given env var names.
|
|
func envFirst(names ...string) string {
|
|
for _, n := range names {
|
|
if v := os.Getenv(n); v != "" {
|
|
return v
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func (c *Config) applyEnvOverrides() {
|
|
if v := envFirst("apiHost", "API_HOST"); v != "" {
|
|
c.Panel.URL = v
|
|
}
|
|
if v := envFirst("apiKey", "API_KEY"); v != "" {
|
|
c.Panel.Token = v
|
|
}
|
|
if v := envFirst("nodeID", "NODE_ID"); v != "" {
|
|
if id, err := strconv.Atoi(v); err == nil {
|
|
c.Panel.NodeID = id
|
|
}
|
|
}
|
|
if v := envFirst("nodeType", "NODE_TYPE"); v != "" {
|
|
c.Panel.NodeType = v
|
|
}
|
|
if v := envFirst("kernel", "KERNEL_TYPE"); v != "" {
|
|
c.Kernel.Type = v
|
|
}
|
|
if v := envFirst("certFile", "CERT_FILE"); v != "" {
|
|
c.Cert.CertFile = v
|
|
}
|
|
if v := envFirst("keyFile", "KEY_FILE"); v != "" {
|
|
c.Cert.KeyFile = v
|
|
}
|
|
if v := envFirst("domain", "DOMAIN"); v != "" {
|
|
c.Cert.Domain = v
|
|
c.Cert.AutoTLS = true
|
|
}
|
|
if v := envFirst("logLevel", "LOG_LEVEL"); v != "" {
|
|
c.Log.Level = v
|
|
}
|
|
}
|
|
|
|
func (c *Config) setDefaults() {
|
|
if c.Kernel.Type == "" {
|
|
c.Kernel.Type = "singbox"
|
|
}
|
|
if c.Kernel.ConfigDir == "" {
|
|
c.Kernel.ConfigDir = "/etc/xboard-node"
|
|
}
|
|
if c.Kernel.GeoDataDir == "" {
|
|
c.Kernel.GeoDataDir = c.Kernel.ConfigDir
|
|
}
|
|
if c.Kernel.LogLevel == "" {
|
|
c.Kernel.LogLevel = "warn"
|
|
}
|
|
if c.Log.Level == "" {
|
|
c.Log.Level = "info"
|
|
}
|
|
if c.Log.Output == "" {
|
|
c.Log.Output = "stdout"
|
|
}
|
|
if c.Cert.CertDir == "" {
|
|
c.Cert.CertDir = filepath.Join(c.Kernel.ConfigDir, "certs")
|
|
}
|
|
if c.Cert.HTTPPort == 0 {
|
|
c.Cert.HTTPPort = 80
|
|
}
|
|
// WS defaults
|
|
if c.WS.StatusInterval == 0 {
|
|
c.WS.StatusInterval = 10
|
|
}
|
|
if c.WS.HandshakeTimeout == 0 {
|
|
c.WS.HandshakeTimeout = 15
|
|
}
|
|
if c.WS.BackoffInitial == 0 {
|
|
c.WS.BackoffInitial = 1
|
|
}
|
|
if c.WS.BackoffMax == 0 {
|
|
c.WS.BackoffMax = 60
|
|
}
|
|
if c.WS.DiscoveryInterval == 0 {
|
|
c.WS.DiscoveryInterval = 300
|
|
}
|
|
// Node defaults
|
|
if c.Node.TrackInterval == 0 {
|
|
c.Node.TrackInterval = 10
|
|
}
|
|
if c.Node.DeviceReportInterval == 0 {
|
|
c.Node.DeviceReportInterval = 30
|
|
}
|
|
}
|
|
|
|
func (c *Config) validate() error {
|
|
if c.IsStandalone() {
|
|
if err := c.validateStandalone(); err != nil {
|
|
return err
|
|
}
|
|
} else {
|
|
if c.Panel.URL == "" {
|
|
return fmt.Errorf("panel.url is required")
|
|
}
|
|
if c.Panel.Token == "" {
|
|
return fmt.Errorf("panel.token is required")
|
|
}
|
|
// In multi-node mode panel.node_id is optional; validate each NodeEntry instead.
|
|
if len(c.Nodes) == 0 && c.Panel.NodeID <= 0 {
|
|
return fmt.Errorf("panel.node_id must be positive (or use 'nodes:' for multi-node)")
|
|
}
|
|
for i, n := range c.Nodes {
|
|
if n.NodeID <= 0 {
|
|
return fmt.Errorf("nodes[%d].node_id must be positive", i)
|
|
}
|
|
}
|
|
}
|
|
switch c.Kernel.Type {
|
|
case "singbox", "xray":
|
|
default:
|
|
return fmt.Errorf("kernel.type must be 'singbox' or 'xray', got '%s'", c.Kernel.Type)
|
|
}
|
|
if c.Cert.AutoTLS && c.Cert.Domain == "" {
|
|
return fmt.Errorf("cert.domain is required when cert.auto_tls is enabled")
|
|
}
|
|
if c.Node.PushInterval < 0 {
|
|
return fmt.Errorf("node.push_interval must not be negative")
|
|
}
|
|
if c.Node.PullInterval < 0 {
|
|
return fmt.Errorf("node.pull_interval must not be negative")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ExpandNodes returns one *Config per node to run.
|
|
// Single-node mode (Nodes empty): returns a slice containing the receiver.
|
|
// Multi-node mode: returns one derived *Config per NodeEntry, each inheriting
|
|
// shared settings and applying per-node overrides.
|
|
func (c *Config) ExpandNodes() []*Config {
|
|
if len(c.Nodes) == 0 {
|
|
return []*Config{c}
|
|
}
|
|
|
|
result := make([]*Config, 0, len(c.Nodes))
|
|
for _, entry := range c.Nodes {
|
|
nodeCfg := *c // shallow copy — safe because slices/maps are not mutated
|
|
nodeCfg.Nodes = nil
|
|
nodeCfg.Panel.NodeID = entry.NodeID
|
|
nodeCfg.Panel.NodeType = entry.NodeType
|
|
|
|
// Per-node kernel overrides
|
|
if entry.Kernel != nil {
|
|
if entry.Kernel.ConfigDir != "" {
|
|
nodeCfg.Kernel.ConfigDir = entry.Kernel.ConfigDir
|
|
if nodeCfg.Kernel.GeoDataDir == c.Kernel.ConfigDir {
|
|
// GeoDataDir was defaulted to ConfigDir — keep it pointing at
|
|
// the new ConfigDir unless the user set it explicitly.
|
|
nodeCfg.Kernel.GeoDataDir = entry.Kernel.ConfigDir
|
|
}
|
|
}
|
|
if entry.Kernel.GeoDataDir != "" {
|
|
nodeCfg.Kernel.GeoDataDir = entry.Kernel.GeoDataDir
|
|
}
|
|
if entry.Kernel.LogLevel != "" {
|
|
nodeCfg.Kernel.LogLevel = entry.Kernel.LogLevel
|
|
}
|
|
if entry.Kernel.CustomConfig != "" {
|
|
nodeCfg.Kernel.CustomConfig = entry.Kernel.CustomConfig
|
|
}
|
|
} else {
|
|
// Auto-derive a unique config_dir per node to avoid conflicts.
|
|
nodeCfg.Kernel.ConfigDir = fmt.Sprintf("%s/node-%d", c.Kernel.ConfigDir, entry.NodeID)
|
|
if nodeCfg.Kernel.GeoDataDir == c.Kernel.ConfigDir {
|
|
// Share the geo data dir with the base dir to avoid re-downloading.
|
|
nodeCfg.Kernel.GeoDataDir = c.Kernel.GeoDataDir
|
|
}
|
|
}
|
|
|
|
// Per-node cert overrides
|
|
if entry.Cert != nil {
|
|
nodeCfg.Cert = *entry.Cert
|
|
}
|
|
if nodeCfg.Cert.CertDir == "" {
|
|
nodeCfg.Cert.CertDir = filepath.Join(nodeCfg.Kernel.ConfigDir, "certs")
|
|
}
|
|
|
|
result = append(result, &nodeCfg)
|
|
}
|
|
return result
|
|
}
|
|
|
|
func InitLogger(cfg LogConfig) {
|
|
var minLevel slog.Level
|
|
switch cfg.Level {
|
|
case "debug":
|
|
minLevel = slog.LevelDebug
|
|
case "warn":
|
|
minLevel = slog.LevelWarn
|
|
case "error":
|
|
minLevel = slog.LevelError
|
|
default:
|
|
minLevel = slog.LevelInfo
|
|
}
|
|
|
|
var w io.Writer
|
|
useColor := false
|
|
switch cfg.Output {
|
|
case "stdout", "":
|
|
w = os.Stdout
|
|
useColor = term.IsTerminal(int(os.Stdout.Fd()))
|
|
case "stderr":
|
|
w = os.Stderr
|
|
useColor = term.IsTerminal(int(os.Stderr.Fd()))
|
|
default:
|
|
dir := filepath.Dir(cfg.Output)
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
fmt.Fprintf(os.Stderr, "failed to create log dir, falling back to stdout: %v\n", err)
|
|
w = os.Stdout
|
|
useColor = term.IsTerminal(int(os.Stdout.Fd()))
|
|
} else {
|
|
f, err := os.OpenFile(cfg.Output, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "failed to open log file, falling back to stdout: %v\n", err)
|
|
w = os.Stdout
|
|
useColor = term.IsTerminal(int(os.Stdout.Fd()))
|
|
} else {
|
|
w = f
|
|
useColor = false
|
|
}
|
|
}
|
|
}
|
|
|
|
nlog.Init(w, minLevel, useColor)
|
|
// Application logging goes through nlog; silence slog.Default for stray library use.
|
|
slog.SetDefault(slog.New(slog.DiscardHandler))
|
|
}
|