Files

459 lines
13 KiB
Go

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