From eb5f14bc158fac9a3d4f2de1e5053257b5a1ff10 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Wed, 5 Aug 2026 19:41:40 -0700 Subject: [PATCH] feat: promote T3 Connect environments to local connections Relay-connected environments now discover direct LAN and Tailscale routes to the same server and reconnect through them automatically, with the relay as fallback. - Server: authenticated GET /api/remote-access/endpoints (relay:read) advertises the server's own loopback/LAN/Tailscale endpoints, aware of the configured binding. Tailscale endpoint synthesis hoisted from desktop into @t3tools/tailscale and shared. - Client: while relay-connected, a scoped discovery fiber fetches advertised endpoints through the tunnel, probes direct candidates, verifies the environment id, and stores a per-environment route override. The supervisor replaces the lease without backoff and the relay broker connects through the override using the cached DPoP access token (host-independent; only per-request proofs are URL-bound). A failed direct route clears the override, starts a cooldown to prevent route flapping, and falls back to the relay in the same prepare call. Co-Authored-By: Claude Fable 5 --- .../src/backend/DesktopServerExposure.ts | 4 +- .../ServerAdvertisedEndpoints.test.ts | 129 +++++++++ .../remoteAccess/ServerAdvertisedEndpoints.ts | 213 +++++++++++++++ apps/server/src/remoteAccess/http.ts | 25 ++ apps/server/src/server.ts | 3 + docs/internals/remote.md | 22 ++ docs/user/remote-access.md | 12 + .../src/authorization/service.ts | 98 ++++++- .../client-runtime/src/connection/index.ts | 1 + .../client-runtime/src/connection/layer.ts | 13 +- .../src/connection/promotion.test.ts | 96 +++++++ .../src/connection/promotion.ts | 256 ++++++++++++++++++ .../client-runtime/src/connection/registry.ts | 16 +- .../src/connection/resolver.test.ts | 93 +++++++ .../client-runtime/src/connection/resolver.ts | 82 ++++-- .../src/connection/supervisor.test.ts | 83 ++++++ .../src/connection/supervisor.ts | 58 +++- packages/contracts/src/environmentHttp.ts | 15 +- packages/tailscale/package.json | 1 + .../tailscale/src/endpointProvider.test.ts | 13 +- .../tailscale/src/endpointProvider.ts | 50 +++- packages/tailscale/src/index.ts | 1 + pnpm-lock.yaml | 3 + 23 files changed, 1225 insertions(+), 62 deletions(-) create mode 100644 apps/server/src/remoteAccess/ServerAdvertisedEndpoints.test.ts create mode 100644 apps/server/src/remoteAccess/ServerAdvertisedEndpoints.ts create mode 100644 apps/server/src/remoteAccess/http.ts create mode 100644 packages/client-runtime/src/connection/promotion.test.ts create mode 100644 packages/client-runtime/src/connection/promotion.ts rename apps/desktop/src/backend/tailscaleEndpointProvider.test.ts => packages/tailscale/src/endpointProvider.test.ts (95%) rename apps/desktop/src/backend/tailscaleEndpointProvider.ts => packages/tailscale/src/endpointProvider.ts (79%) diff --git a/apps/desktop/src/backend/DesktopServerExposure.ts b/apps/desktop/src/backend/DesktopServerExposure.ts index f04d2af7b1f..165e44289a6 100644 --- a/apps/desktop/src/backend/DesktopServerExposure.ts +++ b/apps/desktop/src/backend/DesktopServerExposure.ts @@ -9,7 +9,7 @@ import { type DesktopServerExposureMode, type DesktopServerExposureState, } from "@t3tools/contracts"; -import { readTailscaleStatus } from "@t3tools/tailscale"; +import { readTailscaleStatus, resolveTailscaleAdvertisedEndpoints } from "@t3tools/tailscale"; import * as Context from "effect/Context"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; @@ -23,7 +23,6 @@ import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawne import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; import * as DesktopConfig from "../app/DesktopConfig.ts"; import * as DesktopNetworkInterfaces from "./DesktopNetworkInterfaces.ts"; -import { resolveTailscaleAdvertisedEndpoints } from "./tailscaleEndpointProvider.ts"; const TAILSCALE_STATUS_CACHE_TTL = Duration.seconds(60); @@ -539,6 +538,7 @@ export const make = Effect.gen(function* () { const tailscaleEndpoints = yield* resolveTailscaleAdvertisedEndpoints({ port: state.port, + source: "desktop-addon", serveEnabled: state.tailscaleServeEnabled, servePort: state.tailscaleServePort, networkInterfaces: currentNetworkInterfaces, diff --git a/apps/server/src/remoteAccess/ServerAdvertisedEndpoints.test.ts b/apps/server/src/remoteAccess/ServerAdvertisedEndpoints.test.ts new file mode 100644 index 00000000000..0b0c033b559 --- /dev/null +++ b/apps/server/src/remoteAccess/ServerAdvertisedEndpoints.test.ts @@ -0,0 +1,129 @@ +import type { AdvertisedEndpoint } from "@t3tools/contracts"; +import { createAdvertisedEndpoint } from "@t3tools/shared/advertisedEndpoint"; +import { assert, describe, it } from "@effect/vitest"; + +import { resolveServerAdvertisedEndpoints } from "./ServerAdvertisedEndpoints.ts"; + +const tailscaleProvider = { + id: "tailscale", + label: "Tailscale", + kind: "private-network", + isAddon: true, +} as const; + +const tailscaleIpEndpoint: AdvertisedEndpoint = createAdvertisedEndpoint({ + provider: tailscaleProvider, + source: "server", + id: "tailscale-ip:http://100.100.100.100:3773", + label: "Tailscale IP", + httpBaseUrl: "http://100.100.100.100:3773", + reachability: "private-network", +}); + +const tailscaleServeEndpoint: AdvertisedEndpoint = createAdvertisedEndpoint({ + provider: tailscaleProvider, + source: "server", + id: "tailscale-magicdns:https://server.tail.ts.net/", + label: "Tailscale HTTPS", + httpBaseUrl: "https://server.tail.ts.net", + reachability: "private-network", +}); + +describe("resolveServerAdvertisedEndpoints", () => { + it("advertises only loopback (plus Tailscale Serve) under a loopback binding", () => { + const endpoints = resolveServerAdvertisedEndpoints({ + port: 3773, + host: undefined, + networkInterfaces: { + en0: [{ address: "192.168.1.44", family: "IPv4", internal: false }], + }, + tailscaleEndpoints: [tailscaleIpEndpoint, tailscaleServeEndpoint], + }); + + // The LAN interface is not advertised: the server is not listening on it. + assert.deepEqual( + endpoints.map((endpoint) => endpoint.id), + ["server-loopback:3773", tailscaleServeEndpoint.id], + ); + const loopback = endpoints[0]; + assert.equal(loopback?.httpBaseUrl, "http://127.0.0.1:3773/"); + assert.equal(loopback?.wsBaseUrl, "ws://127.0.0.1:3773/"); + assert.equal(loopback?.reachability, "loopback"); + assert.equal(loopback?.source, "server"); + assert.equal(loopback?.provider.id, "server-core"); + }); + + it("enumerates listening interfaces under a wildcard binding", () => { + const endpoints = resolveServerAdvertisedEndpoints({ + port: 8080, + host: "0.0.0.0", + networkInterfaces: { + lo0: [{ address: "127.0.0.1", family: "IPv4", internal: true }], + en0: [ + { address: "192.168.1.44", family: "IPv4", internal: false }, + { address: "10.4.0.9", family: "IPv4", internal: false }, + { address: "fe80::1", family: "IPv6", internal: false }, + ], + en1: [ + { address: "169.254.10.2", family: "IPv4", internal: false }, + { address: "203.0.113.7", family: "IPv4", internal: false }, + // Duplicated address is deduped. + { address: "192.168.1.44", family: "IPv4", internal: false }, + ], + tailscale0: [{ address: "100.100.100.100", family: "IPv4", internal: false }], + docker0: [{ address: "127.0.0.5", family: "IPv4", internal: false }], + }, + tailscaleEndpoints: [tailscaleIpEndpoint, tailscaleServeEndpoint], + }); + + assert.deepEqual( + endpoints.map((endpoint) => [endpoint.id, endpoint.reachability]), + [ + ["server-loopback:8080", "loopback"], + ["server-lan:http://192.168.1.44:8080", "lan"], + ["server-lan:http://10.4.0.9:8080", "lan"], + ["server-public:http://203.0.113.7:8080", "public"], + [tailscaleIpEndpoint.id, "private-network"], + [tailscaleServeEndpoint.id, "private-network"], + ], + ); + // Only the first LAN endpoint is the default. + assert.deepEqual( + endpoints.filter((endpoint) => endpoint.isDefault === true).map((endpoint) => endpoint.id), + ["server-lan:http://192.168.1.44:8080"], + ); + }); + + it("advertises only the bound host under a specific binding", () => { + const endpoints = resolveServerAdvertisedEndpoints({ + port: 3773, + host: "192.168.1.44", + networkInterfaces: { + en0: [{ address: "192.168.1.44", family: "IPv4", internal: false }], + }, + // Tailscale Serve proxies to 127.0.0.1, which is not listening here. + tailscaleEndpoints: [tailscaleIpEndpoint, tailscaleServeEndpoint], + }); + + assert.deepEqual( + endpoints.map((endpoint) => endpoint.id), + ["server-lan:http://192.168.1.44:3773"], + ); + assert.equal(endpoints[0]?.reachability, "lan"); + assert.equal(endpoints[0]?.httpBaseUrl, "http://192.168.1.44:3773/"); + }); + + it("classifies a bound Tailnet address as private-network", () => { + const endpoints = resolveServerAdvertisedEndpoints({ + port: 3773, + host: "100.100.100.100", + networkInterfaces: {}, + tailscaleEndpoints: [], + }); + + assert.deepEqual( + endpoints.map((endpoint) => [endpoint.id, endpoint.reachability]), + [["server-private-network:http://100.100.100.100:3773", "private-network"]], + ); + }); +}); diff --git a/apps/server/src/remoteAccess/ServerAdvertisedEndpoints.ts b/apps/server/src/remoteAccess/ServerAdvertisedEndpoints.ts new file mode 100644 index 00000000000..d211f082b94 --- /dev/null +++ b/apps/server/src/remoteAccess/ServerAdvertisedEndpoints.ts @@ -0,0 +1,213 @@ +import * as NodeOS from "node:os"; + +import type { AdvertisedEndpoint, AdvertisedEndpointProvider } from "@t3tools/contracts"; +import { createAdvertisedEndpoint } from "@t3tools/shared/advertisedEndpoint"; +import { + isTailscaleIpv4Address, + resolveTailscaleAdvertisedEndpoints, + type TailscaleNetworkInterfaces, +} from "@t3tools/tailscale"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { HttpServer } from "effect/unstable/http"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; + +import * as ServerConfig from "../config.ts"; +import { + formatHostForUrl, + isLoopbackHost, + isWildcardHost, + resolveListeningPort, +} from "../startupAccess.ts"; + +const SERVER_ENDPOINT_PROVIDER: AdvertisedEndpointProvider = { + id: "server-core", + label: "Server", + kind: "core", + isAddon: false, +}; + +/** How long a resolved endpoint set is reused before `tailscale status` runs + * again. Repeated client polls must not re-spawn the Tailscale CLI. */ +const ENDPOINT_CACHE_TTL = "60 seconds"; + +const isPrivateIpv4 = (address: string): boolean => { + const octets = address.split(".").map((part) => Number.parseInt(part, 10)); + if (octets.length !== 4 || octets.some((octet) => !Number.isInteger(octet))) return false; + const [first, second] = octets as [number, number, number, number]; + if (first === 10) return true; + if (first === 172 && second >= 16 && second <= 31) return true; + return first === 192 && second === 168; +}; + +const isIpv4Family = (family: string | number): boolean => family === "IPv4" || family === 4; + +/** Binding mode implied by the configured host, which decides which addresses + * actually accept connections. */ +type BindingMode = "loopback" | "wildcard" | "specific"; + +const resolveBindingMode = (host: string | undefined): BindingMode => { + if (isWildcardHost(host)) return "wildcard"; + // An unset host means the server binds 127.0.0.1 (see server.ts). + if (isLoopbackHost(host)) return "loopback"; + return "specific"; +}; + +/** Tailscale Serve proxies to 127.0.0.1, so its HTTPS endpoint stays reachable + * under a loopback binding; the plain Tailnet-IP HTTP endpoints do not. */ +const isHttpsEndpoint = (endpoint: AdvertisedEndpoint): boolean => + endpoint.httpBaseUrl.startsWith("https://"); + +export interface ResolveServerAdvertisedEndpointsInput { + readonly port: number; + readonly host: string | undefined; + readonly networkInterfaces: TailscaleNetworkInterfaces; + readonly tailscaleEndpoints: readonly AdvertisedEndpoint[]; +} + +/** Pure projection of the server's own reachable endpoints, given its binding, + * the host's network interfaces, and any Tailscale endpoints resolved + * separately. Exported for tests. */ +export function resolveServerAdvertisedEndpoints( + input: ResolveServerAdvertisedEndpointsInput, +): readonly AdvertisedEndpoint[] { + const mode = resolveBindingMode(input.host); + const loopbackEndpoint = createAdvertisedEndpoint({ + provider: SERVER_ENDPOINT_PROVIDER, + source: "server", + id: `server-loopback:${input.port}`, + label: "This machine", + httpBaseUrl: `http://127.0.0.1:${input.port}`, + reachability: "loopback", + status: "available", + description: "Loopback endpoint for this server.", + }); + + if (mode === "loopback") { + return [loopbackEndpoint, ...input.tailscaleEndpoints.filter(isHttpsEndpoint)]; + } + + if (mode === "specific") { + // A specific non-loopback binding is not listening on 127.0.0.1, so neither + // the loopback endpoint nor Tailscale Serve (which proxies there) applies. + // A bound Tailnet IP is covered by the record below. + const rawHost = input.host ?? ""; + const host = formatHostForUrl(rawHost); + const { reachability, label } = isTailscaleIpv4Address(rawHost) + ? ({ reachability: "private-network", label: "Private network" } as const) + : isPrivateIpv4(rawHost) + ? ({ reachability: "lan", label: "Local network" } as const) + : ({ reachability: "public", label: "Public IP" } as const); + const httpBaseUrl = `http://${host}:${input.port}`; + return [ + createAdvertisedEndpoint({ + provider: SERVER_ENDPOINT_PROVIDER, + source: "server", + id: `server-${reachability}:${httpBaseUrl}`, + label, + httpBaseUrl, + reachability, + status: "available", + }), + ]; + } + + const endpoints: AdvertisedEndpoint[] = [loopbackEndpoint]; + const seen = new Set(); + let hasDefaultLan = false; + + for (const interfaceAddresses of Object.values(input.networkInterfaces)) { + if (!interfaceAddresses) continue; + + for (const entry of interfaceAddresses) { + if (entry.internal) continue; + if (!isIpv4Family(entry.family)) continue; + const address = entry.address; + if (address.startsWith("127.") || address.startsWith("169.254.")) continue; + // Tailnet addresses are advertised by the Tailscale provider instead. + if (isTailscaleIpv4Address(address)) continue; + if (seen.has(address)) continue; + seen.add(address); + + const isLan = isPrivateIpv4(address); + const httpBaseUrl = `http://${address}:${input.port}`; + const isDefault = isLan && !hasDefaultLan; + if (isDefault) hasDefaultLan = true; + + endpoints.push( + createAdvertisedEndpoint({ + provider: SERVER_ENDPOINT_PROVIDER, + source: "server", + id: `server-${isLan ? "lan" : "public"}:${httpBaseUrl}`, + label: isLan ? "Local network" : "Public IP", + httpBaseUrl, + reachability: isLan ? "lan" : "public", + status: "available", + ...(isDefault ? { isDefault: true } : {}), + }), + ); + } + } + + return [...endpoints, ...input.tailscaleEndpoints]; +} + +export class ServerAdvertisedEndpoints extends Context.Service< + ServerAdvertisedEndpoints, + { + readonly getEndpoints: Effect.Effect; + } +>()("t3/remoteAccess/ServerAdvertisedEndpoints") {} + +export const make = Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const httpServer = yield* HttpServer.HttpServer; + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const httpClient = yield* HttpClient.HttpClient; + + const readNetworkInterfaces = Effect.sync(() => NodeOS.networkInterfaces()).pipe( + Effect.map((interfaces): TailscaleNetworkInterfaces => interfaces), + ); + + const resolveEndpoints = Effect.gen(function* () { + const port = resolveListeningPort(httpServer.address, config.port); + const networkInterfaces = yield* readNetworkInterfaces; + const mode = resolveBindingMode(config.host); + + // Skip the Tailscale resolver when nothing Tailscale-reachable could exist: + // spawning the CLI triggers a macOS TCC prompt on sandboxed installs. + const shouldResolveTailscale = + mode === "wildcard" || (mode === "loopback" && config.tailscaleServeEnabled); + const tailscaleEndpoints = shouldResolveTailscale + ? yield* resolveTailscaleAdvertisedEndpoints({ + port, + source: "server", + serveEnabled: config.tailscaleServeEnabled, + servePort: config.tailscaleServePort, + networkInterfaces, + }).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, childProcessSpawner), + Effect.provideService(HttpClient.HttpClient, httpClient), + ) + : []; + + return resolveServerAdvertisedEndpoints({ + port, + host: config.host, + networkInterfaces, + tailscaleEndpoints, + }); + }); + + const getEndpoints = yield* Effect.cachedWithTTL(resolveEndpoints, ENDPOINT_CACHE_TTL); + + return ServerAdvertisedEndpoints.of({ + getEndpoints: getEndpoints.pipe( + Effect.withSpan("server.remoteAccess.resolveAdvertisedEndpoints"), + ), + }); +}); + +export const layer = Layer.effect(ServerAdvertisedEndpoints, make); diff --git a/apps/server/src/remoteAccess/http.ts b/apps/server/src/remoteAccess/http.ts new file mode 100644 index 00000000000..251f2e22793 --- /dev/null +++ b/apps/server/src/remoteAccess/http.ts @@ -0,0 +1,25 @@ +import { AuthRelayReadScope, EnvironmentHttpApi } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder"; + +import { annotateEnvironmentRequest, requireEnvironmentScope } from "../auth/http.ts"; +import { ServerAdvertisedEndpoints } from "./ServerAdvertisedEndpoints.ts"; + +// `relay:read` is part of the standard client scope set, so an ordinary paired +// or relay-tunneled client can discover the direct endpoints it might promote +// to. `access:read` would restrict this to admin clients. +export const remoteAccessHttpApiLayer = HttpApiBuilder.group( + EnvironmentHttpApi, + "remoteAccess", + Effect.fnUntraced(function* (handlers) { + const advertisedEndpoints = yield* ServerAdvertisedEndpoints; + return handlers.handle( + "advertisedEndpoints", + Effect.fn("environment.remoteAccess.advertisedEndpoints")(function* (args) { + yield* annotateEnvironmentRequest(args.endpoint.name); + yield* requireEnvironmentScope(AuthRelayReadScope); + return yield* advertisedEndpoints.getEndpoints; + }), + ); + }), +); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 49a3a31940f..02fb62b9b0f 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -42,6 +42,8 @@ import { ProviderInstanceRegistryHydrationLive } from "./provider/Layers/Provide import * as TerminalManager from "./terminal/Manager.ts"; import * as McpHttpServer from "./mcp/McpHttpServer.ts"; import * as McpSessionRegistry from "./mcp/McpSessionRegistry.ts"; +import { remoteAccessHttpApiLayer } from "./remoteAccess/http.ts"; +import * as ServerAdvertisedEndpoints from "./remoteAccess/ServerAdvertisedEndpoints.ts"; import * as PreviewAutomationBroker from "./mcp/PreviewAutomationBroker.ts"; import * as PreviewManager from "./preview/Manager.ts"; import * as PortScanner from "./preview/PortScanner.ts"; @@ -431,6 +433,7 @@ export const makeRoutesLayer = Layer.mergeAll( Layer.provide(connectHttpApiLayer), Layer.provide(orchestrationHttpApiLayer), Layer.provide(serverEnvironmentHttpApiLayer), + Layer.provide(remoteAccessHttpApiLayer.pipe(Layer.provide(ServerAdvertisedEndpoints.layer))), Layer.provide(environmentAuthenticatedAuthLayer), ), otlpTracesProxyRouteLayer, diff --git a/docs/internals/remote.md b/docs/internals/remote.md index afce95f725b..37376651e8e 100644 --- a/docs/internals/remote.md +++ b/docs/internals/remote.md @@ -149,6 +149,27 @@ relay Worker only brokers credentials and a managed endpoint; application traffi the provisioned Cloudflare tunnel hostname for the life of the connection, not through the relay Worker itself. See [t3-connect.md](./t3-connect.md). +#### Local promotion + +A relay connection promotes itself to a direct route when one exists. The server exposes its own +advertised endpoints on the authenticated `GET /api/remote-access/endpoints` surface +(`apps/server/src/remoteAccess/`, `relay:read` scope). While a relay lease is connected, the +supervisor runs a discovery pass ([`connection/promotion.ts`][promotion]): it fetches the +environment's advertised endpoints through the tunnel, filters to direct candidates (`lan` and +`private-network` reachability), probes them with the descriptor endpoint to confirm the same +`environmentId`, and records the best one as a per-environment route override. The supervisor then +replaces the lease without backoff, and the relay broker connects through the override using the +cached DPoP access token. The token is not host-bound; only the per-request DPoP proof is, and +proofs are minted fresh for the direct origin. No relay round-trip or re-bootstrap is involved. + +The relay stays authoritative as the fallback: a failed direct attempt clears the override, starts +a cooldown for that endpoint so a flaky LAN cannot ping-pong the connection, and falls back to the +relay path within the same prepare call. Discovery re-runs on an interval while relay-connected, so +moving onto the environment's network is noticed even though Wi-Fi-to-Wi-Fi moves produce no +connectivity signal. `RelayConnectionTarget` persistence is unchanged; the override is in-memory +per client session. Promotion requires a DPoP credential and never applies to cookie-authenticated +primary connections. + ### Tailscale access A T3-managed `tailscale serve` mapping exposes the server on the tailnet over HTTPS, and the @@ -229,6 +250,7 @@ These remain unbuilt and are listed to keep the model honest: - richer multi-environment UI beyond the current connections list. [model]: ../../packages/client-runtime/src/connection/model.ts +[promotion]: ../../packages/client-runtime/src/connection/promotion.ts [onboarding]: ../../packages/client-runtime/src/connection/onboarding.ts [authremote]: ../../packages/client-runtime/src/authorization/remote.ts [sshenv]: ../../apps/desktop/src/ssh/DesktopSshEnvironment.ts diff --git a/docs/user/remote-access.md b/docs/user/remote-access.md index 05418022d23..9031c85701a 100644 --- a/docs/user/remote-access.md +++ b/docs/user/remote-access.md @@ -22,6 +22,18 @@ This publishes the server over Tailscale Serve HTTPS (configuring the mapping if If no server is running, `t3 pair` says so and points you at `npx t3 serve` or `npx t3 connect`. +## Automatic Local Promotion + +Environments connected through T3 Connect switch to a direct connection on their own when one is +available. While connected through the tunnel, the app asks the environment which local addresses +also reach it, checks them from your device, and reconnects through the local network or tailnet +route when one works. If that direct route later fails, for example because you leave the network, +the connection falls back to the tunnel automatically. + +No setup is needed beyond the environment being reachable directly: same network, a shared +tailnet, or Tailscale Serve. Servers only advertise addresses they actually listen on, so a +loopback-only server does not offer promotion. + ## Recommended Setup Use a trusted private network that meshes your devices together, such as a tailnet. diff --git a/packages/client-runtime/src/authorization/service.ts b/packages/client-runtime/src/authorization/service.ts index 410c7a39dc0..1cefc786a8c 100644 --- a/packages/client-runtime/src/authorization/service.ts +++ b/packages/client-runtime/src/authorization/service.ts @@ -7,7 +7,11 @@ import { resolveRemoteWebSocketConnectionUrl, } from "./remote.ts"; import { environmentMismatchError, mapRemoteEnvironmentError } from "../connection/errors.ts"; -import { ConnectionBlockedError, type ConnectionAttemptError } from "../connection/model.ts"; +import { + ConnectionBlockedError, + ConnectionTransientError, + type ConnectionAttemptError, +} from "../connection/model.ts"; import { fetchRemoteEnvironmentDescriptor } from "../environment/descriptor.ts"; import { environmentEndpointUrl } from "../environment/endpoint.ts"; import * as ClientCapabilities from "../platform/capabilities.ts"; @@ -38,6 +42,16 @@ export interface AuthorizedRemoteEnvironment { readonly httpAuthorization: PreparedHttpAuthorization; } +/** An alternative direct route to the same environment, tried before the + * stored relay endpoint when a cached DPoP access token is available. The + * access token is not bound to a host, so it works against any origin of the + * same server; only the per-request DPoP proof is URL-bound and is minted + * fresh for whichever endpoint is used. */ +export interface AuthorizedEndpointOverride { + readonly httpBaseUrl: string; + readonly wsBaseUrl: string; +} + export class RemoteEnvironmentAuthorization extends Context.Service< RemoteEnvironmentAuthorization, { @@ -54,6 +68,10 @@ export class RemoteEnvironmentAuthorization extends Context.Service< ConnectionAttemptError >; }) => Effect.Effect; + readonly authorizeDpopDirect: (input: { + readonly expectedEnvironmentId: EnvironmentId; + readonly endpoint: AuthorizedEndpointOverride; + }) => Effect.Effect; } >()("@t3tools/client-runtime/authorization/service/RemoteEnvironmentAuthorization") {} @@ -149,11 +167,20 @@ 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, + // The cached token works against any origin of the same server, so a + // direct (LAN/Tailscale) endpoint can be substituted for the stored + // relay endpoint when minting the websocket ticket. + endpoint?: AuthorizedEndpointOverride, + ) { + const httpBaseUrl = endpoint?.httpBaseUrl ?? token.endpoint.httpBaseUrl; + const wsBaseUrl = endpoint?.wsBaseUrl ?? token.endpoint.wsBaseUrl; const ticketProof = yield* signer .createProof({ method: "POST", - url: environmentEndpointUrl(token.endpoint.httpBaseUrl, "/api/auth/websocket-ticket"), + url: environmentEndpointUrl(httpBaseUrl, "/api/auth/websocket-ticket"), accessToken: token.accessToken, }) .pipe( @@ -166,8 +193,8 @@ export const make = Effect.gen(function* () { ), ); return yield* resolveRemoteDpopWebSocketConnectionUrl({ - wsBaseUrl: token.endpoint.wsBaseUrl, - httpBaseUrl: token.endpoint.httpBaseUrl, + wsBaseUrl, + httpBaseUrl, accessToken: token.accessToken, dpopProof: ticketProof, ...(timeoutMs === undefined ? {} : { timeoutMs }), @@ -296,10 +323,71 @@ export const make = Effect.gen(function* () { }, ); + // Connects through a direct route using the cached DPoP access token. There + // is deliberately no bootstrap fallback here: bootstrap credentials are + // single-use relay grants, so when no valid cached token exists the caller + // must connect through the relay path instead. + const authorizeDpopDirect = Effect.fn("clientRuntime.connection.remote.authorizeDpopDirect")( + function* (input: { + readonly expectedEnvironmentId: EnvironmentId; + readonly endpoint: AuthorizedEndpointOverride; + }) { + const thumbprint = yield* signer.thumbprint.pipe( + Effect.mapError( + () => + new ConnectionBlockedError({ + reason: "configuration", + detail: "Could not load the environment authorization key.", + }), + ), + ); + const now = yield* Clock.currentTimeMillis; + const cached = yield* tokenStore.get(input.expectedEnvironmentId); + if ( + Option.isNone(cached) || + cached.value.dpopThumbprint !== thumbprint || + cached.value.expiresAtEpochMs <= now + TOKEN_EXPIRY_SAFETY_MARGIN_MS + ) { + return yield* new ConnectionTransientError({ + reason: "endpoint-unavailable", + detail: "No cached environment credential is available for the direct route.", + }); + } + // The descriptor check keeps us from talking to an unrelated server that + // answers on the direct address. + const descriptor = yield* fetchDescriptor(input.endpoint.httpBaseUrl).pipe( + Effect.provideService(HttpClient.HttpClient, httpClient), + ); + if (descriptor.environmentId !== input.expectedEnvironmentId) { + return yield* environmentMismatchError({ + expected: input.expectedEnvironmentId, + actual: descriptor.environmentId, + }); + } + const socketUrl = yield* createDpopSocketUrl( + cached.value, + CACHED_ENDPOINT_SOCKET_TIMEOUT_MS, + input.endpoint, + ).pipe(Effect.mapError(mapDpopSocketError)); + return { + environmentId: descriptor.environmentId, + label: descriptor.label, + httpBaseUrl: input.endpoint.httpBaseUrl, + socketUrl, + httpAuthorization: { + _tag: "Dpop" as const, + accessToken: cached.value.accessToken, + }, + }; + }, + ); + return RemoteEnvironmentAuthorization.of({ authorizeBearer, authorizeDpop: (input) => authorizeDpop(input).pipe(Effect.withSpan("environment.authorization")), + authorizeDpopDirect: (input) => + authorizeDpopDirect(input).pipe(Effect.withSpan("environment.authorization.direct")), }); }); diff --git a/packages/client-runtime/src/connection/index.ts b/packages/client-runtime/src/connection/index.ts index 53a041bbf30..7f98ead6d3c 100644 --- a/packages/client-runtime/src/connection/index.ts +++ b/packages/client-runtime/src/connection/index.ts @@ -23,6 +23,7 @@ export { } from "./onboarding.ts"; export * from "./presentation.ts"; export * as ProfileStore from "./profileStore.ts"; +export { ConnectionPromotion, type PromotedRoute, selectPromotionCandidates } from "./promotion.ts"; export { EnvironmentNotRegisteredError, EnvironmentRegistry, diff --git a/packages/client-runtime/src/connection/layer.ts b/packages/client-runtime/src/connection/layer.ts index 798ec01e2f0..92645cbf490 100644 --- a/packages/client-runtime/src/connection/layer.ts +++ b/packages/client-runtime/src/connection/layer.ts @@ -4,6 +4,7 @@ import * as Stream from "effect/Stream"; import * as ConnectionResolver from "./resolver.ts"; import * as ConnectionDriver from "./driver.ts"; +import * as ConnectionPromotion from "./promotion.ts"; import * as EnvironmentRegistry from "./registry.ts"; import * as ConnectionOnboarding from "./onboarding.ts"; import * as PlatformConnectionSource from "../platform/source.ts"; @@ -11,15 +12,22 @@ import * as RelayEnvironmentDiscovery from "../relay/discovery.ts"; import * as RemoteEnvironmentAuthorization from "../authorization/service.ts"; import * as RpcSession from "../rpc/session.ts"; +// One promotion service instance is shared by the resolver (route override at +// prepare time) and the supervisors (discovery while relay-connected); layer +// memoization keeps both references pointing at the same instance. +const promotionLayer = ConnectionPromotion.layer; + const resolverLayer = ConnectionResolver.layer.pipe( - Layer.provide(RemoteEnvironmentAuthorization.layer), + Layer.provide(Layer.mergeAll(RemoteEnvironmentAuthorization.layer, promotionLayer)), ); const driverLayer = ConnectionDriver.layer.pipe( Layer.provide(Layer.mergeAll(resolverLayer, RpcSession.layer)), ); -const registryLayer = EnvironmentRegistry.layer.pipe(Layer.provide(driverLayer)); +const registryLayer = EnvironmentRegistry.layer.pipe( + Layer.provide(Layer.mergeAll(driverLayer, promotionLayer)), +); const onboardingLayer = ConnectionOnboarding.layer.pipe(Layer.provide(registryLayer)); @@ -27,6 +35,7 @@ const connectionServicesLayer = Layer.mergeAll( registryLayer, RelayEnvironmentDiscovery.layer, onboardingLayer, + promotionLayer, ); const connectionStartupLayer = Layer.effectDiscard( diff --git a/packages/client-runtime/src/connection/promotion.test.ts b/packages/client-runtime/src/connection/promotion.test.ts new file mode 100644 index 00000000000..81c08a6717a --- /dev/null +++ b/packages/client-runtime/src/connection/promotion.test.ts @@ -0,0 +1,96 @@ +import type { AdvertisedEndpoint } from "@t3tools/contracts"; +import { describe, expect, it } from "@effect/vitest"; + +import { selectPromotionCandidates } from "./promotion.ts"; + +function endpoint(input: { + readonly id: string; + readonly httpBaseUrl: string; + readonly reachability: AdvertisedEndpoint["reachability"]; + readonly status?: AdvertisedEndpoint["status"]; +}): AdvertisedEndpoint { + return { + id: input.id, + label: input.id, + provider: { id: "server-core", label: "Server", kind: "core", isAddon: false }, + httpBaseUrl: input.httpBaseUrl, + wsBaseUrl: input.httpBaseUrl.replace("http", "ws"), + reachability: input.reachability, + compatibility: { hostedHttpsApp: "unknown", desktopApp: "compatible" }, + source: "server", + status: input.status ?? "available", + }; +} + +const RELAY_BASE_URL = "https://tunnel.example.test/"; + +describe("selectPromotionCandidates", () => { + it("keeps direct lan and private-network endpoints, preferring lan", () => { + const candidates = selectPromotionCandidates({ + endpoints: [ + endpoint({ + id: "tailscale-ip:http://100.64.0.7:3773", + httpBaseUrl: "http://100.64.0.7:3773/", + reachability: "private-network", + }), + endpoint({ + id: "server-lan:http://192.168.1.20:3773", + httpBaseUrl: "http://192.168.1.20:3773/", + reachability: "lan", + }), + ], + currentHttpBaseUrl: RELAY_BASE_URL, + }); + expect(candidates.map((candidate) => candidate.id)).toEqual([ + "server-lan:http://192.168.1.20:3773", + "tailscale-ip:http://100.64.0.7:3773", + ]); + }); + + it("excludes loopback, public, unavailable, and current-route endpoints", () => { + const candidates = selectPromotionCandidates({ + endpoints: [ + endpoint({ + id: "server-loopback:3773", + httpBaseUrl: "http://127.0.0.1:3773/", + reachability: "loopback", + }), + endpoint({ + id: "server-public:http://203.0.113.5:3773", + httpBaseUrl: "http://203.0.113.5:3773/", + reachability: "public", + }), + endpoint({ + id: "tailscale-magicdns:https://machine.tail.ts.net/", + httpBaseUrl: "https://machine.tail.ts.net/", + reachability: "private-network", + status: "unavailable", + }), + endpoint({ + id: "server-lan:http://192.168.1.20:3773", + httpBaseUrl: "http://192.168.1.20:3773", + reachability: "lan", + }), + ], + // Already connected through the LAN route (normalization makes the + // trailing-slash difference irrelevant). + currentHttpBaseUrl: "http://192.168.1.20:3773/", + }); + expect(candidates).toEqual([]); + }); + + it("excludes endpoints cooling down after a failed promotion", () => { + const candidates = selectPromotionCandidates({ + endpoints: [ + endpoint({ + id: "server-lan:http://192.168.1.20:3773", + httpBaseUrl: "http://192.168.1.20:3773/", + reachability: "lan", + }), + ], + currentHttpBaseUrl: RELAY_BASE_URL, + cooldownEndpointIds: new Set(["server-lan:http://192.168.1.20:3773"]), + }); + expect(candidates).toEqual([]); + }); +}); diff --git a/packages/client-runtime/src/connection/promotion.ts b/packages/client-runtime/src/connection/promotion.ts new file mode 100644 index 00000000000..b386f96851e --- /dev/null +++ b/packages/client-runtime/src/connection/promotion.ts @@ -0,0 +1,256 @@ +import type { AdvertisedEndpoint, EnvironmentId } from "@t3tools/contracts"; +import * as Clock from "effect/Clock"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Ref from "effect/Ref"; +import * as HttpClient from "effect/unstable/http/HttpClient"; + +import { fetchRemoteEnvironmentDescriptor } from "../environment/descriptor.ts"; +import { environmentEndpointUrl } from "../environment/endpoint.ts"; +import * as ManagedRelay from "../relay/managedRelay.ts"; +import { executeEnvironmentHttpRequest, makeEnvironmentHttpApiClient } from "../rpc/http.ts"; +import { buildEnvironmentAuthHeaders } from "../state/environmentHttpAuth.ts"; +import type { PreparedConnection } from "./model.ts"; + +/** A direct route a relay-connected environment can be promoted to. */ +export interface PromotedRoute { + readonly endpointId: string; + readonly httpBaseUrl: string; + readonly wsBaseUrl: string; +} + +const ENDPOINTS_REQUEST_TIMEOUT_MS = 10_000; +const CANDIDATE_PROBE_TIMEOUT_MS = 3_000; +const MAX_PROBED_CANDIDATES = 5; +/** After a promoted route fails, stay on the relay and do not re-promote that + * endpoint until the cooldown expires. Keeps a flaky LAN from ping-ponging the + * connection between routes. */ +const PROMOTION_FAILURE_COOLDOWN_MS = 5 * 60_000; + +const reachabilityRank: Record = { + lan: 0, + "private-network": 1, + loopback: 2, + public: 3, +}; + +function normalizedBaseUrl(rawValue: string): string | null { + try { + const url = new URL(rawValue); + url.pathname = "/"; + url.search = ""; + url.hash = ""; + return url.toString(); + } catch { + return null; + } +} + +/** + * Filter and rank advertised endpoints down to promotion candidates: reachable + * direct endpoints (LAN or private network) that are not the route we are + * already using and are not cooling down after a recent failure. Loopback is + * excluded (it only ever reaches the server's own machine) and public + * endpoints are excluded (promotion targets same-network or tailnet routes; + * a public route is not obviously better than the relay). + */ +export function selectPromotionCandidates(input: { + readonly endpoints: readonly AdvertisedEndpoint[]; + readonly currentHttpBaseUrl: string; + readonly cooldownEndpointIds?: ReadonlySet; +}): readonly AdvertisedEndpoint[] { + const currentBaseUrl = normalizedBaseUrl(input.currentHttpBaseUrl); + return input.endpoints + .filter((endpoint) => { + if (endpoint.status === "unavailable") return false; + if (endpoint.reachability !== "lan" && endpoint.reachability !== "private-network") { + return false; + } + if (input.cooldownEndpointIds?.has(endpoint.id)) return false; + return normalizedBaseUrl(endpoint.httpBaseUrl) !== currentBaseUrl; + }) + .sort( + (left, right) => reachabilityRank[left.reachability] - reachabilityRank[right.reachability], + ) + .slice(0, MAX_PROBED_CANDIDATES); +} + +interface PromotionCooldown { + readonly endpointId: string; + readonly failedAtEpochMs: number; +} + +/** + * Discovers direct routes for relay-connected environments and remembers the + * chosen route as a per-environment override. The relay connection broker + * consults the override at prepare time and tries the direct route before + * falling back to the managed relay tunnel; the supervisor runs `discover` + * over the live session after a relay connection is established. + */ +export class ConnectionPromotion extends Context.Service< + ConnectionPromotion, + { + /** Current route override for an environment, if a promotion is active. */ + readonly overrideFor: ( + environmentId: EnvironmentId, + ) => Effect.Effect>; + /** Clears the override and starts the failure cooldown for its endpoint. + * Called when connecting through the override failed and the connection + * fell back to the relay. */ + readonly reportOverrideFailed: (environmentId: EnvironmentId) => Effect.Effect; + /** Fetches the environment's advertised endpoints over the connected + * (typically relay-tunneled) session, probes direct candidates, verifies + * they are the same environment, and stores the best one as the override. + * Never fails; returns none when no direct route is usable. */ + readonly discover: ( + prepared: PreparedConnection, + ) => Effect.Effect>; + } +>()("@t3tools/client-runtime/connection/promotion/ConnectionPromotion") {} + +const fetchAdvertisedEndpoints = Effect.fn("clientRuntime.connection.promotion.fetchEndpoints")( + function* (prepared: PreparedConnection, signer: ManagedRelay.ManagedRelayDpopSigner["Service"]) { + const requestUrl = environmentEndpointUrl(prepared.httpBaseUrl, "/api/remote-access/endpoints"); + const headers = yield* buildEnvironmentAuthHeaders( + prepared.httpAuthorization, + "GET", + requestUrl, + Option.some(signer), + ); + const client = yield* makeEnvironmentHttpApiClient(prepared.httpBaseUrl); + return yield* executeEnvironmentHttpRequest( + requestUrl, + ENDPOINTS_REQUEST_TIMEOUT_MS, + client.remoteAccess.advertisedEndpoints({ headers }), + ); + }, +); + +export const make = Effect.gen(function* () { + const signer = yield* ManagedRelay.ManagedRelayDpopSigner; + const httpClient = yield* HttpClient.HttpClient; + const overrides = yield* Ref.make>(new Map()); + const cooldowns = yield* Ref.make>(new Map()); + + const overrideFor = Effect.fn("ConnectionPromotion.overrideFor")(function* ( + environmentId: EnvironmentId, + ) { + const override = (yield* Ref.get(overrides)).get(environmentId); + return override === undefined ? Option.none() : Option.some(override); + }); + + const reportOverrideFailed = Effect.fn("ConnectionPromotion.reportOverrideFailed")(function* ( + environmentId: EnvironmentId, + ) { + const override = (yield* Ref.get(overrides)).get(environmentId); + if (override === undefined) { + return; + } + const now = yield* Clock.currentTimeMillis; + yield* Ref.update(overrides, (current) => { + const next = new Map(current); + next.delete(environmentId); + return next; + }); + yield* Ref.update(cooldowns, (current) => { + const next = new Map(current); + next.set(environmentId, { endpointId: override.endpointId, failedAtEpochMs: now }); + return next; + }); + yield* Effect.logInfo("Direct environment route failed; falling back to the relay.").pipe( + Effect.annotateLogs({ + "environment.id": environmentId, + "promotion.endpoint.id": override.endpointId, + }), + ); + }); + + const activeCooldownEndpointIds = Effect.fnUntraced(function* (environmentId: EnvironmentId) { + const cooldown = (yield* Ref.get(cooldowns)).get(environmentId); + if (cooldown === undefined) { + return new Set(); + } + const now = yield* Clock.currentTimeMillis; + if (cooldown.failedAtEpochMs + PROMOTION_FAILURE_COOLDOWN_MS <= now) { + yield* Ref.update(cooldowns, (current) => { + const next = new Map(current); + next.delete(environmentId); + return next; + }); + return new Set(); + } + return new Set([cooldown.endpointId]); + }); + + const probeCandidate = Effect.fnUntraced(function* ( + candidate: AdvertisedEndpoint, + environmentId: EnvironmentId, + ) { + const descriptor = yield* fetchRemoteEnvironmentDescriptor({ + httpBaseUrl: candidate.httpBaseUrl, + timeoutMs: CANDIDATE_PROBE_TIMEOUT_MS, + }); + return descriptor.environmentId === environmentId; + }); + + const discover = Effect.fn("ConnectionPromotion.discover")(function* ( + prepared: PreparedConnection, + ) { + const endpoints = yield* fetchAdvertisedEndpoints(prepared, signer).pipe( + Effect.catch((error) => + Effect.logDebug("Advertised endpoint discovery failed.", error).pipe( + Effect.as([]), + ), + ), + ); + const candidates = selectPromotionCandidates({ + endpoints, + currentHttpBaseUrl: prepared.httpBaseUrl, + cooldownEndpointIds: yield* activeCooldownEndpointIds(prepared.environmentId), + }); + if (candidates.length === 0) { + return Option.none(); + } + // Probe candidates concurrently; the same-environment check keeps us from + // promoting to an unrelated server that happens to answer on a LAN + // address. Preference order (LAN before tailnet) breaks ties. + const probed = yield* Effect.all( + candidates.map((candidate) => + probeCandidate(candidate, prepared.environmentId).pipe(Effect.orElseSucceed(() => false)), + ), + { concurrency: "unbounded" }, + ); + const chosen = candidates.find((_, index) => probed[index] === true); + if (chosen === undefined) { + return Option.none(); + } + const route: PromotedRoute = { + endpointId: chosen.id, + httpBaseUrl: chosen.httpBaseUrl, + wsBaseUrl: chosen.wsBaseUrl, + }; + yield* Ref.update(overrides, (current) => { + const next = new Map(current); + next.set(prepared.environmentId, route); + return next; + }); + yield* Effect.logInfo("Discovered a direct environment route.").pipe( + Effect.annotateLogs({ + "environment.id": prepared.environmentId, + "promotion.endpoint.id": route.endpointId, + }), + ); + return Option.some(route); + }); + + return ConnectionPromotion.of({ + overrideFor, + reportOverrideFailed, + discover: (prepared) => + discover(prepared).pipe(Effect.provideService(HttpClient.HttpClient, httpClient)), + }); +}); + +export const layer = Layer.effect(ConnectionPromotion, make); diff --git a/packages/client-runtime/src/connection/registry.ts b/packages/client-runtime/src/connection/registry.ts index a3a36272f32..3418a5d4ab4 100644 --- a/packages/client-runtime/src/connection/registry.ts +++ b/packages/client-runtime/src/connection/registry.ts @@ -33,6 +33,7 @@ import type { import * as Persistence from "../platform/persistence.ts"; import * as EnvironmentSupervisor from "./supervisor.ts"; import * as ConnectionDriver from "./driver.ts"; +import * as ConnectionPromotion from "./promotion.ts"; import * as ConnectionWakeups from "./wakeups.ts"; const isSshConnectionProfile = Schema.is(SshConnectionProfile); @@ -135,6 +136,10 @@ export const make = Effect.gen(function* () { const connectivity = yield* Connectivity.Connectivity; const driver = yield* ConnectionDriver.ConnectionDriver; const wakeups = yield* ConnectionWakeups.ConnectionWakeups; + // Optional: without the promotion service, relay connections never promote + // to direct routes. Captured here so supervisors see it regardless of the + // caller's fiber context. + const promotion = yield* Effect.serviceOption(ConnectionPromotion.ConnectionPromotion); const ssh = yield* ClientCapabilities.SshEnvironmentGateway; const persistedTargets = yield* storage.list; const initialEntries = new Map( @@ -250,9 +255,14 @@ export const make = Effect.gen(function* () { Effect.gen(function* () { const environmentId = entry.target.environmentId; const scope = yield* Scope.make(); - const supervisor = yield* EnvironmentSupervisor.make(entry, { - initiallyDesired: false, - }).pipe( + const makeSupervisor = Option.match(promotion, { + onNone: () => EnvironmentSupervisor.make(entry, { initiallyDesired: false }), + onSome: (service) => + EnvironmentSupervisor.make(entry, { initiallyDesired: false }).pipe( + Effect.provideService(ConnectionPromotion.ConnectionPromotion, service), + ), + }); + const supervisor = yield* makeSupervisor.pipe( Effect.provideService(Connectivity.Connectivity, connectivity), Effect.provideService(ConnectionDriver.ConnectionDriver, driver), Effect.provideService(ConnectionWakeups.ConnectionWakeups, wakeups), diff --git a/packages/client-runtime/src/connection/resolver.test.ts b/packages/client-runtime/src/connection/resolver.test.ts index d0375e55556..4a996ad73c5 100644 --- a/packages/client-runtime/src/connection/resolver.test.ts +++ b/packages/client-runtime/src/connection/resolver.test.ts @@ -9,6 +9,7 @@ import * as Ref from "effect/Ref"; import * as Tracer from "effect/Tracer"; import * as ManagedRelay from "../relay/managedRelay.ts"; +import * as ConnectionPromotion from "./promotion.ts"; import * as ConnectionResolver from "./resolver.ts"; import * as ClientCapabilities from "../platform/capabilities.ts"; import * as RemoteEnvironmentAuthorization from "../authorization/service.ts"; @@ -95,8 +96,11 @@ const makeDependencies = Effect.fn("TestConnectionResolver.makeDependencies")((o readonly connectEnvironment?: ManagedRelay.ManagedRelayClient["Service"]["connectEnvironment"]; readonly authorizeBearer?: RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization["Service"]["authorizeBearer"]; readonly authorizeDpop?: RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization["Service"]["authorizeDpop"]; + readonly authorizeDpopDirect?: RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization["Service"]["authorizeDpopDirect"]; readonly primaryBearerToken?: string; readonly prepareSsh?: ClientCapabilities.SshEnvironmentGateway["Service"]["prepare"]; + readonly promotionOverride?: ConnectionPromotion.PromotedRoute; + readonly promotionFailures?: Ref.Ref>; }) => { const profiles = new Map( (options?.profiles ?? []).map((profile) => [profile.connectionId, profile]), @@ -143,6 +147,27 @@ const makeDependencies = Effect.fn("TestConnectionResolver.makeDependencies")((o }, }), )), + authorizeDpopDirect: + options?.authorizeDpopDirect ?? + (() => + Effect.fail( + new ConnectionTransientError({ + reason: "endpoint-unavailable", + detail: "No cached environment credential is available for the direct route.", + }), + )), + }); + const promotionOverride = options?.promotionOverride; + const promotion = ConnectionPromotion.ConnectionPromotion.of({ + overrideFor: () => + Effect.succeed( + promotionOverride === undefined ? Option.none() : Option.some(promotionOverride), + ), + reportOverrideFailed: (environmentId) => + options?.promotionFailures === undefined + ? Effect.void + : Ref.update(options.promotionFailures, (current) => [...current, environmentId]), + discover: () => Effect.succeed(Option.none()), }); const ssh = ClientCapabilities.SshEnvironmentGateway.of({ provision: () => Effect.die("unused"), @@ -181,6 +206,7 @@ const makeDependencies = Effect.fn("TestConnectionResolver.makeDependencies")((o }), ), Layer.succeed(RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization, remote), + Layer.succeed(ConnectionPromotion.ConnectionPromotion, promotion), Layer.succeed(ClientCapabilities.SshEnvironmentGateway, ssh), Layer.succeed( ManagedRelay.ManagedRelayClient, @@ -361,6 +387,73 @@ describe("ConnectionResolver", () => { }), ); + it.effect("prepares a relay environment through a promoted direct route", () => + Effect.gen(function* () { + const directInputs = yield* Ref.make>([]); + const target = new RelayConnectionTarget({ + environmentId: ENVIRONMENT_ID, + label: "Cloud", + }); + const brokerLayer = yield* makeDependencies({ + promotionOverride: { + endpointId: "server-lan:http://192.168.1.20:3773", + httpBaseUrl: "http://192.168.1.20:3773/", + wsBaseUrl: "ws://192.168.1.20:3773/", + }, + authorizeDpopDirect: (input) => + Ref.update(directInputs, (values) => [...values, input.endpoint.httpBaseUrl]).pipe( + Effect.as({ + environmentId: input.expectedEnvironmentId, + label: "Cloud", + httpBaseUrl: input.endpoint.httpBaseUrl, + socketUrl: "ws://192.168.1.20:3773/ws?wsTicket=direct", + httpAuthorization: { + _tag: "Dpop" as const, + accessToken: "dpop-access-token", + }, + }), + ), + connectEnvironment: () => Effect.die("relay must not be contacted for a direct route"), + }); + const broker = yield* ConnectionResolver.ConnectionResolver.pipe(Effect.provide(brokerLayer)); + + const preparedConnection = yield* broker.prepare(catalogEntry(target)); + expect(preparedConnection.httpBaseUrl).toBe("http://192.168.1.20:3773/"); + expect(preparedConnection.socketUrl).toContain("wsTicket=direct"); + expect(yield* Ref.get(directInputs)).toEqual(["http://192.168.1.20:3773/"]); + }), + ); + + it.effect("falls back to the relay and reports the failed direct route", () => + Effect.gen(function* () { + const promotionFailures = yield* Ref.make>([]); + const target = new RelayConnectionTarget({ + environmentId: ENVIRONMENT_ID, + label: "Cloud", + }); + const brokerLayer = yield* makeDependencies({ + promotionOverride: { + endpointId: "server-lan:http://192.168.1.20:3773", + httpBaseUrl: "http://192.168.1.20:3773/", + wsBaseUrl: "ws://192.168.1.20:3773/", + }, + promotionFailures, + authorizeDpopDirect: () => + Effect.fail( + new ConnectionTransientError({ + reason: "timeout", + detail: "The direct endpoint did not respond.", + }), + ), + }); + const broker = yield* ConnectionResolver.ConnectionResolver.pipe(Effect.provide(brokerLayer)); + + const preparedConnection = yield* broker.prepare(catalogEntry(target)); + expect(preparedConnection.socketUrl).toContain("wsTicket=dpop"); + expect(yield* Ref.get(promotionFailures)).toEqual([ENVIRONMENT_ID]); + }), + ); + it.effect("exports the complete relay authorization flow through the product tracer", () => Effect.gen(function* () { const userSpans: Array = []; diff --git a/packages/client-runtime/src/connection/resolver.ts b/packages/client-runtime/src/connection/resolver.ts index c219bde092c..74c3a229234 100644 --- a/packages/client-runtime/src/connection/resolver.ts +++ b/packages/client-runtime/src/connection/resolver.ts @@ -9,6 +9,7 @@ import * as Schema from "effect/Schema"; import * as RemoteEnvironmentAuthorization from "../authorization/service.ts"; import * as ManagedRelay from "../relay/managedRelay.ts"; import * as ClientCapabilities from "../platform/capabilities.ts"; +import * as ConnectionPromotion from "./promotion.ts"; import { BearerConnectionCredential, BearerConnectionProfile, @@ -143,35 +144,66 @@ const makeRelayBroker = Effect.fn("clientRuntime.connection.broker.makeRelay")(f const session = yield* ClientCapabilities.CloudSession; const identity = yield* ClientCapabilities.RelayDeviceIdentity; const remote = yield* RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization; + const promotion = yield* ConnectionPromotion.ConnectionPromotion; + + const authorizeViaRelay = Effect.fnUntraced(function* (target: RelayConnectionTarget) { + return yield* remote.authorizeDpop({ + expectedEnvironmentId: target.environmentId, + obtainBootstrap: Effect.gen(function* () { + const clerkToken = yield* session.clerkToken.pipe( + Effect.withSpan("relay.connection.cloudSessionToken.resolve"), + ); + const deviceId = yield* identity.deviceId.pipe( + Effect.withSpan("relay.connection.deviceIdentity.resolve"), + ); + const connected = yield* relay + .connectEnvironment({ + clerkToken, + scopes: [RelayEnvironmentConnectScope], + environmentId: target.environmentId, + ...(Option.isSome(deviceId) ? { deviceId: deviceId.value } : {}), + }) + .pipe(Effect.mapError(mapManagedRelayError)); + if (connected.environmentId !== target.environmentId) { + return yield* environmentMismatchError({ + expected: target.environmentId, + actual: connected.environmentId, + }); + } + return connected; + }).pipe(Effect.withSpan("relay.connection.bootstrap.obtain")), + }); + }); return Effect.fnUntraced( function* (target: RelayConnectionTarget) { - const authorized = yield* remote.authorizeDpop({ - expectedEnvironmentId: target.environmentId, - obtainBootstrap: Effect.gen(function* () { - const clerkToken = yield* session.clerkToken.pipe( - Effect.withSpan("relay.connection.cloudSessionToken.resolve"), - ); - const deviceId = yield* identity.deviceId.pipe( - Effect.withSpan("relay.connection.deviceIdentity.resolve"), - ); - const connected = yield* relay - .connectEnvironment({ - clerkToken, - scopes: [RelayEnvironmentConnectScope], - environmentId: target.environmentId, - ...(Option.isSome(deviceId) ? { deviceId: deviceId.value } : {}), + // When a direct route was discovered for this environment (see + // promotion.ts), try it before the relay. A failed direct attempt + // reports the failure (clearing the override and starting the + // promotion cooldown) and falls back to the relay within the same + // prepare call, so a stale LAN address never strands the connection. + const override = yield* promotion.overrideFor(target.environmentId); + const direct = Option.isNone(override) + ? null + : yield* remote + .authorizeDpopDirect({ + expectedEnvironmentId: target.environmentId, + endpoint: override.value, }) - .pipe(Effect.mapError(mapManagedRelayError)); - if (connected.environmentId !== target.environmentId) { - return yield* environmentMismatchError({ - expected: target.environmentId, - actual: connected.environmentId, - }); - } - return connected; - }).pipe(Effect.withSpan("relay.connection.bootstrap.obtain")), - }); + .pipe( + Effect.withSpan("clientRuntime.connection.broker.relay.direct"), + Effect.catch((error) => + promotion + .reportOverrideFailed(target.environmentId) + .pipe( + Effect.andThen( + Effect.logDebug("Direct route attempt failed; using the relay.", error), + ), + Effect.as(null), + ), + ), + ); + const authorized = direct ?? (yield* authorizeViaRelay(target)); return { environmentId: authorized.environmentId, label: authorized.label, diff --git a/packages/client-runtime/src/connection/supervisor.test.ts b/packages/client-runtime/src/connection/supervisor.test.ts index a925859049f..6a7ec3c1717 100644 --- a/packages/client-runtime/src/connection/supervisor.test.ts +++ b/packages/client-runtime/src/connection/supervisor.test.ts @@ -15,6 +15,7 @@ import type { WsRpcProtocolClient } from "../rpc/protocol.ts"; import type { ConnectionCatalogEntry } from "./catalog.ts"; import * as Connectivity from "./connectivity.ts"; import * as ConnectionDriver from "./driver.ts"; +import * as ConnectionPromotion from "./promotion.ts"; import { ConnectionBlockedError, ConnectionTransientError, @@ -115,6 +116,9 @@ const makeHarness = Effect.fn("TestConnectionHarness.make")(function* (options?: ) => Effect.Effect; readonly ready?: (attempt: number) => Effect.Effect; readonly probe?: (attempt: number) => Effect.Effect; + readonly discover?: ( + prepared: PreparedConnection, + ) => Effect.Effect>; }) { const networkStatus = yield* SubscriptionRef.make( options?.networkStatus ?? "online", @@ -175,6 +179,7 @@ const makeHarness = Effect.fn("TestConnectionHarness.make")(function* (options?: return { prepared, session } satisfies ConnectionDriver.EnvironmentConnectionLease; }); + const discover = options?.discover; const dependencies = Layer.mergeAll( Layer.succeed(Connectivity.Connectivity, connectivity), Layer.succeed( @@ -190,6 +195,18 @@ const makeHarness = Effect.fn("TestConnectionHarness.make")(function* (options?: ConnectionDriver.ConnectionDriver, ConnectionDriver.ConnectionDriver.of({ connect }), ), + ...(discover === undefined + ? [] + : [ + Layer.succeed( + ConnectionPromotion.ConnectionPromotion, + ConnectionPromotion.ConnectionPromotion.of({ + overrideFor: () => Effect.succeed(Option.none()), + reportOverrideFailed: () => Effect.void, + discover, + }), + ), + ]), ); return { @@ -848,6 +865,72 @@ describe("EnvironmentSupervisor", () => { }).pipe(Effect.provide(TestClock.layer())), ); + it.effect("replaces a relay session when a direct route is discovered", () => + Effect.gen(function* () { + const relayPrepared: PreparedConnection = { + environmentId: RELAY_TARGET.environmentId, + label: RELAY_TARGET.label, + httpBaseUrl: "https://tunnel.example.test", + socketUrl: "wss://tunnel.example.test/ws?wsTicket=dpop", + httpAuthorization: { _tag: "Dpop", accessToken: "dpop-access-token" }, + target: RELAY_TARGET, + }; + const directPrepared: PreparedConnection = { + ...relayPrepared, + httpBaseUrl: "http://192.168.1.20:3773/", + socketUrl: "ws://192.168.1.20:3773/ws?wsTicket=direct", + }; + const discoveries = yield* Ref.make(0); + const harness = yield* makeHarness({ + // First attempt connects through the relay; after discovery the + // replacement attempt connects through the direct route. + prepare: (attempt) => Effect.succeed(attempt === 1 ? relayPrepared : directPrepared), + discover: () => + Ref.updateAndGet(discoveries, (count) => count + 1).pipe( + Effect.map((count) => + count === 1 + ? Option.some({ + endpointId: "server-lan:http://192.168.1.20:3773", + httpBaseUrl: "http://192.168.1.20:3773/", + wsBaseUrl: "ws://192.168.1.20:3773/", + }) + : Option.none(), + ), + ), + }); + const supervisor = yield* EnvironmentSupervisor.make(RELAY_ENTRY, { + initiallyDesired: true, + }).pipe(Effect.provide(harness.dependencies)); + + // The promotion replaces the lease without backoff, like a deliberate + // reconnect: generation advances, attempt restarts at 1. + yield* awaitState( + supervisor.state, + (state) => state.phase === "connected" && state.generation === 2 && state.attempt === 1, + ); + expect(yield* Ref.get(harness.sessionCount)).toBe(2); + expect(yield* Ref.get(harness.releaseCount)).toBe(1); + const prepared = yield* SubscriptionRef.get(supervisor.prepared); + expect(Option.getOrThrow(prepared).httpBaseUrl).toBe("http://192.168.1.20:3773/"); + }), + ); + + it.effect("does not run direct-route discovery for non-relay targets", () => + Effect.gen(function* () { + const discoveries = yield* Ref.make(0); + const harness = yield* makeHarness({ + discover: () => + Ref.update(discoveries, (count) => count + 1).pipe(Effect.as(Option.none())), + }); + const supervisor = yield* EnvironmentSupervisor.make(TARGET_ENTRY, { + initiallyDesired: true, + }).pipe(Effect.provide(harness.dependencies)); + + yield* awaitState(supervisor.state, (state) => state.phase === "connected"); + expect(yield* Ref.get(discoveries)).toBe(0); + }), + ); + it.effect("probes the active session without reconnecting on application activation", () => Effect.gen(function* () { const probeCount = yield* Ref.make(0); diff --git a/packages/client-runtime/src/connection/supervisor.ts b/packages/client-runtime/src/connection/supervisor.ts index 2a9c7519072..47373bb46a6 100644 --- a/packages/client-runtime/src/connection/supervisor.ts +++ b/packages/client-runtime/src/connection/supervisor.ts @@ -17,6 +17,7 @@ import * as Tracer from "effect/Tracer"; import type { ConnectionCatalogEntry } from "./catalog.ts"; import * as Connectivity from "./connectivity.ts"; import * as ConnectionDriver from "./driver.ts"; +import * as ConnectionPromotion from "./promotion.ts"; import { type ConnectionAttemptError, type ConnectionTarget, @@ -34,6 +35,10 @@ const CONNECTION_ESTABLISHMENT_TIMEOUT = "15 seconds"; const CONNECTION_PROBE_TIMEOUT = "15 seconds"; const MOBILE_CONNECTION_PROBE_TIMEOUT = "3 seconds"; const BACKOFF_RESET_AFTER_MS = 30_000; +// While connected through the relay, direct-route discovery re-runs on this +// interval so moving onto the environment's network (which produces no +// connectivity signal) is eventually noticed. +const PROMOTION_REDISCOVERY_INTERVAL = "3 minutes"; interface SupervisorIntent { readonly desired: boolean; @@ -45,7 +50,10 @@ type SupervisorSignal = | { readonly _tag: "DisconnectRequested" } | { readonly _tag: "RetryRequested" } | { readonly _tag: "NetworkChanged"; readonly network: NetworkStatus } - | { readonly _tag: "Wakeup"; readonly reason: ConnectionWakeups.ConnectionWakeup }; + | { readonly _tag: "Wakeup"; readonly reason: ConnectionWakeups.ConnectionWakeup } + // A direct route was discovered for the connected relay environment; + // replace the lease without backoff so the next attempt uses it. + | { readonly _tag: "PromoteRequested" }; interface PendingRetryTrace { readonly previousAttempt: Tracer.Span; @@ -225,6 +233,9 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( const connectivity = yield* Connectivity.Connectivity; const driver = yield* ConnectionDriver.ConnectionDriver; const wakeups = yield* ConnectionWakeups.ConnectionWakeups; + // Promotion is optional: without the service (some tests, platforms that do + // not wire it) relay connections simply never promote to direct routes. + const promotion = yield* Effect.serviceOption(ConnectionPromotion.ConnectionPromotion); const initialIntent: SupervisorIntent = { desired: options?.initiallyDesired ?? false, network: yield* connectivity.status, @@ -362,6 +373,41 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( ); }); + // Runs for the life of a connected relay lease: discovers a direct route to + // the same environment and asks the supervisor to reconnect through it. The + // fiber is forked into the attempt scope, so it is interrupted when the + // lease ends for any reason. + const forkPromotionDiscovery = Effect.fnUntraced(function* ( + activeConnection: PreparedConnection, + ) { + if (Option.isNone(promotion)) { + return; + } + if (target._tag !== "RelayConnectionTarget") { + return; + } + if (activeConnection.httpAuthorization?._tag !== "Dpop") { + return; + } + const service = promotion.value; + const override = yield* service.overrideFor(target.environmentId); + if (Option.isSome(override) && override.value.httpBaseUrl === activeConnection.httpBaseUrl) { + // Already connected through the promoted direct route; nothing to do + // until it fails and the broker falls back to the relay. + return; + } + yield* Effect.gen(function* () { + for (;;) { + const discovered = yield* service.discover(activeConnection); + if (Option.isSome(discovered)) { + yield* signal({ _tag: "PromoteRequested" }); + return; + } + yield* Effect.sleep(PROMOTION_REDISCOVERY_INTERVAL); + } + }).pipe(Effect.forkScoped); + }); + const waitForEstablishmentInterrupt = Effect.fnUntraced(function* () { for (;;) { const next = yield* Queue.take(signals); @@ -375,6 +421,7 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( } break; case "ConnectRequested": + case "PromoteRequested": break; case "Wakeup": if (next.reason === "application-active-reconnect") { @@ -398,6 +445,10 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( case "DisconnectRequested": case "RetryRequested": return false; + case "PromoteRequested": + // A direct route is ready; deliberately replace the lease without + // backoff so the next attempt connects through it. + return true; case "NetworkChanged": if (next.network === "offline") { return false; @@ -449,6 +500,9 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( case "RetryRequested": yield* Fiber.interrupt(probe); return false; + case "PromoteRequested": + yield* Fiber.interrupt(probe); + return true; case "NetworkChanged": if (probeEvent.signal.network === "offline") { yield* Fiber.interrupt(probe); @@ -576,6 +630,7 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( lastFailure: null, retryAt: null, }); + yield* forkPromotionDiscovery(active.lease.prepared); const connectedExit = yield* Effect.raceFirst( active.lease.session.closed.pipe( @@ -620,6 +675,7 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( case "DisconnectRequested": case "RetryRequested": case "NetworkChanged": + case "PromoteRequested": return false; } } diff --git a/packages/contracts/src/environmentHttp.ts b/packages/contracts/src/environmentHttp.ts index 2d40dad60cc..f0566a616c2 100644 --- a/packages/contracts/src/environmentHttp.ts +++ b/packages/contracts/src/environmentHttp.ts @@ -26,6 +26,7 @@ import { } from "./auth.ts"; import { AuthSessionId, ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts"; import { ExecutionEnvironmentDescriptor } from "./environment.ts"; +import { AdvertisedEndpoint } from "./remoteAccess.ts"; import { ClientOrchestrationCommand, DispatchResult, @@ -550,8 +551,20 @@ export class EnvironmentConnectHttpApi extends HttpApiGroup.make("connect") }), ) {} +// Authenticated route discovery: a connected client (often relay-tunneled) asks +// the environment which direct endpoints (loopback, LAN, Tailscale) also reach +// this same server, so it can promote to a local connection when possible. +export class EnvironmentRemoteAccessHttpApi extends HttpApiGroup.make("remoteAccess").add( + HttpApiEndpoint.get("advertisedEndpoints", "/api/remote-access/endpoints", { + headers: OptionalBearerHeaders, + success: Schema.Array(AdvertisedEndpoint), + error: EnvironmentScopedOperationErrors, + }).middleware(EnvironmentAuthenticatedAuth), +) {} + export class EnvironmentHttpApi extends HttpApi.make("environment") .add(EnvironmentMetadataHttpApi) .add(EnvironmentAuthHttpApi) .add(EnvironmentOrchestrationHttpApi) - .add(EnvironmentConnectHttpApi) {} + .add(EnvironmentConnectHttpApi) + .add(EnvironmentRemoteAccessHttpApi) {} diff --git a/packages/tailscale/package.json b/packages/tailscale/package.json index ce020dc8ef5..e5398b168b4 100644 --- a/packages/tailscale/package.json +++ b/packages/tailscale/package.json @@ -14,6 +14,7 @@ }, "dependencies": { "@effect/platform-node": "catalog:", + "@t3tools/contracts": "workspace:*", "@t3tools/shared": "workspace:*", "effect": "catalog:" }, diff --git a/apps/desktop/src/backend/tailscaleEndpointProvider.test.ts b/packages/tailscale/src/endpointProvider.test.ts similarity index 95% rename from apps/desktop/src/backend/tailscaleEndpointProvider.test.ts rename to packages/tailscale/src/endpointProvider.test.ts index 28bf211f09a..47bd1ae1d4d 100644 --- a/apps/desktop/src/backend/tailscaleEndpointProvider.test.ts +++ b/packages/tailscale/src/endpointProvider.test.ts @@ -4,11 +4,8 @@ import * as Layer from "effect/Layer"; import { HttpClient } from "effect/unstable/http"; import { ChildProcessSpawner } from "effect/unstable/process"; -import { - isTailscaleIpv4Address, - parseTailscaleMagicDnsName, - resolveTailscaleAdvertisedEndpoints, -} from "./tailscaleEndpointProvider.ts"; +import { resolveTailscaleAdvertisedEndpoints } from "./endpointProvider.ts"; +import { isTailscaleIpv4Address, parseTailscaleMagicDnsName } from "./tailscale.ts"; const unusedTailscaleExternalServicesLayer = Layer.mergeAll( Layer.succeed( @@ -45,15 +42,13 @@ describe("tailscale endpoint provider", () => { Effect.gen(function* () { const endpoints = yield* resolveTailscaleAdvertisedEndpoints({ port: 3773, + source: "desktop-addon", networkInterfaces: { tailscale0: [ { address: "100.100.100.100", family: "IPv4", internal: false, - netmask: "255.192.0.0", - cidr: "100.100.100.100/10", - mac: "00:00:00:00:00:00", }, ], }, @@ -109,6 +104,7 @@ describe("tailscale endpoint provider", () => { let readerCalls = 0; const endpoints = yield* resolveTailscaleAdvertisedEndpoints({ port: 3773, + source: "desktop-addon", networkInterfaces: {}, readMagicDnsName: Effect.sync(() => { readerCalls += 1; @@ -129,6 +125,7 @@ describe("tailscale endpoint provider", () => { Effect.gen(function* () { const endpoints = yield* resolveTailscaleAdvertisedEndpoints({ port: 3773, + source: "desktop-addon", networkInterfaces: {}, statusJson: `{"Self":{"DNSName":"desktop.tail.ts.net."}}`, serveEnabled: true, diff --git a/apps/desktop/src/backend/tailscaleEndpointProvider.ts b/packages/tailscale/src/endpointProvider.ts similarity index 79% rename from apps/desktop/src/backend/tailscaleEndpointProvider.ts rename to packages/tailscale/src/endpointProvider.ts index 0b48adc308c..3a7a9a68283 100644 --- a/apps/desktop/src/backend/tailscaleEndpointProvider.ts +++ b/packages/tailscale/src/endpointProvider.ts @@ -1,20 +1,21 @@ import { createAdvertisedEndpoint } from "@t3tools/shared/advertisedEndpoint"; -import type { AdvertisedEndpoint, AdvertisedEndpointProvider } from "@t3tools/contracts"; +import type { + AdvertisedEndpoint, + AdvertisedEndpointProvider, + AdvertisedEndpointSource, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; + import { buildTailscaleHttpsBaseUrl, isTailscaleIpv4Address, parseTailscaleMagicDnsName, probeTailscaleHttpsEndpoint, readTailscaleStatus, -} from "@t3tools/tailscale"; -import * as Effect from "effect/Effect"; -import * as Option from "effect/Option"; -import * as HttpClient from "effect/unstable/http/HttpClient"; -import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; - -import type { NetworkInterfaces } from "./DesktopNetworkInterfaces.ts"; - -export { isTailscaleIpv4Address, parseTailscaleMagicDnsName } from "@t3tools/tailscale"; +} from "./tailscale.ts"; const TAILSCALE_ENDPOINT_PROVIDER: AdvertisedEndpointProvider = { id: "tailscale", @@ -23,9 +24,22 @@ const TAILSCALE_ENDPOINT_PROVIDER: AdvertisedEndpointProvider = { isAddon: true, }; +/** Minimal shape of a `node:os` networkInterfaces() entry, kept structural so + * desktop and server can pass their own readings without a shared service. */ +export interface TailscaleNetworkInterfaceInfo { + readonly address: string; + readonly family: string | number; + readonly internal: boolean; +} + +export type TailscaleNetworkInterfaces = Readonly< + Record +>; + function resolveTailscaleIpAdvertisedEndpoints(input: { readonly port: number; - readonly networkInterfaces: NetworkInterfaces; + readonly source: AdvertisedEndpointSource; + readonly networkInterfaces: TailscaleNetworkInterfaces; }): readonly AdvertisedEndpoint[] { const seen = new Set(); const endpoints: AdvertisedEndpoint[] = []; @@ -35,7 +49,7 @@ function resolveTailscaleIpAdvertisedEndpoints(input: { for (const address of interfaceAddresses) { if (address.internal) continue; - if (address.family !== "IPv4") continue; + if (address.family !== "IPv4" && address.family !== 4) continue; if (!isTailscaleIpv4Address(address.address)) continue; if (seen.has(address.address)) continue; seen.add(address.address); @@ -43,7 +57,7 @@ function resolveTailscaleIpAdvertisedEndpoints(input: { endpoints.push( createAdvertisedEndpoint({ provider: TAILSCALE_ENDPOINT_PROVIDER, - source: "desktop-addon", + source: input.source, id: `tailscale-ip:http://${address.address}:${input.port}`, label: "Tailscale IP", httpBaseUrl: `http://${address.address}:${input.port}`, @@ -62,6 +76,7 @@ const resolveTailscaleMagicDnsAdvertisedEndpoint = Effect.fn( "resolveTailscaleMagicDnsAdvertisedEndpoint", )(function* (input: { readonly dnsName: string | null; + readonly source: AdvertisedEndpointSource; readonly serveEnabled: boolean; readonly servePort?: number; readonly probe?: (baseUrl: string) => Effect.Effect; @@ -84,7 +99,7 @@ const resolveTailscaleMagicDnsAdvertisedEndpoint = Effect.fn( return Option.some( createAdvertisedEndpoint({ provider: TAILSCALE_ENDPOINT_PROVIDER, - source: "desktop-addon", + source: input.source, id: `tailscale-magicdns:${httpBaseUrl}`, label: "Tailscale HTTPS", httpBaseUrl, @@ -98,12 +113,16 @@ const resolveTailscaleMagicDnsAdvertisedEndpoint = Effect.fn( ); }); +/** Synthesizes Tailscale advertised endpoints (per-interface Tailnet IPs plus + * the MagicDNS HTTPS endpoint) with stable `tailscale-ip:`/`tailscale-magicdns:` + * ids shared by the desktop and server producers. */ export const resolveTailscaleAdvertisedEndpoints = Effect.fn("resolveTailscaleAdvertisedEndpoints")( function* (input: { readonly port: number; + readonly source: AdvertisedEndpointSource; readonly serveEnabled?: boolean; readonly servePort?: number; - readonly networkInterfaces: NetworkInterfaces; + readonly networkInterfaces: TailscaleNetworkInterfaces; readonly statusJson?: string | null; readonly readMagicDnsName?: Effect.Effect< string | null, @@ -133,6 +152,7 @@ export const resolveTailscaleAdvertisedEndpoints = Effect.fn("resolveTailscaleAd : null; const magicDnsEndpoint = yield* resolveTailscaleMagicDnsAdvertisedEndpoint({ dnsName, + source: input.source, serveEnabled: input.serveEnabled === true, ...(input.servePort === undefined ? {} : { servePort: input.servePort }), ...(input.probe === undefined ? {} : { probe: input.probe }), diff --git a/packages/tailscale/src/index.ts b/packages/tailscale/src/index.ts index b6cba8d7b43..ffd87afc1eb 100644 --- a/packages/tailscale/src/index.ts +++ b/packages/tailscale/src/index.ts @@ -1 +1,2 @@ +export * from "./endpointProvider.ts"; export * from "./tailscale.ts"; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f7c6f4cc1f5..47254c28ef6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -865,6 +865,9 @@ importers: '@effect/platform-node': specifier: 4.0.0-beta.103 version: 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))(ioredis@5.11.0)(utf-8-validate@6.0.6) + '@t3tools/contracts': + specifier: workspace:* + version: link:../contracts '@t3tools/shared': specifier: workspace:* version: link:../shared