()),
+ 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. */