diff --git a/apps/server/src/environment/DirectEndpoints.test.ts b/apps/server/src/environment/DirectEndpoints.test.ts index def0434140c32..418f8469dfd66 100644 --- a/apps/server/src/environment/DirectEndpoints.test.ts +++ b/apps/server/src/environment/DirectEndpoints.test.ts @@ -8,9 +8,11 @@ import * as Ref from "effect/Ref"; import * as Layer from "effect/Layer"; import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; import * as HttpClient from "effect/http/HttpClient"; import * as HttpClientResponse from "effect/http/HttpClientResponse"; import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import { describe, expect, it } from "vite-plus/test"; import * as ServerConfig from "../config.ts"; @@ -182,14 +184,44 @@ describe("resolveBoundEndpoints", () => { ]); }); - it("never reports a host name, which can resolve to another machine per client", () => { + it("never reports a configured host name, which can resolve to another machine per client", () => { for (const host of ["server.local", "devbox", "devbox.home.arpa"]) { expect( - resolveBoundEndpoints({ platform: "linux", host, port: 3773, interfaces: INTERFACES }), + resolveBoundEndpoints({ + platform: "linux", + host, + port: 3773, + interfaces: INTERFACES, + mdnsHost: "bb-1.local", + }), ).toEqual([]); } }); + it("appends the Bonjour name after the addresses only when a LAN address exists", () => { + const input = { + platform: "linux" as const, + host: "0.0.0.0", + port: 3773, + interfaces: INTERFACES, + mdnsHost: "bb-1.local", + }; + const lan = { kind: "lan", httpBaseUrl: "http://192.168.1.10:3773/" }; + const tailnet = { kind: "tailnet", httpBaseUrl: "http://100.101.102.103:3773/" }; + + expect(resolveBoundEndpoints(input)).toEqual([ + lan, + tailnet, + { kind: "lan", httpBaseUrl: "http://bb-1.local:3773/" }, + ]); + expect(resolveBoundEndpoints({ ...input, mdnsHost: null })).toEqual([lan, tailnet]); + expect(resolveBoundEndpoints({ ...input, host: "127.0.0.1" })).toEqual([]); + expect(resolveBoundEndpoints({ ...input, onCloudVm: true })).toEqual([tailnet]); + expect(resolveBoundEndpoints({ ...input, interfaces: { utun4: INTERFACES.utun4 } })).toEqual([ + tailnet, + ]); + }); + it("never reports a public address, which would carry the credential over plain HTTP", () => { expect( resolveBoundEndpoints({ @@ -229,26 +261,31 @@ const TAILSCALE_STATUS_JSON = JSON.stringify({ Self: { DNSName: "bb-1.tail1234.ts.net.", TailscaleIPs: ["100.64.1.2"] }, }); -const layerTailscaleUp = Layer.succeed( - ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make(() => - Effect.succeed( - ChildProcessSpawner.makeHandle({ - pid: ChildProcessSpawner.ProcessId(1), - exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(0)), - isRunning: Effect.succeed(false), - kill: () => Effect.void, - unref: Effect.succeed(Effect.void), - stdin: Sink.drain, - stdout: Stream.make(new TextEncoder().encode(TAILSCALE_STATUS_JSON)), - stderr: Stream.empty, - all: Stream.empty, - getInputFd: () => Sink.drain, - getOutputFd: () => Stream.empty, - }), +const layerSpawnerStdout = (stdout: string, onSpawn: Effect.Effect = Effect.void) => + Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => + onSpawn.pipe( + Effect.as( + ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(0)), + isRunning: Effect.succeed(false), + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + stdin: Sink.drain, + stdout: Stream.make(new TextEncoder().encode(stdout)), + stderr: Stream.empty, + all: Stream.empty, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }), + ), + ), ), - ), -); + ); + +const layerTailscaleUp = layerSpawnerStdout(TAILSCALE_STATUS_JSON); const layerServeProbe = (status: number) => Layer.succeed( @@ -331,12 +368,15 @@ describe("DirectEndpoints Tailscale Serve", () => { ); const resolving = yield* Effect.forkScoped(service.resolve()); yield* Deferred.await(probeStarted); - const bound = { kind: "lan", httpBaseUrl: "http://192.168.1.10:3773/" }; - expect(yield* service.resolveAvailable()).toEqual([bound]); + const bound = [ + { kind: "lan", httpBaseUrl: "http://192.168.1.10:3773/" }, + { kind: "lan", httpBaseUrl: "http://bb-1.local:3773/" }, + ]; + expect(yield* service.resolveAvailable()).toEqual(bound); yield* Deferred.succeed(finishProbe, undefined); const validated = yield* Fiber.join(resolving); expect(validated).toEqual([ - bound, + ...bound, { kind: "tailnet", httpBaseUrl: "https://bb-1.tail1234.ts.net/" }, ]); expect(yield* service.resolveAvailable()).toEqual(validated); @@ -345,9 +385,13 @@ describe("DirectEndpoints Tailscale Serve", () => { yield* Deferred.await(revalidationStarted); expect(yield* service.resolveAvailable()).toEqual(validated); yield* Deferred.succeed(finishRevalidation, undefined); - expect(yield* Fiber.join(revalidating)).toEqual([bound]); - expect(yield* service.resolveAvailable()).toEqual([bound]); - }).pipe(Effect.scoped), + expect(yield* Fiber.join(revalidating)).toEqual(bound); + expect(yield* service.resolveAvailable()).toEqual(bound); + }).pipe( + Effect.scoped, + Effect.provideService(HostProcess.Platform, "linux"), + Effect.provideService(HostProcess.Hostname, "bb-1"), + ), ); effectIt.effect("leaves the tailnet name out when Serve is not forwarding", () => @@ -356,3 +400,85 @@ describe("DirectEndpoints Tailscale Serve", () => { }), ); }); + +describe("DirectEndpoints Bonjour name", () => { + effectIt.effect("asks scutil once across descriptor requests on macOS", () => + Effect.gen(function* () { + const spawns = yield* Ref.make(0); + const service = yield* DirectEndpoints.make.pipe( + Effect.provide( + Layer.mergeAll( + layerServeConfig("192.168.1.10"), + layerServeProbe(200), + layerSpawnerStdout( + "Studio\n", + Ref.update(spawns, (count) => count + 1), + ), + ), + ), + ); + const expected = [ + { kind: "lan", httpBaseUrl: "http://192.168.1.10:3773/" }, + { kind: "lan", httpBaseUrl: "http://Studio.local:3773/" }, + ]; + expect(yield* service.resolveAvailable()).toEqual(expected); + expect(yield* service.resolveAvailable()).toEqual(expected); + expect(yield* Ref.get(spawns)).toBe(1); + }).pipe( + Effect.scoped, + Effect.provideService(HostProcess.Platform, "darwin"), + Effect.provideService(HostProcess.Hostname, "dhcp-12.corp.example"), + ), + ); + + effectIt.effect("reports the correct name again after a failed scutil lookup expires", () => + Effect.gen(function* () { + const spawns = yield* Ref.make(0); + const service = yield* DirectEndpoints.make.pipe( + Effect.provide( + Layer.mergeAll( + layerServeConfig("192.168.1.10"), + layerServeProbe(200), + layerSpawnerStdout( + "Studio\n", + Ref.getAndUpdate(spawns, (count) => count + 1).pipe( + Effect.flatMap((count) => + count === 0 ? Effect.die("scutil failed") : Effect.void, + ), + ), + ), + ), + ), + ); + const addresses = yield* service.resolveAvailable(); + expect(addresses.at(-1)?.httpBaseUrl).toBe("http://dhcp-12.local:3773/"); + yield* TestClock.adjust("5 minutes"); + const recovered = yield* service.resolveAvailable(); + expect(recovered.at(-1)?.httpBaseUrl).toBe("http://Studio.local:3773/"); + }).pipe( + Effect.scoped, + Effect.provideService(HostProcess.Platform, "darwin"), + Effect.provideService(HostProcess.Hostname, "dhcp-12.corp.example"), + ), + ); + + effectIt.effect("does not run scutil on a loopback-only server", () => + Effect.gen(function* () { + const spawns = yield* Ref.make(0); + const service = yield* DirectEndpoints.make.pipe( + Effect.provide( + Layer.mergeAll( + layerServeConfig("127.0.0.1"), + layerServeProbe(200), + layerSpawnerStdout( + "Studio\n", + Ref.update(spawns, (count) => count + 1), + ), + ), + ), + ); + expect(yield* service.resolveAvailable()).toEqual([]); + expect(yield* Ref.get(spawns)).toBe(0); + }).pipe(Effect.scoped, Effect.provideService(HostProcess.Platform, "darwin")), + ); +}); diff --git a/apps/server/src/environment/DirectEndpoints.ts b/apps/server/src/environment/DirectEndpoints.ts index 47e9772e20fe1..fe25b9b253686 100644 --- a/apps/server/src/environment/DirectEndpoints.ts +++ b/apps/server/src/environment/DirectEndpoints.ts @@ -26,6 +26,7 @@ import { isWildcardHost, VIRTUAL_INTERFACE, } from "../startupAccess.ts"; +import { makeCachedMdnsHost } from "./mdnsHost.ts"; type NetworkInterfacesMap = ReturnType; @@ -40,7 +41,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. +// Credentials travel over HTTP; public IPs leak them and a configured host name can resolve differently per client. const isAdvertisableAddress = (address: string): boolean => NodeNet.isIPv4(address) && !address.startsWith("127.") && isPrivateNetworkAddress(address); @@ -62,6 +63,7 @@ export function resolveBoundEndpoints(input: { readonly interfaces: NetworkInterfacesMap; /** A cloud VM's private network is its provider's, which no client shares. */ readonly onCloudVm?: boolean; + readonly mdnsHost?: string | null; }): ReadonlyArray { if (isLoopbackHost(input.host)) return []; const tailscaleAddresses = new Set( @@ -81,12 +83,15 @@ export function resolveBoundEndpoints(input: { ) .map((entry) => entry.address) : [input.host!].filter(isAdvertisableAddress); - return [...new Set(addresses)] + const endpoints = [...new Set(addresses)] .filter((address) => !input.onCloudVm || tailscaleAddresses.has(address)) - .map((address) => ({ + .map((address): ServerDirectEndpoint => ({ kind: tailscaleAddresses.has(address) ? "tailnet" : "lan", httpBaseUrl: `http://${formatHostForUrl(address)}:${input.port}/`, })); + return input.mdnsHost && endpoints.some((endpoint) => endpoint.kind === "lan") + ? [...endpoints, { kind: "lan", httpBaseUrl: `http://${input.mdnsHost}:${input.port}/` }] + : endpoints; } /** @@ -129,6 +134,7 @@ export const make = Effect.gen(function* () { ? yield* Effect.cached(probeCloudMetadata) : Effect.succeed(false); yield* Effect.forkDetach(onCloudVm); + const mdnsHost = yield* makeCachedMdnsHost; const servedEndpoint = yield* Ref.make>(Option.none()); const resolveAvailable = Effect.fn("DirectEndpoints.resolveAvailable")(function* () { @@ -139,6 +145,7 @@ export const make = Effect.gen(function* () { port: config.port, interfaces: NodeOS.networkInterfaces(), onCloudVm: yield* onCloudVm, + mdnsHost: isLoopbackHost(config.host) ? null : yield* mdnsHost, }), ]; const servedUrl = yield* Ref.get(servedEndpoint); diff --git a/apps/server/src/environment/RemoteOpenTargets.ts b/apps/server/src/environment/RemoteOpenTargets.ts index fff8a8168437b..8e7f7f941260b 100644 --- a/apps/server/src/environment/RemoteOpenTargets.ts +++ b/apps/server/src/environment/RemoteOpenTargets.ts @@ -7,6 +7,7 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; +import { makeCachedMdnsHost } from "./mdnsHost.ts"; import { makeStaleWhileRevalidate } from "./staleWhileRevalidate.ts"; const SSH_PORT = 22; @@ -23,6 +24,7 @@ export class RemoteOpenTargets extends Context.Service< export const make = Effect.gen(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const net = yield* NetService.NetService; + const mdnsHost = yield* makeCachedMdnsHost; const discoverTargets = Effect.gen(function* () { // Check both loopback families: sshd can be bound IPv6-only. @@ -67,15 +69,9 @@ export const make = Effect.gen(function* () { }); } - // hostname() may be an FQDN; mDNS always uses .local. - const hostname = yield* HostProcess.Hostname; - const shortHostname = hostname.split(".")[0]?.trim(); - if (shortHostname !== undefined && shortHostname.length > 0) { - targets.push({ - kind: "mdns", - host: `${shortHostname}.local`, - ...(username === null ? {} : { username }), - }); + const host = yield* mdnsHost; + if (host !== null) { + targets.push({ kind: "mdns", host, ...(username === null ? {} : { username }) }); } return targets; diff --git a/apps/server/src/environment/mdnsHost.test.ts b/apps/server/src/environment/mdnsHost.test.ts new file mode 100644 index 0000000000000..b9d388da1d128 --- /dev/null +++ b/apps/server/src/environment/mdnsHost.test.ts @@ -0,0 +1,109 @@ +import { it } from "@effect/vitest"; +import * as HostProcess from "@t3tools/shared/HostProcess"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Ref from "effect/Ref"; +import * as Sink from "effect/Sink"; +import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; +import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; +import { describe, expect } from "vite-plus/test"; + +import { mdnsHostFromName, resolveMdnsHost } from "./mdnsHost.ts"; + +const spawnerWithStdout = (stdout: string) => + ChildProcessSpawner.make(() => + Effect.succeed( + ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(0)), + isRunning: Effect.succeed(false), + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + stdin: Sink.drain, + stdout: Stream.make(new TextEncoder().encode(stdout)), + stderr: Stream.empty, + all: Stream.empty, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }), + ), + ); + +const resolveOn = ( + platform: NodeJS.Platform, + hostname: string, + spawner: ChildProcessSpawner.ChildProcessSpawner["Service"], +) => + resolveMdnsHost().pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.provideService(HostProcess.Platform, platform), + Effect.provideService(HostProcess.Hostname, hostname), + ); + +describe("mdnsHostFromName", () => { + it("keeps the first label of a name and rejects an empty one", () => { + expect(mdnsHostFromName("bb-1")).toBe("bb-1.local"); + expect(mdnsHostFromName("bb-1.corp.example\n")).toBe("bb-1.local"); + expect(mdnsHostFromName("Studio\n")).toBe("Studio.local"); + expect(mdnsHostFromName("")).toBeNull(); + expect(mdnsHostFromName("\n")).toBeNull(); + expect(mdnsHostFromName(".corp.example")).toBeNull(); + }); +}); + +describe("resolveMdnsHost", () => { + it.effect("prefers LocalHostName over a DHCP-assigned hostname on macOS", () => + Effect.gen(function* () { + expect(yield* resolveOn("darwin", "dhcp-12.corp", spawnerWithStdout("Studio\n"))).toBe( + "Studio.local", + ); + }), + ); + + it.effect("falls back to the hostname when scutil prints nothing or cannot run", () => + Effect.gen(function* () { + expect(yield* resolveOn("darwin", "dhcp-12.corp", spawnerWithStdout(""))).toBe( + "dhcp-12.local", + ); + expect( + yield* resolveOn( + "darwin", + "dhcp-12.corp", + ChildProcessSpawner.make(() => Effect.die("ENOTDIR")), + ), + ).toBe("dhcp-12.local"); + }), + ); + + it.effect("falls back to the hostname when scutil hangs past its timeout", () => + Effect.gen(function* () { + const fiber = yield* Effect.forkChild( + resolveOn( + "darwin", + "dhcp-12.corp", + ChildProcessSpawner.make(() => Effect.never), + ), + { startImmediately: true }, + ); + yield* TestClock.adjust("1 second"); + expect(yield* Fiber.join(fiber)).toBe("dhcp-12.local"); + }), + ); + + it.effect("never runs scutil off macOS", () => + Effect.gen(function* () { + const spawns = yield* Ref.make(0); + expect( + yield* resolveOn( + "linux", + "bb-1.corp", + ChildProcessSpawner.make(() => + Ref.update(spawns, (count) => count + 1).pipe(Effect.andThen(Effect.die("spawned"))), + ), + ), + ).toBe("bb-1.local"); + expect(yield* Ref.get(spawns)).toBe(0); + }), + ); +}); diff --git a/apps/server/src/environment/mdnsHost.ts b/apps/server/src/environment/mdnsHost.ts new file mode 100644 index 0000000000000..f0631dde6ea3d --- /dev/null +++ b/apps/server/src/environment/mdnsHost.ts @@ -0,0 +1,42 @@ +import * as HostProcess from "@t3tools/shared/HostProcess"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as ChildProcess from "effect/process/ChildProcess"; +import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; + +const SCUTIL_TIMEOUT = Duration.seconds(1); +const CACHE_TTL = Duration.minutes(5); + +// hostname() may be an FQDN; mDNS always uses .local. +export const mdnsHostFromName = (name: string): string | null => { + const label = name.split(".")[0]?.trim(); + return label ? `${label}.local` : null; +}; + +// Bonjour answers to LocalHostName, which can differ from a hostname() assigned by DHCP. +const readLocalHostName = Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + return yield* spawner.string( + ChildProcess.make("/usr/sbin/scutil", ["--get", "LocalHostName"], { + forceKillAfter: SCUTIL_TIMEOUT, + }), + ); +}).pipe( + Effect.timeout(SCUTIL_TIMEOUT), + Effect.catchCause(() => Effect.succeed("")), +); + +export const resolveMdnsHost = Effect.fn("mdnsHost.resolve")(function* () { + const platform = yield* HostProcess.Platform; + const localHostName = platform === "darwin" ? yield* readLocalHostName : ""; + return mdnsHostFromName(localHostName) ?? mdnsHostFromName(yield* HostProcess.Hostname); +}); + +// Expires so a failed scutil lookup is retried and a rename is picked up without a restart. +export const makeCachedMdnsHost = Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + return yield* Effect.cachedWithTTL( + resolveMdnsHost().pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner)), + CACHE_TTL, + ); +}); diff --git a/docs/internals/remote.md b/docs/internals/remote.md index 0c328c50317c5..e71911e9d1119 100644 --- a/docs/internals/remote.md +++ b/docs/internals/remote.md @@ -53,7 +53,12 @@ when it was itself paired over plain HTTP, so a token paired over HTTPS never starts travelling unencrypted. Learned routes the server stops reporting are dropped, which is how a changed LAN address replaces the old one; routes the user saved are never touched. The reported addresses are hints like any advertised endpoint, so a -learned route still has to answer as this environment before it is used. +learned route still has to answer as this environment before it is used. The +server also reports its `.local` name as a LAN endpoint because an IPv4 address +does not survive a subnet change, and clients already treat a `.local` host as +LAN, so it follows the same plain HTTP token rule and route order as an address. +A LAN credential route cannot use the name, because its proof binds the socket's +IPv4 address. A background service with no configured host is what makes this work for headless machines. It listens on every IPv4 interface and closes each diff --git a/docs/user/remote-access.md b/docs/user/remote-access.md index c7b831e42c0a4..a2f6ab9a30428 100644 --- a/docs/user/remote-access.md +++ b/docs/user/remote-access.md @@ -141,6 +141,12 @@ HTTPS, SSH, and T3 Connect routes, so it is used only when they do not answer. A LAN address learned through T3 Connect is placed the same way. Drag it higher to prefer it at home. +T3 Code also learns the machine's local network name, such as `my-mac.local`, +once it has connected over a plain HTTP address or T3 Connect. The name stays +the same when addresses change, so the machine is reachable again after you +replace your router or switch Wi-Fi networks, including from a browser opened +over HTTP. It needs a network that allows Bonjour. + On desktop and mobile, a machine paired over an HTTP IPv4 LAN address can be found again on the same network if its address changes. Allow local network access when prompted. A device paired over HTTPS or Tailscale does the same once diff --git a/packages/client-runtime/src/connection/routes.test.ts b/packages/client-runtime/src/connection/routes.test.ts index a4a52c51aa361..373eac062aa15 100644 --- a/packages/client-runtime/src/connection/routes.test.ts +++ b/packages/client-runtime/src/connection/routes.test.ts @@ -202,7 +202,10 @@ describe("learned routes", () => { { target: TAILNET.target, profile: TAILNET.profile, enabled: true }, [TAILNET, RELAY], ); - const reported = [{ httpBaseUrl: "http://192.168.1.10:3773/", kind: "lan" }]; + const reported = [ + { httpBaseUrl: "http://192.168.1.10:3773/", kind: "lan" }, + { httpBaseUrl: "http://desk.local:3773/", kind: "lan" }, + ]; expect( mergeLearnedRoutes({ entry, activeRoute: TAILNET, reported, allowInsecure: true }), ).toBeNull(); @@ -222,6 +225,33 @@ describe("learned routes", () => { expect(sendsTokenInClear(routes!, routes![2]!)).toBe(false); }); + it("keeps a name learned through T3 Connect when a LAN credential is available", () => { + const entry = entryWithRoutes( + { target: TAILNET.target, profile: TAILNET.profile, enabled: true }, + [TAILNET, RELAY], + ); + const reported = [{ httpBaseUrl: "http://desk.local:3773/", kind: "lan" }]; + const routes = mergeLearnedRoutes({ + entry, + activeRoute: RELAY, + reported, + allowInsecure: true, + })!; + expect(profileOf(routes.at(-1)!)).toMatchObject({ + httpBaseUrl: "http://desk.local:3773/", + authorization: "t3-connect", + }); + expect( + mergeLearnedRoutes({ + entry: entryWithRoutes(entry, routes), + activeRoute: TAILNET, + reported, + allowInsecure: true, + lanCredential: true, + }), + ).toBeNull(); + }); + it.each([ [ "a tailnet pairing with a LAN address", diff --git a/packages/client-runtime/src/connection/routes.ts b/packages/client-runtime/src/connection/routes.ts index 2671af9d90263..495f7d0c294ab 100644 --- a/packages/client-runtime/src/connection/routes.ts +++ b/packages/client-runtime/src/connection/routes.ts @@ -322,7 +322,7 @@ export function mergeLearnedRoutes(input: { sharedCredential !== undefined && !tailscaleOrigins.has(url.origin) && !tokenAllowsPlainHttp(saved, sharedCredential); - if (needsLanCredential && !canUseLanCredential) continue; + if (needsLanCredential && (!canUseLanCredential || !lanCredentialCanProve(url))) continue; const lanDpop = needsLanCredential && canUseLanCredential && lanParentId !== null; const httpBaseUrl = `${url.origin}/`; const connectionId = learnedConnectionId( @@ -362,6 +362,9 @@ export function mergeLearnedRoutes(input: { return signature(saved) === signature(next) ? null : next; } +// The proof binds the socket's IPv4 address, so a route with a host name can never verify. +const lanCredentialCanProve = (url: URL) => parseLanDiscoveryCandidate(url.href) !== null; + function replacesWithLanCredential( route: ConnectionRoute, httpBaseUrl: string, @@ -374,7 +377,9 @@ function replacesWithLanCredential( } catch { return false; } - if (url.protocol !== "http:" || tailscaleOrigins.has(url.origin)) return false; + if (url.protocol !== "http:" || tailscaleOrigins.has(url.origin) || !lanCredentialCanProve(url)) { + return false; + } const profile = Option.getOrNull(route.profile); return !( connectionRouteId(route.target) ===