// 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 }