feat: machine mode, PEM-only certs, ECH support, multi-instance config, batch ops, xbctl
This commit is contained in:
+491
-7
@@ -1,12 +1,17 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"crypto/sha1"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/cedar2025/xboard-node/internal/nlog"
|
||||
"golang.org/x/term"
|
||||
@@ -14,6 +19,7 @@ import (
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
InstanceID string `yaml:"-"`
|
||||
Panel PanelConfig `yaml:"panel"`
|
||||
Node NodeConfig `yaml:"node"`
|
||||
Kernel KernelConfig `yaml:"kernel"`
|
||||
@@ -30,6 +36,20 @@ type Config struct {
|
||||
// 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"`
|
||||
|
||||
// Machine enables machine mode: a single process manages all nodes
|
||||
// bound to this machine on the panel. Nodes are discovered dynamically
|
||||
// via GET /api/v2/server/machine/nodes. When set, Panel.NodeID, Nodes
|
||||
// and Panel.Token are ignored; the machine token is used instead.
|
||||
Machine *MachineConfig `yaml:"machine,omitempty"`
|
||||
}
|
||||
|
||||
// MachineConfig identifies this process as a panel-managed machine that
|
||||
// dynamically discovers and runs all nodes bound to it.
|
||||
type MachineConfig struct {
|
||||
MachineID int `yaml:"machine_id"`
|
||||
Token string `yaml:"token"`
|
||||
TokenEnv string `yaml:"token_env,omitempty"`
|
||||
}
|
||||
|
||||
// NodeEntry describes a single node in multi-node mode.
|
||||
@@ -73,10 +93,12 @@ type RuntimeConfig struct {
|
||||
}
|
||||
|
||||
type PanelConfig struct {
|
||||
URL string `yaml:"url"`
|
||||
Token string `yaml:"token"`
|
||||
NodeID int `yaml:"node_id"`
|
||||
NodeType string `yaml:"node_type"`
|
||||
URL string `yaml:"url"`
|
||||
Token string `yaml:"token"`
|
||||
TokenEnv string `yaml:"token_env,omitempty"`
|
||||
NodeID int `yaml:"node_id"`
|
||||
NodeType string `yaml:"node_type"`
|
||||
MachineID int `yaml:"-"`
|
||||
}
|
||||
|
||||
type NodeConfig struct {
|
||||
@@ -161,6 +183,174 @@ type LogConfig struct {
|
||||
Output string `yaml:"output"`
|
||||
}
|
||||
|
||||
type RootConfig struct {
|
||||
Instances []Config `yaml:"instances,omitempty"`
|
||||
Config `yaml:",inline"`
|
||||
}
|
||||
|
||||
func LoadRoot(path string) (*RootConfig, error) {
|
||||
rc := &RootConfig{}
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err == nil {
|
||||
if err := yaml.Unmarshal(data, rc); err != nil {
|
||||
return nil, fmt.Errorf("parse config: %w", err)
|
||||
}
|
||||
if len(rc.Instances) == 0 {
|
||||
legacy := &Config{}
|
||||
if err := yaml.Unmarshal(data, legacy); err != nil {
|
||||
return nil, fmt.Errorf("parse legacy config: %w", err)
|
||||
}
|
||||
rc.Config = *legacy
|
||||
}
|
||||
} else if !os.IsNotExist(err) {
|
||||
return nil, fmt.Errorf("read config file: %w", err)
|
||||
}
|
||||
|
||||
// In multi-instance mode, top-level settings (log, kernel, node, ws, etc.)
|
||||
// serve as defaults that each instance inherits. Instance-level settings
|
||||
// take precedence over top-level ones.
|
||||
if len(rc.Instances) > 0 {
|
||||
for i := range rc.Instances {
|
||||
rc.Instances[i].inheritFrom(&rc.Config)
|
||||
}
|
||||
}
|
||||
|
||||
// Derive default base directory from the config file's location so that
|
||||
// data (certs, sing-box cache, …) lives next to the config file when
|
||||
// config_dir is not explicitly set. This makes non-root / dev deployments
|
||||
// work without any extra configuration.
|
||||
baseDir := configBaseDir(path)
|
||||
|
||||
rc.applyEnvOverrides()
|
||||
rc.resolveEnvRefs()
|
||||
|
||||
// Compute stable instance IDs early — they're content-based (derived from
|
||||
// panel URL + machine/node ID), so reordering instances in the YAML doesn't
|
||||
// cause data-directory mix-ups. Must run before setDefaultsFrom which uses
|
||||
// InstanceID to derive per-instance config_dir.
|
||||
if err := rc.assignInstanceIDs(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rc.setDefaultsFrom(baseDir)
|
||||
|
||||
if err := rc.validateRoot(); err != nil {
|
||||
return nil, fmt.Errorf("invalid config: %w", err)
|
||||
}
|
||||
|
||||
return rc, nil
|
||||
}
|
||||
|
||||
func (rc *RootConfig) applyEnvOverrides() {
|
||||
if len(rc.Instances) == 0 {
|
||||
rc.Config.applyEnvOverrides()
|
||||
return
|
||||
}
|
||||
for i := range rc.Instances {
|
||||
rc.Instances[i].applyEnvOverrides()
|
||||
}
|
||||
}
|
||||
|
||||
func (rc *RootConfig) resolveEnvRefs() {
|
||||
if len(rc.Instances) == 0 {
|
||||
rc.Config.resolveEnvRefs()
|
||||
return
|
||||
}
|
||||
for i := range rc.Instances {
|
||||
rc.Instances[i].resolveEnvRefs()
|
||||
}
|
||||
}
|
||||
|
||||
func (rc *RootConfig) setDefaultsFrom(baseDir string) {
|
||||
if len(rc.Instances) == 0 {
|
||||
rc.Config.setDefaultsFrom(baseDir)
|
||||
return
|
||||
}
|
||||
for i := range rc.Instances {
|
||||
instBase := baseDir
|
||||
// When multiple instances share the same base dir, derive a unique
|
||||
// sub-directory per instance to avoid config_dir collisions.
|
||||
// InstanceID is always populated by assignInstanceIDs() before this runs.
|
||||
if len(rc.Instances) > 1 && rc.Instances[i].Kernel.ConfigDir == "" {
|
||||
instBase = filepath.Join(baseDir, rc.Instances[i].InstanceID)
|
||||
}
|
||||
rc.Instances[i].setDefaultsFrom(instBase)
|
||||
}
|
||||
}
|
||||
|
||||
// assignInstanceIDs computes a stable, content-based InstanceID for each
|
||||
// instance that doesn't already have one. The ID is derived from the panel URL
|
||||
// and machine/node ID, so reordering instances in the YAML file doesn't cause
|
||||
// data directories to swap.
|
||||
func (rc *RootConfig) assignInstanceIDs() error {
|
||||
for i := range rc.Instances {
|
||||
if rc.Instances[i].InstanceID != "" {
|
||||
continue
|
||||
}
|
||||
id, err := rc.Instances[i].AutoInstanceID()
|
||||
if err != nil {
|
||||
return fmt.Errorf("instances[%d]: %w", i, err)
|
||||
}
|
||||
rc.Instances[i].InstanceID = id
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// configBaseDir resolves the canonical base directory from the config file path.
|
||||
// This directory is used as the default for config_dir when not explicitly set,
|
||||
// so that data (certs, sing-box cache, …) lives next to the config file.
|
||||
func configBaseDir(configPath string) string {
|
||||
abs, err := filepath.Abs(configPath)
|
||||
if err != nil {
|
||||
return "/etc/xboard-node"
|
||||
}
|
||||
return filepath.Dir(abs)
|
||||
}
|
||||
|
||||
func (rc *RootConfig) validateRoot() error {
|
||||
if len(rc.Instances) == 0 {
|
||||
return rc.Config.validate()
|
||||
}
|
||||
seen := map[string]struct{}{}
|
||||
for i := range rc.Instances {
|
||||
// InstanceID is already computed by assignInstanceIDs().
|
||||
id := rc.Instances[i].InstanceID
|
||||
if err := rc.Instances[i].validate(); err != nil {
|
||||
return fmt.Errorf("instances[%d]: %w", i, err)
|
||||
}
|
||||
if _, ok := seen[id]; ok {
|
||||
return fmt.Errorf("instances[%d]: duplicate instance id %q", i, id)
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (rc *RootConfig) NormalizeInstances() ([]*Config, error) {
|
||||
if len(rc.Instances) == 0 {
|
||||
id, err := rc.Config.AutoInstanceID()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rc.Config.InstanceID = id
|
||||
return []*Config{&rc.Config}, nil
|
||||
}
|
||||
out := make([]*Config, 0, len(rc.Instances))
|
||||
for i := range rc.Instances {
|
||||
cfg := rc.Instances[i]
|
||||
if cfg.InstanceID == "" {
|
||||
id, err := cfg.AutoInstanceID()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfg.InstanceID = id
|
||||
}
|
||||
out = append(out, &cfg)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// 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).
|
||||
@@ -176,8 +366,11 @@ func Load(path string) (*Config, error) {
|
||||
return nil, fmt.Errorf("read config file: %w", err)
|
||||
}
|
||||
|
||||
baseDir := configBaseDir(path)
|
||||
|
||||
cfg.applyEnvOverrides()
|
||||
cfg.setDefaults()
|
||||
cfg.resolveEnvRefs()
|
||||
cfg.setDefaultsFrom(baseDir)
|
||||
|
||||
if err := cfg.validate(); err != nil {
|
||||
return nil, fmt.Errorf("invalid config: %w", err)
|
||||
@@ -227,14 +420,145 @@ func (c *Config) applyEnvOverrides() {
|
||||
if v := envFirst("logLevel", "LOG_LEVEL"); v != "" {
|
||||
c.Log.Level = v
|
||||
}
|
||||
if v := envFirst("MACHINE_ID"); v != "" {
|
||||
if id, err := strconv.Atoi(v); err == nil {
|
||||
if c.Machine == nil {
|
||||
c.Machine = &MachineConfig{}
|
||||
}
|
||||
c.Machine.MachineID = id
|
||||
}
|
||||
}
|
||||
if v := envFirst("MACHINE_TOKEN"); v != "" {
|
||||
if c.Machine == nil {
|
||||
c.Machine = &MachineConfig{}
|
||||
}
|
||||
c.Machine.Token = v
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Config) setDefaults() {
|
||||
func (c *Config) resolveEnvRefs() {
|
||||
if c.Panel.Token == "" && c.Panel.TokenEnv != "" {
|
||||
c.Panel.Token = os.Getenv(c.Panel.TokenEnv)
|
||||
}
|
||||
if c.Machine != nil && c.Machine.Token == "" && c.Machine.TokenEnv != "" {
|
||||
c.Machine.Token = os.Getenv(c.Machine.TokenEnv)
|
||||
}
|
||||
}
|
||||
|
||||
// inheritFrom copies non-zero fields from the parent config into c when c's
|
||||
// corresponding field is zero-valued. This lets top-level settings serve as
|
||||
// defaults for each instance in multi-instance configs.
|
||||
// Fields that must be unique per instance (config_dir, cert_dir, instance_id)
|
||||
// are intentionally excluded.
|
||||
func (c *Config) inheritFrom(parent *Config) {
|
||||
// Log
|
||||
if c.Log.Level == "" {
|
||||
c.Log.Level = parent.Log.Level
|
||||
}
|
||||
if c.Log.Output == "" {
|
||||
c.Log.Output = parent.Log.Output
|
||||
}
|
||||
// Node intervals
|
||||
if c.Node.PushInterval == 0 {
|
||||
c.Node.PushInterval = parent.Node.PushInterval
|
||||
}
|
||||
if c.Node.PullInterval == 0 {
|
||||
c.Node.PullInterval = parent.Node.PullInterval
|
||||
}
|
||||
if c.Node.TrackInterval == 0 {
|
||||
c.Node.TrackInterval = parent.Node.TrackInterval
|
||||
}
|
||||
if c.Node.DeviceReportInterval == 0 {
|
||||
c.Node.DeviceReportInterval = parent.Node.DeviceReportInterval
|
||||
}
|
||||
// WS
|
||||
if c.WS.StatusInterval == 0 {
|
||||
c.WS.StatusInterval = parent.WS.StatusInterval
|
||||
}
|
||||
if c.WS.HandshakeTimeout == 0 {
|
||||
c.WS.HandshakeTimeout = parent.WS.HandshakeTimeout
|
||||
}
|
||||
if c.WS.BackoffInitial == 0 {
|
||||
c.WS.BackoffInitial = parent.WS.BackoffInitial
|
||||
}
|
||||
if c.WS.BackoffMax == 0 {
|
||||
c.WS.BackoffMax = parent.WS.BackoffMax
|
||||
}
|
||||
if c.WS.DiscoveryInterval == 0 {
|
||||
c.WS.DiscoveryInterval = parent.WS.DiscoveryInterval
|
||||
}
|
||||
// Runtime
|
||||
if c.Runtime.GoGCPercent == 0 {
|
||||
c.Runtime.GoGCPercent = parent.Runtime.GoGCPercent
|
||||
}
|
||||
if c.Runtime.GoMemLimit == "" {
|
||||
c.Runtime.GoMemLimit = parent.Runtime.GoMemLimit
|
||||
}
|
||||
// Kernel (NOT config_dir — each instance needs unique dir)
|
||||
if c.Kernel.Type == "" {
|
||||
c.Kernel.Type = parent.Kernel.Type
|
||||
}
|
||||
if c.Kernel.LogLevel == "" {
|
||||
c.Kernel.LogLevel = parent.Kernel.LogLevel
|
||||
}
|
||||
if c.Kernel.GeoDataDir == "" {
|
||||
c.Kernel.GeoDataDir = parent.Kernel.GeoDataDir
|
||||
}
|
||||
if c.Kernel.CustomConfig == "" {
|
||||
c.Kernel.CustomConfig = parent.Kernel.CustomConfig
|
||||
}
|
||||
if len(c.Kernel.CustomOutbound) == 0 {
|
||||
c.Kernel.CustomOutbound = parent.Kernel.CustomOutbound
|
||||
}
|
||||
if len(c.Kernel.CustomRoute) == 0 {
|
||||
c.Kernel.CustomRoute = parent.Kernel.CustomRoute
|
||||
}
|
||||
// Cert (NOT cert_dir — derived from config_dir later)
|
||||
if c.Cert.CertMode == "" {
|
||||
c.Cert.CertMode = parent.Cert.CertMode
|
||||
}
|
||||
if c.Cert.Domain == "" {
|
||||
c.Cert.Domain = parent.Cert.Domain
|
||||
}
|
||||
if c.Cert.Email == "" {
|
||||
c.Cert.Email = parent.Cert.Email
|
||||
}
|
||||
if c.Cert.CertFile == "" {
|
||||
c.Cert.CertFile = parent.Cert.CertFile
|
||||
}
|
||||
if c.Cert.KeyFile == "" {
|
||||
c.Cert.KeyFile = parent.Cert.KeyFile
|
||||
}
|
||||
if c.Cert.DNSProvider == "" {
|
||||
c.Cert.DNSProvider = parent.Cert.DNSProvider
|
||||
}
|
||||
if len(c.Cert.DNSEnv) == 0 {
|
||||
c.Cert.DNSEnv = parent.Cert.DNSEnv
|
||||
}
|
||||
if c.Cert.HTTPPort == 0 {
|
||||
c.Cert.HTTPPort = parent.Cert.HTTPPort
|
||||
}
|
||||
if c.Cert.CertContent == "" {
|
||||
c.Cert.CertContent = parent.Cert.CertContent
|
||||
}
|
||||
if c.Cert.KeyContent == "" {
|
||||
c.Cert.KeyContent = parent.Cert.KeyContent
|
||||
}
|
||||
// Only inherit AutoTLS when the child has no explicit cert configuration.
|
||||
// A child with cert_mode, cert_file, or cert_content explicitly chooses
|
||||
// its own cert strategy — inheriting auto_tls would override that choice.
|
||||
childHasCertConfig := c.Cert.CertMode != "" || c.Cert.CertFile != "" || c.Cert.CertContent != ""
|
||||
if !childHasCertConfig && !c.Cert.AutoTLS && parent.Cert.AutoTLS {
|
||||
c.Cert.AutoTLS = parent.Cert.AutoTLS
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Config) setDefaultsFrom(baseDir string) {
|
||||
if c.Kernel.Type == "" {
|
||||
c.Kernel.Type = "singbox"
|
||||
}
|
||||
if c.Kernel.ConfigDir == "" {
|
||||
c.Kernel.ConfigDir = "/etc/xboard-node"
|
||||
c.Kernel.ConfigDir = baseDir
|
||||
}
|
||||
if c.Kernel.GeoDataDir == "" {
|
||||
c.Kernel.GeoDataDir = c.Kernel.ConfigDir
|
||||
@@ -279,11 +603,105 @@ func (c *Config) setDefaults() {
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Config) IsMachineMode() bool {
|
||||
return c.Machine != nil && c.Machine.MachineID > 0
|
||||
}
|
||||
|
||||
func (c *Config) AutoInstanceID() (string, error) {
|
||||
mode := "node"
|
||||
target := "0"
|
||||
baseURL := strings.TrimSpace(c.Panel.URL)
|
||||
if c.IsStandalone() {
|
||||
mode = "standalone"
|
||||
target = "local"
|
||||
baseURL = "standalone"
|
||||
} else if c.IsMachineMode() {
|
||||
mode = "machine"
|
||||
target = strconv.Itoa(c.Machine.MachineID)
|
||||
} else if len(c.Nodes) > 0 {
|
||||
// Multi-node mode: include sorted node IDs in the hash to avoid collisions.
|
||||
ids := make([]string, len(c.Nodes))
|
||||
for i, n := range c.Nodes {
|
||||
ids[i] = strconv.Itoa(n.NodeID)
|
||||
}
|
||||
sort.Strings(ids)
|
||||
target = strings.Join(ids, ",")
|
||||
} else {
|
||||
target = strconv.Itoa(c.Panel.NodeID)
|
||||
}
|
||||
normalized, slug, err := normalizeBaseURL(baseURL)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
key := normalized + "|" + mode + "|" + target
|
||||
sum := sha1.Sum([]byte(key))
|
||||
short := hex.EncodeToString(sum[:])[:6]
|
||||
return fmt.Sprintf("%s-%s-%s-%s", slug, mode, target, short), nil
|
||||
}
|
||||
|
||||
func normalizeBaseURL(raw string) (string, string, error) {
|
||||
if raw == "standalone" {
|
||||
return raw, raw, nil
|
||||
}
|
||||
u, err := url.Parse(strings.TrimSpace(raw))
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("invalid panel.url %q: %w", raw, err)
|
||||
}
|
||||
if u.Scheme == "" || u.Host == "" {
|
||||
return "", "", fmt.Errorf("invalid panel.url %q", raw)
|
||||
}
|
||||
u.Scheme = strings.ToLower(u.Scheme)
|
||||
u.Host = strings.ToLower(u.Host)
|
||||
u.RawQuery = ""
|
||||
u.Fragment = ""
|
||||
pathPart := strings.TrimSuffix(u.EscapedPath(), "/")
|
||||
normalized := u.Scheme + "://" + u.Host
|
||||
if pathPart != "" && pathPart != "/" {
|
||||
normalized += pathPart
|
||||
}
|
||||
slugBase := u.Host
|
||||
if pathPart != "" && pathPart != "/" {
|
||||
slugBase += "-" + strings.Trim(pathPart, "/")
|
||||
}
|
||||
slugBase = strings.ToLower(slugBase)
|
||||
var b strings.Builder
|
||||
lastDash := false
|
||||
for _, r := range slugBase {
|
||||
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
|
||||
b.WriteRune(r)
|
||||
lastDash = false
|
||||
continue
|
||||
}
|
||||
if !lastDash {
|
||||
b.WriteByte('-')
|
||||
lastDash = true
|
||||
}
|
||||
}
|
||||
slug := strings.Trim(b.String(), "-")
|
||||
if slug == "" {
|
||||
slug = "panel"
|
||||
}
|
||||
if len(slug) > 48 {
|
||||
slug = strings.Trim(slug[:48], "-")
|
||||
}
|
||||
return normalized, slug, nil
|
||||
}
|
||||
|
||||
func (c *Config) validate() error {
|
||||
if c.IsStandalone() {
|
||||
if err := c.validateStandalone(); err != nil {
|
||||
return err
|
||||
}
|
||||
} else if c.IsMachineMode() {
|
||||
if c.Panel.URL == "" {
|
||||
return fmt.Errorf("panel.url is required")
|
||||
}
|
||||
if c.Machine.Token == "" {
|
||||
return fmt.Errorf("machine.token is required")
|
||||
}
|
||||
if len(c.Nodes) > 0 {
|
||||
return fmt.Errorf("machine mode and nodes: are mutually exclusive")
|
||||
}
|
||||
} else {
|
||||
if c.Panel.URL == "" {
|
||||
return fmt.Errorf("panel.url is required")
|
||||
@@ -375,6 +793,23 @@ func (c *Config) ExpandNodes() []*Config {
|
||||
return result
|
||||
}
|
||||
|
||||
func (c *Config) ExpandMachineNode(nodeID int, nodeType string) *Config {
|
||||
nodeCfg := *c
|
||||
nodeCfg.Nodes = nil
|
||||
nodeCfg.Panel.NodeID = nodeID
|
||||
nodeCfg.Panel.NodeType = nodeType
|
||||
nodeCfg.Panel.Token = c.Machine.Token
|
||||
nodeCfg.Panel.MachineID = c.Machine.MachineID
|
||||
|
||||
nodeCfg.Kernel.ConfigDir = fmt.Sprintf("%s/node-%d", c.Kernel.ConfigDir, nodeID)
|
||||
if nodeCfg.Kernel.GeoDataDir == c.Kernel.ConfigDir {
|
||||
nodeCfg.Kernel.GeoDataDir = c.Kernel.GeoDataDir
|
||||
}
|
||||
nodeCfg.Cert.CertDir = filepath.Join(nodeCfg.Kernel.ConfigDir, "certs")
|
||||
|
||||
return &nodeCfg
|
||||
}
|
||||
|
||||
func InitLogger(cfg LogConfig) {
|
||||
var minLevel slog.Level
|
||||
switch cfg.Level {
|
||||
@@ -420,3 +855,52 @@ func InitLogger(cfg LogConfig) {
|
||||
// Application logging goes through nlog; silence slog.Default for stray library use.
|
||||
slog.SetDefault(slog.New(slog.DiscardHandler))
|
||||
}
|
||||
|
||||
// ValidateStartupLayout checks that multiple instances do not conflict on
|
||||
// health ports, kernel config directories, or node bindings.
|
||||
func ValidateStartupLayout(instances []*Config) error {
|
||||
healthPorts := make(map[int]string)
|
||||
configDirs := make(map[string]string)
|
||||
nodeBindings := make(map[string]string)
|
||||
for _, instance := range instances {
|
||||
if instance == nil {
|
||||
continue
|
||||
}
|
||||
owner := instance.InstanceID
|
||||
if instance.HealthPort > 0 {
|
||||
if other, ok := healthPorts[instance.HealthPort]; ok {
|
||||
return fmt.Errorf("health_port %d is used by both %s and %s", instance.HealthPort, other, owner)
|
||||
}
|
||||
healthPorts[instance.HealthPort] = owner
|
||||
}
|
||||
if dir := strings.TrimSpace(instance.Kernel.ConfigDir); dir != "" {
|
||||
if other, ok := configDirs[dir]; ok && other != owner {
|
||||
return fmt.Errorf("kernel config_dir %q is shared by %s and %s", dir, other, owner)
|
||||
}
|
||||
configDirs[dir] = owner
|
||||
}
|
||||
// Machine mode discovers nodes dynamically — skip static binding check,
|
||||
// but still validate custom_config if set at the instance level.
|
||||
if instance.IsMachineMode() {
|
||||
if path := strings.TrimSpace(instance.Kernel.CustomConfig); path != "" {
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
return fmt.Errorf("kernel.custom_config %q for %s: %w", path, owner, err)
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
for _, node := range instance.ExpandNodes() {
|
||||
binding := fmt.Sprintf("%s/%d", strings.TrimSpace(node.Panel.URL), node.Panel.NodeID)
|
||||
if other, ok := nodeBindings[binding]; ok {
|
||||
return fmt.Errorf("node binding %s is declared by both %s and %s", binding, other, owner)
|
||||
}
|
||||
nodeBindings[binding] = owner
|
||||
if path := strings.TrimSpace(node.Kernel.CustomConfig); path != "" {
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
return fmt.Errorf("kernel.custom_config %q for %s: %w", path, owner, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
@@ -67,8 +68,10 @@ panel:
|
||||
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)
|
||||
// config_dir should default to the directory containing the config file.
|
||||
expectedDir := filepath.Dir(path)
|
||||
if cfg.Kernel.ConfigDir != expectedDir {
|
||||
t.Errorf("default config_dir: got %q, want %q", cfg.Kernel.ConfigDir, expectedDir)
|
||||
}
|
||||
if cfg.Kernel.LogLevel != "warn" {
|
||||
t.Errorf("default kernel log_level: got %q, want warn", cfg.Kernel.LogLevel)
|
||||
@@ -82,7 +85,7 @@ panel:
|
||||
if cfg.Cert.HTTPPort != 80 {
|
||||
t.Errorf("default http_port: got %d, want 80", cfg.Cert.HTTPPort)
|
||||
}
|
||||
expectedCertDir := filepath.Join("/etc/xboard-node", "certs")
|
||||
expectedCertDir := filepath.Join(expectedDir, "certs")
|
||||
if cfg.Cert.CertDir != expectedCertDir {
|
||||
t.Errorf("default cert_dir: got %q, want %q", cfg.Cert.CertDir, expectedCertDir)
|
||||
}
|
||||
@@ -323,3 +326,220 @@ kernel:
|
||||
t.Fatal("expected error for standalone mode without users")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func TestLoadRoot_LegacyConfigNormalizesToSingleInstance(t *testing.T) {
|
||||
path := writeTemp(t, `
|
||||
panel:
|
||||
url: "https://panel.example.com"
|
||||
token: "tok"
|
||||
node_id: 1
|
||||
`)
|
||||
root, err := LoadRoot(path)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadRoot: %v", err)
|
||||
}
|
||||
instances, err := root.NormalizeInstances()
|
||||
if err != nil {
|
||||
t.Fatalf("NormalizeInstances: %v", err)
|
||||
}
|
||||
if len(instances) != 1 {
|
||||
t.Fatalf("instances: got %d, want 1", len(instances))
|
||||
}
|
||||
if instances[0].Panel.URL != "https://panel.example.com" {
|
||||
t.Errorf("instance url: got %q", instances[0].Panel.URL)
|
||||
}
|
||||
if instances[0].InstanceID == "" {
|
||||
t.Fatal("expected auto instance id")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadRoot_InstancesConfig(t *testing.T) {
|
||||
path := writeTemp(t, `
|
||||
instances:
|
||||
- panel:
|
||||
url: "https://panel-a.example.com"
|
||||
token_env: "PANEL_A_TOKEN"
|
||||
node_id: 1
|
||||
kernel:
|
||||
type: singbox
|
||||
- panel:
|
||||
url: "https://panel-b.example.com"
|
||||
machine:
|
||||
machine_id: 2
|
||||
token_env: "PANEL_B_MACHINE_TOKEN"
|
||||
kernel:
|
||||
type: singbox
|
||||
`)
|
||||
t.Setenv("PANEL_A_TOKEN", "token-a")
|
||||
t.Setenv("PANEL_B_MACHINE_TOKEN", "token-b")
|
||||
root, err := LoadRoot(path)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadRoot: %v", err)
|
||||
}
|
||||
instances, err := root.NormalizeInstances()
|
||||
if err != nil {
|
||||
t.Fatalf("NormalizeInstances: %v", err)
|
||||
}
|
||||
if len(instances) != 2 {
|
||||
t.Fatalf("instances: got %d, want 2", len(instances))
|
||||
}
|
||||
if instances[0].Panel.Token != "token-a" {
|
||||
t.Errorf("panel a token: got %q", instances[0].Panel.Token)
|
||||
}
|
||||
if instances[1].Machine == nil || instances[1].Machine.Token != "token-b" {
|
||||
t.Fatalf("machine token: got %+v", instances[1].Machine)
|
||||
}
|
||||
if instances[0].InstanceID == instances[1].InstanceID {
|
||||
t.Fatal("expected unique instance ids")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfig_AutoInstanceIDStable(t *testing.T) {
|
||||
cfg := &Config{Panel: PanelConfig{URL: "https://Panel.Example.com/", NodeID: 1, Token: "tok"}}
|
||||
cfg.setDefaultsFrom("/etc/xboard-node")
|
||||
id1, err := cfg.AutoInstanceID()
|
||||
if err != nil {
|
||||
t.Fatalf("AutoInstanceID: %v", err)
|
||||
}
|
||||
id2, err := cfg.AutoInstanceID()
|
||||
if err != nil {
|
||||
t.Fatalf("AutoInstanceID second: %v", err)
|
||||
}
|
||||
if id1 != id2 {
|
||||
t.Fatalf("ids differ: %q vs %q", id1, id2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadRoot_InheritanceFromTopLevel(t *testing.T) {
|
||||
path := writeTemp(t, `
|
||||
log:
|
||||
level: "debug"
|
||||
output: "stderr"
|
||||
kernel:
|
||||
type: xray
|
||||
log_level: "error"
|
||||
node:
|
||||
push_interval: 42
|
||||
pull_interval: 99
|
||||
instances:
|
||||
- panel:
|
||||
url: "https://panel.example.com"
|
||||
token: "tok-a"
|
||||
node_id: 1
|
||||
- panel:
|
||||
url: "https://panel.example.com"
|
||||
token: "tok-b"
|
||||
node_id: 2
|
||||
log:
|
||||
level: "warn"
|
||||
`)
|
||||
root, err := LoadRoot(path)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadRoot: %v", err)
|
||||
}
|
||||
if len(root.Instances) != 2 {
|
||||
t.Fatalf("instances: got %d, want 2", len(root.Instances))
|
||||
}
|
||||
// Instance 0: inherits everything from top-level.
|
||||
inst0 := root.Instances[0]
|
||||
if inst0.Log.Level != "debug" {
|
||||
t.Errorf("inst0 log.level: got %q, want %q", inst0.Log.Level, "debug")
|
||||
}
|
||||
if inst0.Log.Output != "stderr" {
|
||||
t.Errorf("inst0 log.output: got %q, want %q", inst0.Log.Output, "stderr")
|
||||
}
|
||||
if inst0.Kernel.Type != "xray" {
|
||||
t.Errorf("inst0 kernel.type: got %q, want %q", inst0.Kernel.Type, "xray")
|
||||
}
|
||||
if inst0.Kernel.LogLevel != "error" {
|
||||
t.Errorf("inst0 kernel.log_level: got %q, want %q", inst0.Kernel.LogLevel, "error")
|
||||
}
|
||||
if inst0.Node.PushInterval != 42 {
|
||||
t.Errorf("inst0 push_interval: got %d, want 42", inst0.Node.PushInterval)
|
||||
}
|
||||
if inst0.Node.PullInterval != 99 {
|
||||
t.Errorf("inst0 pull_interval: got %d, want 99", inst0.Node.PullInterval)
|
||||
}
|
||||
// Instance 1: overrides log.level, inherits the rest.
|
||||
inst1 := root.Instances[1]
|
||||
if inst1.Log.Level != "warn" {
|
||||
t.Errorf("inst1 log.level: got %q, want %q", inst1.Log.Level, "warn")
|
||||
}
|
||||
if inst1.Log.Output != "stderr" {
|
||||
t.Errorf("inst1 log.output: got %q, want %q", inst1.Log.Output, "stderr")
|
||||
}
|
||||
if inst1.Kernel.Type != "xray" {
|
||||
t.Errorf("inst1 kernel.type: got %q, want %q", inst1.Kernel.Type, "xray")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadRoot_InstanceOrderDoesNotAffectConfigDir(t *testing.T) {
|
||||
yamlTmpl := func(first, second int) string {
|
||||
return fmt.Sprintf(`
|
||||
instances:
|
||||
- panel:
|
||||
url: "https://panel.example.com"
|
||||
token: "tok-a"
|
||||
node_id: %d
|
||||
- panel:
|
||||
url: "https://panel.example.com"
|
||||
token: "tok-b"
|
||||
node_id: %d
|
||||
`, first, second)
|
||||
}
|
||||
|
||||
path1 := writeTemp(t, yamlTmpl(1, 2))
|
||||
path2 := writeTemp(t, yamlTmpl(2, 1))
|
||||
root1, err := LoadRoot(path1)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadRoot order1: %v", err)
|
||||
}
|
||||
root2, err := LoadRoot(path2)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadRoot order2: %v", err)
|
||||
}
|
||||
|
||||
// Collect the config_dir suffix (relative to base) by InstanceID.
|
||||
suffix := func(instances []Config) map[string]string {
|
||||
m := map[string]string{}
|
||||
for _, inst := range instances {
|
||||
// The config_dir ends with .../{instanceID}, extract last component.
|
||||
m[inst.InstanceID] = filepath.Base(inst.Kernel.ConfigDir)
|
||||
}
|
||||
return m
|
||||
}
|
||||
dirs1 := suffix(root1.Instances)
|
||||
dirs2 := suffix(root2.Instances)
|
||||
|
||||
for id, s1 := range dirs1 {
|
||||
s2, ok := dirs2[id]
|
||||
if !ok {
|
||||
t.Fatalf("instance %q missing in reversed order", id)
|
||||
}
|
||||
if s1 != s2 {
|
||||
t.Errorf("instance %q config_dir suffix differs: %q vs %q", id, s1, s2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInheritFrom_AutoTLSNotForcedWhenChildHasCertMode(t *testing.T) {
|
||||
parent := &Config{Cert: CertConfig{AutoTLS: true, Domain: "example.com"}}
|
||||
child := &Config{Cert: CertConfig{CertMode: "file", CertFile: "/etc/cert.pem"}}
|
||||
child.inheritFrom(parent)
|
||||
if child.Cert.AutoTLS {
|
||||
t.Error("auto_tls should NOT be inherited when child has explicit cert_mode")
|
||||
}
|
||||
if child.Cert.Domain != "example.com" {
|
||||
t.Errorf("domain: got %q, want %q", child.Cert.Domain, "example.com")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInheritFrom_AutoTLSInheritedWhenChildHasNoCertConfig(t *testing.T) {
|
||||
parent := &Config{Cert: CertConfig{AutoTLS: true, Domain: "example.com"}}
|
||||
child := &Config{}
|
||||
child.inheritFrom(parent)
|
||||
if !child.Cert.AutoTLS {
|
||||
t.Error("auto_tls should be inherited when child has no cert config")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,17 +19,18 @@ type StandaloneNodeConfig struct {
|
||||
ServerPort int `yaml:"server_port"`
|
||||
Network string `yaml:"network,omitempty"`
|
||||
|
||||
NetworkSettings map[string]any `yaml:"network_settings,omitempty"`
|
||||
Routes []StandaloneRouteRule `yaml:"routes,omitempty"`
|
||||
NetworkSettings map[string]any `yaml:"network_settings,omitempty"`
|
||||
Routes []StandaloneRouteRule `yaml:"routes,omitempty"`
|
||||
CustomRouteRules []StandaloneCustomRouteRule `yaml:"custom_route_rules,omitempty"`
|
||||
|
||||
Cipher string `yaml:"cipher,omitempty"`
|
||||
Plugin string `yaml:"plugin,omitempty"`
|
||||
PluginOpt string `yaml:"plugin_opts,omitempty"`
|
||||
ServerKey string `yaml:"server_key,omitempty"`
|
||||
|
||||
TLS int `yaml:"tls,omitempty"`
|
||||
Flow string `yaml:"flow,omitempty"`
|
||||
Decryption string `yaml:"decryption,omitempty"`
|
||||
TLS int `yaml:"tls,omitempty"`
|
||||
Flow string `yaml:"flow,omitempty"`
|
||||
Decryption string `yaml:"decryption,omitempty"`
|
||||
TLSSettings map[string]any `yaml:"tls_settings,omitempty"`
|
||||
|
||||
Host string `yaml:"host,omitempty"`
|
||||
@@ -57,6 +58,28 @@ type StandaloneRouteRule struct {
|
||||
ActionValue string `yaml:"action_value,omitempty"`
|
||||
}
|
||||
|
||||
type StandaloneCustomRouteRule struct {
|
||||
Name string `yaml:"name,omitempty"`
|
||||
Disabled bool `yaml:"disabled,omitempty"`
|
||||
Match StandaloneRouteMatch `yaml:"match,omitempty"`
|
||||
Action StandaloneRouteAction `yaml:"action,omitempty"`
|
||||
}
|
||||
|
||||
type StandaloneRouteMatch struct {
|
||||
Domains []string `yaml:"domains,omitempty"`
|
||||
DomainSuffixes []string `yaml:"domain_suffixes,omitempty"`
|
||||
IPCIDRs []string `yaml:"ip_cidrs,omitempty"`
|
||||
Ports []string `yaml:"ports,omitempty"`
|
||||
Networks []string `yaml:"networks,omitempty"`
|
||||
SourceCIDRs []string `yaml:"source_cidrs,omitempty"`
|
||||
SourcePorts []string `yaml:"source_ports,omitempty"`
|
||||
}
|
||||
|
||||
type StandaloneRouteAction struct {
|
||||
Type string `yaml:"type,omitempty"`
|
||||
Target string `yaml:"target,omitempty"`
|
||||
}
|
||||
|
||||
type StandaloneMultiplexConfig struct {
|
||||
Enabled bool `yaml:"enabled"`
|
||||
Protocol string `yaml:"protocol,omitempty"`
|
||||
|
||||
@@ -13,10 +13,11 @@ import (
|
||||
|
||||
// Watcher reloads the config file on change (debounced) and calls onChange.
|
||||
type Watcher struct {
|
||||
path string
|
||||
debounce time.Duration
|
||||
onChange func(*Config)
|
||||
watcher *fsnotify.Watcher
|
||||
path string
|
||||
debounce time.Duration
|
||||
onChange func(*Config)
|
||||
onChangeRoot func(*RootConfig)
|
||||
watcher *fsnotify.Watcher
|
||||
|
||||
stopOnce sync.Once
|
||||
stopCh chan struct{}
|
||||
@@ -56,6 +57,38 @@ func WatchConfig(ctx context.Context, path string, onChange func(*Config)) (*Wat
|
||||
return w, nil
|
||||
}
|
||||
|
||||
|
||||
// WatchConfigRoot watches path and reloads the root config model.
|
||||
func WatchConfigRoot(ctx context.Context, path string, onChange func(*RootConfig)) (*Watcher, error) {
|
||||
absPath, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
fsw, err := fsnotify.NewWatcher()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
dir := filepath.Dir(absPath)
|
||||
if err := fsw.Add(dir); err != nil {
|
||||
fsw.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
w := &Watcher{
|
||||
path: absPath,
|
||||
debounce: 1 * time.Second,
|
||||
onChangeRoot: onChange,
|
||||
watcher: fsw,
|
||||
stopCh: make(chan struct{}),
|
||||
}
|
||||
|
||||
go w.loop(ctx)
|
||||
nlog.Core().Info("config watcher started", "path", absPath)
|
||||
return w, nil
|
||||
}
|
||||
|
||||
func (w *Watcher) loop(ctx context.Context) {
|
||||
var timer *time.Timer
|
||||
defer func() {
|
||||
@@ -102,6 +135,16 @@ func (w *Watcher) reload() {
|
||||
if w.stopped.Load() {
|
||||
return
|
||||
}
|
||||
if w.onChangeRoot != nil {
|
||||
root, err := LoadRoot(w.path)
|
||||
if err != nil {
|
||||
nlog.Core().Error("config reload failed, keeping current config", "error", err)
|
||||
return
|
||||
}
|
||||
nlog.Core().Info("config reloaded successfully")
|
||||
w.onChangeRoot(root)
|
||||
return
|
||||
}
|
||||
cfg, err := Load(w.path)
|
||||
if err != nil {
|
||||
nlog.Core().Error("config reload failed, keeping current config", "error", err)
|
||||
|
||||
Reference in New Issue
Block a user