feat(config): add config validator to avoid wrong settings

This commit is contained in:
nc
2026-06-10 01:22:18 +08:00
parent 125bc4bf3d
commit 7c5904cc47
3 changed files with 259 additions and 13 deletions
+146 -12
View File
@@ -2,8 +2,10 @@ package core
import (
"bytes"
"errors"
"fmt"
"io"
"net"
"net/http"
"os"
"strings"
@@ -53,12 +55,150 @@ func (r ConnectRule) LANMotdOr(def string) string {
return def
}
func (r ConnectRule) BindIP() string {
if r.LANEnabled() {
return "0.0.0.0"
}
if r.LocalAddr != "" {
return r.LocalAddr
}
return "127.0.0.1"
}
type Config struct {
Core Core `toml:"core"`
Forward map[string][]ForwardRule `toml:"forward"`
Connect map[string][]ConnectRule `toml:"connect"`
}
func (cfg *Config) ApplyDefaults() {
if cfg.Forward == nil {
cfg.Forward = make(map[string][]ForwardRule)
}
if cfg.Connect == nil {
cfg.Connect = make(map[string][]ConnectRule)
}
if cfg.Core.Hostname == "" {
hostname, err := os.Hostname()
if err != nil {
hostname = "unknown"
}
cfg.Core.Hostname = hostname
}
}
func (cfg *Config) Validate() error {
var errs []error
if strings.TrimSpace(cfg.Core.AuthKey) == "" {
errs = append(errs, errors.New("core.auth_key is required"))
}
usedForwardListeners := make(map[string]string)
usedConnectListeners := make(map[string]string)
for tag, rules := range cfg.Forward {
for i, rule := range rules {
path := fmt.Sprintf("forward.%s[%d]", tag, i)
if rule.Protocol != "tcp" && rule.Protocol != "udp" {
errs = append(errs, fmt.Errorf("%s.protocol must be tcp or udp", path))
}
if !validPort(rule.TailscalePort) {
errs = append(errs, fmt.Errorf("%s.tailscale_port must be between 1 and 65535", path))
} else {
key := fmt.Sprintf("%s:%d", rule.Protocol, rule.TailscalePort)
if prev, ok := usedForwardListeners[key]; ok {
errs = append(errs, fmt.Errorf("%s.tailscale_port duplicates %s", path, prev))
} else {
usedForwardListeners[key] = path
}
}
if strings.TrimSpace(rule.LocalAddr) == "" {
errs = append(errs, fmt.Errorf("%s.local_addr is required", path))
} else if err := validateHostPort(rule.LocalAddr); err != nil {
errs = append(errs, fmt.Errorf("%s.local_addr invalid: %w", path, err))
}
}
}
for tag, rules := range cfg.Connect {
for i, rule := range rules {
path := fmt.Sprintf("connect.%s[%d]", tag, i)
if rule.Protocol != "tcp" && rule.Protocol != "udp" && rule.Protocol != "minecraft" {
errs = append(errs, fmt.Errorf("%s.protocol must be tcp, udp, or minecraft", path))
}
if !validPort(rule.LocalPort) {
errs = append(errs, fmt.Errorf("%s.local_port must be between 1 and 65535", path))
}
if strings.TrimSpace(rule.DstAddr) == "" {
errs = append(errs, fmt.Errorf("%s.dst_addr is required", path))
} else if err := validateHostPort(rule.DstAddr); err != nil {
errs = append(errs, fmt.Errorf("%s.dst_addr invalid: %w", path, err))
}
if rule.LocalAddr != "" && net.ParseIP(rule.LocalAddr) == nil {
errs = append(errs, fmt.Errorf("%s.local_addr must be an IP address", path))
}
if validPort(rule.LocalPort) && (rule.Protocol == "tcp" || rule.Protocol == "udp" || rule.Protocol == "minecraft") {
network := rule.Protocol
if network == "minecraft" {
network = "tcp"
}
if prev, ok := conflictingListener(usedConnectListeners, network, rule.BindIP(), rule.LocalPort); ok {
errs = append(errs, fmt.Errorf("%s local listener duplicates %s", path, prev))
}
usedConnectListeners[listenerKey(network, rule.BindIP(), rule.LocalPort)] = path
}
}
}
return errors.Join(errs...)
}
func validPort(port int) bool {
return port > 0 && port <= 65535
}
func validateHostPort(addr string) error {
host, port, err := net.SplitHostPort(addr)
if err != nil {
return err
}
if strings.TrimSpace(host) == "" {
return errors.New("host is required")
}
if strings.TrimSpace(port) == "" {
return errors.New("port is required")
}
return nil
}
func listenerKey(network, ip string, port int) string {
return fmt.Sprintf("%s/%s", network, net.JoinHostPort(ip, fmt.Sprintf("%d", port)))
}
func conflictingListener(used map[string]string, network, ip string, port int) (string, bool) {
candidates := []string{
listenerKey(network, ip, port),
}
if ip == "0.0.0.0" {
for key, path := range used {
prefix := network + "/"
_, usedPort, err := net.SplitHostPort(strings.TrimPrefix(key, prefix))
if strings.HasPrefix(key, prefix) && err == nil && usedPort == fmt.Sprintf("%d", port) {
return path, true
}
}
} else {
candidates = append(candidates, listenerKey(network, "0.0.0.0", port))
}
for _, key := range candidates {
if prev, ok := used[key]; ok {
return prev, true
}
}
return "", false
}
// LoadConfig loads configuration from a file path or URL.
// If path starts with "http://" or "https://", it fetches the config from the URL.
// Otherwise, it reads from the local file system.
@@ -92,12 +232,9 @@ func LoadConfig(path string) (*Config, error) {
return nil, err
}
if cfg.Core.Hostname == "" {
hostname, err := os.Hostname()
if err != nil {
hostname = "unknown"
}
cfg.Core.Hostname = hostname
cfg.ApplyDefaults()
if err := cfg.Validate(); err != nil {
return nil, err
}
return cfg, nil
@@ -139,12 +276,9 @@ func loadConfigFromURL(url string) (*Config, error) {
return nil, fmt.Errorf("failed to decode TOML config from %s: %w", url, err)
}
if cfg.Core.Hostname == "" {
hostname, err := os.Hostname()
if err != nil {
hostname = "unknown"
}
cfg.Core.Hostname = hostname
cfg.ApplyDefaults()
if err := cfg.Validate(); err != nil {
return nil, err
}
return cfg, nil