From c0837fefcfa8c1f5d6b5937e1734154f9ed8ba3a Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Nov 2026 04:20:30 +0000 Subject: [PATCH 1/2] test(server): an explicit Tailscale IPv6 bind is advertised as a tailnet route Fails on main: resolveBoundEndpoints drops every non-IPv4 bind, so a server bound to its Tailscale IPv6 address reports no direct endpoints (#34738). AI-assisted; verified with the tests and tools listed above. --- apps/server/src/environment/DirectEndpoints.test.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/apps/server/src/environment/DirectEndpoints.test.ts b/apps/server/src/environment/DirectEndpoints.test.ts index def0434140c32..b5cab770235a1 100644 --- a/apps/server/src/environment/DirectEndpoints.test.ts +++ b/apps/server/src/environment/DirectEndpoints.test.ts @@ -213,6 +213,17 @@ describe("resolveBoundEndpoints", () => { ).toEqual([{ kind: "tailnet", httpBaseUrl: "http://100.101.102.103:3773/" }]); }); + it("reports an explicit bind to Tailscale's IPv6 address as the tailnet route", () => { + expect( + resolveBoundEndpoints({ + platform: "linux", + host: "fd7a:115c:a1e0::1", + port: 3773, + interfaces: INTERFACES, + }), + ).toEqual([{ kind: "tailnet", httpBaseUrl: "http://[fd7a:115c:a1e0::1]:3773/" }]); + }); + it("never reports an IPv4-mapped host, which can name the client's own loopback", () => { expect( resolveBoundEndpoints({ From b4ddd596e220dff9a7b022d078adcf214808711d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Nov 2026 04:23:29 +0000 Subject: [PATCH 2/2] fix(server): an explicit Tailscale IPv6 bind is advertised as a tailnet route resolveBoundEndpoints accepted only IPv4 binds, so a server bound to its Tailscale IPv6 address reported no direct endpoints. Explicit binds inside Tailscale's fd7a:115c:a1e0::/48 range are now advertised (as tailnet when the address is on the Tailscale interface). Wildcard binds stay IPv4-only because the private-network listener check accepts only IPv4 peers. Fixes #34738 AI-assisted; verified with the tests and tools listed above. --- apps/server/src/environment/DirectEndpoints.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/server/src/environment/DirectEndpoints.ts b/apps/server/src/environment/DirectEndpoints.ts index 5b9ecc98299c7..c3eb5d4b75fda 100644 --- a/apps/server/src/environment/DirectEndpoints.ts +++ b/apps/server/src/environment/DirectEndpoints.ts @@ -39,6 +39,7 @@ export class DirectEndpoints extends Context.Service< >()("t3/environment/DirectEndpoints") {} // Credentials travel over HTTP; public IPs leak them and hostnames can resolve differently per client. +// Wildcard binds stay IPv4: the private-network listener check accepts only IPv4 peers. const isAdvertisableAddress = (address: string): boolean => NodeNet.isIPv4(address) && !address.startsWith("127.") && isPrivateNetworkAddress(address); @@ -78,7 +79,10 @@ export function resolveBoundEndpoints(input: { !(input.platform === "win32" && WINDOWS_HOST_ADAPTER_MAC.test(entry.mac)), ) .map((entry) => entry.address) - : [input.host!].filter(isAdvertisableAddress); + : [input.host!].filter( + (address) => + isAdvertisableAddress(address) || (NodeNet.isIPv6(address) && isTailnetHost(address)), + ); return [...new Set(addresses)] .filter((address) => !input.onCloudVm || tailscaleAddresses.has(address)) .map((address) => ({