From 745c225f92401ba0950770fa7c3ec67a7067dbeb Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 4 Oct 2026 18:11:19 -0700 Subject: [PATCH] feat(clients): learn an environment's LAN and tailnet addresses (#15468) Co-authored-by: Claude Opus 5.5 (1M context) --- .../mobile/src/features/cloud/cloud-drafts.ts | 6 +- .../settings/EnvironmentRoutesSection.tsx | 9 +- .../src/environment/DirectEndpoints.test.ts | 207 ++++++++++++++ .../server/src/environment/DirectEndpoints.ts | 143 ++++++++++ apps/server/src/server.ts | 2 + apps/server/src/ws.ts | 3 + .../settings/EnvironmentRoutesList.tsx | 17 +- docs/internals/remote.md | 9 + docs/user/remote-access.md | 9 + .../src/authorization/layer.test.ts | 111 ++++++++ .../src/authorization/service.ts | 86 ++++-- .../client-runtime/src/connection/catalog.ts | 11 + .../connection/githubRoutingPermissions.ts | 16 +- .../src/connection/registry.test.ts | 19 ++ .../client-runtime/src/connection/registry.ts | 84 +++++- .../client-runtime/src/connection/resolver.ts | 11 +- .../src/connection/routes.test.ts | 258 +++++++++++++++++- .../client-runtime/src/connection/routes.ts | 170 +++++++++++- .../src/connection/supervisor.test.ts | 70 ++++- .../src/connection/supervisor.ts | 86 ++++-- .../src/state/environmentHttpAuth.ts | 5 +- packages/contracts/src/server.ts | 16 ++ 22 files changed, 1278 insertions(+), 70 deletions(-) create mode 100644 apps/server/src/environment/DirectEndpoints.test.ts create mode 100644 apps/server/src/environment/DirectEndpoints.ts diff --git a/apps/mobile/src/features/cloud/cloud-drafts.ts b/apps/mobile/src/features/cloud/cloud-drafts.ts index 9adc1ee9503d0d79ef934dbd99e4562c140042b2..124aef277485e646079a0647e70ba4f4b2305be0 100644 --- a/apps/mobile/src/features/cloud/cloud-drafts.ts +++ b/apps/mobile/src/features/cloud/cloud-drafts.ts @@ -1,4 +1,4 @@ -import { EnvironmentRegistry, connectionRoutes } from "@t3tools/client-runtime/connection"; +import { EnvironmentRegistry, removedWithRelay } from "@t3tools/client-runtime/connection"; import { createRuntimeCommand } from "@t3tools/client-runtime/state/runtime"; import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; @@ -28,9 +28,7 @@ export const removeCloudEnvironments = createRuntimeCommand(connectionAtomRuntim const environmentIds = new Set( [...entries.values()] // Only environments that sign-out removes entirely lose their drafts. - .filter((entry) => - connectionRoutes(entry).every((route) => route.target._tag === "RelayConnectionTarget"), - ) + .filter(removedWithRelay) .map((entry) => entry.target.environmentId), ); // Credentials are already revoked. A failed backup must leave the local diff --git a/apps/mobile/src/features/settings/EnvironmentRoutesSection.tsx b/apps/mobile/src/features/settings/EnvironmentRoutesSection.tsx index 7fcb99fb68069cc522668292761f850003658c1f..c95306c901328d263d64b3c5c6d8c90202c06a40 100644 --- a/apps/mobile/src/features/settings/EnvironmentRoutesSection.tsx +++ b/apps/mobile/src/features/settings/EnvironmentRoutesSection.tsx @@ -5,6 +5,7 @@ import { connectionRouteId, connectionRouteLabel, connectionRoutes, + isLearned, } from "@t3tools/client-runtime/connection"; import type { EnvironmentId } from "@t3tools/contracts"; import * as Option from "effect/Option"; @@ -141,7 +142,10 @@ export function EnvironmentRoutesSection({ }} onStep={(direction) => move(index, direction === "up" ? index - 1 : index + 1)} // The last route goes with the machine, which is "Remove" on the row. - onRemove={routes.length > 1 ? () => confirmRemove(route) : undefined} + // A learned route would be learned again, so it is only reordered. + onRemove={ + routes.length > 1 && !isLearned(route) ? () => confirmRemove(route) : undefined + } /> ); })} @@ -211,6 +215,7 @@ function RouteRow(props: { accessibilityLabel={[ label, address, + isLearned(route) ? "Found automatically" : null, props.inUse ? "In use" : null, `Route ${props.position} of ${props.count}`, ] @@ -226,7 +231,7 @@ function RouteRow(props: { {address !== null ? ( - {address} + {isLearned(route) ? `${address} · found automatically` : address} ) : null} diff --git a/apps/server/src/environment/DirectEndpoints.test.ts b/apps/server/src/environment/DirectEndpoints.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..5bbce9d8668649632091b1fa6c394098bbe8ed5b --- /dev/null +++ b/apps/server/src/environment/DirectEndpoints.test.ts @@ -0,0 +1,207 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { it as effectIt } from "@effect/vitest"; +import type * as NodeOS from "node:os"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Sink from "effect/Sink"; +import * as Stream from "effect/Stream"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; +import { describe, expect, it } from "vite-plus/test"; + +import * as ServerConfig from "../config.ts"; +import * as DirectEndpoints from "./DirectEndpoints.ts"; +import { resolveBoundEndpoints } from "./DirectEndpoints.ts"; + +const INTERFACES: ReturnType = { + lo0: [ + { + address: "127.0.0.1", + netmask: "255.0.0.0", + family: "IPv4", + mac: "00:00:00:00:00:00", + internal: true, + cidr: "127.0.0.1/8", + }, + ], + en0: [ + { + address: "192.168.1.10", + netmask: "255.255.255.0", + family: "IPv4", + mac: "aa:bb:cc:dd:ee:ff", + internal: false, + cidr: "192.168.1.10/24", + }, + { + address: "fe80::1", + netmask: "ffff:ffff:ffff:ffff::", + family: "IPv6", + mac: "aa:bb:cc:dd:ee:ff", + internal: false, + cidr: "fe80::1/64", + scopeid: 4, + }, + ], + en1: [ + { + address: "203.0.113.20", + netmask: "255.255.255.0", + family: "IPv4", + mac: "aa:bb:cc:dd:ee:00", + internal: false, + cidr: "203.0.113.20/24", + }, + ], + utun4: [ + { + address: "100.101.102.103", + netmask: "255.255.255.255", + family: "IPv4", + mac: "00:00:00:00:00:00", + internal: false, + cidr: "100.101.102.103/32", + }, + ], +}; + +const virtualInterface = (address: string) => [ + { + address, + netmask: "255.255.0.0", + family: "IPv4" as const, + mac: "02:42:ac:11:00:01", + internal: false, + cidr: `${address}/16`, + }, +]; + +describe("resolveBoundEndpoints", () => { + it("lists nothing for a loopback-only server", () => { + expect(resolveBoundEndpoints({ host: undefined, port: 3773, interfaces: INTERFACES })).toEqual( + [], + ); + expect( + resolveBoundEndpoints({ host: "127.0.0.1", port: 3773, interfaces: INTERFACES }), + ).toEqual([]); + }); + + it("lists every external IPv4 address for a wildcard bind, tagging the tailnet one", () => { + expect(resolveBoundEndpoints({ host: "0.0.0.0", port: 3773, interfaces: INTERFACES })).toEqual([ + { kind: "lan", httpBaseUrl: "http://192.168.1.10:3773/" }, + { kind: "tailnet", httpBaseUrl: "http://100.101.102.103:3773/" }, + ]); + }); + + it("skips container and VM networks, which only this machine reaches", () => { + const interfaces = { + ...INTERFACES, + docker0: virtualInterface("172.17.0.1"), + "br-3f2a1b": virtualInterface("172.18.0.1"), + virbr0: virtualInterface("192.168.122.1"), + "vEthernet (WSL)": virtualInterface("172.24.0.1"), + bridge100: virtualInterface("192.168.64.1"), + vmbr0: virtualInterface("192.168.1.20"), + }; + expect(resolveBoundEndpoints({ host: "0.0.0.0", port: 3773, interfaces })).toEqual([ + { kind: "lan", httpBaseUrl: "http://192.168.1.10:3773/" }, + { kind: "tailnet", httpBaseUrl: "http://100.101.102.103:3773/" }, + { kind: "lan", httpBaseUrl: "http://192.168.1.20:3773/" }, + ]); + }); + + it("lists only the bound address for a specific bind", () => { + expect( + resolveBoundEndpoints({ host: "100.101.102.103", port: 3773, interfaces: INTERFACES }), + ).toEqual([{ kind: "tailnet", httpBaseUrl: "http://100.101.102.103:3773/" }]); + }); + + it("never reports a host name, which can resolve to another machine per client", () => { + for (const host of ["server.local", "devbox", "devbox.home.arpa"]) { + expect(resolveBoundEndpoints({ host, port: 3773, interfaces: INTERFACES })).toEqual([]); + } + }); + + it("never reports a public address, which would carry the credential over plain HTTP", () => { + expect( + resolveBoundEndpoints({ host: "203.0.113.20", port: 3773, interfaces: INTERFACES }), + ).toEqual([]); + }); +}); + +const TAILSCALE_STATUS_JSON = JSON.stringify({ + Self: { DNSName: "bb-1.tail1234.ts.net.", TailscaleIPs: ["100.64.1.2"] }, +}); + +/** `tailscale status --json` reporting a MagicDNS name. */ +const tailscaleUpLayer = 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, + }), + ), + ), +); + +/** Answers the Serve probe with `status`. */ +const serveProbeLayer = (status: number) => + Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.succeed(HttpClientResponse.fromWeb(request, new Response(null, { status }))), + ), + ); + +/** A loopback-only server with Tailscale Serve on, so only the Serve name can be listed. */ +const serveConfigLayer = Layer.effect( + ServerConfig.ServerConfig, + Effect.map(ServerConfig.ServerConfig, (config) => ({ + ...config, + host: "127.0.0.1", + tailscaleServeEnabled: true, + tailscaleServePort: 443, + })), +).pipe( + Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-direct-" })), + Layer.provide(NodeServices.layer), +); + +const resolveWithServe = (probeStatus: number) => + Effect.flatMap(DirectEndpoints.DirectEndpoints, (service) => service.resolve()).pipe( + Effect.provide( + DirectEndpoints.layer.pipe( + Layer.provide( + Layer.mergeAll(serveConfigLayer, tailscaleUpLayer, serveProbeLayer(probeStatus)), + ), + ), + ), + ); + +describe("DirectEndpoints Tailscale Serve", () => { + effectIt.effect("lists the tailnet name once Serve answers for this server", () => + Effect.gen(function* () { + expect(yield* resolveWithServe(200)).toEqual([ + { kind: "tailnet", httpBaseUrl: "https://bb-1.tail1234.ts.net/" }, + ]); + }), + ); + + effectIt.effect("leaves the tailnet name out when Serve is not forwarding", () => + Effect.gen(function* () { + expect(yield* resolveWithServe(502)).toEqual([]); + }), + ); +}); diff --git a/apps/server/src/environment/DirectEndpoints.ts b/apps/server/src/environment/DirectEndpoints.ts new file mode 100644 index 0000000000000000000000000000000000000000..aa333a598ca4589df2d9cc410ad40842ef04a9a7 --- /dev/null +++ b/apps/server/src/environment/DirectEndpoints.ts @@ -0,0 +1,143 @@ +/** + * DirectEndpoints - the LAN and tailnet addresses this server listens on now. + * + * Clients connected one way (often T3 Connect) save these as extra routes so + * they can move to a faster path when one is reachable, and replace a saved + * LAN address when DHCP or a new Wi-Fi network changes it. Only addresses the + * server is actually bound to are listed: a loopback-only server lists none, + * because its loopback address means a different machine to every client. + */ +import type { ServerDirectEndpoint } from "@t3tools/contracts"; +import { + buildTailscaleHttpsBaseUrl, + isTailscaleIpv4Address, + probeTailscaleHttpsEndpoint, + readTailscaleStatus, +} from "@t3tools/tailscale"; +import * as Context from "effect/Context"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import { HttpClient } from "effect/unstable/http"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; +import * as NodeNet from "node:net"; +import * as NodeOS from "node:os"; + +import { isPrivateNetworkHost } from "@t3tools/shared/hostClassification"; + +import * as ServerConfig from "../config.ts"; +import { formatHostForUrl, isLoopbackHost, isWildcardHost } from "../startupAccess.ts"; + +type NetworkInterfacesMap = ReturnType; + +/** + * Stays under the config read's own discovery timeout, so a slow `tailscale` + * CLI drops only the tailnet name and never the LAN addresses. An empty list + * would make clients forget the routes they learned. + */ +const TAILSCALE_ENDPOINT_TIMEOUT = Duration.seconds(3); + +export class DirectEndpoints extends Context.Service< + DirectEndpoints, + { + readonly resolve: () => Effect.Effect>; + } +>()("t3/environment/DirectEndpoints") {} + +/** + * Only numeric private-network and tailnet IPv4 addresses are reported. These + * routes are plain HTTP and carry the client's credential, so a public address + * would send it across the internet unencrypted, and a name (`server.local`) + * can resolve to a different machine on each client's network. + */ +const isAdvertisableAddress = (address: string): boolean => + NodeNet.isIPv4(address) && + !address.startsWith("127.") && + !address.startsWith("169.254.") && + (isTailscaleIpv4Address(address) || isPrivateNetworkHost(address)); + +/** + * Container and VM networks (Docker, libvirt, VMware, VirtualBox, Hyper-V, and + * macOS's `bridge100`+) have private addresses only this machine reaches. A + * host bridge such as `br0` or Proxmox's `vmbr0` carries the LAN address, so + * it stays listed. + */ +const VIRTUAL_INTERFACE = + /^(docker|br-|veth|virbr|vmnet|vboxnet|vEthernet|podman|cni|flannel|cali|lxcbr|lxdbr|bridge1\d\d)/; + +/** + * Plain HTTP endpoints for the private addresses a server bound to `host` + * accepts. IPv4 only: link-local and temporary IPv6 addresses change too + * often to be worth saving. + */ +export function resolveBoundEndpoints(input: { + readonly host: string | undefined; + readonly port: number; + readonly interfaces: NetworkInterfacesMap; +}): ReadonlyArray { + if (isLoopbackHost(input.host)) return []; + const addresses = isWildcardHost(input.host) + ? Object.entries(input.interfaces) + .flatMap(([name, entries]) => (VIRTUAL_INTERFACE.test(name) ? [] : (entries ?? []))) + .filter( + (entry) => + !entry.internal && entry.family === "IPv4" && isAdvertisableAddress(entry.address), + ) + .map((entry) => entry.address) + : [input.host!].filter(isAdvertisableAddress); + return [...new Set(addresses)].map((address) => ({ + kind: isTailscaleIpv4Address(address) ? "tailnet" : "lan", + httpBaseUrl: `http://${formatHostForUrl(address)}:${input.port}/`, + })); +} + +/** @public Service construction is part of the canonical Effect module API. */ +export const make = Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const httpClient = yield* HttpClient.HttpClient; + + const resolve = Effect.gen(function* () { + const endpoints = [ + ...resolveBoundEndpoints({ + host: config.host, + port: config.port, + interfaces: NodeOS.networkInterfaces(), + }), + ]; + // Tailscale Serve terminates HTTPS on the tailnet name and forwards to + // loopback, so it works even for a loopback-only server. The name is only + // listed once it answers as this server: Serve setup can fail without + // stopping startup. Anything slower than the probe leaves it out rather + // than holding back the addresses already found. + if (config.tailscaleServeEnabled) { + const servedUrl = yield* readTailscaleStatus.pipe( + Effect.map((status) => status.magicDnsName), + Effect.flatMap((magicDnsName) => { + if (magicDnsName === null) return Effect.succeed(null); + const httpBaseUrl = buildTailscaleHttpsBaseUrl({ + magicDnsName, + servePort: config.tailscaleServePort, + }); + return probeTailscaleHttpsEndpoint({ baseUrl: httpBaseUrl }).pipe( + Effect.map((reachable) => (reachable ? httpBaseUrl : null)), + ); + }), + Effect.timeoutOption(TAILSCALE_ENDPOINT_TIMEOUT), + Effect.map(Option.flatMap(Option.fromNullishOr)), + Effect.orElseSucceed(() => Option.none()), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.provideService(HttpClient.HttpClient, httpClient), + ); + if (Option.isSome(servedUrl)) { + endpoints.push({ kind: "tailnet", httpBaseUrl: servedUrl.value }); + } + } + return endpoints; + }); + + return DirectEndpoints.of({ resolve: () => resolve }); +}); + +export const layer = Layer.effect(DirectEndpoints, make); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 9e9d5c5e306443d3ffd215ed6ba2e9e771b65f0d..96639a4d41aa20976d5a5f3bf9548184ab44d5d0 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -112,6 +112,7 @@ import { ObservabilityLive } from "./observability/Layers/Observability.ts"; import * as HeapSnapshot from "./observability/HeapSnapshot.ts"; import * as EventLoopMonitor from "./observability/EventLoopMonitor.ts"; import * as ServerEnvironment from "./environment/ServerEnvironment.ts"; +import * as DirectEndpoints from "./environment/DirectEndpoints.ts"; import * as RemoteOpenTargets from "./environment/RemoteOpenTargets.ts"; import { authHttpApiLayer, environmentAuthenticatedAuthLayer } from "./auth/http.ts"; import * as ReplayMarkers from "./auth/replayMarkers.ts"; @@ -620,6 +621,7 @@ const RuntimeDependenciesLive = RuntimeCoreDependenciesLive.pipe( Layer.provideMerge(AnalyticsService.layer), Layer.provideMerge(ExternalLauncher.layer), Layer.provideMerge(RemoteOpenTargets.layer), + Layer.provideMerge(DirectEndpoints.layer), Layer.provideMerge(ServerLifecycleEvents.layer), Layer.provide(NetService.layer), ); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 7800603f28180a5d289534e5a47ed71ad2c135f4..92eb7c0f9a6deab0bf24e352993ba7582e789ebb 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -204,6 +204,7 @@ import * as ProjectCloneTracker from "./project/ProjectCloneTracker.ts"; import * as RepositoryIdentityResolver from "./project/RepositoryIdentityResolver.ts"; import * as WorktreeSetupTracker from "./project/WorktreeSetupTracker.ts"; import * as ServerEnvironment from "./environment/ServerEnvironment.ts"; +import * as DirectEndpoints from "./environment/DirectEndpoints.ts"; import * as RemoteOpenTargets from "./environment/RemoteOpenTargets.ts"; import * as BackgroundPolicy from "./background/BackgroundPolicy.ts"; import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; @@ -1250,6 +1251,7 @@ const makeWsRpcLayer = ( const environmentTheme = yield* EnvironmentTheme.EnvironmentThemeService; const externalLauncher = yield* ExternalLauncher.ExternalLauncher; const remoteOpenTargets = yield* RemoteOpenTargets.RemoteOpenTargets; + const directEndpoints = yield* DirectEndpoints.DirectEndpoints; const gitWorkflow = yield* GitWorkflowService.GitWorkflowService; const review = yield* ReviewService.ReviewService; const vcsProvisioning = yield* VcsProvisioningService.VcsProvisioningService; @@ -1689,6 +1691,7 @@ const makeWsRpcLayer = ( remoteOpenTargets: yield* resolveAvailableEditorsForConfig( remoteOpenTargets.resolveTargets(), ), + directEndpoints: yield* resolveAvailableEditorsForConfig(directEndpoints.resolve()), observability: { logsDirectoryPath: config.logsDir, localTracingEnabled: true, diff --git a/apps/web/src/components/settings/EnvironmentRoutesList.tsx b/apps/web/src/components/settings/EnvironmentRoutesList.tsx index d024976104436f6c85534b181d1b508e2a3b4be8..9bf096688006fcaccc67c79f86a688c4ce79659f 100644 --- a/apps/web/src/components/settings/EnvironmentRoutesList.tsx +++ b/apps/web/src/components/settings/EnvironmentRoutesList.tsx @@ -22,6 +22,7 @@ import { connectionRouteId, connectionRouteLabel, connectionRoutes, + isLearned, } from "@t3tools/client-runtime/connection"; import { GripVerticalIcon, PlusIcon, XIcon } from "lucide-react"; import { useState } from "react"; @@ -118,8 +119,9 @@ export function EnvironmentRoutesList({ position={index + 1} inUse={connectionRouteId(route.target) === activeRouteId} // The last route goes with the machine; that is "Remove from - // this device", not a route action. - removable={routes.length > 1} + // this device", not a route action. A learned route would be + // learned again, so it is only reordered. + removable={routes.length > 1 && !isLearned(route)} onRemove={() => void confirmRemove(route)} /> ))} @@ -193,7 +195,7 @@ function SortableRouteRow({ {address !== null ? (

{address} - {isLearnedRoute(route) ? " · found automatically" : ""} + {isLearned(route) ? " · found automatically" : ""}

) : null} @@ -218,12 +220,3 @@ function SortableRouteRow({ ); } - -function isLearnedRoute(route: ConnectionRoute): boolean { - return ( - route.profile._tag === "Some" && - route.profile.value._tag === "BearerConnectionProfile" && - "learned" in route.profile.value && - route.profile.value.learned === true - ); -} diff --git a/docs/internals/remote.md b/docs/internals/remote.md index b2cc918b6cfc5a7e8758e8c6633f117e66e04562..04d5a8f9dd88b221ffe3c1a147c0a9b4d19aa32d 100644 --- a/docs/internals/remote.md +++ b/docs/internals/remote.md @@ -39,6 +39,15 @@ Preflight includes authorization so a route that answers but rejects this client never costs a working session; a route that still fails afterwards is held back for a cooldown so a flaky network cannot bounce the connection. +A connected server reports the LAN and tailnet addresses it is bound to, and the +client saves them as learned routes. A learned route reuses the credential of +the route it was learned over: the T3 Connect access token, which is not bound +to an origin because each DPoP proof names the URL it signs, or the paired +bearer token. 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. + GitHub routing trust covers the whole route list. Adding or changing a route revokes it; reordering does not, because the same addresses remain trusted. diff --git a/docs/user/remote-access.md b/docs/user/remote-access.md index f4d5433aba7c99e857423925d8338e155c873ece..2192a23934456dd2788cd589028dcc2d611304ef 100644 --- a/docs/user/remote-access.md +++ b/docs/user/remote-access.md @@ -70,6 +70,15 @@ next to it in the T3 Connect list. Pairing the same machine again over another address also adds a route instead of a second machine. A new route is placed by speed, in that order, and you can reorder routes at any time. +While connected through T3 Connect or a paired address, T3 Code also learns the +machine's current LAN and Tailscale addresses and adds them as routes, so +pairing once through T3 Connect is enough to use the LAN at home. When the +machine's LAN address changes, for example after it joins another Wi-Fi network, +the learned route follows it. The machine must allow network access for its LAN +address to be learned. You can reorder a learned route, but not remove it; it +goes away with the route it was learned through, or when the machine stops +reporting that address. + T3 Code connects over the first route that answers. Away from home, a LAN address that does not answer is checked briefly and skipped. It is only tried again, after the other routes, if none of them connect. While connected over a diff --git a/packages/client-runtime/src/authorization/layer.test.ts b/packages/client-runtime/src/authorization/layer.test.ts index d31f37db2eb7a7c9ccf42c3bccc37fd6209e8774..3f8dd1a926e5da6d34ac62a47ee943095cda41b0 100644 --- a/packages/client-runtime/src/authorization/layer.test.ts +++ b/packages/client-runtime/src/authorization/layer.test.ts @@ -340,6 +340,117 @@ describe("RemoteEnvironmentAuthorization", () => { }), ); + it.effect("uses the T3 Connect token on a learned direct address without the relay", () => + Effect.gen(function* () { + const cached = new TokenStore.RemoteDpopAccessToken({ + environmentId: ENVIRONMENT_ID, + accountId: "account-1", + label: DESCRIPTOR.label, + endpoint: ENDPOINT, + accessToken: "cached-access-token", + expiresAtEpochMs: Number.MAX_SAFE_INTEGER, + dpopThumbprint: "thumbprint-1", + }); + const harness = yield* makeHarness({ + initialToken: cached, + responses: [Response.json(DESCRIPTOR), websocketTicket("lan-ticket")], + }); + + const authorized = yield* Effect.gen(function* () { + const remote = yield* RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization; + return yield* remote.authorizeDpop({ + expectedEnvironmentId: ENVIRONMENT_ID, + directEndpoint: { + httpBaseUrl: "http://192.168.1.10:3773/", + wsBaseUrl: "ws://192.168.1.10:3773/", + }, + }); + }).pipe(Effect.provide(harness.layer)); + + expect(authorized.httpBaseUrl).toBe("http://192.168.1.10:3773/"); + expect(authorized.socketUrl).toMatch(/^ws:\/\/192\.168\.1\.10:3773\/ws\?/); + expect(yield* Ref.get(harness.bootstrapCalls)).toBe(0); + expect(harness.fetch.calls.map(([url]) => String(url))).toEqual([ + "http://192.168.1.10:3773/.well-known/t3/environment", + "http://192.168.1.10:3773/api/auth/websocket-ticket", + ]); + }), + ); + + it.effect("sends no token to a direct address that answers as another machine", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ + initialToken: persistedToken({ accessToken: "cached-access-token" }), + responses: [ + Response.json({ ...DESCRIPTOR, environmentId: EnvironmentId.make("someone-else") }), + ], + }); + + const error = yield* Effect.gen(function* () { + const remote = yield* RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization; + return yield* remote + .authorizeDpop({ + expectedEnvironmentId: ENVIRONMENT_ID, + directEndpoint: { + httpBaseUrl: "http://192.168.1.10:3773/", + wsBaseUrl: "ws://192.168.1.10:3773/", + }, + }) + .pipe(Effect.flip); + }).pipe(Effect.provide(harness.layer)); + + expect(error).toMatchObject({ _tag: "ConnectionBlockedError", reason: "configuration" }); + expect(harness.fetch.calls.map(([url]) => String(url))).toEqual([ + "http://192.168.1.10:3773/.well-known/t3/environment", + ]); + // The token stays saved for the T3 Connect route. + expect((yield* Ref.get(harness.tokens)).get(ENVIRONMENT_ID)?.accessToken).toBe( + "cached-access-token", + ); + }), + ); + + it.effect("replaces a token a learned direct address rejects", () => + Effect.gen(function* () { + const cached = new TokenStore.RemoteDpopAccessToken({ + environmentId: ENVIRONMENT_ID, + accountId: "account-1", + label: DESCRIPTOR.label, + endpoint: ENDPOINT, + accessToken: "revoked-access-token", + expiresAtEpochMs: Number.MAX_SAFE_INTEGER, + dpopThumbprint: "thumbprint-1", + }); + const harness = yield* makeHarness({ + initialToken: cached, + responses: [ + Response.json(DESCRIPTOR), + authInvalid(), + Response.json(DESCRIPTOR), + accessToken("replacement-access-token"), + websocketTicket("lan-ticket"), + ], + }); + + const authorized = yield* Effect.gen(function* () { + const remote = yield* RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization; + return yield* remote.authorizeDpop({ + expectedEnvironmentId: ENVIRONMENT_ID, + directEndpoint: { + httpBaseUrl: "http://192.168.1.10:3773/", + wsBaseUrl: "ws://192.168.1.10:3773/", + }, + }); + }).pipe(Effect.provide(harness.layer)); + + expect(authorized.socketUrl).toContain("wsTicket=lan-ticket"); + expect(yield* Ref.get(harness.bootstrapCalls)).toBe(1); + expect((yield* Ref.get(harness.tokens)).get(ENVIRONMENT_ID)?.accessToken).toBe( + "replacement-access-token", + ); + }), + ); + it.effect("refreshes and persists an expired environment token", () => Effect.gen(function* () { const expired = new TokenStore.RemoteDpopAccessToken({ diff --git a/packages/client-runtime/src/authorization/service.ts b/packages/client-runtime/src/authorization/service.ts index 85929f73dd38cd641f60414b8c53ccb995134fad..56b0ced307293ec8dbc41cfd62a82fb4b2ee8fb0 100644 --- a/packages/client-runtime/src/authorization/service.ts +++ b/packages/client-runtime/src/authorization/service.ts @@ -71,6 +71,13 @@ export class RemoteEnvironmentAuthorization extends Context.Service< }) => Effect.Effect; readonly authorizeDpop: (input: { readonly expectedEnvironmentId: EnvironmentId; + /** + * Connect to this origin instead of the T3 Connect tunnel. The access + * token is not bound to an origin, and each proof is minted for the URL + * it signs, so a token from the relay also works on the environment's + * LAN or tailnet address. + */ + readonly directEndpoint?: { readonly httpBaseUrl: string; readonly wsBaseUrl: string }; }) => Effect.Effect; readonly authorizeDpopHttp: (input: { readonly expectedEnvironmentId: EnvironmentId; @@ -132,14 +139,15 @@ export const make = Effect.gen(function* () { > >(new Map()); - const authorizeBearer = Effect.fn("clientRuntime.connection.remote.authorizeBearer")( + /** + * Confirms a direct address still serves the expected environment before a + * credential is sent there. A saved LAN address can belong to another + * machine on a different network. + */ + const verifyDirectEndpoint = Effect.fn("clientRuntime.connection.remote.verifyDirectEndpoint")( function* (input: { - readonly expectedEnvironmentId: Parameters< - RemoteEnvironmentAuthorization["Service"]["authorizeBearer"] - >[0]["expectedEnvironmentId"]; + readonly expectedEnvironmentId: EnvironmentId; readonly httpBaseUrl: string; - readonly wsBaseUrl: string; - readonly bearerToken: string; readonly connectionMethod: ClientConnectionMethod; }) { const now = yield* Clock.currentTimeMillis; @@ -169,6 +177,21 @@ export const make = Effect.gen(function* () { return next; }); } + return descriptor; + }, + ); + + const authorizeBearer = Effect.fn("clientRuntime.connection.remote.authorizeBearer")( + function* (input: { + readonly expectedEnvironmentId: Parameters< + RemoteEnvironmentAuthorization["Service"]["authorizeBearer"] + >[0]["expectedEnvironmentId"]; + readonly httpBaseUrl: string; + readonly wsBaseUrl: string; + readonly bearerToken: string; + readonly connectionMethod: ClientConnectionMethod; + }) { + const descriptor = yield* verifyDirectEndpoint(input); const socketUrl = yield* resolveRemoteWebSocketConnectionUrl({ wsBaseUrl: input.wsBaseUrl, httpBaseUrl: input.httpBaseUrl, @@ -193,11 +216,15 @@ export const make = Effect.gen(function* () { ); const createDpopSocketUrl = Effect.fn("clientRuntime.connection.remote.createDpopSocketUrl")( - function* (token: TokenStore.RemoteDpopAccessToken, timeoutMs?: number) { + function* ( + token: TokenStore.RemoteDpopAccessToken, + timeoutMs?: number, + endpoint: { readonly httpBaseUrl: string; readonly wsBaseUrl: string } = token.endpoint, + ) { const ticketProof = yield* signer .createProof({ method: "POST", - url: environmentEndpointUrl(token.endpoint.httpBaseUrl, "/api/auth/websocket-ticket"), + url: environmentEndpointUrl(endpoint.httpBaseUrl, "/api/auth/websocket-ticket"), accessToken: token.accessToken, }) .pipe( @@ -210,8 +237,8 @@ export const make = Effect.gen(function* () { ), ); return yield* resolveRemoteDpopWebSocketConnectionUrl({ - wsBaseUrl: token.endpoint.wsBaseUrl, - httpBaseUrl: token.endpoint.httpBaseUrl, + wsBaseUrl: endpoint.wsBaseUrl, + httpBaseUrl: endpoint.httpBaseUrl, accessToken: token.accessToken, dpopProof: ticketProof, clientMetadata: presentation.metadata, @@ -449,10 +476,11 @@ export const make = Effect.gen(function* () { const httpAuthorization = ( token: TokenStore.RemoteDpopAccessToken, + httpBaseUrl = token.endpoint.httpBaseUrl, ): AuthorizedRemoteHttpEnvironment => ({ environmentId: token.environmentId, label: token.label, - httpBaseUrl: token.endpoint.httpBaseUrl, + httpBaseUrl, httpAuthorization: { _tag: "Dpop", accessToken: token.accessToken, @@ -463,33 +491,57 @@ export const make = Effect.gen(function* () { const authorizeDpop = Effect.fn("clientRuntime.connection.remote.authorizeDpop")(function* ( input: Parameters[0], ) { + const endpoint = input.directEndpoint; + if (endpoint !== undefined) { + yield* verifyDirectEndpoint({ + expectedEnvironmentId: input.expectedEnvironmentId, + httpBaseUrl: endpoint.httpBaseUrl, + connectionMethod: "direct", + }); + } + const authorized = (token: TokenStore.RemoteDpopAccessToken, socketUrl: string) => ({ + ...httpAuthorization(token, endpoint?.httpBaseUrl), + socketUrl, + }); let selected = yield* getDpopToken(input); if (selected.fromCache) { const cachedSocket = yield* createDpopSocketUrl( selected.token, CACHED_ENDPOINT_SOCKET_TIMEOUT_MS, + endpoint, ).pipe(Effect.result); if (Result.isSuccess(cachedSocket)) { yield* assertSession(selected.identity); - return { ...httpAuthorization(selected.token), socketUrl: cachedSocket.success }; + return authorized(selected.token, cachedSocket.success); } if (cachedSocket.failure._tag === "ConnectionBlockedError") { return yield* mapDpopSocketError(cachedSocket.failure); } + // An unreachable direct address says nothing about the token, so keep + // it for the T3 Connect route rather than forcing a relay round trip. + // A rejected token falls through and is replaced like on the tunnel. + if (endpoint !== undefined && cachedSocket.failure._tag !== "EnvironmentAuthInvalidError") { + return yield* mapDpopSocketError(cachedSocket.failure); + } selected = yield* getDpopToken({ ...input, rejectedAccessToken: selected.token.accessToken, }); } - const socket = yield* createDpopSocketUrl(selected.token).pipe(Effect.result); + const socket = yield* createDpopSocketUrl(selected.token, undefined, endpoint).pipe( + Effect.result, + ); if (Result.isFailure(socket)) { - yield* tokenLock.withPermits(1)( - removeRejectedToken(input.expectedEnvironmentId, selected.token.accessToken), - ); + // A direct address that refuses the socket says nothing about the token. + if (endpoint === undefined || socket.failure._tag === "ConnectionBlockedError") { + yield* tokenLock.withPermits(1)( + removeRejectedToken(input.expectedEnvironmentId, selected.token.accessToken), + ); + } return yield* mapDpopSocketError(socket.failure); } yield* assertSession(selected.identity); - return { ...httpAuthorization(selected.token), socketUrl: socket.success }; + return authorized(selected.token, socket.success); }); return RemoteEnvironmentAuthorization.of({ diff --git a/packages/client-runtime/src/connection/catalog.ts b/packages/client-runtime/src/connection/catalog.ts index 83ee1f72938b1f937b46b153c0def8e827f2b1a7..88896e78a8950249dae3698ba0de524dfca86748 100644 --- a/packages/client-runtime/src/connection/catalog.ts +++ b/packages/client-runtime/src/connection/catalog.ts @@ -22,6 +22,17 @@ export class BearerConnectionProfile extends Schema.TaggedClass - routeConnectionKey(route.target, route.profile), - ); + // Learned routes come and go with the server's addresses and reuse a saved + // route's credential, so they leave trust where the user put it. + const routes = [ + { target: entry.target, profile: entry.profile }, + ...(entry.alternateRoutes ?? []), + ].filter((route) => { + const profile = Option.getOrNull(route.profile); + return !(profile?._tag === "BearerConnectionProfile" && profile.learned === true); + }); + if (routes.length === 1) return routeConnectionKey(routes[0]!.target, routes[0]!.profile); + const keys = routes.map((route) => routeConnectionKey(route.target, route.profile)); return keys.every((key) => key !== null) ? JSON.stringify([...keys].sort()) : null; } diff --git a/packages/client-runtime/src/connection/registry.test.ts b/packages/client-runtime/src/connection/registry.test.ts index f86b4b52d7233d147d2ceae061b26bb996e8a663..a2dee960c1fd2050d004fa5af14605409ef8342c 100644 --- a/packages/client-runtime/src/connection/registry.test.ts +++ b/packages/client-runtime/src/connection/registry.test.ts @@ -1874,4 +1874,23 @@ describe("EnvironmentRegistry routes", () => { }).pipe(Effect.provide(harness.layer), Effect.scoped); }), ); + + it.effect("drops a learned route whose profile was not saved instead of duplicating it", () => + Effect.gen(function* () { + const learnedId = `learned:${LAN_TARGET.environmentId}:100.64.0.9:3773@${LAN_TARGET.connectionId}`; + const learned = new BearerConnectionTarget({ ...LAN_TARGET, connectionId: learnedId }); + // An earlier build saved the learned target twice and never its profile. + const harness = yield* makeHarness( + [LAN_TARGET, learned, learned], + [LAN_PROFILE], + [[LAN_TARGET.connectionId, BEARER_CREDENTIAL]], + ); + yield* Effect.gen(function* () { + const registry = yield* EnvironmentRegistry.EnvironmentRegistry; + const entry = (yield* SubscriptionRef.get(registry.entries)).get(LAN_TARGET.environmentId); + expect(entry?.target).toEqual(LAN_TARGET); + expect(entry?.alternateRoutes ?? []).toEqual([]); + }).pipe(Effect.provide(harness.layer), Effect.scoped); + }), + ); }); diff --git a/packages/client-runtime/src/connection/registry.ts b/packages/client-runtime/src/connection/registry.ts index 03a1ccb073712eb13f34276a4cf8e9bc3320fa48..e0c45445e25f6c56556c0f90639d02e6996d33c6 100644 --- a/packages/client-runtime/src/connection/registry.ts +++ b/packages/client-runtime/src/connection/registry.ts @@ -48,6 +48,9 @@ import { connectionRoutes, entryWithRoutes, findRouteToSameAddress, + isLearned, + mergeLearnedRoutes, + routesAfterRemoving, upsertRoute, } from "./routes.ts"; @@ -234,7 +237,18 @@ export const make = Effect.gen(function* () { yield* Effect.forEach( persistedRoutesByEnvironment, Effect.fn("EnvironmentRegistry.loadCatalogEntry")(function* ([environmentId, targets]) { - const routes = yield* Effect.forEach(targets, loadRoute, { concurrency: "unbounded" }); + const loaded = yield* Effect.forEach(targets, loadRoute, { concurrency: "unbounded" }); + // A learned route without its profile has no address to reach; it is + // learned again on the next connection. A paired route keeps its slot + // so its missing profile still surfaces as a connection error. + const seen = new Set(); + const usable = loaded.filter((route) => { + const id = connectionRouteId(route.target); + if (seen.has(id)) return false; + seen.add(id); + return !(id.startsWith("learned:") && Option.isNone(route.profile)); + }); + const routes = usable.length > 0 ? usable : loaded.slice(0, 1); const first = routes[0]!; return [ environmentId, @@ -350,6 +364,7 @@ export const make = Effect.gen(function* () { const scope = yield* Scope.fork(registryScope); const supervisor = yield* EnvironmentSupervisor.make(entry, { initiallyDesired: false, + learnRoutes: (input) => learnRoutes({ environmentId, ...input }), }).pipe( Effect.provideService(Connectivity.Connectivity, connectivity), Effect.provideService(ConnectionDriver.ConnectionDriver, driver), @@ -827,6 +842,71 @@ export const make = Effect.gen(function* () { Effect.ignore, ); + // Plain HTTP routes are unusable from an HTTPS page, which blocks mixed content. + const allowInsecureRoutes = + typeof globalThis.location === "undefined" || globalThis.location.protocol !== "https:"; + + /** + * Saves the direct addresses a connected server reports as learned routes, + * replacing learned routes it no longer reports. Routes the user saved are + * never changed. Learned routes reuse the active route's credential, so they + * do not revoke GitHub routing trust the way a newly paired address does. + */ + const learnRoutes = Effect.fn("EnvironmentRegistry.learnRoutes")(function* (input: { + readonly environmentId: EnvironmentId; + readonly activeRoute: ConnectionRoute; + readonly reported: ReadonlyArray<{ readonly httpBaseUrl: string }>; + }) { + return yield* withLeaseLock( + input.environmentId, + Effect.gen(function* () { + if ((yield* Ref.get(platformEnvironmentIds)).has(input.environmentId)) { + return Option.none(); + } + const entry = (yield* SubscriptionRef.get(entries)).get(input.environmentId); + if (entry === undefined) return Option.none(); + const routes = mergeLearnedRoutes({ + entry, + activeRoute: input.activeRoute, + reported: input.reported, + allowInsecure: allowInsecureRoutes, + }); + if (routes === null) return Option.none(); + const next = entryWithRoutes(entry, routes); + // A learned route owns its profile (address and authorization); the + // credential stays with the route it borrows from. + const previousIds = new Set( + connectionRoutes(entry).map((route) => connectionRouteId(route.target)), + ); + for (const route of routes) { + if (!isLearned(route) || previousIds.has(connectionRouteId(route.target))) continue; + const profile = Option.getOrNull(route.profile); + if (profile !== null) yield* profiles.put(profile); + } + yield* registrations.setRoutes(input.environmentId, persistedRoutes(next)); + // Update the lease in place: the live session already works, and + // `installEntryLocked` would replace it for a route list change. + const lease = (yield* SubscriptionRef.get(serviceScopes)).get(input.environmentId); + if (lease !== undefined) { + yield* SubscriptionRef.update(serviceScopes, (current) => + new Map(current).set(input.environmentId, { ...lease, entry: next }), + ); + } + yield* SubscriptionRef.update(entries, (current) => + new Map(current).set(input.environmentId, next), + ); + return Option.some(next); + }), + ).pipe( + Effect.catch((error) => + Effect.logWarning("Could not save routes learned from the environment.", { + environmentId: input.environmentId, + error, + }).pipe(Effect.as(Option.none())), + ), + ); + }); + const removeRoute = Effect.fn("EnvironmentRegistry.removeRoute")(function* ( environmentId: EnvironmentId, routeId: string, @@ -840,7 +920,7 @@ export const make = Effect.gen(function* () { const routes = connectionRoutes(entry); const route = routes.find((candidate) => connectionRouteId(candidate.target) === routeId); if (route === undefined) return; - const remaining = routes.filter((candidate) => candidate !== route); + const remaining = routesAfterRemoving(routes, routeId); if (remaining.length === 0) return yield* removeLocked(environmentId); yield* replaceRoutesLocked(entry, remaining); const profile = Option.getOrNull(route.profile); diff --git a/packages/client-runtime/src/connection/resolver.ts b/packages/client-runtime/src/connection/resolver.ts index 77b778a50aa8fa787d12f9a26ac2fc581b5ed38a..c1f6ac9ea41658f1aced240450374db876711559 100644 --- a/packages/client-runtime/src/connection/resolver.ts +++ b/packages/client-runtime/src/connection/resolver.ts @@ -44,6 +44,7 @@ import { appendOrchestrationProtocol, orchestrationProtocolCompatibilityError, } from "./compatibility.ts"; +import { credentialConnectionId } from "./routes.ts"; import { fetchRemoteEnvironmentDescriptor } from "../environment/descriptor.ts"; export class ConnectionResolver extends Context.Service< @@ -139,7 +140,15 @@ const makeBearerBroker = Effect.fn("clientRuntime.connection.broker.makeBearer") actual: profile.environmentId, }); } - const credential = yield* credentials.get(target.connectionId).pipe( + if (profile.authorization === "t3-connect") { + const authorized = yield* remote.authorizeDpop({ + expectedEnvironmentId: target.environmentId, + directEndpoint: { httpBaseUrl: profile.httpBaseUrl, wsBaseUrl: profile.wsBaseUrl }, + }); + return { ...authorized, target } satisfies PreparedConnection; + } + // A learned route borrows the credential of the route it was learned from. + const credential = yield* credentials.get(credentialConnectionId(target.connectionId)).pipe( Effect.flatMap( Option.match({ onNone: () => Effect.fail(credentialMissingError(target.connectionId)), diff --git a/packages/client-runtime/src/connection/routes.test.ts b/packages/client-runtime/src/connection/routes.test.ts index 373585e4ef44cc6e1246ad96edf6f8ed3e823fb8..19b433f9573816bd0e7ebeba4ad817bc85eb23c7 100644 --- a/packages/client-runtime/src/connection/routes.test.ts +++ b/packages/client-runtime/src/connection/routes.test.ts @@ -2,9 +2,25 @@ import { EnvironmentId } from "@t3tools/contracts"; import { describe, expect, it } from "@effect/vitest"; import * as Option from "effect/Option"; -import { BearerConnectionProfile, type ConnectionRoute } from "./catalog.ts"; +import { + BearerConnectionProfile, + type ConnectionCatalogEntry, + type ConnectionRoute, +} from "./catalog.ts"; +import { gitHubRoutingConnectionKey } from "./githubRoutingPermissions.ts"; import { BearerConnectionTarget, RelayConnectionTarget } from "./model.ts"; -import { connectionRouteKind, connectionRouteLabel, insertRoute, upsertRoute } from "./routes.ts"; +import { + connectionRouteId, + connectionRouteKind, + connectionRouteLabel, + credentialConnectionId, + insertRoute, + entryWithRoutes, + mergeLearnedRoutes, + removedWithRelay, + routesAfterRemoving, + upsertRoute, +} from "./routes.ts"; const ENVIRONMENT_ID = EnvironmentId.make("environment-1"); @@ -60,3 +76,241 @@ describe("connection routes", () => { expect(upsertRoute([RELAY, LAN], repaired)).toEqual([RELAY, repaired]); }); }); + +describe("learned routes", () => { + const relayOnly: ConnectionCatalogEntry = { + target: RELAY.target, + profile: RELAY.profile, + enabled: true, + }; + const ids = (routes: ReadonlyArray | null) => + routes?.map((route) => connectionRouteId(route.target)) ?? null; + const profileOf = (route: ConnectionRoute) => Option.getOrThrow(route.profile); + + it("learns a LAN address over T3 Connect, ahead of it, using the T3 Connect credential", () => { + const routes = mergeLearnedRoutes({ + entry: relayOnly, + activeRoute: RELAY, + reported: [{ httpBaseUrl: "http://192.168.1.10:3773/" }], + allowInsecure: true, + }); + expect(ids(routes)).toEqual([`learned:${ENVIRONMENT_ID}:http://192.168.1.10:3773`, "relay"]); + expect(profileOf(routes![0]!)).toMatchObject({ + learned: true, + authorization: "t3-connect", + wsBaseUrl: "ws://192.168.1.10:3773/", + }); + }); + + it("replaces a learned LAN address when the server reports a new one", () => { + const first = mergeLearnedRoutes({ + entry: relayOnly, + activeRoute: RELAY, + reported: [{ httpBaseUrl: "http://192.168.1.10:3773/" }], + allowInsecure: true, + })!; + const entry: ConnectionCatalogEntry = { + ...relayOnly, + target: first[0]!.target, + profile: first[0]!.profile, + alternateRoutes: first.slice(1), + }; + const moved = mergeLearnedRoutes({ + entry, + activeRoute: RELAY, + reported: [{ httpBaseUrl: "http://10.0.0.42:3773/" }], + allowInsecure: true, + }); + expect(ids(moved)).toEqual([`learned:${ENVIRONMENT_ID}:http://10.0.0.42:3773`, "relay"]); + }); + + it("keeps a learned route where the user moved it while the server reports it", () => { + const lan = { httpBaseUrl: "http://192.168.1.10:3773/" }; + const first = mergeLearnedRoutes({ + entry: relayOnly, + activeRoute: RELAY, + reported: [lan], + allowInsecure: true, + })!; + // The user prefers T3 Connect over the learned LAN address. + const reordered = entryWithRoutes(relayOnly, [first[1]!, first[0]!]); + expect( + mergeLearnedRoutes({ + entry: reordered, + activeRoute: RELAY, + reported: [lan], + allowInsecure: true, + }), + ).toBeNull(); + // A newly reported address is still placed by speed. + const next = mergeLearnedRoutes({ + entry: reordered, + activeRoute: RELAY, + reported: [lan, { httpBaseUrl: "http://100.101.102.103:3773/" }], + allowInsecure: true, + }); + expect(ids(next)).toEqual([ + `learned:${ENVIRONMENT_ID}:http://100.101.102.103:3773`, + "relay", + `learned:${ENVIRONMENT_ID}:http://192.168.1.10:3773`, + ]); + }); + + it("leaves user routes alone and does not learn an address already saved", () => { + const entry: ConnectionCatalogEntry = { + target: LAN.target, + profile: LAN.profile, + alternateRoutes: [RELAY], + enabled: true, + }; + expect( + mergeLearnedRoutes({ + entry, + activeRoute: RELAY, + reported: [{ httpBaseUrl: "http://192.168.1.10:3773" }], + allowInsecure: true, + }), + ).toBeNull(); + // The server stops reporting the LAN address; the paired route stays. + expect( + mergeLearnedRoutes({ entry, activeRoute: RELAY, reported: [], allowInsecure: true }), + ).toBeNull(); + }); + + it("borrows the paired token when learned over a bearer route", () => { + const entry: ConnectionCatalogEntry = { + ...relayOnly, + target: LAN.target, + profile: LAN.profile, + }; + const routes = mergeLearnedRoutes({ + entry, + activeRoute: LAN, + reported: [{ httpBaseUrl: "https://desk.tail1234.ts.net/" }], + allowInsecure: true, + })!; + const learned = routes.find((route) => connectionRouteKind(route) === "tailnet")!; + expect(profileOf(learned)).not.toHaveProperty("authorization"); + expect(credentialConnectionId(connectionRouteId(learned.target))).toBe("lan"); + }); + + it("skips plain HTTP from an HTTPS page and never learns loopback", () => { + expect( + mergeLearnedRoutes({ + entry: relayOnly, + activeRoute: RELAY, + reported: [ + { httpBaseUrl: "http://192.168.1.10:3773/" }, + { httpBaseUrl: "http://127.0.0.1:3773/" }, + ], + allowInsecure: false, + }), + ).toBeNull(); + }); + + it("removes learned routes along with the route whose credential they borrow", () => { + const overRelay = mergeLearnedRoutes({ + entry: relayOnly, + activeRoute: RELAY, + reported: [{ httpBaseUrl: "http://192.168.1.10:3773/" }], + allowInsecure: true, + })!; + expect(routesAfterRemoving(overRelay, "relay")).toEqual([]); + + const paired: ConnectionCatalogEntry = { + ...relayOnly, + target: LAN.target, + profile: LAN.profile, + alternateRoutes: [RELAY], + }; + const overLan = mergeLearnedRoutes({ + entry: paired, + activeRoute: LAN, + reported: [{ httpBaseUrl: "https://desk.tail1234.ts.net/" }], + allowInsecure: true, + })!; + expect(ids(routesAfterRemoving(overLan, "lan"))).toEqual(["relay"]); + // Removing T3 Connect keeps the paired LAN and what it learned. + expect(ids(routesAfterRemoving(overLan, "relay"))).toHaveLength(2); + }); + + it("counts an environment reached only through T3 Connect as removed with it", () => { + const learned = mergeLearnedRoutes({ + entry: relayOnly, + activeRoute: RELAY, + reported: [{ httpBaseUrl: "http://192.168.1.10:3773/" }], + allowInsecure: true, + })!; + expect(removedWithRelay(relayOnly)).toBe(true); + expect(removedWithRelay(entryWithRoutes(relayOnly, learned))).toBe(true); + expect(removedWithRelay(entryWithRoutes(relayOnly, [LAN, RELAY]))).toBe(false); + }); + + it("keeps GitHub routing trust when a route is learned", () => { + const learned = mergeLearnedRoutes({ + entry: relayOnly, + activeRoute: RELAY, + reported: [{ httpBaseUrl: "http://192.168.1.10:3773/" }], + allowInsecure: true, + })!; + const withLearned: ConnectionCatalogEntry = { + ...relayOnly, + target: learned[0]!.target, + profile: learned[0]!.profile, + alternateRoutes: learned.slice(1), + }; + expect(gitHubRoutingConnectionKey(withLearned)).toBe(gitHubRoutingConnectionKey(relayOnly)); + }); + + it("keeps the T3 Connect credential when learning over a learned T3 Connect route", () => { + const first = mergeLearnedRoutes({ + entry: relayOnly, + activeRoute: RELAY, + reported: [{ httpBaseUrl: "http://192.168.1.10:3773/" }], + allowInsecure: true, + })!; + const learnedLan = first[0]!; + const entry: ConnectionCatalogEntry = { + ...relayOnly, + target: learnedLan.target, + profile: learnedLan.profile, + alternateRoutes: first.slice(1), + }; + const next = mergeLearnedRoutes({ + entry, + activeRoute: learnedLan, + reported: [ + { httpBaseUrl: "http://192.168.1.10:3773/" }, + { httpBaseUrl: "https://desk.tail1234.ts.net/" }, + ], + allowInsecure: true, + })!; + for (const route of next.filter((candidate) => connectionRouteKind(candidate) !== "relay")) { + expect(profileOf(route)).toMatchObject({ authorization: "t3-connect" }); + expect(connectionRouteId(route.target)).not.toContain("@"); + } + }); + + it("saves a scheme change on the same host as a new address", () => { + const first = mergeLearnedRoutes({ + entry: relayOnly, + activeRoute: RELAY, + reported: [{ httpBaseUrl: "http://desk.local:3773/" }], + allowInsecure: true, + })!; + const entry: ConnectionCatalogEntry = { + ...relayOnly, + target: first[0]!.target, + profile: first[0]!.profile, + alternateRoutes: first.slice(1), + }; + const moved = mergeLearnedRoutes({ + entry, + activeRoute: RELAY, + reported: [{ httpBaseUrl: "https://desk.local:3773/" }], + allowInsecure: true, + }); + expect(moved).not.toBeNull(); + expect(profileOf(moved![0]!)).toMatchObject({ httpBaseUrl: "https://desk.local:3773/" }); + }); +}); diff --git a/packages/client-runtime/src/connection/routes.ts b/packages/client-runtime/src/connection/routes.ts index b8f6fae0bdd1cab0b490ed666c4c4947d5008a33..8b2ebf3e07435ae88068dc4b23e30f9fde768984 100644 --- a/packages/client-runtime/src/connection/routes.ts +++ b/packages/client-runtime/src/connection/routes.ts @@ -6,8 +6,12 @@ import { import type { DesktopSshEnvironmentTarget } from "@t3tools/contracts"; import * as Option from "effect/Option"; -import type { ConnectionCatalogEntry, ConnectionRoute } from "./catalog.ts"; -import type { ConnectionTarget } from "./model.ts"; +import { + BearerConnectionProfile, + type ConnectionCatalogEntry, + type ConnectionRoute, +} from "./catalog.ts"; +import { BearerConnectionTarget, type ConnectionTarget } from "./model.ts"; /** * A saved environment can hold several routes: T3 Connect, direct URLs (LAN, @@ -202,6 +206,168 @@ export function hasRelayRoute( ); } +/** + * The routes after the server reports where it listens. Each newly reported + * address becomes a learned route that authenticates the same way as the route + * in use: the paired token for a bearer route, the T3 Connect credential for + * relay. A learned route the server still reports keeps its place, so the + * user's order holds; one it no longer reports is dropped, so a changed LAN + * address replaces the old one. Routes the user saved are never touched, and + * an address already saved is not learned twice. + */ +export function mergeLearnedRoutes(input: { + readonly entry: ConnectionCatalogEntry; + readonly activeRoute: ConnectionRoute; + readonly reported: ReadonlyArray<{ readonly httpBaseUrl: string }>; + /** Plain HTTP routes are unusable from an HTTPS page (mixed content). */ + readonly allowInsecure: boolean; +}): ReadonlyArray | null { + const { entry } = input; + const active = input.activeRoute.target; + const saved = connectionRoutes(entry); + // The primary and SSH routes have no credential a learned route could reuse. + if (active._tag !== "RelayConnectionTarget" && active._tag !== "BearerConnectionTarget") { + return null; + } + // A route learned over another learned route inherits what that one uses: + // the T3 Connect credential, or the paired token it borrows. + const activeProfile = Option.getOrNull(input.activeRoute.profile); + const authorization = + active._tag === "RelayConnectionTarget" || + (activeProfile?._tag === "BearerConnectionProfile" && + activeProfile.authorization === "t3-connect") + ? ("t3-connect" as const) + : undefined; + const sharedCredential = + authorization === undefined && active._tag === "BearerConnectionTarget" + ? credentialConnectionId(active.connectionId) + : undefined; + + // Usable reported addresses, by origin. + const reported = new Map(); + for (const endpoint of input.reported) { + let url: URL; + try { + url = new URL(endpoint.httpBaseUrl); + } catch { + continue; + } + if (url.protocol !== "http:" && url.protocol !== "https:") continue; + if (url.protocol === "http:" && !input.allowInsecure) continue; + // A loopback address names whichever device opens it, never the server. + if (isLocalLoopbackHost(url.hostname)) continue; + reported.set(url.origin, url); + } + const normalized = (url: string) => url.replace(/\/+$/, ""); + const known = new Set( + saved.flatMap((route) => { + const url = routeHttpBaseUrl(route); + return url === null || isLearned(route) ? [] : [normalized(url)]; + }), + ); + const kept = saved.filter((route) => { + if (!isLearned(route)) return true; + const url = routeHttpBaseUrl(route); + if (url === null || !reported.has(normalized(url)) || known.has(normalized(url))) return false; + known.add(normalized(url)); + return true; + }); + let next: ReadonlyArray = kept; + for (const url of reported.values()) { + if (known.has(url.origin)) continue; + const httpBaseUrl = `${url.origin}/`; + const connectionId = learnedConnectionId( + entry.target.environmentId, + url.origin, + sharedCredential, + ); + next = insertRoute(next, { + target: new BearerConnectionTarget({ + environmentId: entry.target.environmentId, + label: entry.target.label, + connectionId, + }), + profile: Option.some( + new BearerConnectionProfile({ + connectionId, + environmentId: entry.target.environmentId, + label: entry.target.label, + httpBaseUrl, + wsBaseUrl: `${url.protocol === "https:" ? "wss:" : "ws:"}//${url.host}/`, + learned: true, + ...(authorization === undefined ? {} : { authorization }), + }), + ), + }); + } + // Compare addresses too: a scheme or port change keeps no id stable. + const signature = (routes: ReadonlyArray) => + routes + .map((route) => `${connectionRouteId(route.target)} ${routeHttpBaseUrl(route) ?? ""}`) + .join("\n"); + return signature(saved) === signature(next) ? null : next; +} + +/** + * A learned bearer route borrows the credential of the route it was learned + * from, so its id points back at that credential's owner. + */ +function learnedConnectionId( + environmentId: string, + origin: string, + sharedCredential: string | undefined, +): string { + return sharedCredential === undefined + ? `learned:${environmentId}:${origin}` + : `learned:${environmentId}:${origin}@${sharedCredential}`; +} + +/** The connection id whose stored credential a bearer route uses. */ +export function credentialConnectionId(connectionId: string): string { + // The borrowed id follows the first "@"; neither an environment id nor an + // origin contains one. + const at = connectionId.indexOf("@"); + return connectionId.startsWith("learned:") && at !== -1 + ? connectionId.slice(at + 1) + : connectionId; +} + +export function isLearned(route: ConnectionRoute): boolean { + const profile = Option.getOrNull(route.profile); + return profile?._tag === "BearerConnectionProfile" && profile.learned === true; +} + +/** + * Routes left after the user removes one. A learned route borrows the + * credential of the route it was learned over, so it cannot outlive that + * route: removing T3 Connect also removes routes learned through it, and + * removing a paired address removes routes that borrow its token. + */ +export function routesAfterRemoving( + routes: ReadonlyArray, + removedId: string, +): ReadonlyArray { + const removed = routes.find((route) => connectionRouteId(route.target) === removedId); + if (removed === undefined) return routes; + return routes.filter((route) => { + if (route === removed) return false; + if (!isLearned(route)) return true; + const profile = Option.getOrNull(route.profile); + if (profile?._tag === "BearerConnectionProfile" && profile.authorization === "t3-connect") { + return removed.target._tag !== "RelayConnectionTarget"; + } + return credentialConnectionId(connectionRouteId(route.target)) !== removedId; + }); +} + +/** + * Whether removing T3 Connect leaves the environment no route, so signing out + * removes it entirely. Routes learned through T3 Connect go with it. + */ +export function removedWithRelay(entry: ConnectionCatalogEntry): boolean { + return routesAfterRemoving(connectionRoutes(entry), RELAY_ROUTE_ID).length === 0; +} + /** One SSH target, as desktop keys its tunnels: alias, host, user, and port. */ export function sshTargetKey(target: DesktopSshEnvironmentTarget): string { return JSON.stringify([target.alias, target.hostname, target.username, target.port]); diff --git a/packages/client-runtime/src/connection/supervisor.test.ts b/packages/client-runtime/src/connection/supervisor.test.ts index 260a9c090a331bbc8325e39ca32d29574d853187..235eedfde2186876d77d584e11f246cbab1df4b4 100644 --- a/packages/client-runtime/src/connection/supervisor.test.ts +++ b/packages/client-runtime/src/connection/supervisor.test.ts @@ -1,4 +1,4 @@ -import { AuthStandardClientScopes, EnvironmentId } from "@t3tools/contracts"; +import { AuthStandardClientScopes, EnvironmentId, type ServerConfig } from "@t3tools/contracts"; import { RelayClientTracer } from "@t3tools/shared/relayTracing"; import { describe, expect, it } from "@effect/vitest"; import * as Deferred from "effect/Deferred"; @@ -38,6 +38,7 @@ import { import * as RpcSession from "../rpc/session.ts"; import * as EnvironmentSupervisor from "./supervisor.ts"; import * as ConnectionWakeups from "./wakeups.ts"; +import { connectionRouteId, entryWithRoutes, isLearned, mergeLearnedRoutes } from "./routes.ts"; import { NETWORK_BLOCKING_HINT } from "../errors/network.ts"; const TARGET = new PrimaryConnectionTarget({ @@ -156,6 +157,7 @@ const makeHarness = Effect.fn("TestConnectionHarness.make")(function* (options?: ) => Effect.Effect; readonly ready?: (attempt: number) => Effect.Effect; readonly probe?: (attempt: number) => Effect.Effect; + readonly initialConfig?: (attempt: number) => Effect.Effect; readonly checkRoute?: (route: ConnectionRoute) => Effect.Effect; }) { const networkStatus = yield* SubscriptionRef.make( @@ -205,7 +207,9 @@ const makeHarness = Effect.fn("TestConnectionHarness.make")(function* (options?: const session = yield* Effect.acquireRelease( Effect.succeed({ client: TEST_RPC_CLIENT, - initialConfig: Effect.die(new Error("Initial config is not used by supervisor tests.")), + initialConfig: + options?.initialConfig?.(attempt) ?? + Effect.die(new Error("Initial config is not used by supervisor tests.")), subscribeServerConfig: (input) => TEST_RPC_CLIENT.subscribeServerConfig(input), ready: options?.ready?.(attempt) ?? Effect.void, probe: options?.probe?.(attempt) ?? Effect.void, @@ -1983,4 +1987,66 @@ describe("EnvironmentSupervisor routes", () => { }); }), ); + + it.effect("learns the LAN address over T3 Connect and moves to it", () => + Effect.gen(function* () { + const relayEntry: ConnectionCatalogEntry = { + target: RELAY_TARGET, + profile: Option.none(), + enabled: true, + }; + const lanAddress = yield* Ref.make("http://192.168.1.10:3773/"); + const learned = yield* Ref.make>([]); + const harness = yield* makeHarness({ + checkRoute: (route) => + route.target._tag === "BearerConnectionTarget" + ? Effect.succeed("answered") + : Effect.succeed("unchecked"), + prepare: (_attempt, target) => Effect.succeed(preparedFor(target)), + // Only the reported endpoints matter to the supervisor. + initialConfig: () => + Ref.get(lanAddress).pipe( + Effect.map((httpBaseUrl): Pick => ({ + directEndpoints: [{ kind: "lan", httpBaseUrl }], + })), + Effect.map((config) => config as ServerConfig), + ), + }); + const current = yield* Ref.make(relayEntry); + const supervisor = yield* EnvironmentSupervisor.make(relayEntry, { + initiallyDesired: true, + learnRoutes: ({ activeRoute, reported }) => + Effect.gen(function* () { + const entry = yield* Ref.get(current); + const routes = mergeLearnedRoutes({ + entry, + activeRoute, + reported, + allowInsecure: true, + }); + if (routes === null) return Option.none(); + const next = entryWithRoutes(entry, routes); + yield* Ref.set(current, next); + yield* Ref.update(learned, (all) => [ + ...all, + ...routes.filter(isLearned).map((route) => connectionRouteId(route.target)), + ]); + return Option.some(next); + }), + }).pipe(Effect.provide(harness.dependencies)); + + // Connected over T3 Connect, the server reports its LAN address; the + // learned route ranks first, answers, and the session moves to it. + yield* awaitState( + supervisor.state, + (state) => state.phase === "connected" && state.generation === 2, + ); + expect(Option.getOrThrow(yield* SubscriptionRef.get(supervisor.prepared)).target._tag).toBe( + "BearerConnectionTarget", + ); + expect(yield* Ref.get(learned)).toEqual([ + `learned:${TARGET.environmentId}:http://192.168.1.10:3773`, + ]); + }), + ); }); diff --git a/packages/client-runtime/src/connection/supervisor.ts b/packages/client-runtime/src/connection/supervisor.ts index 0a442aa3f2f4a7b9760a730c71508c8e5c8408b5..36becf73061e3400b001c061ca2b3e152355effe 100644 --- a/packages/client-runtime/src/connection/supervisor.ts +++ b/packages/client-runtime/src/connection/supervisor.ts @@ -113,6 +113,15 @@ function exitUnlessInterrupted( export interface EnvironmentSupervisorOptions { readonly initiallyDesired?: boolean; + /** + * Saves the direct addresses the server reports once a session is ready and + * returns the entry with its updated routes, which later attempts use. The + * live session is left alone: it already works. + */ + readonly learnRoutes?: (input: { + readonly activeRoute: ConnectionRoute; + readonly reported: ReadonlyArray<{ readonly httpBaseUrl: string }>; + }) => Effect.Effect>; } /** @@ -279,7 +288,10 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( ); const session = yield* SubscriptionRef.make>(Option.none()); const prepared = yield* SubscriptionRef.make>(Option.none()); - const routes = connectionRoutes(entry); + // Learned routes arrive while a session is live, so the route list is read + // fresh by each attempt and check instead of being fixed at creation. + const currentEntry = yield* Ref.make(entry); + const currentRoutes = Ref.get(currentEntry).pipe(Effect.map(connectionRoutes)); // Set when a better route answered while connected over a worse one; the // replacement attempt tries it first. Cleared once an attempt starts. const preferredRouteId = yield* Ref.make(Option.none()); @@ -287,19 +299,45 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( const routeCooldowns = yield* Ref.make>(new Map()); const attemptEntry = Effect.gen(function* () { + const latest = yield* Ref.get(currentEntry); + const routes = connectionRoutes(latest); const preferred = yield* Ref.getAndSet(preferredRouteId, Option.none()); - if (Option.isNone(preferred)) return entry; + if (Option.isNone(preferred)) return latest; const route = routes.find( (candidate) => connectionRouteId(candidate.target) === preferred.value, ); return route === undefined - ? entry - : entryWithRoutes(entry, [route, ...routes.filter((candidate) => candidate !== route)]); + ? latest + : entryWithRoutes(latest, [route, ...routes.filter((candidate) => candidate !== route)]); + }); + + const learnRoutesFrom = Effect.fnUntraced(function* ( + learn: NonNullable, + lease: ConnectionDriver.EnvironmentConnectionLease, + ) { + const config = yield* lease.session.initialConfig.pipe(Effect.option); + const reported = Option.getOrUndefined(config)?.directEndpoints; + if (reported === undefined) return; + const activeId = connectionRouteId(lease.prepared.target); + const activeRoute = (yield* currentRoutes).find( + (route) => connectionRouteId(route.target) === activeId, + ); + if (activeRoute === undefined) return; + const updated = yield* learn({ activeRoute, reported }); + if (Option.isSome(updated)) { + yield* Ref.set(currentEntry, updated.value); + // A learned route may rank above the one in use. + yield* requestBetterRouteCheck(lease); + } }); const routeIndex = (lease: ConnectionDriver.EnvironmentConnectionLease) => - routes.findIndex( - (route) => connectionRouteId(route.target) === connectionRouteId(lease.prepared.target), + currentRoutes.pipe( + Effect.map((routes) => + routes.findIndex( + (route) => connectionRouteId(route.target) === connectionRouteId(lease.prepared.target), + ), + ), ); /** @@ -310,16 +348,17 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( const checkBetterRoutes = Effect.fnUntraced(function* ( lease: ConnectionDriver.EnvironmentConnectionLease, ) { - const current = routeIndex(lease); + const latest = yield* Ref.get(currentEntry); + const current = yield* routeIndex(lease); if (current <= 0) return; const now = yield* Clock.monotonicTimeNanos; const cooldowns = yield* Ref.get(routeCooldowns); - const better = routes + const better = connectionRoutes(latest) .slice(0, current) .filter((route) => (cooldowns.get(connectionRouteId(route.target)) ?? 0n) <= now); const passed = yield* Effect.forEach( better, - (route: ConnectionRoute) => driver.preflight(entry, route), + (route: ConnectionRoute) => driver.preflight(latest, route), { concurrency: "unbounded" }, ); const index = passed.indexOf(true); @@ -336,7 +375,11 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( // One check at a time; a trigger during a check is dropped, not queued. const betterRouteChecks = yield* Queue.dropping(1); const requestBetterRouteCheck = (lease: ConnectionDriver.EnvironmentConnectionLease) => - routeIndex(lease) > 0 ? Queue.offer(betterRouteChecks, lease).pipe(Effect.asVoid) : Effect.void; + routeIndex(lease).pipe( + Effect.flatMap((index) => + index > 0 ? Queue.offer(betterRouteChecks, lease).pipe(Effect.asVoid) : Effect.void, + ), + ); const clearLease = Effect.all( [SubscriptionRef.set(session, Option.none()), SubscriptionRef.set(prepared, Option.none())], @@ -557,13 +600,13 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( next._tag === "RetryRequested" ? Ref.set(resetRetryState, false) : Effect.void, ), ); - if (routes.length > 1 && routeIndex(lease) > 0) { - yield* Stream.tick(BETTER_ROUTE_CHECK_INTERVAL).pipe( - Stream.drop(1), - Stream.runForEach(() => requestBetterRouteCheck(lease)), - Effect.forkScoped, - ); - } + // Ticks always run: the check skips the preferred route itself, and the + // route list can grow while connected (learned routes). + yield* Stream.tick(BETTER_ROUTE_CHECK_INTERVAL).pipe( + Stream.drop(1), + Stream.runForEach(() => requestBetterRouteCheck(lease)), + Effect.forkScoped, + ); for (;;) { const next = yield* takeSignal; const end = yield* connectedLeaseEnd(next, lease); @@ -652,9 +695,9 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( })), ), // Each route may use the full setup time before the next is tried. - Effect.sleep(Duration.times(establishmentTimeout, routes.length)).pipe( - Effect.as({ _tag: "TimedOut" }), - ), + Effect.sleep( + Duration.times(establishmentTimeout, connectionRoutes(yield* Ref.get(currentEntry)).length), + ).pipe(Effect.as({ _tag: "TimedOut" })), ]); if (establishment._tag === "Interrupted") { @@ -722,6 +765,9 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( const connectedAt = yield* Clock.currentTimeMillis; yield* SubscriptionRef.set(prepared, Option.some(active.lease.prepared)); yield* SubscriptionRef.set(session, Option.some(active.lease.session)); + if (options?.learnRoutes !== undefined) { + yield* learnRoutesFrom(options.learnRoutes, active.lease).pipe(Effect.forkScoped); + } yield* setState({ desired: true, network: currentIntent.network, diff --git a/packages/client-runtime/src/state/environmentHttpAuth.ts b/packages/client-runtime/src/state/environmentHttpAuth.ts index 0fa7bbc7ffe2aa5c9d54cad9e616f376f1cca2a6..1d6d9b2f144f64efbb1e276d259639a03f3d71bc 100644 --- a/packages/client-runtime/src/state/environmentHttpAuth.ts +++ b/packages/client-runtime/src/state/environmentHttpAuth.ts @@ -152,7 +152,10 @@ export const executeAuthenticatedEnvironmentHttpRequest = Effect.fn( }), ), ); - httpBaseUrl = current.httpBaseUrl; + // A learned direct route keeps its own origin; only the token renews. + if (input.prepared.target._tag === "RelayConnectionTarget") { + httpBaseUrl = current.httpBaseUrl; + } authorization = current.httpAuthorization; } diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index 81e0899777a55388bf5e81a76ba84a374fa8cb3a..68ffef7d110b32146da524009c34eb5f48e0cc51 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -603,6 +603,15 @@ export function environmentThemeFileHasColors(file: EnvironmentThemeFile): boole ); } +export const ServerDirectEndpointKind = Schema.Literals(["lan", "tailnet"]); +export type ServerDirectEndpointKind = typeof ServerDirectEndpointKind.Type; + +export const ServerDirectEndpoint = Schema.Struct({ + kind: ServerDirectEndpointKind, + httpBaseUrl: TrimmedNonEmptyString, +}); +export type ServerDirectEndpoint = typeof ServerDirectEndpoint.Type; + export const ServerConfig = Schema.Struct({ environment: ExecutionEnvironmentDescriptor, auth: ServerAuthDescriptor, @@ -620,6 +629,13 @@ export const ServerConfig = Schema.Struct({ * sshd or no advertisable name. */ remoteOpenTargets: Schema.optionalKey(ForwardCompatibleArray(RemoteOpenTarget)), + /** + * Direct addresses this server listens on right now (LAN and tailnet), so a + * client connected one way can learn the others. Hints only: the client + * checks each address answers as this environment before using it. Absent on + * servers that predate the feature; empty when bound to loopback only. + */ + directEndpoints: Schema.optionalKey(ForwardCompatibleArray(ServerDirectEndpoint)), observability: ServerObservability, settings: ServerSettings, /** Whether shell subscriptions can emit an opt-in catch-up completion marker. */ -- 2.54.0