diff --git a/cmd/ts-unplug-proxy/README.md b/cmd/ts-unplug-proxy/README.md index af43820..0e79bba 100644 --- a/cmd/ts-unplug-proxy/README.md +++ b/cmd/ts-unplug-proxy/README.md @@ -33,6 +33,8 @@ Proxy clients provide the remote destination, so this command does not take a po Use `-v` to log proxy connections. Use `-vv` to include `tsnet.Server` debug logs. +Use `-accept-routes` when reaching subnet routes or Tailscale Services advertised by other nodes. On macOS, use `-disable-interface-binding` when another network tunnel controls the system's interface and route selection. + ## Documentation See **[docs/ts-unplug-proxy.md](../../docs/ts-unplug-proxy.md)** for complete documentation. diff --git a/cmd/ts-unplug-proxy/ts-unplug-proxy.go b/cmd/ts-unplug-proxy/ts-unplug-proxy.go index b8919aa..f4aee39 100644 --- a/cmd/ts-unplug-proxy/ts-unplug-proxy.go +++ b/cmd/ts-unplug-proxy/ts-unplug-proxy.go @@ -16,11 +16,13 @@ import ( "slices" "strings" "syscall" + "time" "golang.org/x/net/dns/dnsmessage" "tailscale.com/client/local" "tailscale.com/ipn" "tailscale.com/ipn/ipnstate" + "tailscale.com/net/netns" "tailscale.com/net/proxymux" "tailscale.com/net/socks5" "tailscale.com/tsnet" @@ -29,12 +31,14 @@ import ( const tsnetHostname = "tsunplug-proxy" var ( - flagDir = flag.String("dir", "", "tsnet server directory") - flagVerboseProxy = flag.Bool("v", false, "log proxy connections") - flagVerboseTSNet = flag.Bool("vv", false, "log proxy connections and tsnet debug info") - flagSOCKS5Addr = flag.String("socks5", "", "SOCKS5 proxy listen address") - flagHTTPAddr = flag.String("http", "", "HTTP proxy listen address") - flagNoExitNode = flag.Bool("disable-exit-node", false, "disable automatic tailnet exit node use") + flagDir = flag.String("dir", "", "tsnet server directory") + flagVerboseProxy = flag.Bool("v", false, "log proxy connections") + flagVerboseTSNet = flag.Bool("vv", false, "log proxy connections and tsnet debug info") + flagSOCKS5Addr = flag.String("socks5", "", "SOCKS5 proxy listen address") + flagHTTPAddr = flag.String("http", "", "HTTP proxy listen address") + flagNoExitNode = flag.Bool("disable-exit-node", false, "disable automatic tailnet exit node use") + flagDisableInterfaceBinding = flag.Bool("disable-interface-binding", false, "disable Tailscale's macOS network namespace integration") + flagAcceptRoutes = flag.Bool("accept-routes", false, "accept advertised subnet and Service routes") ) type serveResult struct { @@ -103,6 +107,12 @@ func main() { slog.Debug(fmt.Sprintf(format, args...)) } } + if *flagDisableInterfaceBinding { + // tsnet's network namespace can bind outbound connections to the + // macOS default interface. Disable it when another tunnel controls + // the system's interface and route selection. + netns.SetEnabled(false) + } st, err := ts.Up(ctx) if err != nil { @@ -122,6 +132,16 @@ func main() { slog.Error("failed to get tsnet local client", slog.Any("error", err)) os.Exit(1) } + if *flagAcceptRoutes { + if _, err := lc.EditPrefs(ctx, &ipn.MaskedPrefs{ + Prefs: ipn.Prefs{RouteAll: true}, + RouteAllSet: true, + }); err != nil { + slog.Error("failed to enable advertised routes", slog.Any("error", err)) + os.Exit(1) + } + slog.Info("advertised subnet and Service routes enabled") + } if !*flagNoExitNode { if err := useExitNodeIfAvailable(ctx, lc); err != nil { slog.Warn("failed to configure tailnet exit node", slog.Any("error", err)) @@ -219,7 +239,10 @@ func useExitNodeIfAvailable(ctx context.Context, lc *local.Client) error { } func (d *tailnetDialer) Dial(ctx context.Context, network, addr string) (net.Conn, error) { - resolvedAddr, resolved, err := d.resolveAddr(ctx, network, addr) + dialCtx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + + resolvedAddr, resolved, err := d.resolveAddr(dialCtx, network, addr) if err != nil { return nil, err } @@ -228,14 +251,14 @@ func (d *tailnetDialer) Dial(ctx context.Context, network, addr string) (net.Con slog.String("network", network), slog.String("addr", addr), ) - return d.ts.Dial(ctx, network, addr) + return d.ts.Dial(dialCtx, network, addr) } slog.Debug("dialing through tailnet", slog.String("network", network), slog.String("addr", addr), slog.String("resolved_addr", resolvedAddr), ) - return d.ts.Dial(ctx, network, resolvedAddr) + return d.ts.Dial(dialCtx, network, resolvedAddr) } func (d *tailnetDialer) resolveAddr(ctx context.Context, network, addr string) (resolvedAddr string, resolved bool, err error) { @@ -247,13 +270,22 @@ func (d *tailnetDialer) resolveAddr(ctx context.Context, network, addr string) ( return net.JoinHostPort(ip.String(), port), true, nil } - status, err := d.lc.Status(ctx) - if err != nil { - slog.Debug("tailnet status lookup failed", slog.String("host", host), slog.Any("error", err)) + status, statusErr := d.lc.Status(ctx) + if statusErr != nil { + slog.Debug("tailnet status lookup failed", slog.String("host", host), slog.Any("error", statusErr)) } - ips := lookupMagicDNS(status, network, host) + + // Query Tailscale DNS before using peer status aliases. A Tailscale Service + // name can intentionally collide with a host name (for example, the + // service "example" hosted by the peer "example"). Peer status then + // contains the host IP, while Tailscale DNS contains the Service VIP. + ips, lookupErr := d.lookupIP(ctx, network, host) + err = lookupErr if len(ips) == 0 { - ips, err = d.lookupIP(ctx, network, host) + ips = lookupMagicDNS(status, network, host) + if len(ips) > 0 { + err = nil + } } if err != nil { if isStrictTailnetHost(status, host) { diff --git a/docs/ts-unplug-proxy.md b/docs/ts-unplug-proxy.md index 82cf23c..1ff3229 100644 --- a/docs/ts-unplug-proxy.md +++ b/docs/ts-unplug-proxy.md @@ -78,6 +78,10 @@ ts-unplug-proxy -dir ./state -socks5 localhost:1080 -http localhost:1080 ts-unplug-proxy -dir ./state -vv -socks5 localhost:1080 ``` +- `-accept-routes` - Accept subnet and Tailscale Service routes advertised by other nodes + +- `-disable-interface-binding` - On macOS, disable Tailscale's network namespace integration, including physical-interface binding. Use this when another network tunnel controls the system's interface and route selection. + ## Examples ### SOCKS5 With curl @@ -140,8 +144,8 @@ ts-unplug-proxy: 1. Connects a `tsnet.Server` to your tailnet 2. Listens locally for SOCKS5 and/or HTTP proxy connections 3. Receives each requested destination from the proxy client -4. Resolves MagicDNS peer names from Tailscale status, including short names like `sippy` -5. Falls back to Tailscale DNS via `LocalClient.QueryDNS` for other DNS records +4. Resolves destinations through Tailscale DNS via `LocalClient.QueryDNS` +5. Falls back to MagicDNS peer names from Tailscale status, including short names like `sippy` 6. Falls back to normal `tsnet.Server.Dial` for non-tailnet public names such as `www.google.ca` 7. Dials resolved IPs with `tsnet.Server.Dial` 8. Relays traffic between the client and the destination