Skip to content
BoWen Chai's Notes
Go back

Use Tailscale Behind a Transparent Proxy (sing-box / Momo)

Updated:
Edit page

Running a transparent sing-box (Momo) proxy on OpenWrt solves the headache of configuring proxies on every device, but it can break inbound Tailscale SSH in a way that looks completely random:

TL;DR

Why the failure feels intermittent

Tailscale prefers a direct peer-to-peer UDP path and falls back to DERP relays (or a configured Peer Relay) when NAT traversal cannot establish one.

In this incident, the failure followed a predictable loop:

  1. A cloud node attempted a cold inbound SSH connection to the home node.
  2. The home router had no active NAT mapping for a direct peer path, so Tailscale fell back to DERP.
  3. Momo routed the DERP connection into outbound/socks, which failed immediately with EOF.
  4. Running a one-off tailscale ping from the home node to the cloud node punched an outbound UDP NAT state, bringing up a direct peer path.
  5. SSH worked normally over that direct path—until the session went idle, the NAT mapping expired, and Tailscale had no working DERP fallback to catch the connection.

Because SSH works the moment a direct path exists and freezes silently instead of closing cleanly when NAT expires, sshd is not the culprit; the missing relay fallback is.

Separate control plane from transport plane

When OpenWrt runs a transparent proxy in mainland China, Tailscale traffic splits into two classes with opposite routing needs:

A whole-device DIRECT rule breaks control-plane reachability if direct access to Tailscale coordination servers is blocked; proxying everything breaks UDP and long-lived DERP streams. You need a split policy.

Diagnose the path

1. Check the Tailscale daemon on the home node

tailscale status
tailscale netcheck
systemctl status tailscaled

Look for a healthy, authenticated daemon whose netcheck or health output reports DERP/relay failures. Avoid restarting tailscaled over an active remote session—it drops your shell without fixing the router policy.

2. Compare cold inbound vs. warm NAT

From the cloud node, test SSH before the home node sends any traffic:

ssh <USER>@<HOME_NODE_TAILSCALE_IP>

Then punch an outbound packet from the home node:

tailscale ping <CLOUD_NODE_TAILSCALE_IP>

If SSH succeeds immediately after tailscale ping, outbound traffic is warming a direct NAT path while inbound relay fallback is broken.

3. Correlate tailscaled and Momo logs

On the home node:

journalctl -u tailscaled --since "30 minutes ago"

On OpenWrt:

logread -e momo | tail -n 120

Look for tailscaled DERP EOF errors paired with Momo routing literal DERP IPs into the proxy:

tailscaled: DERP connection -> EOF
Momo: <DERP_IP>:3478 -> outbound/socks
Momo: <DERP_IP>:80   -> outbound/socks
Momo: <DERP_IP>:443  -> outbound/socks

Why a DNS-only fix fails

An initial fix placed Tailscale domain rules ahead of Fake-IP and GeoIP rules, giving DERP hostnames real DNS resolution and a direct route. sing-box check passed, yet DERP connections still hit outbound/socks.

The router logs showed why: tailscaled downloads the DERP map from the control plane and dials relay servers by literal IPv4/IPv6 address alongside or instead of hostname SNI. Because those connections carry no domain name for Momo’s domain rules to match, they fall through to the default proxy outbound. Matching DERP traffic requires transport and IP rules, not just DNS rules.

Build the durable Momo / sing-box policy

Place the Tailscale transport rules immediately after protocol sniffing—ahead of source bypass, Fake-IP, GeoIP, private-CIDR, and selector rules:

{
  "route": {
    "rule_set": [
      {
        "type": "local",
        "tag": "tailscale-derp",
        "format": "source",
        "path": "/etc/momo/ruleset/tailscale-derp.json"
      }
    ],
    "rules": [
      {
        "network": "udp",
        "source_port": 41641,
        "action": "route",
        "outbound": "direct"
      },
      {
        "network": "udp",
        "port": 3478,
        "action": "route",
        "outbound": "direct"
      },
      {
        "network": "tcp",
        "port": [80, 443],
        "rule_set": ["tailscale-derp"],
        "action": "route",
        "outbound": "direct"
      },
      {
        "ip_cidr": [
          "100.64.0.0/10",
          "fd7a:115c:a1e0::/48",
          "<CLOUD_NODE_PUBLIC_IP>/32"
        ],
        "action": "route",
        "outbound": "direct"
      },
      {
        "domain_suffix": [".tailscale.com", ".tailscale.io"],
        "action": "route",
        "outbound": "<STABLE_PROXY_OUTBOUND>"
      }
    ]
  }
}

Confirm your node’s actual tailscaled UDP port before hard-coding 41641 (see Tailscale firewall ports). If your setup also matches DERP hostnames (derp*.tailscale.com), put that narrower direct rule right above the .tailscale.com control-plane rule.

Keep the DERP IP rule-set updated

Tailscale rotates DERP relay IPs over time, so don’t rely on a static hand-copied list. Pull the official DERP map from:

curl -fsS https://login.tailscale.com/derpmap/default | jq .

Run a periodic job on the router that:

  1. Downloads https://login.tailscale.com/derpmap/default to a temporary file and validates the JSON.
  2. Extracts all IPv4 and IPv6 addresses into a temporary sing-box source rule-set (rule-set format).
  3. Validates the full config with sing-box check -c <MOMO_CONFIG>.
  4. Atomically replaces /etc/momo/ruleset/tailscale-derp.json only if validation succeeds, keeping the previous known-good file on failure.

Apply and verify with a cold-inbound test

Before reloading Momo over SSH, keep a second session open and schedule a timed rollback in case routing drops.

sing-box check -c <MOMO_CONFIG>
/etc/init.d/momo reload
/etc/init.d/momo status
logread -e momo | tail -n 120

Verify in logread that all four flows hit their intended outbounds:

UDP source 41641        -> outbound/direct
UDP destination 3478    -> outbound/direct
DERP TCP 80 / 443       -> outbound/direct
Tailscale control plane -> <STABLE_PROXY_OUTBOUND>

Once the logs look right, prove the fix from a cold state (a warm NAT test proves nothing):

  1. Stop any temporary ping keepalive between the home node and cloud node.
  2. Wait longer than the NAT idle timeout without running tailscale ping.
  3. SSH from the cloud node to <HOME_NODE_TAILSCALE_IP> and confirm it connects on the first try.
  4. Leave the SSH session idle past the old freeze window and verify the terminal stays responsive.
  5. Reboot the home node during a maintenance window and repeat the cold-inbound SSH test.

Optional: add a self-hosted Peer Relay

If public DERP latency is still poor, a cloud node with a public IP running Tailscale 1.86+ can act as a private Tailscale Peer Relay:

sudo tailscale set \
  --relay-server-port=<PEER_RELAY_UDP_PORT> \
  --relay-server-static-endpoints="<CLOUD_NODE_PUBLIC_IP>:<PEER_RELAY_UDP_PORT>"

Open <PEER_RELAY_UDP_PORT>/udp in the cloud firewall, include <CLOUD_NODE_PUBLIC_IP>/32 in Momo’s direct CIDR list, and grant tailscale.com/cap/relay to the nodes that need it.


Edit page
Share this post:

Previous Post
Forward Domain Email with Cloudflare
Next Post
Heat Waves Inspired Arrangement in Strudel