Skip to content

[Bug]: t3 pair --tailscale replaces another app's Tailscale Serve route on port 443 when the host cannot reach its own MagicDNS URL #35516

Closed

Description

@coygeek

Before submitting

  • I searched existing issues and did not find a duplicate.
  • I included enough detail to reproduce or investigate the problem.

Area

apps/server

Steps to reproduce

  1. Put a fake tailscale first on PATH that appends its arguments to a log file. Make it answer status --json with {"BackendState":"Running","Self":{"DNSName":"host-a.tailnet-name.ts.net.","TailscaleIPs":["100.64.0.1"],"CapMap":{"https":null}}} and serve status --json with {"TCP":{"443":{"HTTPS":true}},"Web":{"host-a.tailnet-name.ts.net:443":{"Handlers":{"/":{"Proxy":"http://127.0.0.1:8080"}}}}}, which is another app on the Serve root. Make it exit 0 for everything else.
  2. Start a stand-in server in a scratch T3 home: a small Node HTTP server on 127.0.0.1:<port> that answers /.well-known/t3/environment with a valid ExecutionEnvironmentDescriptor (protocol 2) and writes <home>/userdata/server-runtime.json with its own pid, port, and origin.
  3. From apps/server, run node src/bin.ts pair --base-dir <home> --tailscale. The name host-a.tailnet-name.ts.net does not resolve on this machine, as on a host with tailscale set --accept-dns=false.
  4. Read the fake's log.

Ran once and it failed as described. The behavior is deterministic in source: any probe outcome other than a T3 descriptor or a non-gateway HTTP response leads to ensureTailscaleServe. The real-daemon effect, that tailscale serve --bg replaces the existing / handler, is source-only (see evidence). It was not run against a real tailscaled.

Expected behavior

t3 pair --tailscale should change the Serve port only when the port is free, already fronts this environment, or points at a dead loopback port. That is the rule pair.ts states ("Only unreachable ports or this environment's mappings are safe to reconfigure.", apps/server/src/cli/pair.ts:389). It is also the rule server startup now enforces by reading the Serve config (apps/server/src/server.ts:909-957). If another app or another live server holds / on that port, the command should refuse with ServePortOccupiedError or ServesOtherEnvironmentError and point to --tailscale-serve-port, as it already does when the probe succeeds (apps/server/src/cli/pair.ts:123-148). Whether the host can resolve or reach its own MagicDNS URL should not change that outcome.

Actual behavior

t3 pair --tailscale (apps/server/src/cli/pair.ts) decides whether the Serve port is free by fetching https://<MagicDNS name>/.well-known/t3/environment from the host itself. It never reads tailscale serve status. When that fetch fails for any reason, the command treats the port as free and runs tailscale serve --bg --https=443 http://127.0.0.1:<server port>, which replaces whatever handler was already mounted at / on that port. In a run with a fake tailscale whose Serve config already had / on port 443 proxied to another app at http://127.0.0.1:8080, and with the machine's MagicDNS name unresolvable locally, the command called tailscale status --json and then tailscale serve --bg --https=443 http://127.0.0.1:<port>. It never called tailscale serve status, printed a pairing URL, and reported "Tailscale Serve now maps https://host-a.tailnet-name.ts.net/ to this server and persists across restarts". The other app's route was gone.

Impact and scope: Impact: P2 - a documented command (docs/user/remote-access.md, "For an already-running server: t3 pair --tailscale") silently deletes another service's Tailscale Serve route on supported setups. The route can be restored by hand with tailscale serve, and passing --tailscale-serve-port with a free port avoids it.

The probe treats these cases as "unreachable", so each one lets the command take the port:

  1. The host cannot resolve its own *.ts.net name, for example with tailscale set --accept-dns=false, the setup Tailscale HTTPS shows off and cannot be disabled in Settings when local DNS fails #14746 describes.
  2. The host cannot route to its own tailnet address, for example a tailscaled in userspace-networking mode.
  3. The existing route answers slower than the 2.5 s probe timeout (PAIR_PROBE_TIMEOUT, apps/server/src/cli/pair.ts:53). The comment on the next line notes that Tailscale's first HTTPS request can take seconds while it provisions a certificate.

A 502 from a route whose loopback target has stopped also reads as "unreachable" (apps/server/src/cli/pair.ts:225-228). Server startup treats that case as a dead route it may take back, so this report does not count it as a failure.

Affected: users who already publish something else at / on the chosen Serve port (443 by default), or who run a second T3 server there. The replaced route stays replaced after t3 pair exits. NOT_ENOUGH_INFO: how often hosts run with --accept-dns=false or userspace networking, which telemetry or user reports would show.

Impact

Minor bug or occasional failure

Version or commit

main @ 86ff9d0 (apps/server/package.json version 0.1.9)

Environment

Linux x86_64, Node.js v26.13.1, run from source with a fake tailscale CLI on PATH. The Tailscale behavior is cited from tailscale/tailscale@071bf947 (1.105.0). No real Tailscale daemon was used.

Logs or stack traces

Fake tailscale argument log from the run (complete):

tailscale status --json
tailscale serve --bg --https=443 http://127.0.0.1:33825

t3 pair output from the same run (QR code omitted, token replaced):

Pairing with host-a (http://127.0.0.1:33825).

Pairing URL: https://host-a.tailnet-name.ts.net/pair#token=[REDACTED]
...
Note: Tailscale Serve now maps https://host-a.tailnet-name.ts.net/ to this server and persists across restarts. Remove it with `tailscale serve --https=443 --set-path=/ off`.

Note: The HTTPS endpoint has not answered yet. First use can take a moment while Tailscale provisions certificates.

Source references:

  • apps/server/src/cli/pair.ts:213-236: probeEnvironmentDescriptor maps a timeout, DNS or connection failure, or a 502/503/504 response to unreachable.
  • apps/server/src/cli/pair.ts:389-416: only a descriptor or not-a-t3-server result stops the command. Every other result goes on to ensureTailscaleServe, with no read of tailscale serve status.
  • apps/server/src/server.ts:909-957: server startup reads the Serve root's holders with readTailscaleServeRootHolders and leaves a live holder's route alone. The pair path has no equivalent.
  • tailscale/tailscale@071bf947 cmd/tailscale/cli/serve_v2.go:1307 and ipn/serve.go:427-453: tailscale serve --https=<port> <target> calls SetWebHandler, which overwrites the handler at the mount (/ by default) without asking.

Screenshots, recordings, or supporting files

No response

Workaround

See Impact and scope under Actual behavior.

Additional details

  • Interface: CLI: t3 pair --tailscale (default --tailscale-serve-port 443)
  • Earliest failure stage: Integration or transport

Additional context

The description of PR #35042, which fixed #10363 for server startup, says: "cli/pair.ts still calls ensureTailscaleServe without checking what the port already fronts; it is a separate entry point the report does not cover." No issue tracks that gap.

References

#10363 (closed; the same takeover in server startup, fixed there by PR #35042, whose description lists cli/pair.ts as not covered), #32332 (open; adds a port check to the desktop toggle and server startup, not to t3 pair), #14746 (open; shows the same --accept-dns=false setup breaking the desktop's MagicDNS probe, a different entry point and symptom)


Found by reading T3 Code's Tailscale integration against the Tailscale source (tailscale/tailscale@071bf947, v1.105.0); written up with AI assistance (Claude Code).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions