authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-10-04 18:11:19-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-04 18:11:19-07:00
log745c225f92401ba0950770fa7c3ec67a7067dbeb
tree63d64e2b3df9834f54395e4c7250f8a46fe4d4be
parent979ca66cedf10d685c7486da1929297c3693fe43
signature Signed by PGP key B5690EEEBB952194

feat(clients): learn an environment's LAN and tailnet addresses (#15468)

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

22 files changed, 1278 insertions(+), 70 deletions(-)

apps/mobile/src/features/cloud/cloud-drafts.ts+2-4
......@@ -1,4 +1,4 @@
1import { EnvironmentRegistry, connectionRoutes } from "@t3tools/client-runtime/connection";
1import { EnvironmentRegistry, removedWithRelay } from "@t3tools/client-runtime/connection";
22import { createRuntimeCommand } from "@t3tools/client-runtime/state/runtime";
33import * as Effect from "effect/Effect";
44import * as Schema from "effect/Schema";
......@@ -28,9 +28,7 @@ export const removeCloudEnvironments = createRuntimeCommand(connectionAtomRuntim
2828 const environmentIds = new Set(
2929 [...entries.values()]
3030 // Only environments that sign-out removes entirely lose their drafts.
31 .filter((entry) =>
32 connectionRoutes(entry).every((route) => route.target._tag === "RelayConnectionTarget"),
33 )
31 .filter(removedWithRelay)
3432 .map((entry) => entry.target.environmentId),
3533 );
3634 // Credentials are already revoked. A failed backup must leave the local
apps/mobile/src/features/settings/EnvironmentRoutesSection.tsx+7-2
......@@ -5,6 +5,7 @@ import {
55 connectionRouteId,
66 connectionRouteLabel,
77 connectionRoutes,
8 isLearned,
89} from "@t3tools/client-runtime/connection";
910import type { EnvironmentId } from "@t3tools/contracts";
1011import * as Option from "effect/Option";
......@@ -141,7 +142,10 @@ export function EnvironmentRoutesSection({
141142 }}
142143 onStep={(direction) => move(index, direction === "up" ? index - 1 : index + 1)}
143144 // The last route goes with the machine, which is "Remove" on the row.
144 onRemove={routes.length > 1 ? () => confirmRemove(route) : undefined}
145 // A learned route would be learned again, so it is only reordered.
146 onRemove={
147 routes.length > 1 && !isLearned(route) ? () => confirmRemove(route) : undefined
148 }
145149 />
146150 );
147151 })}
......@@ -211,6 +215,7 @@ function RouteRow(props: {
211215 accessibilityLabel={[
212216 label,
213217 address,
218 isLearned(route) ? "Found automatically" : null,
214219 props.inUse ? "In use" : null,
215220 `Route ${props.position} of ${props.count}`,
216221 ]
......@@ -226,7 +231,7 @@ function RouteRow(props: {
226231 </View>
227232 {address !== null ? (
228233 <Text numberOfLines={1} className="text-sm text-foreground-muted">
229 {address}
234 {isLearned(route) ? `${address} · found automatically` : address}
230235 </Text>
231236 ) : null}
232237 </View>
apps/server/src/environment/DirectEndpoints.test.ts created+207
......@@ -0,0 +1,207 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { it as effectIt } from "@effect/vitest";
3import type * as NodeOS from "node:os";
4import * as Effect from "effect/Effect";
5import * as Layer from "effect/Layer";
6import * as Sink from "effect/Sink";
7import * as Stream from "effect/Stream";
8import * as HttpClient from "effect/unstable/http/HttpClient";
9import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse";
10import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner";
11import { describe, expect, it } from "vite-plus/test";
12
13import * as ServerConfig from "../config.ts";
14import * as DirectEndpoints from "./DirectEndpoints.ts";
15import { resolveBoundEndpoints } from "./DirectEndpoints.ts";
16
17const INTERFACES: ReturnType<typeof NodeOS.networkInterfaces> = {
18 lo0: [
19 {
20 address: "127.0.0.1",
21 netmask: "255.0.0.0",
22 family: "IPv4",
23 mac: "00:00:00:00:00:00",
24 internal: true,
25 cidr: "127.0.0.1/8",
26 },
27 ],
28 en0: [
29 {
30 address: "192.168.1.10",
31 netmask: "255.255.255.0",
32 family: "IPv4",
33 mac: "aa:bb:cc:dd:ee:ff",
34 internal: false,
35 cidr: "192.168.1.10/24",
36 },
37 {
38 address: "fe80::1",
39 netmask: "ffff:ffff:ffff:ffff::",
40 family: "IPv6",
41 mac: "aa:bb:cc:dd:ee:ff",
42 internal: false,
43 cidr: "fe80::1/64",
44 scopeid: 4,
45 },
46 ],
47 en1: [
48 {
49 address: "203.0.113.20",
50 netmask: "255.255.255.0",
51 family: "IPv4",
52 mac: "aa:bb:cc:dd:ee:00",
53 internal: false,
54 cidr: "203.0.113.20/24",
55 },
56 ],
57 utun4: [
58 {
59 address: "100.101.102.103",
60 netmask: "255.255.255.255",
61 family: "IPv4",
62 mac: "00:00:00:00:00:00",
63 internal: false,
64 cidr: "100.101.102.103/32",
65 },
66 ],
67};
68
69const virtualInterface = (address: string) => [
70 {
71 address,
72 netmask: "255.255.0.0",
73 family: "IPv4" as const,
74 mac: "02:42:ac:11:00:01",
75 internal: false,
76 cidr: `${address}/16`,
77 },
78];
79
80describe("resolveBoundEndpoints", () => {
81 it("lists nothing for a loopback-only server", () => {
82 expect(resolveBoundEndpoints({ host: undefined, port: 3773, interfaces: INTERFACES })).toEqual(
83 [],
84 );
85 expect(
86 resolveBoundEndpoints({ host: "127.0.0.1", port: 3773, interfaces: INTERFACES }),
87 ).toEqual([]);
88 });
89
90 it("lists every external IPv4 address for a wildcard bind, tagging the tailnet one", () => {
91 expect(resolveBoundEndpoints({ host: "0.0.0.0", port: 3773, interfaces: INTERFACES })).toEqual([
92 { kind: "lan", httpBaseUrl: "http://192.168.1.10:3773/" },
93 { kind: "tailnet", httpBaseUrl: "http://100.101.102.103:3773/" },
94 ]);
95 });
96
97 it("skips container and VM networks, which only this machine reaches", () => {
98 const interfaces = {
99 ...INTERFACES,
100 docker0: virtualInterface("172.17.0.1"),
101 "br-3f2a1b": virtualInterface("172.18.0.1"),
102 virbr0: virtualInterface("192.168.122.1"),
103 "vEthernet (WSL)": virtualInterface("172.24.0.1"),
104 bridge100: virtualInterface("192.168.64.1"),
105 vmbr0: virtualInterface("192.168.1.20"),
106 };
107 expect(resolveBoundEndpoints({ host: "0.0.0.0", port: 3773, interfaces })).toEqual([
108 { kind: "lan", httpBaseUrl: "http://192.168.1.10:3773/" },
109 { kind: "tailnet", httpBaseUrl: "http://100.101.102.103:3773/" },
110 { kind: "lan", httpBaseUrl: "http://192.168.1.20:3773/" },
111 ]);
112 });
113
114 it("lists only the bound address for a specific bind", () => {
115 expect(
116 resolveBoundEndpoints({ host: "100.101.102.103", port: 3773, interfaces: INTERFACES }),
117 ).toEqual([{ kind: "tailnet", httpBaseUrl: "http://100.101.102.103:3773/" }]);
118 });
119
120 it("never reports a host name, which can resolve to another machine per client", () => {
121 for (const host of ["server.local", "devbox", "devbox.home.arpa"]) {
122 expect(resolveBoundEndpoints({ host, port: 3773, interfaces: INTERFACES })).toEqual([]);
123 }
124 });
125
126 it("never reports a public address, which would carry the credential over plain HTTP", () => {
127 expect(
128 resolveBoundEndpoints({ host: "203.0.113.20", port: 3773, interfaces: INTERFACES }),
129 ).toEqual([]);
130 });
131});
132
133const TAILSCALE_STATUS_JSON = JSON.stringify({
134 Self: { DNSName: "bb-1.tail1234.ts.net.", TailscaleIPs: ["100.64.1.2"] },
135});
136
137/** `tailscale status --json` reporting a MagicDNS name. */
138const tailscaleUpLayer = Layer.succeed(
139 ChildProcessSpawner.ChildProcessSpawner,
140 ChildProcessSpawner.make(() =>
141 Effect.succeed(
142 ChildProcessSpawner.makeHandle({
143 pid: ChildProcessSpawner.ProcessId(1),
144 exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(0)),
145 isRunning: Effect.succeed(false),
146 kill: () => Effect.void,
147 unref: Effect.succeed(Effect.void),
148 stdin: Sink.drain,
149 stdout: Stream.make(new TextEncoder().encode(TAILSCALE_STATUS_JSON)),
150 stderr: Stream.empty,
151 all: Stream.empty,
152 getInputFd: () => Sink.drain,
153 getOutputFd: () => Stream.empty,
154 }),
155 ),
156 ),
157);
158
159/** Answers the Serve probe with `status`. */
160const serveProbeLayer = (status: number) =>
161 Layer.succeed(
162 HttpClient.HttpClient,
163 HttpClient.make((request) =>
164 Effect.succeed(HttpClientResponse.fromWeb(request, new Response(null, { status }))),
165 ),
166 );
167
168/** A loopback-only server with Tailscale Serve on, so only the Serve name can be listed. */
169const serveConfigLayer = Layer.effect(
170 ServerConfig.ServerConfig,
171 Effect.map(ServerConfig.ServerConfig, (config) => ({
172 ...config,
173 host: "127.0.0.1",
174 tailscaleServeEnabled: true,
175 tailscaleServePort: 443,
176 })),
177).pipe(
178 Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-direct-" })),
179 Layer.provide(NodeServices.layer),
180);
181
182const resolveWithServe = (probeStatus: number) =>
183 Effect.flatMap(DirectEndpoints.DirectEndpoints, (service) => service.resolve()).pipe(
184 Effect.provide(
185 DirectEndpoints.layer.pipe(
186 Layer.provide(
187 Layer.mergeAll(serveConfigLayer, tailscaleUpLayer, serveProbeLayer(probeStatus)),
188 ),
189 ),
190 ),
191 );
192
193describe("DirectEndpoints Tailscale Serve", () => {
194 effectIt.effect("lists the tailnet name once Serve answers for this server", () =>
195 Effect.gen(function* () {
196 expect(yield* resolveWithServe(200)).toEqual([
197 { kind: "tailnet", httpBaseUrl: "https://bb-1.tail1234.ts.net/" },
198 ]);
199 }),
200 );
201
202 effectIt.effect("leaves the tailnet name out when Serve is not forwarding", () =>
203 Effect.gen(function* () {
204 expect(yield* resolveWithServe(502)).toEqual([]);
205 }),
206 );
207});
apps/server/src/environment/DirectEndpoints.ts created+143
......@@ -0,0 +1,143 @@
1/**
2 * DirectEndpoints - the LAN and tailnet addresses this server listens on now.
3 *
4 * Clients connected one way (often T3 Connect) save these as extra routes so
5 * they can move to a faster path when one is reachable, and replace a saved
6 * LAN address when DHCP or a new Wi-Fi network changes it. Only addresses the
7 * server is actually bound to are listed: a loopback-only server lists none,
8 * because its loopback address means a different machine to every client.
9 */
10import type { ServerDirectEndpoint } from "@t3tools/contracts";
11import {
12 buildTailscaleHttpsBaseUrl,
13 isTailscaleIpv4Address,
14 probeTailscaleHttpsEndpoint,
15 readTailscaleStatus,
16} from "@t3tools/tailscale";
17import * as Context from "effect/Context";
18import * as Duration from "effect/Duration";
19import * as Effect from "effect/Effect";
20import * as Layer from "effect/Layer";
21import * as Option from "effect/Option";
22import { HttpClient } from "effect/unstable/http";
23import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner";
24import * as NodeNet from "node:net";
25import * as NodeOS from "node:os";
26
27import { isPrivateNetworkHost } from "@t3tools/shared/hostClassification";
28
29import * as ServerConfig from "../config.ts";
30import { formatHostForUrl, isLoopbackHost, isWildcardHost } from "../startupAccess.ts";
31
32type NetworkInterfacesMap = ReturnType<typeof NodeOS.networkInterfaces>;
33
34/**
35 * Stays under the config read's own discovery timeout, so a slow `tailscale`
36 * CLI drops only the tailnet name and never the LAN addresses. An empty list
37 * would make clients forget the routes they learned.
38 */
39const TAILSCALE_ENDPOINT_TIMEOUT = Duration.seconds(3);
40
41export class DirectEndpoints extends Context.Service<
42 DirectEndpoints,
43 {
44 readonly resolve: () => Effect.Effect<ReadonlyArray<ServerDirectEndpoint>>;
45 }
46>()("t3/environment/DirectEndpoints") {}
47
48/**
49 * Only numeric private-network and tailnet IPv4 addresses are reported. These
50 * routes are plain HTTP and carry the client's credential, so a public address
51 * would send it across the internet unencrypted, and a name (`server.local`)
52 * can resolve to a different machine on each client's network.
53 */
54const isAdvertisableAddress = (address: string): boolean =>
55 NodeNet.isIPv4(address) &&
56 !address.startsWith("127.") &&
57 !address.startsWith("169.254.") &&
58 (isTailscaleIpv4Address(address) || isPrivateNetworkHost(address));
59
60/**
61 * Container and VM networks (Docker, libvirt, VMware, VirtualBox, Hyper-V, and
62 * macOS's `bridge100`+) have private addresses only this machine reaches. A
63 * host bridge such as `br0` or Proxmox's `vmbr0` carries the LAN address, so
64 * it stays listed.
65 */
66const VIRTUAL_INTERFACE =
67 /^(docker|br-|veth|virbr|vmnet|vboxnet|vEthernet|podman|cni|flannel|cali|lxcbr|lxdbr|bridge1\d\d)/;
68
69/**
70 * Plain HTTP endpoints for the private addresses a server bound to `host`
71 * accepts. IPv4 only: link-local and temporary IPv6 addresses change too
72 * often to be worth saving.
73 */
74export function resolveBoundEndpoints(input: {
75 readonly host: string | undefined;
76 readonly port: number;
77 readonly interfaces: NetworkInterfacesMap;
78}): ReadonlyArray<ServerDirectEndpoint> {
79 if (isLoopbackHost(input.host)) return [];
80 const addresses = isWildcardHost(input.host)
81 ? Object.entries(input.interfaces)
82 .flatMap(([name, entries]) => (VIRTUAL_INTERFACE.test(name) ? [] : (entries ?? [])))
83 .filter(
84 (entry) =>
85 !entry.internal && entry.family === "IPv4" && isAdvertisableAddress(entry.address),
86 )
87 .map((entry) => entry.address)
88 : [input.host!].filter(isAdvertisableAddress);
89 return [...new Set(addresses)].map((address) => ({
90 kind: isTailscaleIpv4Address(address) ? "tailnet" : "lan",
91 httpBaseUrl: `http://${formatHostForUrl(address)}:${input.port}/`,
92 }));
93}
94
95/** @public Service construction is part of the canonical Effect module API. */
96export const make = Effect.gen(function* () {
97 const config = yield* ServerConfig.ServerConfig;
98 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
99 const httpClient = yield* HttpClient.HttpClient;
100
101 const resolve = Effect.gen(function* () {
102 const endpoints = [
103 ...resolveBoundEndpoints({
104 host: config.host,
105 port: config.port,
106 interfaces: NodeOS.networkInterfaces(),
107 }),
108 ];
109 // Tailscale Serve terminates HTTPS on the tailnet name and forwards to
110 // loopback, so it works even for a loopback-only server. The name is only
111 // listed once it answers as this server: Serve setup can fail without
112 // stopping startup. Anything slower than the probe leaves it out rather
113 // than holding back the addresses already found.
114 if (config.tailscaleServeEnabled) {
115 const servedUrl = yield* readTailscaleStatus.pipe(
116 Effect.map((status) => status.magicDnsName),
117 Effect.flatMap((magicDnsName) => {
118 if (magicDnsName === null) return Effect.succeed(null);
119 const httpBaseUrl = buildTailscaleHttpsBaseUrl({
120 magicDnsName,
121 servePort: config.tailscaleServePort,
122 });
123 return probeTailscaleHttpsEndpoint({ baseUrl: httpBaseUrl }).pipe(
124 Effect.map((reachable) => (reachable ? httpBaseUrl : null)),
125 );
126 }),
127 Effect.timeoutOption(TAILSCALE_ENDPOINT_TIMEOUT),
128 Effect.map(Option.flatMap(Option.fromNullishOr)),
129 Effect.orElseSucceed(() => Option.none<string>()),
130 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
131 Effect.provideService(HttpClient.HttpClient, httpClient),
132 );
133 if (Option.isSome(servedUrl)) {
134 endpoints.push({ kind: "tailnet", httpBaseUrl: servedUrl.value });
135 }
136 }
137 return endpoints;
138 });
139
140 return DirectEndpoints.of({ resolve: () => resolve });
141});
142
143export const layer = Layer.effect(DirectEndpoints, make);
apps/server/src/server.ts+2
......@@ -112,6 +112,7 @@ import { ObservabilityLive } from "./observability/Layers/Observability.ts";
112112import * as HeapSnapshot from "./observability/HeapSnapshot.ts";
113113import * as EventLoopMonitor from "./observability/EventLoopMonitor.ts";
114114import * as ServerEnvironment from "./environment/ServerEnvironment.ts";
115import * as DirectEndpoints from "./environment/DirectEndpoints.ts";
115116import * as RemoteOpenTargets from "./environment/RemoteOpenTargets.ts";
116117import { authHttpApiLayer, environmentAuthenticatedAuthLayer } from "./auth/http.ts";
117118import * as ReplayMarkers from "./auth/replayMarkers.ts";
......@@ -620,6 +621,7 @@ const RuntimeDependenciesLive = RuntimeCoreDependenciesLive.pipe(
620621 Layer.provideMerge(AnalyticsService.layer),
621622 Layer.provideMerge(ExternalLauncher.layer),
622623 Layer.provideMerge(RemoteOpenTargets.layer),
624 Layer.provideMerge(DirectEndpoints.layer),
623625 Layer.provideMerge(ServerLifecycleEvents.layer),
624626 Layer.provide(NetService.layer),
625627);
apps/server/src/ws.ts+3
......@@ -204,6 +204,7 @@ import * as ProjectCloneTracker from "./project/ProjectCloneTracker.ts";
204204import * as RepositoryIdentityResolver from "./project/RepositoryIdentityResolver.ts";
205205import * as WorktreeSetupTracker from "./project/WorktreeSetupTracker.ts";
206206import * as ServerEnvironment from "./environment/ServerEnvironment.ts";
207import * as DirectEndpoints from "./environment/DirectEndpoints.ts";
207208import * as RemoteOpenTargets from "./environment/RemoteOpenTargets.ts";
208209import * as BackgroundPolicy from "./background/BackgroundPolicy.ts";
209210import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts";
......@@ -1250,6 +1251,7 @@ const makeWsRpcLayer = (
12501251 const environmentTheme = yield* EnvironmentTheme.EnvironmentThemeService;
12511252 const externalLauncher = yield* ExternalLauncher.ExternalLauncher;
12521253 const remoteOpenTargets = yield* RemoteOpenTargets.RemoteOpenTargets;
1254 const directEndpoints = yield* DirectEndpoints.DirectEndpoints;
12531255 const gitWorkflow = yield* GitWorkflowService.GitWorkflowService;
12541256 const review = yield* ReviewService.ReviewService;
12551257 const vcsProvisioning = yield* VcsProvisioningService.VcsProvisioningService;
......@@ -1689,6 +1691,7 @@ const makeWsRpcLayer = (
16891691 remoteOpenTargets: yield* resolveAvailableEditorsForConfig(
16901692 remoteOpenTargets.resolveTargets(),
16911693 ),
1694 directEndpoints: yield* resolveAvailableEditorsForConfig(directEndpoints.resolve()),
16921695 observability: {
16931696 logsDirectoryPath: config.logsDir,
16941697 localTracingEnabled: true,
apps/web/src/components/settings/EnvironmentRoutesList.tsx+5-12
......@@ -22,6 +22,7 @@ import {
2222 connectionRouteId,
2323 connectionRouteLabel,
2424 connectionRoutes,
25 isLearned,
2526} from "@t3tools/client-runtime/connection";
2627import { GripVerticalIcon, PlusIcon, XIcon } from "lucide-react";
2728import { useState } from "react";
......@@ -118,8 +119,9 @@ export function EnvironmentRoutesList({
118119 position={index + 1}
119120 inUse={connectionRouteId(route.target) === activeRouteId}
120121 // The last route goes with the machine; that is "Remove from
121 // this device", not a route action.
122 removable={routes.length > 1}
122 // this device", not a route action. A learned route would be
123 // learned again, so it is only reordered.
124 removable={routes.length > 1 && !isLearned(route)}
123125 onRemove={() => void confirmRemove(route)}
124126 />
125127 ))}
......@@ -193,7 +195,7 @@ function SortableRouteRow({
193195 {address !== null ? (
194196 <p className="truncate text-2xs text-muted-foreground">
195197 {address}
196 {isLearnedRoute(route) ? " · found automatically" : ""}
198 {isLearned(route) ? " · found automatically" : ""}
197199 </p>
198200 ) : null}
199201 </div>
......@@ -218,12 +220,3 @@ function SortableRouteRow({
218220 </li>
219221 );
220222}
221
222function isLearnedRoute(route: ConnectionRoute): boolean {
223 return (
224 route.profile._tag === "Some" &&
225 route.profile.value._tag === "BearerConnectionProfile" &&
226 "learned" in route.profile.value &&
227 route.profile.value.learned === true
228 );
229}
docs/internals/remote.md+9
......@@ -39,6 +39,15 @@ Preflight includes authorization so a route that answers but rejects this
3939client never costs a working session; a route that still fails afterwards is
4040held back for a cooldown so a flaky network cannot bounce the connection.
4141
42A connected server reports the LAN and tailnet addresses it is bound to, and the
43client saves them as learned routes. A learned route reuses the credential of
44the route it was learned over: the T3 Connect access token, which is not bound
45to an origin because each DPoP proof names the URL it signs, or the paired
46bearer token. Learned routes the server stops reporting are dropped, which is
47how a changed LAN address replaces the old one; routes the user saved are never
48touched. The reported addresses are hints like any advertised endpoint, so a
49learned route still has to answer as this environment before it is used.
50
4251GitHub routing trust covers the whole route list. Adding or changing a route
4352revokes it; reordering does not, because the same addresses remain trusted.
4453
docs/user/remote-access.md+9
......@@ -70,6 +70,15 @@ next to it in the T3 Connect list. Pairing the same machine again over another
7070address also adds a route instead of a second machine. A new route is placed by
7171speed, in that order, and you can reorder routes at any time.
7272
73While connected through T3 Connect or a paired address, T3 Code also learns the
74machine's current LAN and Tailscale addresses and adds them as routes, so
75pairing once through T3 Connect is enough to use the LAN at home. When the
76machine's LAN address changes, for example after it joins another Wi-Fi network,
77the learned route follows it. The machine must allow network access for its LAN
78address to be learned. You can reorder a learned route, but not remove it; it
79goes away with the route it was learned through, or when the machine stops
80reporting that address.
81
7382T3 Code connects over the first route that answers. Away from home, a LAN
7483address that does not answer is checked briefly and skipped. It is only tried
7584again, after the other routes, if none of them connect. While connected over a
packages/client-runtime/src/authorization/layer.test.ts+111
......@@ -340,6 +340,117 @@ describe("RemoteEnvironmentAuthorization", () => {
340340 }),
341341 );
342342
343 it.effect("uses the T3 Connect token on a learned direct address without the relay", () =>
344 Effect.gen(function* () {
345 const cached = new TokenStore.RemoteDpopAccessToken({
346 environmentId: ENVIRONMENT_ID,
347 accountId: "account-1",
348 label: DESCRIPTOR.label,
349 endpoint: ENDPOINT,
350 accessToken: "cached-access-token",
351 expiresAtEpochMs: Number.MAX_SAFE_INTEGER,
352 dpopThumbprint: "thumbprint-1",
353 });
354 const harness = yield* makeHarness({
355 initialToken: cached,
356 responses: [Response.json(DESCRIPTOR), websocketTicket("lan-ticket")],
357 });
358
359 const authorized = yield* Effect.gen(function* () {
360 const remote = yield* RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization;
361 return yield* remote.authorizeDpop({
362 expectedEnvironmentId: ENVIRONMENT_ID,
363 directEndpoint: {
364 httpBaseUrl: "http://192.168.1.10:3773/",
365 wsBaseUrl: "ws://192.168.1.10:3773/",
366 },
367 });
368 }).pipe(Effect.provide(harness.layer));
369
370 expect(authorized.httpBaseUrl).toBe("http://192.168.1.10:3773/");
371 expect(authorized.socketUrl).toMatch(/^ws:\/\/192\.168\.1\.10:3773\/ws\?/);
372 expect(yield* Ref.get(harness.bootstrapCalls)).toBe(0);
373 expect(harness.fetch.calls.map(([url]) => String(url))).toEqual([
374 "http://192.168.1.10:3773/.well-known/t3/environment",
375 "http://192.168.1.10:3773/api/auth/websocket-ticket",
376 ]);
377 }),
378 );
379
380 it.effect("sends no token to a direct address that answers as another machine", () =>
381 Effect.gen(function* () {
382 const harness = yield* makeHarness({
383 initialToken: persistedToken({ accessToken: "cached-access-token" }),
384 responses: [
385 Response.json({ ...DESCRIPTOR, environmentId: EnvironmentId.make("someone-else") }),
386 ],
387 });
388
389 const error = yield* Effect.gen(function* () {
390 const remote = yield* RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization;
391 return yield* remote
392 .authorizeDpop({
393 expectedEnvironmentId: ENVIRONMENT_ID,
394 directEndpoint: {
395 httpBaseUrl: "http://192.168.1.10:3773/",
396 wsBaseUrl: "ws://192.168.1.10:3773/",
397 },
398 })
399 .pipe(Effect.flip);
400 }).pipe(Effect.provide(harness.layer));
401
402 expect(error).toMatchObject({ _tag: "ConnectionBlockedError", reason: "configuration" });
403 expect(harness.fetch.calls.map(([url]) => String(url))).toEqual([
404 "http://192.168.1.10:3773/.well-known/t3/environment",
405 ]);
406 // The token stays saved for the T3 Connect route.
407 expect((yield* Ref.get(harness.tokens)).get(ENVIRONMENT_ID)?.accessToken).toBe(
408 "cached-access-token",
409 );
410 }),
411 );
412
413 it.effect("replaces a token a learned direct address rejects", () =>
414 Effect.gen(function* () {
415 const cached = new TokenStore.RemoteDpopAccessToken({
416 environmentId: ENVIRONMENT_ID,
417 accountId: "account-1",
418 label: DESCRIPTOR.label,
419 endpoint: ENDPOINT,
420 accessToken: "revoked-access-token",
421 expiresAtEpochMs: Number.MAX_SAFE_INTEGER,
422 dpopThumbprint: "thumbprint-1",
423 });
424 const harness = yield* makeHarness({
425 initialToken: cached,
426 responses: [
427 Response.json(DESCRIPTOR),
428 authInvalid(),
429 Response.json(DESCRIPTOR),
430 accessToken("replacement-access-token"),
431 websocketTicket("lan-ticket"),
432 ],
433 });
434
435 const authorized = yield* Effect.gen(function* () {
436 const remote = yield* RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization;
437 return yield* remote.authorizeDpop({
438 expectedEnvironmentId: ENVIRONMENT_ID,
439 directEndpoint: {
440 httpBaseUrl: "http://192.168.1.10:3773/",
441 wsBaseUrl: "ws://192.168.1.10:3773/",
442 },
443 });
444 }).pipe(Effect.provide(harness.layer));
445
446 expect(authorized.socketUrl).toContain("wsTicket=lan-ticket");
447 expect(yield* Ref.get(harness.bootstrapCalls)).toBe(1);
448 expect((yield* Ref.get(harness.tokens)).get(ENVIRONMENT_ID)?.accessToken).toBe(
449 "replacement-access-token",
450 );
451 }),
452 );
453
343454 it.effect("refreshes and persists an expired environment token", () =>
344455 Effect.gen(function* () {
345456 const expired = new TokenStore.RemoteDpopAccessToken({
packages/client-runtime/src/authorization/service.ts+69-17
......@@ -71,6 +71,13 @@ export class RemoteEnvironmentAuthorization extends Context.Service<
7171 }) => Effect.Effect<AuthorizedRemoteEnvironment, ConnectionAttemptError>;
7272 readonly authorizeDpop: (input: {
7373 readonly expectedEnvironmentId: EnvironmentId;
74 /**
75 * Connect to this origin instead of the T3 Connect tunnel. The access
76 * token is not bound to an origin, and each proof is minted for the URL
77 * it signs, so a token from the relay also works on the environment's
78 * LAN or tailnet address.
79 */
80 readonly directEndpoint?: { readonly httpBaseUrl: string; readonly wsBaseUrl: string };
7481 }) => Effect.Effect<AuthorizedRemoteEnvironment, ConnectionAttemptError>;
7582 readonly authorizeDpopHttp: (input: {
7683 readonly expectedEnvironmentId: EnvironmentId;
......@@ -132,14 +139,15 @@ export const make = Effect.gen(function* () {
132139 >
133140 >(new Map());
134141
135 const authorizeBearer = Effect.fn("clientRuntime.connection.remote.authorizeBearer")(
142 /**
143 * Confirms a direct address still serves the expected environment before a
144 * credential is sent there. A saved LAN address can belong to another
145 * machine on a different network.
146 */
147 const verifyDirectEndpoint = Effect.fn("clientRuntime.connection.remote.verifyDirectEndpoint")(
136148 function* (input: {
137 readonly expectedEnvironmentId: Parameters<
138 RemoteEnvironmentAuthorization["Service"]["authorizeBearer"]
139 >[0]["expectedEnvironmentId"];
149 readonly expectedEnvironmentId: EnvironmentId;
140150 readonly httpBaseUrl: string;
141 readonly wsBaseUrl: string;
142 readonly bearerToken: string;
143151 readonly connectionMethod: ClientConnectionMethod;
144152 }) {
145153 const now = yield* Clock.currentTimeMillis;
......@@ -169,6 +177,21 @@ export const make = Effect.gen(function* () {
169177 return next;
170178 });
171179 }
180 return descriptor;
181 },
182 );
183
184 const authorizeBearer = Effect.fn("clientRuntime.connection.remote.authorizeBearer")(
185 function* (input: {
186 readonly expectedEnvironmentId: Parameters<
187 RemoteEnvironmentAuthorization["Service"]["authorizeBearer"]
188 >[0]["expectedEnvironmentId"];
189 readonly httpBaseUrl: string;
190 readonly wsBaseUrl: string;
191 readonly bearerToken: string;
192 readonly connectionMethod: ClientConnectionMethod;
193 }) {
194 const descriptor = yield* verifyDirectEndpoint(input);
172195 const socketUrl = yield* resolveRemoteWebSocketConnectionUrl({
173196 wsBaseUrl: input.wsBaseUrl,
174197 httpBaseUrl: input.httpBaseUrl,
......@@ -193,11 +216,15 @@ export const make = Effect.gen(function* () {
193216 );
194217
195218 const createDpopSocketUrl = Effect.fn("clientRuntime.connection.remote.createDpopSocketUrl")(
196 function* (token: TokenStore.RemoteDpopAccessToken, timeoutMs?: number) {
219 function* (
220 token: TokenStore.RemoteDpopAccessToken,
221 timeoutMs?: number,
222 endpoint: { readonly httpBaseUrl: string; readonly wsBaseUrl: string } = token.endpoint,
223 ) {
197224 const ticketProof = yield* signer
198225 .createProof({
199226 method: "POST",
200 url: environmentEndpointUrl(token.endpoint.httpBaseUrl, "/api/auth/websocket-ticket"),
227 url: environmentEndpointUrl(endpoint.httpBaseUrl, "/api/auth/websocket-ticket"),
201228 accessToken: token.accessToken,
202229 })
203230 .pipe(
......@@ -210,8 +237,8 @@ export const make = Effect.gen(function* () {
210237 ),
211238 );
212239 return yield* resolveRemoteDpopWebSocketConnectionUrl({
213 wsBaseUrl: token.endpoint.wsBaseUrl,
214 httpBaseUrl: token.endpoint.httpBaseUrl,
240 wsBaseUrl: endpoint.wsBaseUrl,
241 httpBaseUrl: endpoint.httpBaseUrl,
215242 accessToken: token.accessToken,
216243 dpopProof: ticketProof,
217244 clientMetadata: presentation.metadata,
......@@ -449,10 +476,11 @@ export const make = Effect.gen(function* () {
449476
450477 const httpAuthorization = (
451478 token: TokenStore.RemoteDpopAccessToken,
479 httpBaseUrl = token.endpoint.httpBaseUrl,
452480 ): AuthorizedRemoteHttpEnvironment => ({
453481 environmentId: token.environmentId,
454482 label: token.label,
455 httpBaseUrl: token.endpoint.httpBaseUrl,
483 httpBaseUrl,
456484 httpAuthorization: {
457485 _tag: "Dpop",
458486 accessToken: token.accessToken,
......@@ -463,33 +491,57 @@ export const make = Effect.gen(function* () {
463491 const authorizeDpop = Effect.fn("clientRuntime.connection.remote.authorizeDpop")(function* (
464492 input: Parameters<RemoteEnvironmentAuthorization["Service"]["authorizeDpop"]>[0],
465493 ) {
494 const endpoint = input.directEndpoint;
495 if (endpoint !== undefined) {
496 yield* verifyDirectEndpoint({
497 expectedEnvironmentId: input.expectedEnvironmentId,
498 httpBaseUrl: endpoint.httpBaseUrl,
499 connectionMethod: "direct",
500 });
501 }
502 const authorized = (token: TokenStore.RemoteDpopAccessToken, socketUrl: string) => ({
503 ...httpAuthorization(token, endpoint?.httpBaseUrl),
504 socketUrl,
505 });
466506 let selected = yield* getDpopToken(input);
467507 if (selected.fromCache) {
468508 const cachedSocket = yield* createDpopSocketUrl(
469509 selected.token,
470510 CACHED_ENDPOINT_SOCKET_TIMEOUT_MS,
511 endpoint,
471512 ).pipe(Effect.result);
472513 if (Result.isSuccess(cachedSocket)) {
473514 yield* assertSession(selected.identity);
474 return { ...httpAuthorization(selected.token), socketUrl: cachedSocket.success };
515 return authorized(selected.token, cachedSocket.success);
475516 }
476517 if (cachedSocket.failure._tag === "ConnectionBlockedError") {
477518 return yield* mapDpopSocketError(cachedSocket.failure);
478519 }
520 // An unreachable direct address says nothing about the token, so keep
521 // it for the T3 Connect route rather than forcing a relay round trip.
522 // A rejected token falls through and is replaced like on the tunnel.
523 if (endpoint !== undefined && cachedSocket.failure._tag !== "EnvironmentAuthInvalidError") {
524 return yield* mapDpopSocketError(cachedSocket.failure);
525 }
479526 selected = yield* getDpopToken({
480527 ...input,
481528 rejectedAccessToken: selected.token.accessToken,
482529 });
483530 }
484 const socket = yield* createDpopSocketUrl(selected.token).pipe(Effect.result);
531 const socket = yield* createDpopSocketUrl(selected.token, undefined, endpoint).pipe(
532 Effect.result,
533 );
485534 if (Result.isFailure(socket)) {
486 yield* tokenLock.withPermits(1)(
487 removeRejectedToken(input.expectedEnvironmentId, selected.token.accessToken),
488 );
535 // A direct address that refuses the socket says nothing about the token.
536 if (endpoint === undefined || socket.failure._tag === "ConnectionBlockedError") {
537 yield* tokenLock.withPermits(1)(
538 removeRejectedToken(input.expectedEnvironmentId, selected.token.accessToken),
539 );
540 }
489541 return yield* mapDpopSocketError(socket.failure);
490542 }
491543 yield* assertSession(selected.identity);
492 return { ...httpAuthorization(selected.token), socketUrl: socket.success };
544 return authorized(selected.token, socket.success);
493545 });
494546
495547 return RemoteEnvironmentAuthorization.of({
packages/client-runtime/src/connection/catalog.ts+11
......@@ -22,6 +22,17 @@ export class BearerConnectionProfile extends Schema.TaggedClass<BearerConnection
2222 ...ConnectionProfileBase,
2323 httpBaseUrl: Schema.String,
2424 wsBaseUrl: Schema.String,
25 /**
26 * Set on a route the server reported while this client was connected,
27 * rather than one the user paired. Learned routes are replaced when the
28 * server reports a different address, for example after a DHCP change.
29 */
30 learned: Schema.optionalKey(Schema.Literal(true)),
31 /**
32 * "t3-connect" when the route authenticates with the environment's T3
33 * Connect credential instead of a stored bearer token.
34 */
35 authorization: Schema.optionalKey(Schema.Literal("t3-connect")),
2536 },
2637) {}
2738
packages/client-runtime/src/connection/githubRoutingPermissions.ts+11-5
......@@ -25,11 +25,17 @@ export type StoredGitHubRoutingPermission = typeof StoredGitHubRoutingPermission
2525 * reordering keeps trust but adding or changing an address revokes it.
2626 */
2727export function gitHubRoutingConnectionKey(entry: ConnectionCatalogEntry): string | null {
28 const alternates = entry.alternateRoutes ?? [];
29 if (alternates.length === 0) return routeConnectionKey(entry.target, entry.profile);
30 const keys = [{ target: entry.target, profile: entry.profile }, ...alternates].map((route) =>
31 routeConnectionKey(route.target, route.profile),
32 );
28 // Learned routes come and go with the server's addresses and reuse a saved
29 // route's credential, so they leave trust where the user put it.
30 const routes = [
31 { target: entry.target, profile: entry.profile },
32 ...(entry.alternateRoutes ?? []),
33 ].filter((route) => {
34 const profile = Option.getOrNull(route.profile);
35 return !(profile?._tag === "BearerConnectionProfile" && profile.learned === true);
36 });
37 if (routes.length === 1) return routeConnectionKey(routes[0]!.target, routes[0]!.profile);
38 const keys = routes.map((route) => routeConnectionKey(route.target, route.profile));
3339 return keys.every((key) => key !== null) ? JSON.stringify([...keys].sort()) : null;
3440}
3541
packages/client-runtime/src/connection/registry.test.ts+19
......@@ -1874,4 +1874,23 @@ describe("EnvironmentRegistry routes", () => {
18741874 }).pipe(Effect.provide(harness.layer), Effect.scoped);
18751875 }),
18761876 );
1877
1878 it.effect("drops a learned route whose profile was not saved instead of duplicating it", () =>
1879 Effect.gen(function* () {
1880 const learnedId = `learned:${LAN_TARGET.environmentId}:100.64.0.9:3773@${LAN_TARGET.connectionId}`;
1881 const learned = new BearerConnectionTarget({ ...LAN_TARGET, connectionId: learnedId });
1882 // An earlier build saved the learned target twice and never its profile.
1883 const harness = yield* makeHarness(
1884 [LAN_TARGET, learned, learned],
1885 [LAN_PROFILE],
1886 [[LAN_TARGET.connectionId, BEARER_CREDENTIAL]],
1887 );
1888 yield* Effect.gen(function* () {
1889 const registry = yield* EnvironmentRegistry.EnvironmentRegistry;
1890 const entry = (yield* SubscriptionRef.get(registry.entries)).get(LAN_TARGET.environmentId);
1891 expect(entry?.target).toEqual(LAN_TARGET);
1892 expect(entry?.alternateRoutes ?? []).toEqual([]);
1893 }).pipe(Effect.provide(harness.layer), Effect.scoped);
1894 }),
1895 );
18771896});
packages/client-runtime/src/connection/registry.ts+82-2
......@@ -48,6 +48,9 @@ import {
4848 connectionRoutes,
4949 entryWithRoutes,
5050 findRouteToSameAddress,
51 isLearned,
52 mergeLearnedRoutes,
53 routesAfterRemoving,
5154 upsertRoute,
5255} from "./routes.ts";
5356
......@@ -234,7 +237,18 @@ export const make = Effect.gen(function* () {
234237 yield* Effect.forEach(
235238 persistedRoutesByEnvironment,
236239 Effect.fn("EnvironmentRegistry.loadCatalogEntry")(function* ([environmentId, targets]) {
237 const routes = yield* Effect.forEach(targets, loadRoute, { concurrency: "unbounded" });
240 const loaded = yield* Effect.forEach(targets, loadRoute, { concurrency: "unbounded" });
241 // A learned route without its profile has no address to reach; it is
242 // learned again on the next connection. A paired route keeps its slot
243 // so its missing profile still surfaces as a connection error.
244 const seen = new Set<string>();
245 const usable = loaded.filter((route) => {
246 const id = connectionRouteId(route.target);
247 if (seen.has(id)) return false;
248 seen.add(id);
249 return !(id.startsWith("learned:") && Option.isNone(route.profile));
250 });
251 const routes = usable.length > 0 ? usable : loaded.slice(0, 1);
238252 const first = routes[0]!;
239253 return [
240254 environmentId,
......@@ -350,6 +364,7 @@ export const make = Effect.gen(function* () {
350364 const scope = yield* Scope.fork(registryScope);
351365 const supervisor = yield* EnvironmentSupervisor.make(entry, {
352366 initiallyDesired: false,
367 learnRoutes: (input) => learnRoutes({ environmentId, ...input }),
353368 }).pipe(
354369 Effect.provideService(Connectivity.Connectivity, connectivity),
355370 Effect.provideService(ConnectionDriver.ConnectionDriver, driver),
......@@ -827,6 +842,71 @@ export const make = Effect.gen(function* () {
827842 Effect.ignore,
828843 );
829844
845 // Plain HTTP routes are unusable from an HTTPS page, which blocks mixed content.
846 const allowInsecureRoutes =
847 typeof globalThis.location === "undefined" || globalThis.location.protocol !== "https:";
848
849 /**
850 * Saves the direct addresses a connected server reports as learned routes,
851 * replacing learned routes it no longer reports. Routes the user saved are
852 * never changed. Learned routes reuse the active route's credential, so they
853 * do not revoke GitHub routing trust the way a newly paired address does.
854 */
855 const learnRoutes = Effect.fn("EnvironmentRegistry.learnRoutes")(function* (input: {
856 readonly environmentId: EnvironmentId;
857 readonly activeRoute: ConnectionRoute;
858 readonly reported: ReadonlyArray<{ readonly httpBaseUrl: string }>;
859 }) {
860 return yield* withLeaseLock(
861 input.environmentId,
862 Effect.gen(function* () {
863 if ((yield* Ref.get(platformEnvironmentIds)).has(input.environmentId)) {
864 return Option.none<ConnectionCatalogEntry>();
865 }
866 const entry = (yield* SubscriptionRef.get(entries)).get(input.environmentId);
867 if (entry === undefined) return Option.none<ConnectionCatalogEntry>();
868 const routes = mergeLearnedRoutes({
869 entry,
870 activeRoute: input.activeRoute,
871 reported: input.reported,
872 allowInsecure: allowInsecureRoutes,
873 });
874 if (routes === null) return Option.none<ConnectionCatalogEntry>();
875 const next = entryWithRoutes(entry, routes);
876 // A learned route owns its profile (address and authorization); the
877 // credential stays with the route it borrows from.
878 const previousIds = new Set(
879 connectionRoutes(entry).map((route) => connectionRouteId(route.target)),
880 );
881 for (const route of routes) {
882 if (!isLearned(route) || previousIds.has(connectionRouteId(route.target))) continue;
883 const profile = Option.getOrNull(route.profile);
884 if (profile !== null) yield* profiles.put(profile);
885 }
886 yield* registrations.setRoutes(input.environmentId, persistedRoutes(next));
887 // Update the lease in place: the live session already works, and
888 // `installEntryLocked` would replace it for a route list change.
889 const lease = (yield* SubscriptionRef.get(serviceScopes)).get(input.environmentId);
890 if (lease !== undefined) {
891 yield* SubscriptionRef.update(serviceScopes, (current) =>
892 new Map(current).set(input.environmentId, { ...lease, entry: next }),
893 );
894 }
895 yield* SubscriptionRef.update(entries, (current) =>
896 new Map(current).set(input.environmentId, next),
897 );
898 return Option.some(next);
899 }),
900 ).pipe(
901 Effect.catch((error) =>
902 Effect.logWarning("Could not save routes learned from the environment.", {
903 environmentId: input.environmentId,
904 error,
905 }).pipe(Effect.as(Option.none<ConnectionCatalogEntry>())),
906 ),
907 );
908 });
909
830910 const removeRoute = Effect.fn("EnvironmentRegistry.removeRoute")(function* (
831911 environmentId: EnvironmentId,
832912 routeId: string,
......@@ -840,7 +920,7 @@ export const make = Effect.gen(function* () {
840920 const routes = connectionRoutes(entry);
841921 const route = routes.find((candidate) => connectionRouteId(candidate.target) === routeId);
842922 if (route === undefined) return;
843 const remaining = routes.filter((candidate) => candidate !== route);
923 const remaining = routesAfterRemoving(routes, routeId);
844924 if (remaining.length === 0) return yield* removeLocked(environmentId);
845925 yield* replaceRoutesLocked(entry, remaining);
846926 const profile = Option.getOrNull(route.profile);
packages/client-runtime/src/connection/resolver.ts+10-1
......@@ -44,6 +44,7 @@ import {
4444 appendOrchestrationProtocol,
4545 orchestrationProtocolCompatibilityError,
4646} from "./compatibility.ts";
47import { credentialConnectionId } from "./routes.ts";
4748import { fetchRemoteEnvironmentDescriptor } from "../environment/descriptor.ts";
4849
4950export class ConnectionResolver extends Context.Service<
......@@ -139,7 +140,15 @@ const makeBearerBroker = Effect.fn("clientRuntime.connection.broker.makeBearer")
139140 actual: profile.environmentId,
140141 });
141142 }
142 const credential = yield* credentials.get(target.connectionId).pipe(
143 if (profile.authorization === "t3-connect") {
144 const authorized = yield* remote.authorizeDpop({
145 expectedEnvironmentId: target.environmentId,
146 directEndpoint: { httpBaseUrl: profile.httpBaseUrl, wsBaseUrl: profile.wsBaseUrl },
147 });
148 return { ...authorized, target } satisfies PreparedConnection;
149 }
150 // A learned route borrows the credential of the route it was learned from.
151 const credential = yield* credentials.get(credentialConnectionId(target.connectionId)).pipe(
143152 Effect.flatMap(
144153 Option.match({
145154 onNone: () => Effect.fail(credentialMissingError(target.connectionId)),
packages/client-runtime/src/connection/routes.test.ts+256-2
......@@ -2,9 +2,25 @@ import { EnvironmentId } from "@t3tools/contracts";
22import { describe, expect, it } from "@effect/vitest";
33import * as Option from "effect/Option";
44
5import { BearerConnectionProfile, type ConnectionRoute } from "./catalog.ts";
5import {
6 BearerConnectionProfile,
7 type ConnectionCatalogEntry,
8 type ConnectionRoute,
9} from "./catalog.ts";
10import { gitHubRoutingConnectionKey } from "./githubRoutingPermissions.ts";
611import { BearerConnectionTarget, RelayConnectionTarget } from "./model.ts";
7import { connectionRouteKind, connectionRouteLabel, insertRoute, upsertRoute } from "./routes.ts";
12import {
13 connectionRouteId,
14 connectionRouteKind,
15 connectionRouteLabel,
16 credentialConnectionId,
17 insertRoute,
18 entryWithRoutes,
19 mergeLearnedRoutes,
20 removedWithRelay,
21 routesAfterRemoving,
22 upsertRoute,
23} from "./routes.ts";
824
925const ENVIRONMENT_ID = EnvironmentId.make("environment-1");
1026
......@@ -60,3 +76,241 @@ describe("connection routes", () => {
6076 expect(upsertRoute([RELAY, LAN], repaired)).toEqual([RELAY, repaired]);
6177 });
6278});
79
80describe("learned routes", () => {
81 const relayOnly: ConnectionCatalogEntry = {
82 target: RELAY.target,
83 profile: RELAY.profile,
84 enabled: true,
85 };
86 const ids = (routes: ReadonlyArray<ConnectionRoute> | null) =>
87 routes?.map((route) => connectionRouteId(route.target)) ?? null;
88 const profileOf = (route: ConnectionRoute) => Option.getOrThrow(route.profile);
89
90 it("learns a LAN address over T3 Connect, ahead of it, using the T3 Connect credential", () => {
91 const routes = mergeLearnedRoutes({
92 entry: relayOnly,
93 activeRoute: RELAY,
94 reported: [{ httpBaseUrl: "http://192.168.1.10:3773/" }],
95 allowInsecure: true,
96 });
97 expect(ids(routes)).toEqual([`learned:${ENVIRONMENT_ID}:http://192.168.1.10:3773`, "relay"]);
98 expect(profileOf(routes![0]!)).toMatchObject({
99 learned: true,
100 authorization: "t3-connect",
101 wsBaseUrl: "ws://192.168.1.10:3773/",
102 });
103 });
104
105 it("replaces a learned LAN address when the server reports a new one", () => {
106 const first = mergeLearnedRoutes({
107 entry: relayOnly,
108 activeRoute: RELAY,
109 reported: [{ httpBaseUrl: "http://192.168.1.10:3773/" }],
110 allowInsecure: true,
111 })!;
112 const entry: ConnectionCatalogEntry = {
113 ...relayOnly,
114 target: first[0]!.target,
115 profile: first[0]!.profile,
116 alternateRoutes: first.slice(1),
117 };
118 const moved = mergeLearnedRoutes({
119 entry,
120 activeRoute: RELAY,
121 reported: [{ httpBaseUrl: "http://10.0.0.42:3773/" }],
122 allowInsecure: true,
123 });
124 expect(ids(moved)).toEqual([`learned:${ENVIRONMENT_ID}:http://10.0.0.42:3773`, "relay"]);
125 });
126
127 it("keeps a learned route where the user moved it while the server reports it", () => {
128 const lan = { httpBaseUrl: "http://192.168.1.10:3773/" };
129 const first = mergeLearnedRoutes({
130 entry: relayOnly,
131 activeRoute: RELAY,
132 reported: [lan],
133 allowInsecure: true,
134 })!;
135 // The user prefers T3 Connect over the learned LAN address.
136 const reordered = entryWithRoutes(relayOnly, [first[1]!, first[0]!]);
137 expect(
138 mergeLearnedRoutes({
139 entry: reordered,
140 activeRoute: RELAY,
141 reported: [lan],
142 allowInsecure: true,
143 }),
144 ).toBeNull();
145 // A newly reported address is still placed by speed.
146 const next = mergeLearnedRoutes({
147 entry: reordered,
148 activeRoute: RELAY,
149 reported: [lan, { httpBaseUrl: "http://100.101.102.103:3773/" }],
150 allowInsecure: true,
151 });
152 expect(ids(next)).toEqual([
153 `learned:${ENVIRONMENT_ID}:http://100.101.102.103:3773`,
154 "relay",
155 `learned:${ENVIRONMENT_ID}:http://192.168.1.10:3773`,
156 ]);
157 });
158
159 it("leaves user routes alone and does not learn an address already saved", () => {
160 const entry: ConnectionCatalogEntry = {
161 target: LAN.target,
162 profile: LAN.profile,
163 alternateRoutes: [RELAY],
164 enabled: true,
165 };
166 expect(
167 mergeLearnedRoutes({
168 entry,
169 activeRoute: RELAY,
170 reported: [{ httpBaseUrl: "http://192.168.1.10:3773" }],
171 allowInsecure: true,
172 }),
173 ).toBeNull();
174 // The server stops reporting the LAN address; the paired route stays.
175 expect(
176 mergeLearnedRoutes({ entry, activeRoute: RELAY, reported: [], allowInsecure: true }),
177 ).toBeNull();
178 });
179
180 it("borrows the paired token when learned over a bearer route", () => {
181 const entry: ConnectionCatalogEntry = {
182 ...relayOnly,
183 target: LAN.target,
184 profile: LAN.profile,
185 };
186 const routes = mergeLearnedRoutes({
187 entry,
188 activeRoute: LAN,
189 reported: [{ httpBaseUrl: "https://desk.tail1234.ts.net/" }],
190 allowInsecure: true,
191 })!;
192 const learned = routes.find((route) => connectionRouteKind(route) === "tailnet")!;
193 expect(profileOf(learned)).not.toHaveProperty("authorization");
194 expect(credentialConnectionId(connectionRouteId(learned.target))).toBe("lan");
195 });
196
197 it("skips plain HTTP from an HTTPS page and never learns loopback", () => {
198 expect(
199 mergeLearnedRoutes({
200 entry: relayOnly,
201 activeRoute: RELAY,
202 reported: [
203 { httpBaseUrl: "http://192.168.1.10:3773/" },
204 { httpBaseUrl: "http://127.0.0.1:3773/" },
205 ],
206 allowInsecure: false,
207 }),
208 ).toBeNull();
209 });
210
211 it("removes learned routes along with the route whose credential they borrow", () => {
212 const overRelay = mergeLearnedRoutes({
213 entry: relayOnly,
214 activeRoute: RELAY,
215 reported: [{ httpBaseUrl: "http://192.168.1.10:3773/" }],
216 allowInsecure: true,
217 })!;
218 expect(routesAfterRemoving(overRelay, "relay")).toEqual([]);
219
220 const paired: ConnectionCatalogEntry = {
221 ...relayOnly,
222 target: LAN.target,
223 profile: LAN.profile,
224 alternateRoutes: [RELAY],
225 };
226 const overLan = mergeLearnedRoutes({
227 entry: paired,
228 activeRoute: LAN,
229 reported: [{ httpBaseUrl: "https://desk.tail1234.ts.net/" }],
230 allowInsecure: true,
231 })!;
232 expect(ids(routesAfterRemoving(overLan, "lan"))).toEqual(["relay"]);
233 // Removing T3 Connect keeps the paired LAN and what it learned.
234 expect(ids(routesAfterRemoving(overLan, "relay"))).toHaveLength(2);
235 });
236
237 it("counts an environment reached only through T3 Connect as removed with it", () => {
238 const learned = mergeLearnedRoutes({
239 entry: relayOnly,
240 activeRoute: RELAY,
241 reported: [{ httpBaseUrl: "http://192.168.1.10:3773/" }],
242 allowInsecure: true,
243 })!;
244 expect(removedWithRelay(relayOnly)).toBe(true);
245 expect(removedWithRelay(entryWithRoutes(relayOnly, learned))).toBe(true);
246 expect(removedWithRelay(entryWithRoutes(relayOnly, [LAN, RELAY]))).toBe(false);
247 });
248
249 it("keeps GitHub routing trust when a route is learned", () => {
250 const learned = mergeLearnedRoutes({
251 entry: relayOnly,
252 activeRoute: RELAY,
253 reported: [{ httpBaseUrl: "http://192.168.1.10:3773/" }],
254 allowInsecure: true,
255 })!;
256 const withLearned: ConnectionCatalogEntry = {
257 ...relayOnly,
258 target: learned[0]!.target,
259 profile: learned[0]!.profile,
260 alternateRoutes: learned.slice(1),
261 };
262 expect(gitHubRoutingConnectionKey(withLearned)).toBe(gitHubRoutingConnectionKey(relayOnly));
263 });
264
265 it("keeps the T3 Connect credential when learning over a learned T3 Connect route", () => {
266 const first = mergeLearnedRoutes({
267 entry: relayOnly,
268 activeRoute: RELAY,
269 reported: [{ httpBaseUrl: "http://192.168.1.10:3773/" }],
270 allowInsecure: true,
271 })!;
272 const learnedLan = first[0]!;
273 const entry: ConnectionCatalogEntry = {
274 ...relayOnly,
275 target: learnedLan.target,
276 profile: learnedLan.profile,
277 alternateRoutes: first.slice(1),
278 };
279 const next = mergeLearnedRoutes({
280 entry,
281 activeRoute: learnedLan,
282 reported: [
283 { httpBaseUrl: "http://192.168.1.10:3773/" },
284 { httpBaseUrl: "https://desk.tail1234.ts.net/" },
285 ],
286 allowInsecure: true,
287 })!;
288 for (const route of next.filter((candidate) => connectionRouteKind(candidate) !== "relay")) {
289 expect(profileOf(route)).toMatchObject({ authorization: "t3-connect" });
290 expect(connectionRouteId(route.target)).not.toContain("@");
291 }
292 });
293
294 it("saves a scheme change on the same host as a new address", () => {
295 const first = mergeLearnedRoutes({
296 entry: relayOnly,
297 activeRoute: RELAY,
298 reported: [{ httpBaseUrl: "http://desk.local:3773/" }],
299 allowInsecure: true,
300 })!;
301 const entry: ConnectionCatalogEntry = {
302 ...relayOnly,
303 target: first[0]!.target,
304 profile: first[0]!.profile,
305 alternateRoutes: first.slice(1),
306 };
307 const moved = mergeLearnedRoutes({
308 entry,
309 activeRoute: RELAY,
310 reported: [{ httpBaseUrl: "https://desk.local:3773/" }],
311 allowInsecure: true,
312 });
313 expect(moved).not.toBeNull();
314 expect(profileOf(moved![0]!)).toMatchObject({ httpBaseUrl: "https://desk.local:3773/" });
315 });
316});
packages/client-runtime/src/connection/routes.ts+168-2
......@@ -6,8 +6,12 @@ import {
66import type { DesktopSshEnvironmentTarget } from "@t3tools/contracts";
77import * as Option from "effect/Option";
88
9import type { ConnectionCatalogEntry, ConnectionRoute } from "./catalog.ts";
10import type { ConnectionTarget } from "./model.ts";
9import {
10 BearerConnectionProfile,
11 type ConnectionCatalogEntry,
12 type ConnectionRoute,
13} from "./catalog.ts";
14import { BearerConnectionTarget, type ConnectionTarget } from "./model.ts";
1115
1216/**
1317 * A saved environment can hold several routes: T3 Connect, direct URLs (LAN,
......@@ -202,6 +206,168 @@ export function hasRelayRoute(
202206 );
203207}
204208
209/**
210 * The routes after the server reports where it listens. Each newly reported
211 * address becomes a learned route that authenticates the same way as the route
212 * in use: the paired token for a bearer route, the T3 Connect credential for
213 * relay. A learned route the server still reports keeps its place, so the
214 * user's order holds; one it no longer reports is dropped, so a changed LAN
215 * address replaces the old one. Routes the user saved are never touched, and
216 * an address already saved is not learned twice.
217 */
218export function mergeLearnedRoutes(input: {
219 readonly entry: ConnectionCatalogEntry;
220 readonly activeRoute: ConnectionRoute;
221 readonly reported: ReadonlyArray<{ readonly httpBaseUrl: string }>;
222 /** Plain HTTP routes are unusable from an HTTPS page (mixed content). */
223 readonly allowInsecure: boolean;
224}): ReadonlyArray<ConnectionRoute> | null {
225 const { entry } = input;
226 const active = input.activeRoute.target;
227 const saved = connectionRoutes(entry);
228 // The primary and SSH routes have no credential a learned route could reuse.
229 if (active._tag !== "RelayConnectionTarget" && active._tag !== "BearerConnectionTarget") {
230 return null;
231 }
232 // A route learned over another learned route inherits what that one uses:
233 // the T3 Connect credential, or the paired token it borrows.
234 const activeProfile = Option.getOrNull(input.activeRoute.profile);
235 const authorization =
236 active._tag === "RelayConnectionTarget" ||
237 (activeProfile?._tag === "BearerConnectionProfile" &&
238 activeProfile.authorization === "t3-connect")
239 ? ("t3-connect" as const)
240 : undefined;
241 const sharedCredential =
242 authorization === undefined && active._tag === "BearerConnectionTarget"
243 ? credentialConnectionId(active.connectionId)
244 : undefined;
245
246 // Usable reported addresses, by origin.
247 const reported = new Map<string, URL>();
248 for (const endpoint of input.reported) {
249 let url: URL;
250 try {
251 url = new URL(endpoint.httpBaseUrl);
252 } catch {
253 continue;
254 }
255 if (url.protocol !== "http:" && url.protocol !== "https:") continue;
256 if (url.protocol === "http:" && !input.allowInsecure) continue;
257 // A loopback address names whichever device opens it, never the server.
258 if (isLocalLoopbackHost(url.hostname)) continue;
259 reported.set(url.origin, url);
260 }
261 const normalized = (url: string) => url.replace(/\/+$/, "");
262 const known = new Set(
263 saved.flatMap((route) => {
264 const url = routeHttpBaseUrl(route);
265 return url === null || isLearned(route) ? [] : [normalized(url)];
266 }),
267 );
268 const kept = saved.filter((route) => {
269 if (!isLearned(route)) return true;
270 const url = routeHttpBaseUrl(route);
271 if (url === null || !reported.has(normalized(url)) || known.has(normalized(url))) return false;
272 known.add(normalized(url));
273 return true;
274 });
275 let next: ReadonlyArray<ConnectionRoute> = kept;
276 for (const url of reported.values()) {
277 if (known.has(url.origin)) continue;
278 const httpBaseUrl = `${url.origin}/`;
279 const connectionId = learnedConnectionId(
280 entry.target.environmentId,
281 url.origin,
282 sharedCredential,
283 );
284 next = insertRoute(next, {
285 target: new BearerConnectionTarget({
286 environmentId: entry.target.environmentId,
287 label: entry.target.label,
288 connectionId,
289 }),
290 profile: Option.some(
291 new BearerConnectionProfile({
292 connectionId,
293 environmentId: entry.target.environmentId,
294 label: entry.target.label,
295 httpBaseUrl,
296 wsBaseUrl: `${url.protocol === "https:" ? "wss:" : "ws:"}//${url.host}/`,
297 learned: true,
298 ...(authorization === undefined ? {} : { authorization }),
299 }),
300 ),
301 });
302 }
303 // Compare addresses too: a scheme or port change keeps no id stable.
304 const signature = (routes: ReadonlyArray<ConnectionRoute>) =>
305 routes
306 .map((route) => `${connectionRouteId(route.target)} ${routeHttpBaseUrl(route) ?? ""}`)
307 .join("\n");
308 return signature(saved) === signature(next) ? null : next;
309}
310
311/**
312 * A learned bearer route borrows the credential of the route it was learned
313 * from, so its id points back at that credential's owner.
314 */
315function learnedConnectionId(
316 environmentId: string,
317 origin: string,
318 sharedCredential: string | undefined,
319): string {
320 return sharedCredential === undefined
321 ? `learned:${environmentId}:${origin}`
322 : `learned:${environmentId}:${origin}@${sharedCredential}`;
323}
324
325/** The connection id whose stored credential a bearer route uses. */
326export function credentialConnectionId(connectionId: string): string {
327 // The borrowed id follows the first "@"; neither an environment id nor an
328 // origin contains one.
329 const at = connectionId.indexOf("@");
330 return connectionId.startsWith("learned:") && at !== -1
331 ? connectionId.slice(at + 1)
332 : connectionId;
333}
334
335export function isLearned(route: ConnectionRoute): boolean {
336 const profile = Option.getOrNull(route.profile);
337 return profile?._tag === "BearerConnectionProfile" && profile.learned === true;
338}
339
340/**
341 * Routes left after the user removes one. A learned route borrows the
342 * credential of the route it was learned over, so it cannot outlive that
343 * route: removing T3 Connect also removes routes learned through it, and
344 * removing a paired address removes routes that borrow its token.
345 */
346export function routesAfterRemoving(
347 routes: ReadonlyArray<ConnectionRoute>,
348 removedId: string,
349): ReadonlyArray<ConnectionRoute> {
350 const removed = routes.find((route) => connectionRouteId(route.target) === removedId);
351 if (removed === undefined) return routes;
352 return routes.filter((route) => {
353 if (route === removed) return false;
354 if (!isLearned(route)) return true;
355 const profile = Option.getOrNull(route.profile);
356 if (profile?._tag === "BearerConnectionProfile" && profile.authorization === "t3-connect") {
357 return removed.target._tag !== "RelayConnectionTarget";
358 }
359 return credentialConnectionId(connectionRouteId(route.target)) !== removedId;
360 });
361}
362
363/**
364 * Whether removing T3 Connect leaves the environment no route, so signing out
365 * removes it entirely. Routes learned through T3 Connect go with it.
366 */
367export function removedWithRelay(entry: ConnectionCatalogEntry): boolean {
368 return routesAfterRemoving(connectionRoutes(entry), RELAY_ROUTE_ID).length === 0;
369}
370
205371/** One SSH target, as desktop keys its tunnels: alias, host, user, and port. */
206372export function sshTargetKey(target: DesktopSshEnvironmentTarget): string {
207373 return JSON.stringify([target.alias, target.hostname, target.username, target.port]);
packages/client-runtime/src/connection/supervisor.test.ts+68-2
......@@ -1,4 +1,4 @@
1import { AuthStandardClientScopes, EnvironmentId } from "@t3tools/contracts";
1import { AuthStandardClientScopes, EnvironmentId, type ServerConfig } from "@t3tools/contracts";
22import { RelayClientTracer } from "@t3tools/shared/relayTracing";
33import { describe, expect, it } from "@effect/vitest";
44import * as Deferred from "effect/Deferred";
......@@ -38,6 +38,7 @@ import {
3838import * as RpcSession from "../rpc/session.ts";
3939import * as EnvironmentSupervisor from "./supervisor.ts";
4040import * as ConnectionWakeups from "./wakeups.ts";
41import { connectionRouteId, entryWithRoutes, isLearned, mergeLearnedRoutes } from "./routes.ts";
4142import { NETWORK_BLOCKING_HINT } from "../errors/network.ts";
4243
4344const TARGET = new PrimaryConnectionTarget({
......@@ -156,6 +157,7 @@ const makeHarness = Effect.fn("TestConnectionHarness.make")(function* (options?:
156157 ) => Effect.Effect<PreparedConnection, ConnectionAttemptError>;
157158 readonly ready?: (attempt: number) => Effect.Effect<void, ConnectionAttemptError>;
158159 readonly probe?: (attempt: number) => Effect.Effect<void, ConnectionAttemptError>;
160 readonly initialConfig?: (attempt: number) => Effect.Effect<ServerConfig, ConnectionAttemptError>;
159161 readonly checkRoute?: (route: ConnectionRoute) => Effect.Effect<ConnectionDriver.RouteCheck>;
160162}) {
161163 const networkStatus = yield* SubscriptionRef.make<NetworkStatus>(
......@@ -205,7 +207,9 @@ const makeHarness = Effect.fn("TestConnectionHarness.make")(function* (options?:
205207 const session = yield* Effect.acquireRelease(
206208 Effect.succeed({
207209 client: TEST_RPC_CLIENT,
208 initialConfig: Effect.die(new Error("Initial config is not used by supervisor tests.")),
210 initialConfig:
211 options?.initialConfig?.(attempt) ??
212 Effect.die(new Error("Initial config is not used by supervisor tests.")),
209213 subscribeServerConfig: (input) => TEST_RPC_CLIENT.subscribeServerConfig(input),
210214 ready: options?.ready?.(attempt) ?? Effect.void,
211215 probe: options?.probe?.(attempt) ?? Effect.void,
......@@ -1983,4 +1987,66 @@ describe("EnvironmentSupervisor routes", () => {
19831987 });
19841988 }),
19851989 );
1990
1991 it.effect("learns the LAN address over T3 Connect and moves to it", () =>
1992 Effect.gen(function* () {
1993 const relayEntry: ConnectionCatalogEntry = {
1994 target: RELAY_TARGET,
1995 profile: Option.none(),
1996 enabled: true,
1997 };
1998 const lanAddress = yield* Ref.make("http://192.168.1.10:3773/");
1999 const learned = yield* Ref.make<ReadonlyArray<string>>([]);
2000 const harness = yield* makeHarness({
2001 checkRoute: (route) =>
2002 route.target._tag === "BearerConnectionTarget"
2003 ? Effect.succeed("answered")
2004 : Effect.succeed("unchecked"),
2005 prepare: (_attempt, target) => Effect.succeed(preparedFor(target)),
2006 // Only the reported endpoints matter to the supervisor.
2007 initialConfig: () =>
2008 Ref.get(lanAddress).pipe(
2009 Effect.map((httpBaseUrl): Pick<ServerConfig, "directEndpoints"> => ({
2010 directEndpoints: [{ kind: "lan", httpBaseUrl }],
2011 })),
2012 Effect.map((config) => config as ServerConfig),
2013 ),
2014 });
2015 const current = yield* Ref.make(relayEntry);
2016 const supervisor = yield* EnvironmentSupervisor.make(relayEntry, {
2017 initiallyDesired: true,
2018 learnRoutes: ({ activeRoute, reported }) =>
2019 Effect.gen(function* () {
2020 const entry = yield* Ref.get(current);
2021 const routes = mergeLearnedRoutes({
2022 entry,
2023 activeRoute,
2024 reported,
2025 allowInsecure: true,
2026 });
2027 if (routes === null) return Option.none();
2028 const next = entryWithRoutes(entry, routes);
2029 yield* Ref.set(current, next);
2030 yield* Ref.update(learned, (all) => [
2031 ...all,
2032 ...routes.filter(isLearned).map((route) => connectionRouteId(route.target)),
2033 ]);
2034 return Option.some(next);
2035 }),
2036 }).pipe(Effect.provide(harness.dependencies));
2037
2038 // Connected over T3 Connect, the server reports its LAN address; the
2039 // learned route ranks first, answers, and the session moves to it.
2040 yield* awaitState(
2041 supervisor.state,
2042 (state) => state.phase === "connected" && state.generation === 2,
2043 );
2044 expect(Option.getOrThrow(yield* SubscriptionRef.get(supervisor.prepared)).target._tag).toBe(
2045 "BearerConnectionTarget",
2046 );
2047 expect(yield* Ref.get(learned)).toEqual([
2048 `learned:${TARGET.environmentId}:http://192.168.1.10:3773`,
2049 ]);
2050 }),
2051 );
19862052});
packages/client-runtime/src/connection/supervisor.ts+66-20
......@@ -113,6 +113,15 @@ function exitUnlessInterrupted<A, E, R>(
113113
114114export interface EnvironmentSupervisorOptions {
115115 readonly initiallyDesired?: boolean;
116 /**
117 * Saves the direct addresses the server reports once a session is ready and
118 * returns the entry with its updated routes, which later attempts use. The
119 * live session is left alone: it already works.
120 */
121 readonly learnRoutes?: (input: {
122 readonly activeRoute: ConnectionRoute;
123 readonly reported: ReadonlyArray<{ readonly httpBaseUrl: string }>;
124 }) => Effect.Effect<Option.Option<ConnectionCatalogEntry>>;
116125}
117126
118127/**
......@@ -279,7 +288,10 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* (
279288 );
280289 const session = yield* SubscriptionRef.make<Option.Option<RpcSession.RpcSession>>(Option.none());
281290 const prepared = yield* SubscriptionRef.make<Option.Option<PreparedConnection>>(Option.none());
282 const routes = connectionRoutes(entry);
291 // Learned routes arrive while a session is live, so the route list is read
292 // fresh by each attempt and check instead of being fixed at creation.
293 const currentEntry = yield* Ref.make(entry);
294 const currentRoutes = Ref.get(currentEntry).pipe(Effect.map(connectionRoutes));
283295 // Set when a better route answered while connected over a worse one; the
284296 // replacement attempt tries it first. Cleared once an attempt starts.
285297 const preferredRouteId = yield* Ref.make(Option.none<string>());
......@@ -287,19 +299,45 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* (
287299 const routeCooldowns = yield* Ref.make<ReadonlyMap<string, bigint>>(new Map());
288300
289301 const attemptEntry = Effect.gen(function* () {
302 const latest = yield* Ref.get(currentEntry);
303 const routes = connectionRoutes(latest);
290304 const preferred = yield* Ref.getAndSet(preferredRouteId, Option.none());
291 if (Option.isNone(preferred)) return entry;
305 if (Option.isNone(preferred)) return latest;
292306 const route = routes.find(
293307 (candidate) => connectionRouteId(candidate.target) === preferred.value,
294308 );
295309 return route === undefined
296 ? entry
297 : entryWithRoutes(entry, [route, ...routes.filter((candidate) => candidate !== route)]);
310 ? latest
311 : entryWithRoutes(latest, [route, ...routes.filter((candidate) => candidate !== route)]);
312 });
313
314 const learnRoutesFrom = Effect.fnUntraced(function* (
315 learn: NonNullable<EnvironmentSupervisorOptions["learnRoutes"]>,
316 lease: ConnectionDriver.EnvironmentConnectionLease,
317 ) {
318 const config = yield* lease.session.initialConfig.pipe(Effect.option);
319 const reported = Option.getOrUndefined(config)?.directEndpoints;
320 if (reported === undefined) return;
321 const activeId = connectionRouteId(lease.prepared.target);
322 const activeRoute = (yield* currentRoutes).find(
323 (route) => connectionRouteId(route.target) === activeId,
324 );
325 if (activeRoute === undefined) return;
326 const updated = yield* learn({ activeRoute, reported });
327 if (Option.isSome(updated)) {
328 yield* Ref.set(currentEntry, updated.value);
329 // A learned route may rank above the one in use.
330 yield* requestBetterRouteCheck(lease);
331 }
298332 });
299333
300334 const routeIndex = (lease: ConnectionDriver.EnvironmentConnectionLease) =>
301 routes.findIndex(
302 (route) => connectionRouteId(route.target) === connectionRouteId(lease.prepared.target),
335 currentRoutes.pipe(
336 Effect.map((routes) =>
337 routes.findIndex(
338 (route) => connectionRouteId(route.target) === connectionRouteId(lease.prepared.target),
339 ),
340 ),
303341 );
304342
305343 /**
......@@ -310,16 +348,17 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* (
310348 const checkBetterRoutes = Effect.fnUntraced(function* (
311349 lease: ConnectionDriver.EnvironmentConnectionLease,
312350 ) {
313 const current = routeIndex(lease);
351 const latest = yield* Ref.get(currentEntry);
352 const current = yield* routeIndex(lease);
314353 if (current <= 0) return;
315354 const now = yield* Clock.monotonicTimeNanos;
316355 const cooldowns = yield* Ref.get(routeCooldowns);
317 const better = routes
356 const better = connectionRoutes(latest)
318357 .slice(0, current)
319358 .filter((route) => (cooldowns.get(connectionRouteId(route.target)) ?? 0n) <= now);
320359 const passed = yield* Effect.forEach(
321360 better,
322 (route: ConnectionRoute) => driver.preflight(entry, route),
361 (route: ConnectionRoute) => driver.preflight(latest, route),
323362 { concurrency: "unbounded" },
324363 );
325364 const index = passed.indexOf(true);
......@@ -336,7 +375,11 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* (
336375 // One check at a time; a trigger during a check is dropped, not queued.
337376 const betterRouteChecks = yield* Queue.dropping<ConnectionDriver.EnvironmentConnectionLease>(1);
338377 const requestBetterRouteCheck = (lease: ConnectionDriver.EnvironmentConnectionLease) =>
339 routeIndex(lease) > 0 ? Queue.offer(betterRouteChecks, lease).pipe(Effect.asVoid) : Effect.void;
378 routeIndex(lease).pipe(
379 Effect.flatMap((index) =>
380 index > 0 ? Queue.offer(betterRouteChecks, lease).pipe(Effect.asVoid) : Effect.void,
381 ),
382 );
340383
341384 const clearLease = Effect.all(
342385 [SubscriptionRef.set(session, Option.none()), SubscriptionRef.set(prepared, Option.none())],
......@@ -557,13 +600,13 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* (
557600 next._tag === "RetryRequested" ? Ref.set(resetRetryState, false) : Effect.void,
558601 ),
559602 );
560 if (routes.length > 1 && routeIndex(lease) > 0) {
561 yield* Stream.tick(BETTER_ROUTE_CHECK_INTERVAL).pipe(
562 Stream.drop(1),
563 Stream.runForEach(() => requestBetterRouteCheck(lease)),
564 Effect.forkScoped,
565 );
566 }
603 // Ticks always run: the check skips the preferred route itself, and the
604 // route list can grow while connected (learned routes).
605 yield* Stream.tick(BETTER_ROUTE_CHECK_INTERVAL).pipe(
606 Stream.drop(1),
607 Stream.runForEach(() => requestBetterRouteCheck(lease)),
608 Effect.forkScoped,
609 );
567610 for (;;) {
568611 const next = yield* takeSignal;
569612 const end = yield* connectedLeaseEnd(next, lease);
......@@ -652,9 +695,9 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* (
652695 })),
653696 ),
654697 // Each route may use the full setup time before the next is tried.
655 Effect.sleep(Duration.times(establishmentTimeout, routes.length)).pipe(
656 Effect.as<EstablishmentEvent>({ _tag: "TimedOut" }),
657 ),
698 Effect.sleep(
699 Duration.times(establishmentTimeout, connectionRoutes(yield* Ref.get(currentEntry)).length),
700 ).pipe(Effect.as<EstablishmentEvent>({ _tag: "TimedOut" })),
658701 ]);
659702
660703 if (establishment._tag === "Interrupted") {
......@@ -722,6 +765,9 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* (
722765 const connectedAt = yield* Clock.currentTimeMillis;
723766 yield* SubscriptionRef.set(prepared, Option.some(active.lease.prepared));
724767 yield* SubscriptionRef.set(session, Option.some(active.lease.session));
768 if (options?.learnRoutes !== undefined) {
769 yield* learnRoutesFrom(options.learnRoutes, active.lease).pipe(Effect.forkScoped);
770 }
725771 yield* setState({
726772 desired: true,
727773 network: currentIntent.network,
packages/client-runtime/src/state/environmentHttpAuth.ts+4-1
......@@ -152,7 +152,10 @@ export const executeAuthenticatedEnvironmentHttpRequest = Effect.fn(
152152 }),
153153 ),
154154 );
155 httpBaseUrl = current.httpBaseUrl;
155 // A learned direct route keeps its own origin; only the token renews.
156 if (input.prepared.target._tag === "RelayConnectionTarget") {
157 httpBaseUrl = current.httpBaseUrl;
158 }
156159 authorization = current.httpAuthorization;
157160 }
158161
packages/contracts/src/server.ts+16
......@@ -603,6 +603,15 @@ export function environmentThemeFileHasColors(file: EnvironmentThemeFile): boole
603603 );
604604}
605605
606export const ServerDirectEndpointKind = Schema.Literals(["lan", "tailnet"]);
607export type ServerDirectEndpointKind = typeof ServerDirectEndpointKind.Type;
608
609export const ServerDirectEndpoint = Schema.Struct({
610 kind: ServerDirectEndpointKind,
611 httpBaseUrl: TrimmedNonEmptyString,
612});
613export type ServerDirectEndpoint = typeof ServerDirectEndpoint.Type;
614
606615export const ServerConfig = Schema.Struct({
607616 environment: ExecutionEnvironmentDescriptor,
608617 auth: ServerAuthDescriptor,
......@@ -620,6 +629,13 @@ export const ServerConfig = Schema.Struct({
620629 * sshd or no advertisable name.
621630 */
622631 remoteOpenTargets: Schema.optionalKey(ForwardCompatibleArray(RemoteOpenTarget)),
632 /**
633 * Direct addresses this server listens on right now (LAN and tailnet), so a
634 * client connected one way can learn the others. Hints only: the client
635 * checks each address answers as this environment before using it. Absent on
636 * servers that predate the feature; empty when bound to loopback only.
637 */
638 directEndpoints: Schema.optionalKey(ForwardCompatibleArray(ServerDirectEndpoint)),
623639 observability: ServerObservability,
624640 settings: ServerSettings,
625641 /** Whether shell subscriptions can emit an opt-in catch-up completion marker. */