feat(config): add validated configuration with atomic persistence
This commit is contained in:
@@ -0,0 +1,458 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
// Package config loads, validates and persists the prx user configuration.
|
||||||
|
//
|
||||||
|
// It also exposes the small set of validators that both the unprivileged CLI
|
||||||
|
// and the privileged helper rely on. Sharing them keeps a single source of
|
||||||
|
// truth for the values that end up in nftables scripts, ip commands and
|
||||||
|
// systemctl invocations.
|
||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"prx/internal/fsx"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config is the on-disk prx configuration.
|
||||||
|
type Config struct {
|
||||||
|
Engine Engine `json:"engine"`
|
||||||
|
Network Network `json:"network"`
|
||||||
|
Marks Marks `json:"marks"`
|
||||||
|
Routing Routing `json:"routing"`
|
||||||
|
Bypass Bypass `json:"bypass"`
|
||||||
|
ProcessBypass ProcessBypass `json:"process_bypass"`
|
||||||
|
Health Health `json:"health"`
|
||||||
|
State StateSettings `json:"state"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Engine describes the proxy engine prx orchestrates.
|
||||||
|
type Engine struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
ConfigPath string `json:"config_path"`
|
||||||
|
ServiceName string `json:"service_name"`
|
||||||
|
Manage bool `json:"manage"`
|
||||||
|
StopOnOff bool `json:"stop_on_off"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Network holds optional overrides for interface detection.
|
||||||
|
type Network struct {
|
||||||
|
WANInterface string `json:"wan_interface"`
|
||||||
|
WANGateway string `json:"wan_gateway"`
|
||||||
|
WANGateway6 string `json:"wan_gateway6"`
|
||||||
|
DNSKeepalive bool `json:"dns_keepalive"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Marks holds the conntrack mark used by the routing rules.
|
||||||
|
type Marks struct {
|
||||||
|
InboundMark uint32 `json:"inbound_mark"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Routing holds the policy routing identifiers.
|
||||||
|
type Routing struct {
|
||||||
|
TableV4 int `json:"table_v4"`
|
||||||
|
TableV6 int `json:"table_v6"`
|
||||||
|
PriorityV4 int `json:"priority_v4"`
|
||||||
|
PriorityV6 int `json:"priority_v6"`
|
||||||
|
|
||||||
|
BypassTableV4 int `json:"bypass_table_v4"`
|
||||||
|
BypassTableV6 int `json:"bypass_table_v6"`
|
||||||
|
BypassPrioV4 int `json:"bypass_priority_v4"`
|
||||||
|
BypassPrioV6 int `json:"bypass_priority_v6"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bypass lists destinations that must never go through the proxy.
|
||||||
|
type Bypass struct {
|
||||||
|
AlwaysCIDRs []string `json:"always_cidrs"`
|
||||||
|
ExtraCIDRs []string `json:"extra_cidrs"`
|
||||||
|
Domains []string `json:"domains"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProcessBypass lists process names the engine should bypass.
|
||||||
|
type ProcessBypass struct {
|
||||||
|
Names []string `json:"names"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Health configures the post-apply connectivity probe.
|
||||||
|
type Health struct {
|
||||||
|
// Probe is a host:port dialed after enabling to confirm connectivity.
|
||||||
|
// An empty probe disables the check.
|
||||||
|
Probe string `json:"probe"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// StateSettings points at the runtime state snapshot.
|
||||||
|
type StateSettings struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
// DefaultStatePath is where the helper records its snapshot.
|
||||||
|
DefaultStatePath = "/run/prx/state.json"
|
||||||
|
// DefaultEngineConfig is the sing-box configuration path.
|
||||||
|
DefaultEngineConfig = "/etc/sing-box/config.json"
|
||||||
|
// DefaultServiceName is the sing-box systemd unit name.
|
||||||
|
DefaultServiceName = "sing-box"
|
||||||
|
// DefaultMark is the conntrack mark (0x100).
|
||||||
|
DefaultMark uint32 = 0x100
|
||||||
|
// DefaultHealthProbe is the connectivity probe dialed after applying.
|
||||||
|
DefaultHealthProbe = "1.1.1.1:443"
|
||||||
|
// MaxTableID is the largest usable custom route table identifier.
|
||||||
|
MaxTableID = 2147483646
|
||||||
|
// MaxPriority is the largest usable ip rule priority.
|
||||||
|
MaxPriority = 32765
|
||||||
|
)
|
||||||
|
|
||||||
|
// reservedRouteTables are the route table identifiers reserved by the kernel.
|
||||||
|
var reservedRouteTables = map[int]bool{0: true, 253: true, 254: true, 255: true}
|
||||||
|
|
||||||
|
var (
|
||||||
|
ifaceRE = regexp.MustCompile(`^[A-Za-z0-9_.:@-]{1,15}$`)
|
||||||
|
serviceRE = regexp.MustCompile(`^[A-Za-z0-9_.@:-]{1,128}$`)
|
||||||
|
// domainRE accepts a DNS name, optionally with a leading wildcard label.
|
||||||
|
domainRE = regexp.MustCompile(`^(\*\.)?([A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.)+[A-Za-z]{2,63}$`)
|
||||||
|
)
|
||||||
|
|
||||||
|
// ValidInterfaceName reports whether name is a safe network interface name.
|
||||||
|
func ValidInterfaceName(name string) bool { return ifaceRE.MatchString(name) }
|
||||||
|
|
||||||
|
// ValidServiceName reports whether name is a safe systemd unit name.
|
||||||
|
func ValidServiceName(name string) bool { return serviceRE.MatchString(name) }
|
||||||
|
|
||||||
|
// ValidDomain reports whether d is a plausible DNS name (optionally wildcard).
|
||||||
|
func ValidDomain(d string) bool { return domainRE.MatchString(strings.TrimSpace(d)) }
|
||||||
|
|
||||||
|
// CheckTableID validates a route table identifier for the named field.
|
||||||
|
func CheckTableID(field string, id int) error {
|
||||||
|
if id < 1 || id > MaxTableID {
|
||||||
|
return fmt.Errorf("%s must be between 1 and %d, got %d", field, MaxTableID, id)
|
||||||
|
}
|
||||||
|
if reservedRouteTables[id] {
|
||||||
|
return fmt.Errorf("%s must not use reserved table %d", field, id)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckPriority validates an ip rule priority for the named field.
|
||||||
|
func CheckPriority(field string, p int) error {
|
||||||
|
if p < 1 || p > MaxPriority {
|
||||||
|
return fmt.Errorf("%s must be between 1 and %d, got %d", field, MaxPriority, p)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckDomain validates a bypass domain for the named field.
|
||||||
|
func CheckDomain(field, d string) error {
|
||||||
|
if strings.TrimSpace(d) == "" {
|
||||||
|
return fmt.Errorf("%s must not be empty", field)
|
||||||
|
}
|
||||||
|
if !ValidDomain(d) {
|
||||||
|
return fmt.Errorf("%s %q is not a valid domain name", field, d)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default returns the built-in configuration.
|
||||||
|
func Default() *Config {
|
||||||
|
return &Config{
|
||||||
|
Engine: Engine{
|
||||||
|
Type: "sing-box",
|
||||||
|
ConfigPath: DefaultEngineConfig,
|
||||||
|
ServiceName: DefaultServiceName,
|
||||||
|
Manage: true,
|
||||||
|
StopOnOff: false,
|
||||||
|
},
|
||||||
|
Network: Network{DNSKeepalive: true},
|
||||||
|
Marks: Marks{InboundMark: DefaultMark},
|
||||||
|
Routing: Routing{
|
||||||
|
TableV4: 100,
|
||||||
|
TableV6: 101,
|
||||||
|
PriorityV4: 100,
|
||||||
|
PriorityV6: 100,
|
||||||
|
BypassTableV4: 102,
|
||||||
|
BypassTableV6: 103,
|
||||||
|
BypassPrioV4: 50,
|
||||||
|
BypassPrioV6: 50,
|
||||||
|
},
|
||||||
|
Bypass: Bypass{
|
||||||
|
AlwaysCIDRs: []string{
|
||||||
|
"127.0.0.0/8",
|
||||||
|
"10.0.0.0/8",
|
||||||
|
"172.16.0.0/12",
|
||||||
|
"192.168.0.0/16",
|
||||||
|
"169.254.0.0/16",
|
||||||
|
"::1/128",
|
||||||
|
"fc00::/7",
|
||||||
|
"fe80::/10",
|
||||||
|
},
|
||||||
|
ExtraCIDRs: []string{},
|
||||||
|
Domains: []string{},
|
||||||
|
},
|
||||||
|
ProcessBypass: ProcessBypass{Names: []string{"ssh", "scp", "sftp"}},
|
||||||
|
Health: Health{Probe: DefaultHealthProbe},
|
||||||
|
State: StateSettings{Path: DefaultStatePath},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultPath resolves the configuration file location.
|
||||||
|
func DefaultPath() string {
|
||||||
|
if p := os.Getenv("PRX_CONFIG"); p != "" {
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
base := os.Getenv("XDG_CONFIG_HOME")
|
||||||
|
if base == "" {
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err == nil && home != "" {
|
||||||
|
base = filepath.Join(home, ".config")
|
||||||
|
} else {
|
||||||
|
base = "."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return filepath.Join(base, "prx", "config.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exists reports whether a configuration file is present at path.
|
||||||
|
func Exists(path string) bool {
|
||||||
|
_, err := os.Stat(path)
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load reads the configuration from path. A missing file yields defaults.
|
||||||
|
//
|
||||||
|
// Unknown fields are rejected so typos surface immediately instead of being
|
||||||
|
// silently ignored.
|
||||||
|
func Load(path string) (*Config, error) {
|
||||||
|
raw, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
return Default(), nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("read config %s: %w", path, err)
|
||||||
|
}
|
||||||
|
cfg := Default()
|
||||||
|
dec := json.NewDecoder(bytes.NewReader(raw))
|
||||||
|
dec.DisallowUnknownFields()
|
||||||
|
if err := dec.Decode(cfg); err != nil {
|
||||||
|
return nil, fmt.Errorf("parse config %s: %w", path, err)
|
||||||
|
}
|
||||||
|
if err := cfg.Validate(); err != nil {
|
||||||
|
return nil, fmt.Errorf("config %s: %w", path, err)
|
||||||
|
}
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save validates and atomically writes the configuration to path.
|
||||||
|
func Save(path string, cfg *Config) error {
|
||||||
|
if err := cfg.Validate(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
raw, err := json.MarshalIndent(cfg, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("encode config: %w", err)
|
||||||
|
}
|
||||||
|
raw = append(raw, '\n')
|
||||||
|
if err := fsx.WriteFileAtomic(path, raw, 0o644); err != nil {
|
||||||
|
return fmt.Errorf("write config %s: %w", path, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate checks the configuration for internally consistent, safe values.
|
||||||
|
func (c *Config) Validate() error {
|
||||||
|
if c.Engine.Type == "" {
|
||||||
|
return errors.New("engine.type must not be empty")
|
||||||
|
}
|
||||||
|
if c.Engine.Manage {
|
||||||
|
if c.Engine.ServiceName == "" {
|
||||||
|
return errors.New("engine.service_name must not be empty when engine.manage is true")
|
||||||
|
}
|
||||||
|
if !ValidServiceName(c.Engine.ServiceName) {
|
||||||
|
return fmt.Errorf("engine.service_name %q contains invalid characters", c.Engine.ServiceName)
|
||||||
|
}
|
||||||
|
if c.Engine.ConfigPath != "" && !filepath.IsAbs(c.Engine.ConfigPath) {
|
||||||
|
return fmt.Errorf("engine.config_path %q must be absolute", c.Engine.ConfigPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if c.Network.WANInterface != "" && !ValidInterfaceName(c.Network.WANInterface) {
|
||||||
|
return fmt.Errorf("network.wan_interface %q is not a valid interface name", c.Network.WANInterface)
|
||||||
|
}
|
||||||
|
if c.Network.WANGateway != "" && net.ParseIP(c.Network.WANGateway) == nil {
|
||||||
|
return fmt.Errorf("network.wan_gateway %q is not a valid IP address", c.Network.WANGateway)
|
||||||
|
}
|
||||||
|
if c.Network.WANGateway6 != "" {
|
||||||
|
ip := net.ParseIP(c.Network.WANGateway6)
|
||||||
|
if ip == nil || ip.To4() != nil {
|
||||||
|
return fmt.Errorf("network.wan_gateway6 %q is not a valid IPv6 address", c.Network.WANGateway6)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if c.Marks.InboundMark == 0 {
|
||||||
|
return errors.New("marks.inbound_mark must be non-zero")
|
||||||
|
}
|
||||||
|
for _, check := range []struct {
|
||||||
|
field string
|
||||||
|
value int
|
||||||
|
}{
|
||||||
|
{"routing.table_v4", c.Routing.TableV4},
|
||||||
|
{"routing.table_v6", c.Routing.TableV6},
|
||||||
|
{"routing.bypass_table_v4", c.Routing.BypassTableV4},
|
||||||
|
{"routing.bypass_table_v6", c.Routing.BypassTableV6},
|
||||||
|
} {
|
||||||
|
if err := CheckTableID(check.field, check.value); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, check := range []struct {
|
||||||
|
field string
|
||||||
|
value int
|
||||||
|
}{
|
||||||
|
{"routing.priority_v4", c.Routing.PriorityV4},
|
||||||
|
{"routing.priority_v6", c.Routing.PriorityV6},
|
||||||
|
{"routing.bypass_priority_v4", c.Routing.BypassPrioV4},
|
||||||
|
{"routing.bypass_priority_v6", c.Routing.BypassPrioV6},
|
||||||
|
} {
|
||||||
|
if err := CheckPriority(check.field, check.value); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, group := range [][]string{c.Bypass.AlwaysCIDRs, c.Bypass.ExtraCIDRs} {
|
||||||
|
for _, cidr := range group {
|
||||||
|
if _, err := ParseCIDR(cidr); err != nil {
|
||||||
|
return fmt.Errorf("bypass: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, d := range c.Bypass.Domains {
|
||||||
|
if err := CheckDomain("bypass.domains", d); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if c.Health.Probe != "" {
|
||||||
|
host, port, err := net.SplitHostPort(c.Health.Probe)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("health.probe %q must be host:port: %w", c.Health.Probe, err)
|
||||||
|
}
|
||||||
|
if host == "" {
|
||||||
|
return fmt.Errorf("health.probe %q must include a host", c.Health.Probe)
|
||||||
|
}
|
||||||
|
if n, err := net.LookupPort("tcp", port); err != nil || n < 1 {
|
||||||
|
return fmt.Errorf("health.probe %q has an invalid port", c.Health.Probe)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if c.State.Path == "" {
|
||||||
|
return errors.New("state.path must not be empty")
|
||||||
|
}
|
||||||
|
if !filepath.IsAbs(c.State.Path) {
|
||||||
|
return fmt.Errorf("state.path %q must be absolute", c.State.Path)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseCIDR validates and normalises a CIDR or bare IP into CIDR form.
|
||||||
|
func ParseCIDR(v string) (string, error) {
|
||||||
|
v = strings.TrimSpace(v)
|
||||||
|
if v == "" {
|
||||||
|
return "", errors.New("empty address")
|
||||||
|
}
|
||||||
|
if ip, ipnet, err := net.ParseCIDR(v); err == nil {
|
||||||
|
ones, _ := ipnet.Mask.Size()
|
||||||
|
return fmt.Sprintf("%s/%d", ip.String(), ones), nil
|
||||||
|
}
|
||||||
|
if ip := net.ParseIP(v); ip != nil {
|
||||||
|
if ip.To4() != nil {
|
||||||
|
return ip.String() + "/32", nil
|
||||||
|
}
|
||||||
|
return ip.String() + "/128", nil
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("invalid CIDR or IP %q", v)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddExtraCIDR appends a normalised CIDR to the extra bypass list, reporting
|
||||||
|
// whether the list changed.
|
||||||
|
func (c *Config) AddExtraCIDR(v string) (bool, error) {
|
||||||
|
cidr, err := ParseCIDR(v)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if err := guardBypassCIDR(cidr); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
for _, existing := range c.Bypass.ExtraCIDRs {
|
||||||
|
if existing == cidr {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.Bypass.ExtraCIDRs = append(c.Bypass.ExtraCIDRs, cidr)
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveExtraCIDR removes a normalised CIDR from the extra bypass list.
|
||||||
|
func (c *Config) RemoveExtraCIDR(v string) (bool, error) {
|
||||||
|
cidr, err := ParseCIDR(v)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
out := c.Bypass.ExtraCIDRs[:0]
|
||||||
|
changed := false
|
||||||
|
for _, existing := range c.Bypass.ExtraCIDRs {
|
||||||
|
if existing == cidr {
|
||||||
|
changed = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, existing)
|
||||||
|
}
|
||||||
|
c.Bypass.ExtraCIDRs = out
|
||||||
|
return changed, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddDomain appends a validated domain to the bypass list.
|
||||||
|
func (c *Config) AddDomain(d string) (bool, error) {
|
||||||
|
d = strings.TrimSpace(d)
|
||||||
|
if err := CheckDomain("domain", d); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
for _, existing := range c.Bypass.Domains {
|
||||||
|
if strings.EqualFold(existing, d) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.Bypass.Domains = append(c.Bypass.Domains, d)
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveDomain removes a domain (case-insensitively) from the bypass list.
|
||||||
|
func (c *Config) RemoveDomain(d string) bool {
|
||||||
|
d = strings.TrimSpace(d)
|
||||||
|
out := c.Bypass.Domains[:0]
|
||||||
|
changed := false
|
||||||
|
for _, existing := range c.Bypass.Domains {
|
||||||
|
if strings.EqualFold(existing, d) {
|
||||||
|
changed = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, existing)
|
||||||
|
}
|
||||||
|
c.Bypass.Domains = out
|
||||||
|
return changed
|
||||||
|
}
|
||||||
|
|
||||||
|
// guardBypassCIDR refuses values that would blackhole essential traffic.
|
||||||
|
func guardBypassCIDR(cidr string) error {
|
||||||
|
switch cidr {
|
||||||
|
case "0.0.0.0/0", "::/0":
|
||||||
|
return fmt.Errorf("refusing to add default route %s to bypass list", cidr)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsCIDR reports whether v parses as a CIDR or bare IP.
|
||||||
|
func IsCIDR(v string) bool {
|
||||||
|
_, err := ParseCIDR(v)
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDefaultIsValid(t *testing.T) {
|
||||||
|
if err := Default().Validate(); err != nil {
|
||||||
|
t.Fatalf("default config invalid: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseCIDR(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
in string
|
||||||
|
want string
|
||||||
|
bad bool
|
||||||
|
}{
|
||||||
|
{"192.168.1.0/24", "192.168.1.0/24", false},
|
||||||
|
{"1.2.3.4", "1.2.3.4/32", false},
|
||||||
|
{"2001:db8::/32", "2001:db8::/32", false},
|
||||||
|
{"::1", "::1/128", false},
|
||||||
|
{"10.0.0.0/8", "10.0.0.0/8", false},
|
||||||
|
{"not-an-ip", "", true},
|
||||||
|
{"", "", true},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
got, err := ParseCIDR(tt.in)
|
||||||
|
if tt.bad {
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("ParseCIDR(%q): expected error", tt.in)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("ParseCIDR(%q): %v", tt.in, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("ParseCIDR(%q) = %q, want %q", tt.in, got, tt.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAddRemoveExtraCIDR(t *testing.T) {
|
||||||
|
c := Default()
|
||||||
|
changed, err := c.AddExtraCIDR("203.0.113.7")
|
||||||
|
if err != nil || !changed {
|
||||||
|
t.Fatalf("AddExtraCIDR: changed=%v err=%v", changed, err)
|
||||||
|
}
|
||||||
|
changed, err = c.AddExtraCIDR("203.0.113.7/32")
|
||||||
|
if err != nil || changed {
|
||||||
|
t.Fatalf("duplicate should be a no-op: changed=%v err=%v", changed, err)
|
||||||
|
}
|
||||||
|
if _, err := c.AddExtraCIDR("0.0.0.0/0"); err == nil {
|
||||||
|
t.Fatal("expected refusal to bypass default route")
|
||||||
|
}
|
||||||
|
changed, err = c.RemoveExtraCIDR("203.0.113.7")
|
||||||
|
if err != nil || !changed {
|
||||||
|
t.Fatalf("RemoveExtraCIDR: changed=%v err=%v", changed, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateRejectsBadValues(t *testing.T) {
|
||||||
|
cases := map[string]func(*Config){
|
||||||
|
"zero mark": func(c *Config) { c.Marks.InboundMark = 0 },
|
||||||
|
"reserved table": func(c *Config) { c.Routing.TableV4 = 254 },
|
||||||
|
"bad priority": func(c *Config) { c.Routing.PriorityV4 = 70000 },
|
||||||
|
"bad cidr": func(c *Config) { c.Bypass.ExtraCIDRs = []string{"nope"} },
|
||||||
|
"relative state": func(c *Config) { c.State.Path = "state.json" },
|
||||||
|
"empty type": func(c *Config) { c.Engine.Type = "" },
|
||||||
|
"bad service": func(c *Config) { c.Engine.ServiceName = "bad name!" },
|
||||||
|
"bad wan iface": func(c *Config) { c.Network.WANInterface = "bad iface" },
|
||||||
|
"bad gateway": func(c *Config) { c.Network.WANGateway = "nope" },
|
||||||
|
"relative engine": func(c *Config) { c.Engine.ConfigPath = "rel.json" },
|
||||||
|
}
|
||||||
|
for name, mutate := range cases {
|
||||||
|
c := Default()
|
||||||
|
mutate(c)
|
||||||
|
if err := c.Validate(); err == nil {
|
||||||
|
t.Errorf("case %q: expected validation error", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSaveLoadRoundTrip(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "prx", "config.json")
|
||||||
|
c := Default()
|
||||||
|
if changed, _ := c.AddExtraCIDR("198.51.100.0/24"); !changed {
|
||||||
|
t.Fatal("expected change")
|
||||||
|
}
|
||||||
|
if err := Save(path, c); err != nil {
|
||||||
|
t.Fatalf("Save: %v", err)
|
||||||
|
}
|
||||||
|
got, err := Load(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load: %v", err)
|
||||||
|
}
|
||||||
|
if len(got.Bypass.ExtraCIDRs) != 1 || got.Bypass.ExtraCIDRs[0] != "198.51.100.0/24" {
|
||||||
|
t.Fatalf("unexpected extra cidrs: %+v", got.Bypass.ExtraCIDRs)
|
||||||
|
}
|
||||||
|
if got.Marks.InboundMark != DefaultMark {
|
||||||
|
t.Fatalf("mark not preserved: %x", got.Marks.InboundMark)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadMissingReturnsDefault(t *testing.T) {
|
||||||
|
got, err := Load(filepath.Join(t.TempDir(), "absent.json"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load: %v", err)
|
||||||
|
}
|
||||||
|
if got.Engine.Type != "sing-box" {
|
||||||
|
t.Fatalf("expected default engine, got %q", got.Engine.Type)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAddDomainValidatesAndDedupes(t *testing.T) {
|
||||||
|
c := Default()
|
||||||
|
if _, err := c.AddDomain("not a domain"); err == nil {
|
||||||
|
t.Fatal("expected error for invalid domain")
|
||||||
|
}
|
||||||
|
if _, err := c.AddDomain(""); err == nil {
|
||||||
|
t.Fatal("expected error for empty domain")
|
||||||
|
}
|
||||||
|
changed, err := c.AddDomain("Example.com")
|
||||||
|
if err != nil || !changed {
|
||||||
|
t.Fatalf("AddDomain: changed=%v err=%v", changed, err)
|
||||||
|
}
|
||||||
|
if changed, _ := c.AddDomain("example.com"); changed {
|
||||||
|
t.Fatal("duplicate domain should be a no-op (case-insensitive)")
|
||||||
|
}
|
||||||
|
if changed, _ := c.AddDomain("*.wild.example.com"); !changed {
|
||||||
|
t.Fatal("wildcard domain should be accepted")
|
||||||
|
}
|
||||||
|
if !c.RemoveDomain("EXAMPLE.COM") {
|
||||||
|
t.Fatal("RemoveDomain should be case-insensitive")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidDomain(t *testing.T) {
|
||||||
|
valid := []string{"example.com", "a.b.c.example.org", "*.example.com", "xn--bcher-kva.example"}
|
||||||
|
for _, d := range valid {
|
||||||
|
if !ValidDomain(d) {
|
||||||
|
t.Errorf("ValidDomain(%q) = false, want true", d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
invalid := []string{"", "no_dots", "trailing-.com", "-leading.com", "sp ace.com", "*.", "a..b.com"}
|
||||||
|
for _, d := range invalid {
|
||||||
|
if ValidDomain(d) {
|
||||||
|
t.Errorf("ValidDomain(%q) = true, want false", d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHealthProbeValidation(t *testing.T) {
|
||||||
|
ok := Default()
|
||||||
|
if err := ok.Validate(); err != nil {
|
||||||
|
t.Fatalf("default probe rejected: %v", err)
|
||||||
|
}
|
||||||
|
disabled := Default()
|
||||||
|
disabled.Health.Probe = ""
|
||||||
|
if err := disabled.Validate(); err != nil {
|
||||||
|
t.Fatalf("empty probe should disable the check: %v", err)
|
||||||
|
}
|
||||||
|
for _, bad := range []string{"nope", ":443", "1.1.1.1:0", "1.1.1.1:99999"} {
|
||||||
|
c := Default()
|
||||||
|
c.Health.Probe = bad
|
||||||
|
if err := c.Validate(); err == nil {
|
||||||
|
t.Errorf("Health.Probe %q: expected validation error", bad)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckTableIDRejectsReserved(t *testing.T) {
|
||||||
|
for _, id := range []int{0, 253, 254, 255, -1, MaxTableID + 1} {
|
||||||
|
if err := CheckTableID("table", id); err == nil {
|
||||||
|
t.Errorf("CheckTableID(%d): expected error", id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := CheckTableID("table", 100); err != nil {
|
||||||
|
t.Errorf("CheckTableID(100): unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWANGateway6Validation(t *testing.T) {
|
||||||
|
c := Default()
|
||||||
|
c.Network.WANGateway6 = "2001:db8::1"
|
||||||
|
if err := c.Validate(); err != nil {
|
||||||
|
t.Fatalf("valid IPv6 gateway rejected: %v", err)
|
||||||
|
}
|
||||||
|
c.Network.WANGateway6 = "192.168.1.1"
|
||||||
|
if err := c.Validate(); err == nil {
|
||||||
|
t.Fatal("IPv4 address in wan_gateway6 should be rejected")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user