From 812bf6dd156b1ad6fc0659f7bf505a10369002f1 Mon Sep 17 00:00:00 2001 From: ericbear Date: Thu, 30 Jul 2026 18:29:19 +0800 Subject: [PATCH] Add domain whitelist routing to Socks5/HTTP/SNI proxies Adds an optional TunnelDomains regex whitelist to the hostname-aware proxy listeners. When set, only connections whose destination host matches a pattern are routed through WireGuard; everything else is dialed directly, giving per-domain split tunneling inside wireproxy. When unset, all traffic is tunneled as before (no regression). Each TunnelDomains line is one full Go regex (repeat the key for multiple patterns) so quantifiers like {2,4} survive; invalid patterns fail --configtest. A per-section LogDomains bool logs each connection's host and its TUNNEL/DIRECT decision so users can discover which domains to whitelist. SOCKS5 keeps the original FQDN via a passthrough resolver plus WithDialAndRequest so routing decisions use the hostname the client asked for; the legacy WithDial/WithResolver path is kept when routing and logging are both off. --- README.md | 22 +++++++ config.go | 96 +++++++++++++++++++++++++++--- router.go | 63 ++++++++++++++++++++ router_test.go | 154 +++++++++++++++++++++++++++++++++++++++++++++++++ routine.go | 65 +++++++++++++++++++-- 5 files changed, 387 insertions(+), 13 deletions(-) create mode 100644 router.go create mode 100644 router_test.go diff --git a/README.md b/README.md index ebe76b2e..a37964e0 100644 --- a/README.md +++ b/README.md @@ -157,6 +157,20 @@ BindAddress = 127.0.0.1:25344 # Avoid using spaces in the password field #Password = ... +# Domain whitelist routing (optional). When TunnelDomains is set, only connections +# whose destination host matches one of the patterns are routed through wireguard; +# every other connection is dialed directly over your normal network. When +# TunnelDomains is unset, all traffic is routed through wireguard (default). +# Each TunnelDomains line is a single, full Go regular expression (RE2). Repeat +# the key for multiple patterns; do NOT comma-separate (so quantifiers like {2,4} +# keep working). Matching is case-insensitive and a trailing dot is ignored. +#TunnelDomains = ^(.*\.)?example\.com$ +#TunnelDomains = ^ipinfo\.io$ +# Set LogDomains = true to log every connection's destination host and whether it +# was routed to the TUNNEL or DIRECT. Useful for discovering which domains your +# apps reach before writing TunnelDomains. Off by default. +#LogDomains = true + # http creates a http proxy on your LAN, and all traffic would be routed via wireguard. [http] BindAddress = 127.0.0.1:25345 @@ -171,10 +185,18 @@ BindAddress = 127.0.0.1:25345 #CertFile = ... #KeyFile = ... +# TunnelDomains / LogDomains work here too (same semantics as [Socks5] above). +#TunnelDomains = ^(.*\.)?example\.com$ +#LogDomains = true + # SNI creates a transparent TLS proxy on your LAN, and all traffic would be routed via wireguard, # using Server Name Indication as routing destination. [SNI] BindAddress = 0.0.0.0:443 + +# TunnelDomains / LogDomains work here too, matched against the TLS SNI hostname. +#TunnelDomains = ^(.*\.)?example\.com$ +#LogDomains = true ``` Alternatively, if you already have a wireguard config, you can import it in the diff --git a/config.go b/config.go index 04c516b8..a37957d3 100644 --- a/config.go +++ b/config.go @@ -7,6 +7,7 @@ import ( "net" "os" "path/filepath" + "regexp" "strings" "github.com/go-ini/ini" @@ -57,21 +58,27 @@ type TCPServerTunnelConfig struct { } type Socks5Config struct { - BindAddress string - Username string - Password string + BindAddress string + Username string + Password string + TunnelDomains []*regexp.Regexp + LogDomains bool } type SNIConfig struct { - BindAddress string + BindAddress string + TunnelDomains []*regexp.Regexp + LogDomains bool } type HTTPConfig struct { - BindAddress string - Username string - Password string - CertFile string - KeyFile string + BindAddress string + Username string + Password string + CertFile string + KeyFile string + TunnelDomains []*regexp.Regexp + LogDomains bool } type ResolveConfig struct { @@ -421,6 +428,41 @@ func parseTCPServerTunnelConfig(section *ini.Section) (RoutineSpawner, error) { return config, nil } +// parseRegexList reads a whitelist of regular expressions from a section key. +// Each occurrence of the key (one per line; AllowShadows is enabled) is treated +// as a single, complete regex — the value is NOT split on commas, so quantifiers +// like `a{2,4}` work. An absent key yields no patterns. An invalid pattern is a +// configuration error, so --configtest rejects it before any traffic flows. +func parseRegexList(section *ini.Section, keyName string) ([]*regexp.Regexp, error) { + key, err := section.GetKey(keyName) + if err != nil { + return nil, nil + } + + var patterns []*regexp.Regexp + for _, raw := range key.ValueWithShadows() { + raw = strings.TrimSpace(raw) + if raw == "" { + continue + } + re, cerr := regexp.Compile(raw) + if cerr != nil { + return nil, errors.New("invalid " + keyName + " regex `" + raw + "`: " + cerr.Error()) + } + patterns = append(patterns, re) + } + return patterns, nil +} + +// parseBoolKey reads an optional boolean key, defaulting to false when absent. +func parseBoolKey(section *ini.Section, keyName string) (bool, error) { + key, err := section.GetKey(keyName) + if err != nil { + return false, nil + } + return key.Bool() +} + func parseSocks5Config(section *ini.Section) (RoutineSpawner, error) { config := &Socks5Config{} @@ -436,6 +478,18 @@ func parseSocks5Config(section *ini.Section) (RoutineSpawner, error) { password, _ := parseString(section, "Password") config.Password = password + tunnelDomains, err := parseRegexList(section, "TunnelDomains") + if err != nil { + return nil, err + } + config.TunnelDomains = tunnelDomains + + logDomains, err := parseBoolKey(section, "LogDomains") + if err != nil { + return nil, err + } + config.LogDomains = logDomains + return config, nil } @@ -448,6 +502,18 @@ func parseSNIConfig(section *ini.Section) (RoutineSpawner, error) { } config.BindAddress = bindAddress + tunnelDomains, err := parseRegexList(section, "TunnelDomains") + if err != nil { + return nil, err + } + config.TunnelDomains = tunnelDomains + + logDomains, err := parseBoolKey(section, "LogDomains") + if err != nil { + return nil, err + } + config.LogDomains = logDomains + return config, nil } @@ -472,6 +538,18 @@ func parseHTTPConfig(section *ini.Section) (RoutineSpawner, error) { keyFile, _ := parseString(section, "KeyFile") config.KeyFile = keyFile + tunnelDomains, err := parseRegexList(section, "TunnelDomains") + if err != nil { + return nil, err + } + config.TunnelDomains = tunnelDomains + + logDomains, err := parseBoolKey(section, "LogDomains") + if err != nil { + return nil, err + } + config.LogDomains = logDomains + return config, nil } diff --git a/router.go b/router.go new file mode 100644 index 00000000..4c326d0f --- /dev/null +++ b/router.go @@ -0,0 +1,63 @@ +package wireproxy + +import ( + "log" + "net" + "regexp" + "strings" +) + +// DomainRouter decides whether a connection to a given host is routed through +// the WireGuard tunnel (host matches the whitelist) or dialed directly. +// +// An empty pattern list means "tunnel everything", preserving the legacy +// behaviour where every proxied connection goes through the tunnel. +type DomainRouter struct { + patterns []*regexp.Regexp + logNames bool +} + +// NewDomainRouter builds a DomainRouter from compiled regex patterns. When +// logNames is true, every routing decision is logged so users can discover +// which domains their applications reach (and thus what to whitelist). +func NewDomainRouter(patterns []*regexp.Regexp, logNames bool) *DomainRouter { + return &DomainRouter{patterns: patterns, logNames: logNames} +} + +// shouldTunnel reports whether host should be routed through the tunnel. +func (r *DomainRouter) shouldTunnel(host string) bool { + if r == nil || len(r.patterns) == 0 { + return true + } + host = strings.ToLower(strings.TrimSuffix(host, ".")) + for _, p := range r.patterns { + if p.MatchString(host) { + return true + } + } + return false +} + +// route decides how to dial host and, when logging is enabled, records the +// decision. It returns true when the connection should use the tunnel. +func (r *DomainRouter) route(host string) bool { + tunnel := r.shouldTunnel(host) + if r != nil && r.logNames { + dest := "DIRECT" + if tunnel { + dest = "TUNNEL" + } + log.Printf("route: %s -> %s\n", host, dest) + } + return tunnel +} + +// hostFromAddr extracts the host portion from a "host:port" address, falling +// back to the whole string when there is no port. +func hostFromAddr(address string) string { + host, _, err := net.SplitHostPort(address) + if err != nil { + return address + } + return host +} diff --git a/router_test.go b/router_test.go new file mode 100644 index 00000000..0903df81 --- /dev/null +++ b/router_test.go @@ -0,0 +1,154 @@ +package wireproxy + +import ( + "regexp" + "testing" +) + +func mustCompile(t *testing.T, patterns ...string) []*regexp.Regexp { + t.Helper() + out := make([]*regexp.Regexp, 0, len(patterns)) + for _, p := range patterns { + re, err := regexp.Compile(p) + if err != nil { + t.Fatalf("compile %q: %v", p, err) + } + out = append(out, re) + } + return out +} + +func TestDomainRouterShouldTunnel(t *testing.T) { + patterns := mustCompile(t, `^(.*\.)?example\.com$`, `^ipinfo\.io$`) + + cases := []struct { + name string + host string + want bool + }{ + {"exact match", "example.com", true}, + {"subdomain match", "www.example.com", true}, + {"deep subdomain match", "a.b.example.com", true}, + {"second pattern exact", "ipinfo.io", true}, + {"trailing dot stripped", "example.com.", true}, + {"uppercase normalised", "WWW.EXAMPLE.COM", true}, + {"no match", "other.net", false}, + {"partial no match (suffix guard)", "notexample.com", false}, + {"ip literal no match", "93.184.216.34", false}, + } + + router := NewDomainRouter(patterns, false) + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if got := router.route(c.host); got != c.want { + t.Fatalf("route(%q) = %v, want %v", c.host, got, c.want) + } + }) + } +} + +func TestDomainRouterEmptyTunnelsEverything(t *testing.T) { + router := NewDomainRouter(nil, false) + for _, host := range []string{"anything.example", "1.2.3.4", ""} { + if !router.route(host) { + t.Fatalf("empty whitelist should tunnel %q", host) + } + } + + // A nil router must also default to tunnelling (legacy behaviour). + var nilRouter *DomainRouter + if !nilRouter.route("example.com") { + t.Fatal("nil router should tunnel everything") + } +} + +func TestHostFromAddr(t *testing.T) { + cases := map[string]string{ + "example.com:443": "example.com", + "example.com": "example.com", + "1.2.3.4:80": "1.2.3.4", + "[::1]:443": "::1", + } + for in, want := range cases { + if got := hostFromAddr(in); got != want { + t.Fatalf("hostFromAddr(%q) = %q, want %q", in, got, want) + } + } +} + +func TestParseRegexListMultiLine(t *testing.T) { + // Multiple TunnelDomains lines each parse as one full regex; commas inside a + // quantifier must survive (no comma-splitting). + const config = ` +[Socks5] +BindAddress = 127.0.0.1:25344 +TunnelDomains = ^(.*\.)?example\.com$ +TunnelDomains = ^cache[0-9]{2,4}\.cdn\.net$ +LogDomains = true` + + iniData, err := loadIniConfig(config) + if err != nil { + t.Fatal(err) + } + section := iniData.Section("Socks5") + + spawner, err := parseSocks5Config(section) + if err != nil { + t.Fatal(err) + } + cfg := spawner.(*Socks5Config) + + if len(cfg.TunnelDomains) != 2 { + t.Fatalf("expected 2 patterns, got %d", len(cfg.TunnelDomains)) + } + if !cfg.LogDomains { + t.Fatal("expected LogDomains=true") + } + + router := NewDomainRouter(cfg.TunnelDomains, cfg.LogDomains) + if !router.route("www.example.com") { + t.Fatal("www.example.com should tunnel") + } + if !router.route("cache123.cdn.net") { + t.Fatal("cache123.cdn.net should tunnel (quantifier with comma)") + } + if router.route("evil.net") { + t.Fatal("evil.net should be direct") + } +} + +func TestParseRegexListInvalidRejected(t *testing.T) { + const config = ` +[http] +BindAddress = 127.0.0.1:25345 +TunnelDomains = ^(unclosed` + + iniData, err := loadIniConfig(config) + if err != nil { + t.Fatal(err) + } + section := iniData.Section("http") + + if _, err := parseHTTPConfig(section); err == nil { + t.Fatal("expected invalid regex to be rejected") + } +} + +func TestParseConfigDefaultsNoRouting(t *testing.T) { + const config = ` +[SNI] +BindAddress = 0.0.0.0:443` + + iniData, err := loadIniConfig(config) + if err != nil { + t.Fatal(err) + } + spawner, err := parseSNIConfig(iniData.Section("SNI")) + if err != nil { + t.Fatal(err) + } + cfg := spawner.(*SNIConfig) + if len(cfg.TunnelDomains) != 0 || cfg.LogDomains { + t.Fatal("defaults should be empty TunnelDomains and LogDomains=false") + } +} diff --git a/routine.go b/routine.go index 4f0d991c..1b3b8850 100644 --- a/routine.go +++ b/routine.go @@ -155,6 +155,46 @@ func (d VirtualTun) resolveToAddrPort(endpoint *addressPort) (*netip.AddrPort, e return &addrPort, nil } +// passthroughResolver is a socks5 NameResolver that performs no resolution: it +// leaves the FQDN untouched (returns a nil IP) so the FQDN survives to the dial +// callback, where the domain-based routing decision is made. Resolution then +// happens on the chosen network — the tunnel netstack for tunnelled hosts, the +// system resolver for direct hosts. +type passthroughResolver struct{} + +func (passthroughResolver) Resolve(ctx context.Context, name string) (context.Context, net.IP, error) { + return ctx, nil, nil +} + +// RoutedDial returns a dialer that sends whitelisted hosts through the tunnel +// and dials everything else directly. Used by the HTTP and SNI proxies, whose +// dial callbacks receive the destination hostname intact. +func (d VirtualTun) RoutedDial(router *DomainRouter) func(network, address string) (net.Conn, error) { + return func(network, address string) (net.Conn, error) { + if router.route(hostFromAddr(address)) { + return d.Tnet.Dial(network, address) + } + return net.Dial(network, address) + } +} + +// routedSocks5Dial returns a socks5 dial-with-request callback that routes based +// on the original destination FQDN (preserved on request.DestAddr), falling back +// to the address host for IP-literal targets. +func (d VirtualTun) routedSocks5Dial(router *DomainRouter) func(context.Context, string, string, *socks5.Request) (net.Conn, error) { + return func(ctx context.Context, network, address string, request *socks5.Request) (net.Conn, error) { + host := request.DestAddr.FQDN + if host == "" { + host = hostFromAddr(address) + } + if router.route(host) { + return d.Tnet.DialContext(ctx, network, address) + } + var dialer net.Dialer + return dialer.DialContext(ctx, network, address) + } +} + // SpawnRoutine spawns a socks5 server. func (config *Socks5Config) SpawnRoutine(vt *VirtualTun) { var authMethods []socks5.Authenticator @@ -167,12 +207,25 @@ func (config *Socks5Config) SpawnRoutine(vt *VirtualTun) { } options := []socks5.Option{ - socks5.WithDial(vt.Tnet.DialContext), - socks5.WithResolver(vt), socks5.WithAuthMethods(authMethods), socks5.WithBufferPool(bufferpool.NewPool(256 * 1024)), } + if len(config.TunnelDomains) == 0 && !config.LogDomains { + // Legacy path: everything through the tunnel, resolved via tunnel DNS. + options = append(options, + socks5.WithDial(vt.Tnet.DialContext), + socks5.WithResolver(vt), + ) + } else { + // Split-routing path: keep the FQDN so we can decide per connection. + router := NewDomainRouter(config.TunnelDomains, config.LogDomains) + options = append(options, + socks5.WithDialAndRequest(vt.routedSocks5Dial(router)), + socks5.WithResolver(passthroughResolver{}), + ) + } + server := socks5.NewServer(options...) if err := server.ListenAndServe("tcp", config.BindAddress); err != nil { @@ -182,9 +235,10 @@ func (config *Socks5Config) SpawnRoutine(vt *VirtualTun) { // SpawnRoutine spawns a http server. func (config *HTTPConfig) SpawnRoutine(vt *VirtualTun) { + router := NewDomainRouter(config.TunnelDomains, config.LogDomains) server := &HTTPServer{ config: config, - dial: vt.Tnet.Dial, + dial: vt.RoutedDial(router), auth: CredentialValidator{config.Username, config.Password}, } if config.Username != "" || config.Password != "" { @@ -334,6 +388,9 @@ func (conf *TCPServerTunnelConfig) SpawnRoutine(vt *VirtualTun) { // SpawnRoutine spawns an SNI proxy server. func (config *SNIConfig) SpawnRoutine(vt *VirtualTun) { + router := NewDomainRouter(config.TunnelDomains, config.LogDomains) + dial := vt.RoutedDial(router) + listener, err := net.Listen("tcp", config.BindAddress) if err != nil { log.Fatal(err) @@ -344,7 +401,7 @@ func (config *SNIConfig) SpawnRoutine(vt *VirtualTun) { if err != nil { log.Fatal(err) } - go sniServe(vt.Tnet.Dial, conn) + go sniServe(dial, conn) } }