diff --git a/README.md b/README.md index ebe76b2..a37964e 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 04c516b..a37957d 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 0000000..4c326d0 --- /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 0000000..0903df8 --- /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 4f0d991..1b3b885 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) } }