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