-
Notifications
You must be signed in to change notification settings - Fork 3.8k
feat: T3 Connect environments switch to local connections automatically #5463
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,129 @@ | ||
| import type { AdvertisedEndpoint } from "@t3tools/contracts"; | ||
| import { createAdvertisedEndpoint } from "@t3tools/shared/advertisedEndpoint"; | ||
| import { assert, describe, it } from "@effect/vitest"; | ||
|
|
||
| import { resolveServerAdvertisedEndpoints } from "./ServerAdvertisedEndpoints.ts"; | ||
|
|
||
| const tailscaleProvider = { | ||
| id: "tailscale", | ||
| label: "Tailscale", | ||
| kind: "private-network", | ||
| isAddon: true, | ||
| } as const; | ||
|
|
||
| const tailscaleIpEndpoint: AdvertisedEndpoint = createAdvertisedEndpoint({ | ||
| provider: tailscaleProvider, | ||
| source: "server", | ||
| id: "tailscale-ip:http://100.100.100.100:3773", | ||
| label: "Tailscale IP", | ||
| httpBaseUrl: "http://100.100.100.100:3773", | ||
| reachability: "private-network", | ||
| }); | ||
|
|
||
| const tailscaleServeEndpoint: AdvertisedEndpoint = createAdvertisedEndpoint({ | ||
| provider: tailscaleProvider, | ||
| source: "server", | ||
| id: "tailscale-magicdns:https://server.tail.ts.net/", | ||
| label: "Tailscale HTTPS", | ||
| httpBaseUrl: "https://server.tail.ts.net", | ||
| reachability: "private-network", | ||
| }); | ||
|
|
||
| describe("resolveServerAdvertisedEndpoints", () => { | ||
| it("advertises only loopback (plus Tailscale Serve) under a loopback binding", () => { | ||
| const endpoints = resolveServerAdvertisedEndpoints({ | ||
| port: 3773, | ||
| host: undefined, | ||
| networkInterfaces: { | ||
| en0: [{ address: "192.168.1.44", family: "IPv4", internal: false }], | ||
| }, | ||
| tailscaleEndpoints: [tailscaleIpEndpoint, tailscaleServeEndpoint], | ||
| }); | ||
|
|
||
| // The LAN interface is not advertised: the server is not listening on it. | ||
| assert.deepEqual( | ||
| endpoints.map((endpoint) => endpoint.id), | ||
| ["server-loopback:3773", tailscaleServeEndpoint.id], | ||
| ); | ||
| const loopback = endpoints[0]; | ||
| assert.equal(loopback?.httpBaseUrl, "http://127.0.0.1:3773/"); | ||
| assert.equal(loopback?.wsBaseUrl, "ws://127.0.0.1:3773/"); | ||
| assert.equal(loopback?.reachability, "loopback"); | ||
| assert.equal(loopback?.source, "server"); | ||
| assert.equal(loopback?.provider.id, "server-core"); | ||
| }); | ||
|
|
||
| it("enumerates listening interfaces under a wildcard binding", () => { | ||
| const endpoints = resolveServerAdvertisedEndpoints({ | ||
| port: 8080, | ||
| host: "0.0.0.0", | ||
| networkInterfaces: { | ||
| lo0: [{ address: "127.0.0.1", family: "IPv4", internal: true }], | ||
| en0: [ | ||
| { address: "192.168.1.44", family: "IPv4", internal: false }, | ||
| { address: "10.4.0.9", family: "IPv4", internal: false }, | ||
| { address: "fe80::1", family: "IPv6", internal: false }, | ||
| ], | ||
| en1: [ | ||
| { address: "169.254.10.2", family: "IPv4", internal: false }, | ||
| { address: "203.0.113.7", family: "IPv4", internal: false }, | ||
| // Duplicated address is deduped. | ||
| { address: "192.168.1.44", family: "IPv4", internal: false }, | ||
| ], | ||
| tailscale0: [{ address: "100.100.100.100", family: "IPv4", internal: false }], | ||
| docker0: [{ address: "127.0.0.5", family: "IPv4", internal: false }], | ||
| }, | ||
| tailscaleEndpoints: [tailscaleIpEndpoint, tailscaleServeEndpoint], | ||
| }); | ||
|
|
||
| assert.deepEqual( | ||
| endpoints.map((endpoint) => [endpoint.id, endpoint.reachability]), | ||
| [ | ||
| ["server-loopback:8080", "loopback"], | ||
| ["server-lan:http://192.168.1.44:8080", "lan"], | ||
| ["server-lan:http://10.4.0.9:8080", "lan"], | ||
| ["server-public:http://203.0.113.7:8080", "public"], | ||
| [tailscaleIpEndpoint.id, "private-network"], | ||
| [tailscaleServeEndpoint.id, "private-network"], | ||
| ], | ||
| ); | ||
| // Only the first LAN endpoint is the default. | ||
| assert.deepEqual( | ||
| endpoints.filter((endpoint) => endpoint.isDefault === true).map((endpoint) => endpoint.id), | ||
| ["server-lan:http://192.168.1.44:8080"], | ||
| ); | ||
| }); | ||
|
|
||
| it("advertises only the bound host under a specific binding", () => { | ||
| const endpoints = resolveServerAdvertisedEndpoints({ | ||
| port: 3773, | ||
| host: "192.168.1.44", | ||
| networkInterfaces: { | ||
| en0: [{ address: "192.168.1.44", family: "IPv4", internal: false }], | ||
| }, | ||
| // Tailscale Serve proxies to 127.0.0.1, which is not listening here. | ||
| tailscaleEndpoints: [tailscaleIpEndpoint, tailscaleServeEndpoint], | ||
| }); | ||
|
|
||
| assert.deepEqual( | ||
| endpoints.map((endpoint) => endpoint.id), | ||
| ["server-lan:http://192.168.1.44:3773"], | ||
| ); | ||
| assert.equal(endpoints[0]?.reachability, "lan"); | ||
| assert.equal(endpoints[0]?.httpBaseUrl, "http://192.168.1.44:3773/"); | ||
| }); | ||
|
|
||
| it("classifies a bound Tailnet address as private-network", () => { | ||
| const endpoints = resolveServerAdvertisedEndpoints({ | ||
| port: 3773, | ||
| host: "100.100.100.100", | ||
| networkInterfaces: {}, | ||
| tailscaleEndpoints: [], | ||
| }); | ||
|
|
||
| assert.deepEqual( | ||
| endpoints.map((endpoint) => [endpoint.id, endpoint.reachability]), | ||
| [["server-private-network:http://100.100.100.100:3773", "private-network"]], | ||
| ); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,213 @@ | ||
| import * as NodeOS from "node:os"; | ||
|
|
||
| import type { AdvertisedEndpoint, AdvertisedEndpointProvider } from "@t3tools/contracts"; | ||
| import { createAdvertisedEndpoint } from "@t3tools/shared/advertisedEndpoint"; | ||
| import { | ||
| isTailscaleIpv4Address, | ||
| resolveTailscaleAdvertisedEndpoints, | ||
| type TailscaleNetworkInterfaces, | ||
| } from "@t3tools/tailscale"; | ||
| import * as Context from "effect/Context"; | ||
| import * as Effect from "effect/Effect"; | ||
| import * as Layer from "effect/Layer"; | ||
| import { HttpServer } from "effect/unstable/http"; | ||
| import * as HttpClient from "effect/unstable/http/HttpClient"; | ||
| import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; | ||
|
|
||
| import * as ServerConfig from "../config.ts"; | ||
| import { | ||
| formatHostForUrl, | ||
| isLoopbackHost, | ||
| isWildcardHost, | ||
| resolveListeningPort, | ||
| } from "../startupAccess.ts"; | ||
|
|
||
| const SERVER_ENDPOINT_PROVIDER: AdvertisedEndpointProvider = { | ||
| id: "server-core", | ||
| label: "Server", | ||
| kind: "core", | ||
| isAddon: false, | ||
| }; | ||
|
|
||
| /** How long a resolved endpoint set is reused before `tailscale status` runs | ||
| * again. Repeated client polls must not re-spawn the Tailscale CLI. */ | ||
| const ENDPOINT_CACHE_TTL = "60 seconds"; | ||
|
|
||
| const isPrivateIpv4 = (address: string): boolean => { | ||
| const octets = address.split(".").map((part) => Number.parseInt(part, 10)); | ||
| if (octets.length !== 4 || octets.some((octet) => !Number.isInteger(octet))) return false; | ||
| const [first, second] = octets as [number, number, number, number]; | ||
| if (first === 10) return true; | ||
| if (first === 172 && second >= 16 && second <= 31) return true; | ||
| return first === 192 && second === 168; | ||
| }; | ||
|
|
||
| const isIpv4Family = (family: string | number): boolean => family === "IPv4" || family === 4; | ||
|
|
||
| /** Binding mode implied by the configured host, which decides which addresses | ||
| * actually accept connections. */ | ||
| type BindingMode = "loopback" | "wildcard" | "specific"; | ||
|
|
||
| const resolveBindingMode = (host: string | undefined): BindingMode => { | ||
| if (isWildcardHost(host)) return "wildcard"; | ||
| // An unset host means the server binds 127.0.0.1 (see server.ts). | ||
| if (isLoopbackHost(host)) return "loopback"; | ||
| return "specific"; | ||
| }; | ||
|
|
||
| /** Tailscale Serve proxies to 127.0.0.1, so its HTTPS endpoint stays reachable | ||
| * under a loopback binding; the plain Tailnet-IP HTTP endpoints do not. */ | ||
| const isHttpsEndpoint = (endpoint: AdvertisedEndpoint): boolean => | ||
| endpoint.httpBaseUrl.startsWith("https://"); | ||
|
|
||
| export interface ResolveServerAdvertisedEndpointsInput { | ||
| readonly port: number; | ||
| readonly host: string | undefined; | ||
| readonly networkInterfaces: TailscaleNetworkInterfaces; | ||
| readonly tailscaleEndpoints: readonly AdvertisedEndpoint[]; | ||
| } | ||
|
|
||
| /** Pure projection of the server's own reachable endpoints, given its binding, | ||
| * the host's network interfaces, and any Tailscale endpoints resolved | ||
| * separately. Exported for tests. */ | ||
| export function resolveServerAdvertisedEndpoints( | ||
| input: ResolveServerAdvertisedEndpointsInput, | ||
| ): readonly AdvertisedEndpoint[] { | ||
| const mode = resolveBindingMode(input.host); | ||
| const loopbackEndpoint = createAdvertisedEndpoint({ | ||
| provider: SERVER_ENDPOINT_PROVIDER, | ||
| source: "server", | ||
| id: `server-loopback:${input.port}`, | ||
| label: "This machine", | ||
| httpBaseUrl: `http://127.0.0.1:${input.port}`, | ||
| reachability: "loopback", | ||
| status: "available", | ||
| description: "Loopback endpoint for this server.", | ||
| }); | ||
|
|
||
| if (mode === "loopback") { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Medium In the 🚀 Reply "fix it for me" or copy this AI Prompt for your agent: |
||
| return [loopbackEndpoint, ...input.tailscaleEndpoints.filter(isHttpsEndpoint)]; | ||
| } | ||
|
|
||
| if (mode === "specific") { | ||
| // A specific non-loopback binding is not listening on 127.0.0.1, so neither | ||
| // the loopback endpoint nor Tailscale Serve (which proxies there) applies. | ||
| // A bound Tailnet IP is covered by the record below. | ||
| const rawHost = input.host ?? ""; | ||
| const host = formatHostForUrl(rawHost); | ||
| const { reachability, label } = isTailscaleIpv4Address(rawHost) | ||
| ? ({ reachability: "private-network", label: "Private network" } as const) | ||
| : isPrivateIpv4(rawHost) | ||
| ? ({ reachability: "lan", label: "Local network" } as const) | ||
| : ({ reachability: "public", label: "Public IP" } as const); | ||
| const httpBaseUrl = `http://${host}:${input.port}`; | ||
| return [ | ||
| createAdvertisedEndpoint({ | ||
| provider: SERVER_ENDPOINT_PROVIDER, | ||
| source: "server", | ||
| id: `server-${reachability}:${httpBaseUrl}`, | ||
| label, | ||
| httpBaseUrl, | ||
| reachability, | ||
| status: "available", | ||
| }), | ||
| ]; | ||
| } | ||
|
|
||
| const endpoints: AdvertisedEndpoint[] = [loopbackEndpoint]; | ||
| const seen = new Set<string>(); | ||
| let hasDefaultLan = false; | ||
|
|
||
| for (const interfaceAddresses of Object.values(input.networkInterfaces)) { | ||
| if (!interfaceAddresses) continue; | ||
|
|
||
| for (const entry of interfaceAddresses) { | ||
| if (entry.internal) continue; | ||
| if (!isIpv4Family(entry.family)) continue; | ||
| const address = entry.address; | ||
| if (address.startsWith("127.") || address.startsWith("169.254.")) continue; | ||
| // Tailnet addresses are advertised by the Tailscale provider instead. | ||
| if (isTailscaleIpv4Address(address)) continue; | ||
| if (seen.has(address)) continue; | ||
| seen.add(address); | ||
|
|
||
| const isLan = isPrivateIpv4(address); | ||
| const httpBaseUrl = `http://${address}:${input.port}`; | ||
| const isDefault = isLan && !hasDefaultLan; | ||
| if (isDefault) hasDefaultLan = true; | ||
|
|
||
| endpoints.push( | ||
| createAdvertisedEndpoint({ | ||
| provider: SERVER_ENDPOINT_PROVIDER, | ||
| source: "server", | ||
| id: `server-${isLan ? "lan" : "public"}:${httpBaseUrl}`, | ||
| label: isLan ? "Local network" : "Public IP", | ||
| httpBaseUrl, | ||
| reachability: isLan ? "lan" : "public", | ||
| status: "available", | ||
| ...(isDefault ? { isDefault: true } : {}), | ||
| }), | ||
| ); | ||
| } | ||
| } | ||
|
|
||
| return [...endpoints, ...input.tailscaleEndpoints]; | ||
| } | ||
|
|
||
| export class ServerAdvertisedEndpoints extends Context.Service< | ||
| ServerAdvertisedEndpoints, | ||
| { | ||
| readonly getEndpoints: Effect.Effect<readonly AdvertisedEndpoint[]>; | ||
| } | ||
| >()("t3/remoteAccess/ServerAdvertisedEndpoints") {} | ||
|
|
||
| export const make = Effect.gen(function* () { | ||
| const config = yield* ServerConfig.ServerConfig; | ||
| const httpServer = yield* HttpServer.HttpServer; | ||
| const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; | ||
| const httpClient = yield* HttpClient.HttpClient; | ||
|
|
||
| const readNetworkInterfaces = Effect.sync(() => NodeOS.networkInterfaces()).pipe( | ||
| Effect.map((interfaces): TailscaleNetworkInterfaces => interfaces), | ||
| ); | ||
|
|
||
| const resolveEndpoints = Effect.gen(function* () { | ||
| const port = resolveListeningPort(httpServer.address, config.port); | ||
| const networkInterfaces = yield* readNetworkInterfaces; | ||
| const mode = resolveBindingMode(config.host); | ||
|
|
||
| // Skip the Tailscale resolver when nothing Tailscale-reachable could exist: | ||
| // spawning the CLI triggers a macOS TCC prompt on sandboxed installs. | ||
| const shouldResolveTailscale = | ||
| mode === "wildcard" || (mode === "loopback" && config.tailscaleServeEnabled); | ||
| const tailscaleEndpoints = shouldResolveTailscale | ||
| ? yield* resolveTailscaleAdvertisedEndpoints({ | ||
| port, | ||
| source: "server", | ||
| serveEnabled: config.tailscaleServeEnabled, | ||
| servePort: config.tailscaleServePort, | ||
| networkInterfaces, | ||
| }).pipe( | ||
| Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, childProcessSpawner), | ||
| Effect.provideService(HttpClient.HttpClient, httpClient), | ||
| ) | ||
| : []; | ||
|
|
||
| return resolveServerAdvertisedEndpoints({ | ||
| port, | ||
| host: config.host, | ||
| networkInterfaces, | ||
| tailscaleEndpoints, | ||
| }); | ||
| }); | ||
|
|
||
| const getEndpoints = yield* Effect.cachedWithTTL(resolveEndpoints, ENDPOINT_CACHE_TTL); | ||
|
|
||
| return ServerAdvertisedEndpoints.of({ | ||
| getEndpoints: getEndpoints.pipe( | ||
| Effect.withSpan("server.remoteAccess.resolveAdvertisedEndpoints"), | ||
| ), | ||
| }); | ||
| }); | ||
|
|
||
| export const layer = Layer.effect(ServerAdvertisedEndpoints, make); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,25 @@ | ||
| import { AuthRelayReadScope, EnvironmentHttpApi } from "@t3tools/contracts"; | ||
| import * as Effect from "effect/Effect"; | ||
| import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder"; | ||
|
|
||
| import { annotateEnvironmentRequest, requireEnvironmentScope } from "../auth/http.ts"; | ||
| import { ServerAdvertisedEndpoints } from "./ServerAdvertisedEndpoints.ts"; | ||
|
|
||
| // `relay:read` is part of the standard client scope set, so an ordinary paired | ||
| // or relay-tunneled client can discover the direct endpoints it might promote | ||
| // to. `access:read` would restrict this to admin clients. | ||
| export const remoteAccessHttpApiLayer = HttpApiBuilder.group( | ||
| EnvironmentHttpApi, | ||
| "remoteAccess", | ||
| Effect.fnUntraced(function* (handlers) { | ||
| const advertisedEndpoints = yield* ServerAdvertisedEndpoints; | ||
| return handlers.handle( | ||
| "advertisedEndpoints", | ||
| Effect.fn("environment.remoteAccess.advertisedEndpoints")(function* (args) { | ||
| yield* annotateEnvironmentRequest(args.endpoint.name); | ||
| yield* requireEnvironmentScope(AuthRelayReadScope); | ||
| return yield* advertisedEndpoints.getEndpoints; | ||
| }), | ||
| ); | ||
| }), | ||
| ); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🟡 Medium
remoteAccess/ServerAdvertisedEndpoints.ts:77resolveServerAdvertisedEndpointsalways constructs the loopback endpoint ashttp://127.0.0.1:..., even when the server binds to an IPv6 loopback address like::1or[::1]. Consumers of the advertised endpoints receive an IPv4 URL that the server is not listening on, so the endpoint is unreachable. The function should use the actual configured loopback host (wrapped withformatHostForUrl) to build the loopbackhttpBaseUrlinstead of hardcoding127.0.0.1.🚀 Reply "fix it for me" or copy this AI Prompt for your agent: