init xboard node
This commit is contained in:
@@ -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))
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func writeTemp(t *testing.T, content string) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "config.yaml")
|
||||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func TestLoad_ValidConfig(t *testing.T) {
|
||||
path := writeTemp(t, `
|
||||
panel:
|
||||
url: "https://panel.example.com"
|
||||
token: "secret-token"
|
||||
node_id: 5
|
||||
node_type: "v2ray"
|
||||
kernel:
|
||||
type: singbox
|
||||
log_level: warn
|
||||
log:
|
||||
level: debug
|
||||
`)
|
||||
cfg, err := Load(path)
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if cfg.Panel.URL != "https://panel.example.com" {
|
||||
t.Errorf("url: got %q", cfg.Panel.URL)
|
||||
}
|
||||
if cfg.Panel.Token != "secret-token" {
|
||||
t.Errorf("token: got %q", cfg.Panel.Token)
|
||||
}
|
||||
if cfg.Panel.NodeID != 5 {
|
||||
t.Errorf("node_id: got %d", cfg.Panel.NodeID)
|
||||
}
|
||||
if cfg.Panel.NodeType != "v2ray" {
|
||||
t.Errorf("node_type: got %q", cfg.Panel.NodeType)
|
||||
}
|
||||
if cfg.Kernel.Type != "singbox" {
|
||||
t.Errorf("kernel.type: got %q", cfg.Kernel.Type)
|
||||
}
|
||||
if cfg.Log.Level != "debug" {
|
||||
t.Errorf("log.level: got %q", cfg.Log.Level)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_Defaults(t *testing.T) {
|
||||
path := writeTemp(t, `
|
||||
panel:
|
||||
url: "https://panel.example.com"
|
||||
token: "tok"
|
||||
node_id: 1
|
||||
`)
|
||||
cfg, err := Load(path)
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if cfg.Kernel.Type != "singbox" {
|
||||
t.Errorf("default kernel.type: got %q, want singbox", cfg.Kernel.Type)
|
||||
}
|
||||
if cfg.Kernel.ConfigDir != "/etc/xboard-node" {
|
||||
t.Errorf("default config_dir: got %q", cfg.Kernel.ConfigDir)
|
||||
}
|
||||
if cfg.Kernel.LogLevel != "warn" {
|
||||
t.Errorf("default kernel log_level: got %q, want warn", cfg.Kernel.LogLevel)
|
||||
}
|
||||
if cfg.Log.Level != "info" {
|
||||
t.Errorf("default log.level: got %q, want info", cfg.Log.Level)
|
||||
}
|
||||
if cfg.Log.Output != "stdout" {
|
||||
t.Errorf("default log.output: got %q, want stdout", cfg.Log.Output)
|
||||
}
|
||||
if cfg.Cert.HTTPPort != 80 {
|
||||
t.Errorf("default http_port: got %d, want 80", cfg.Cert.HTTPPort)
|
||||
}
|
||||
expectedCertDir := filepath.Join("/etc/xboard-node", "certs")
|
||||
if cfg.Cert.CertDir != expectedCertDir {
|
||||
t.Errorf("default cert_dir: got %q, want %q", cfg.Cert.CertDir, expectedCertDir)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_MissingURL(t *testing.T) {
|
||||
path := writeTemp(t, `
|
||||
panel:
|
||||
token: "tok"
|
||||
node_id: 1
|
||||
`)
|
||||
_, err := Load(path)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing url")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_MissingToken(t *testing.T) {
|
||||
path := writeTemp(t, `
|
||||
panel:
|
||||
url: "https://example.com"
|
||||
node_id: 1
|
||||
`)
|
||||
_, err := Load(path)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_InvalidNodeID(t *testing.T) {
|
||||
path := writeTemp(t, `
|
||||
panel:
|
||||
url: "https://example.com"
|
||||
token: "tok"
|
||||
node_id: 0
|
||||
`)
|
||||
_, err := Load(path)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for node_id=0")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_NegativeNodeID(t *testing.T) {
|
||||
path := writeTemp(t, `
|
||||
panel:
|
||||
url: "https://example.com"
|
||||
token: "tok"
|
||||
node_id: -1
|
||||
`)
|
||||
_, err := Load(path)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for negative node_id")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_InvalidKernelType(t *testing.T) {
|
||||
path := writeTemp(t, `
|
||||
panel:
|
||||
url: "https://example.com"
|
||||
token: "tok"
|
||||
node_id: 1
|
||||
kernel:
|
||||
type: "invalid"
|
||||
`)
|
||||
_, err := Load(path)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid kernel type")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_XrayKernel(t *testing.T) {
|
||||
path := writeTemp(t, `
|
||||
panel:
|
||||
url: "https://example.com"
|
||||
token: "tok"
|
||||
node_id: 1
|
||||
kernel:
|
||||
type: xray
|
||||
`)
|
||||
cfg, err := Load(path)
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if cfg.Kernel.Type != "xray" {
|
||||
t.Errorf("kernel.type: got %q, want xray", cfg.Kernel.Type)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_AutoTLS_NoDomain(t *testing.T) {
|
||||
path := writeTemp(t, `
|
||||
panel:
|
||||
url: "https://example.com"
|
||||
token: "tok"
|
||||
node_id: 1
|
||||
cert:
|
||||
auto_tls: true
|
||||
`)
|
||||
_, err := Load(path)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for auto_tls without domain")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_AutoTLS_WithDomain(t *testing.T) {
|
||||
path := writeTemp(t, `
|
||||
panel:
|
||||
url: "https://example.com"
|
||||
token: "tok"
|
||||
node_id: 1
|
||||
cert:
|
||||
auto_tls: true
|
||||
domain: "node.example.com"
|
||||
email: "admin@example.com"
|
||||
`)
|
||||
cfg, err := Load(path)
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if !cfg.Cert.AutoTLS {
|
||||
t.Error("auto_tls should be true")
|
||||
}
|
||||
if cfg.Cert.Domain != "node.example.com" {
|
||||
t.Errorf("domain: got %q", cfg.Cert.Domain)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_FileNotFound(t *testing.T) {
|
||||
_, err := Load("/nonexistent/path/config.yaml")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_InvalidYAML(t *testing.T) {
|
||||
path := writeTemp(t, "{{{{invalid yaml}}}")
|
||||
_, err := Load(path)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid YAML")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_CustomCert(t *testing.T) {
|
||||
path := writeTemp(t, `
|
||||
panel:
|
||||
url: "https://example.com"
|
||||
token: "tok"
|
||||
node_id: 1
|
||||
cert:
|
||||
cert_file: "/custom/cert.pem"
|
||||
key_file: "/custom/key.pem"
|
||||
`)
|
||||
cfg, err := Load(path)
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if cfg.Cert.CertFile != "/custom/cert.pem" {
|
||||
t.Errorf("cert_file: got %q", cfg.Cert.CertFile)
|
||||
}
|
||||
if cfg.Cert.KeyFile != "/custom/key.pem" {
|
||||
t.Errorf("key_file: got %q", cfg.Cert.KeyFile)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_CustomIntervals(t *testing.T) {
|
||||
path := writeTemp(t, `
|
||||
panel:
|
||||
url: "https://example.com"
|
||||
token: "tok"
|
||||
node_id: 1
|
||||
node:
|
||||
push_interval: 30
|
||||
pull_interval: 60
|
||||
`)
|
||||
cfg, err := Load(path)
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if cfg.Node.PushInterval != 30 {
|
||||
t.Errorf("push_interval: got %d", cfg.Node.PushInterval)
|
||||
}
|
||||
if cfg.Node.PullInterval != 60 {
|
||||
t.Errorf("pull_interval: got %d", cfg.Node.PullInterval)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user