Skip to content
BoWen Chai's Notes
Go back

When Tailscale SSH Freezes Behind a Transparent Proxy

Edit page

Remote access failures often look random. An SSH connection works after several retries. It stays responsive for a while. Then the terminal freezes during an idle period. The Tailscale node still appears online, so neither SSH nor Tailscale seems fully broken.

In one home-network incident, the missing piece was the fallback path. A transparent sing-box proxy on an OpenWRT router sent Tailscale relay traffic through an outbound that could not keep the relay connection open. A direct peer path worked after the home node sent traffic first, but the path disappeared after the NAT state expired. Tailscale then had no reliable relay path.

The first fix used DNS and domain rules. It looked correct, but it could not match relay connections that tailscaled made to literal IP addresses from its DERP map. The durable design needed one more layer: transport rules that run before the proxy rules.

This guide explains how to identify that failure mode, how to recover access without mistaking a keepalive for a fix, and how to build a routing policy that you can prove with a cold-inbound test.

Incident status: The diagnosis was confirmed from live router and Tailscale logs. The final IP/port routing policy was proposed but had not yet passed the cold-inbound acceptance test when this guide was written.

Table of contents

Open Table of contents

The network model

Use these names in the examples. Replace each placeholder with a value from your network.

PlaceholderMeaning
<HOME_NODE>The node behind OpenWRT and the transparent proxy
<HOME_NODE_TAILSCALE_IP>The Tailscale address of the home node
<CLOUD_NODE>A stable cloud node with a public endpoint
<CLOUD_NODE_TAILSCALE_IP>The Tailscale address of the cloud node
<CLOUD_NODE_PUBLIC_IP>The public address of the cloud node
<USER>The SSH account on the home node
<TAILNET_IPV4_CIDR>The IPv4 range used for tailnet traffic
<TAILNET_IPV6_CIDR>The IPv6 range used for tailnet traffic
<TAILSCALE_UDP_PORT>The UDP port used by tailscaled; the default is 41641
<PEER_RELAY_UDP_PORT>The UDP port selected for an optional Peer Relay
<DERP_ADDRESS>A current DERP IPv4 or IPv6 address from the DERP map
<STABLE_PROXY_OUTBOUND>The Momo outbound tag used for Tailscale control traffic
<MOMO_CONFIG>The generated sing-box configuration file
<DERP_RULE_SET>The local rule-set file that contains current DERP addresses

Do not publish the replaced values. Do not publish router credentials, proxy credentials, subscription URLs, API secrets, or full generated outbound objects.

Why the failure feels intermittent

Tailscale first tries to create a direct peer-to-peer connection. If network conditions prevent a direct path, Tailscale uses a relay. Public DERP servers provide the normal relay fallback. A configured Peer Relay can provide another fallback inside the tailnet.

The incident followed this sequence:

  1. The cloud node started a cold inbound SSH connection.
  2. The home router had no usable NAT mapping for the peer path.
  3. The DERP fallback failed with an immediate EOF.
  4. The home node sent a one-time tailscale ping to the cloud node.
  5. The outbound packet created a direct path through NAT.
  6. The cloud node connected by SSH over the direct path.
  7. The direct path became idle and the NAT state expired.
  8. The SSH terminal froze because the relay fallback was still unavailable.

This sequence distinguishes the problem from an sshd failure. SSH is healthy if it works as soon as the direct Tailscale path exists. The network path is the failing component if the terminal becomes silent instead of closing with a normal SSH timeout.

Separate the control plane from the transport plane

A transparent proxy can affect two different classes of Tailscale traffic.

The control plane handles authentication, coordination, keys, policy updates, and logging. In some networks, these domains need a stable proxy path.

The transport plane carries peer UDP, STUN, and relay traffic. This traffic must not enter a proxy outbound that breaks UDP or long-lived DERP connections.

Do not use a whole-device DIRECT rule if the node needs a proxy to reach the Tailscale control plane. Do not send all Tailscale traffic through the proxy. Use a split policy.

Diagnose the path

1. Check the Tailscale daemon

Run these commands on the home node:

tailscale status
tailscale netcheck
systemctl status tailscaled

Confirm these conditions:

Do not restart tailscaled during a working remote session. A restart interrupts the session and does not correct a router policy.

2. Compare cold inbound and warm-path behavior

From the cloud node, try SSH before the home node sends Tailscale traffic:

ssh <USER>@<HOME_NODE_TAILSCALE_IP>

Record the result. Then run one command on the home node:

tailscale ping <CLOUD_NODE_TAILSCALE_IP>

Try SSH again from the cloud node. If the second attempt works, the outbound packet probably created or refreshed the direct NAT path.

This test is diagnostic. It is not a permanent repair.

3. Read both sides of the router decision

Read recent Tailscale logs on the home node:

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

Read recent Momo logs on OpenWRT:

logread -e momo | tail -n 120

Look for matching events:

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

The exact DERP address can change. The important evidence is the route decision: STUN or DERP traffic uses the proxy outbound while Tailscale reports relay failure.

Why the DNS-only fix was incomplete

The first policy used real DNS and direct routes for DERP names. It used direct routes for tailnet traffic and the cloud endpoint. It sent control-plane domains through a stable proxy.

That policy corrected several real problems:

The generated sing-box configuration passed validation. The same failure still occurred after Momo loaded it.

Router logs explained why. tailscaled received relay endpoints from its DERP map and connected to literal IP addresses. The connection did not contain a domain name that Momo could match. The generic route sent the connection to SOCKS, and the long-lived DERP session ended with EOF.

Fake-IP was therefore not the complete root cause. Domain order mattered, but transport matching was also required.

Use a keepalive only for recovery

A keepalive can preserve remote access while you change the router. It does not repair DERP.

First, establish the direct path once:

tailscale ping <CLOUD_NODE_TAILSCALE_IP>

Then send ordinary ICMP over the Tailscale interface at a short interval:

/usr/bin/ping -n -i 15 <CLOUD_NODE_TAILSCALE_IP>

Run this command in a bounded service or maintenance session. Set an automatic stop time. Stop the keepalive before the final acceptance test.

The distinction matters: tailscale ping establishes and diagnoses a Tailscale path. Ordinary ping only supplies traffic that can keep an existing NAT path warm.

Build the durable Momo policy

Put the Tailscale transport rules immediately after protocol sniffing. Put them before source bypass, Fake-IP, GeoIP, private-address, and selector rules.

The following fragment shows the required order. Adapt the tags and file paths to your Momo configuration.

{
  "route": {
    "rule_set": [
      {
        "type": "local",
        "tag": "tailscale-derp",
        "format": "source",
        "path": "<DERP_RULE_SET>"
      }
    ],
    "rules": [
      {
        "network": "udp",
        "source_port": <TAILSCALE_UDP_PORT>,
        "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": [
          "<TAILNET_IPV4_CIDR>",
          "<TAILNET_IPV6_CIDR>",
          "<CLOUD_NODE_PUBLIC_IP>/32"
        ],
        "action": "route",
        "outbound": "direct"
      },
      {
        "domain_suffix": [".tailscale.com", ".tailscale.io"],
        "action": "route",
        "outbound": "<STABLE_PROXY_OUTBOUND>"
      }
    ]
  }
}

Add a narrower direct rule for DERP domains before the broad .tailscale.com proxy rule if your configuration can match the name. Keep the IP rule-set because literal DERP endpoints do not match domain rules.

Tailscale recommends outbound TCP 443, UDP from the configured Tailscale source port, and UDP to destination port 3478. The default Tailscale UDP port is 41641, but it can change. Confirm the port before you write the rule. See Tailscale firewall and port guidance.

Maintain the DERP rule-set

Do not keep a one-time list of DERP IP addresses. Tailscale adds and changes relay servers.

Use this official endpoint as the source:

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

Create an update job that performs these actions:

  1. Download the DERP map to a temporary file.
  2. Confirm that the file is valid JSON.
  3. Extract all current DERP IPv4 and IPv6 addresses.
  4. Generate a temporary sing-box source rule-set.
  5. Validate the complete generated Momo configuration.
  6. Replace the active rule-set only after validation succeeds.
  7. Keep the last known-good rule-set if any step fails.

sing-box local rule-sets reload when the file changes in supported versions. Remote rule-sets also support an update interval. See the sing-box rule-set reference.

Tailscale recommends broad outbound TCP 443 because a fixed relay list can become stale. A generated DERP direct rule-set is an implementation-specific compromise for routers that must proxy unrelated TCP 443 traffic.

Apply the change safely

Keep the current SSH session open. Open a second session for the test. Create a router-side backup and a timed rollback before you reload Momo.

Use the persistent Momo profile as the source. Use the generated file only for validation.

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

Use /etc/init.d/momo restart only when a full restart is necessary. A reload can also interrupt traffic for a short time.

After the reload, confirm these route decisions in the log:

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

Do not continue if STUN or DERP still uses outbound/socks.

Prove the fix

A warm-path test is not sufficient. Use a cold-inbound test.

  1. Stop the temporary keepalive.
  2. Confirm that no home-node process sends periodic traffic to the cloud node.
  3. Wait longer than the observed NAT idle interval.
  4. Do not run tailscale ping from the home node.
  5. Start SSH from the cloud node.
  6. Confirm that SSH works without a retry.
  7. Leave the session idle for the previous failure interval.
  8. Confirm that the terminal stays responsive.
  9. Reboot the home node during a planned maintenance window.
  10. Repeat the cold-inbound test.

Call the routing fix successful only after both the idle test and the post-reboot test pass.

Add a Peer Relay when you need a controlled fallback

A stable cloud node can act as a Tailscale Peer Relay. This gives restricted nodes a nearby relay when a direct path is not possible. It does not replace DERP.

Peer Relays require Tailscale 1.86 or later on the relay and client nodes. The relay needs a reachable UDP port and an access-control grant. Use a narrow source selector in the grant.

Configure the cloud node:

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

Allow the selected UDP port in the cloud firewall and security group. Route the public relay endpoint directly in Momo. Add the tailscale.com/cap/relay grant only for the nodes that need the relay.

See the Tailscale Peer Relay guide for current prerequisites and policy syntax.

The practical lesson

The most useful clue was not that SSH worked after a retry. It was that SSH worked only after the home node sent traffic first.

That behavior points to a warm NAT path with a missing fallback. A DNS-only proxy rule can improve the design without solving that fallback. Read the router’s route decision for the actual flow. If Tailscale uses a literal relay address, match the transport, not only the name.

The reliable architecture has four parts:

Then remove the keepalive and prove the result from a cold start. A fix is complete when the fallback works, not when one direct path stays warm.


Edit page
Share this post:

Previous Post
Build a Cozy Lo-fi Study Loop with Strudel
Next Post
Note App: 胶片闪光设置:GN、功率与曝光微调