init xboard node

This commit is contained in:
xboard
2026-03-15 09:27:24 +08:00
commit 37e6c016cb
40 changed files with 11011 additions and 0 deletions
+370
View File
@@ -0,0 +1,370 @@
package config
import (
"fmt"
"io"
"log/slog"
"os"
"path/filepath"
"strconv"
"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"`
// 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"`
}
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
}
}
func (c *Config) validate() error {
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 level slog.Level
switch cfg.Level {
case "debug":
level = slog.LevelDebug
case "warn":
level = slog.LevelWarn
case "error":
level = slog.LevelError
default:
level = slog.LevelInfo
}
var w io.Writer
switch cfg.Output {
case "stdout", "":
w = os.Stdout
case "stderr":
w = os.Stderr
default:
dir := filepath.Dir(cfg.Output)
if err := os.MkdirAll(dir, 0o755); err != nil {
slog.Error("failed to create log dir, falling back to stdout", "error", err)
w = os.Stdout
} else {
f, err := os.OpenFile(cfg.Output, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
if err != nil {
slog.Error("failed to open log file, falling back to stdout", "error", err)
w = os.Stdout
} else {
w = f
}
}
}
handler := slog.NewTextHandler(w, &slog.HandlerOptions{Level: level})
slog.SetDefault(slog.New(handler))
}