From 4048507f26c4849888354947bd26757201857a68 Mon Sep 17 00:00:00 2001 From: Amir Husayn Panahifar Date: Thu, 15 Oct 2026 14:29:19 +0330 Subject: [PATCH] feat(config): add validated configuration with atomic persistence --- internal/config/config.go | 458 +++++++++++++++++++++++++++++++++ internal/config/config_test.go | 199 ++++++++++++++ 2 files changed, 657 insertions(+) create mode 100644 internal/config/config.go create mode 100644 internal/config/config_test.go diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..3cb3429 --- /dev/null +++ b/internal/config/config.go @@ -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 +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..20c162f --- /dev/null +++ b/internal/config/config_test.go @@ -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") + } +}