diff --git a/apps/server/src/cli/config.test.ts b/apps/server/src/cli/config.test.ts index 5e09dda2c164f..cf14a474cce51 100644 --- a/apps/server/src/cli/config.test.ts +++ b/apps/server/src/cli/config.test.ts @@ -49,6 +49,7 @@ const minimalDesktopFlags = (baseDir: string) => ({ logWebSocketEvents: Option.none(), tailscaleServeEnabled: Option.none(), tailscaleServePort: Option.none(), + advertisedUrls: [], lan: Option.none(), }); @@ -232,6 +233,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { logWebSocketEvents: Option.none(), tailscaleServeEnabled: Option.none(), tailscaleServePort: Option.none(), + advertisedUrls: [], lan: Option.none(), }; const layerConfig = ConfigProvider.layer( @@ -277,6 +279,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { logWebSocketEvents: Option.none(), tailscaleServeEnabled: Option.none(), tailscaleServePort: Option.none(), + advertisedUrls: [], lan: Option.none(), }; const layerConfig = ConfigProvider.layer( @@ -333,6 +336,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { logWebSocketEvents: Option.some(false), tailscaleServeEnabled: Option.none(), tailscaleServePort: Option.none(), + advertisedUrls: [], lan: Option.none(), }, Option.none(), @@ -410,6 +414,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { logWebSocketEvents: Option.none(), tailscaleServeEnabled: Option.none(), tailscaleServePort: Option.none(), + advertisedUrls: [], lan: Option.none(), }, Option.some("Debug"), @@ -558,6 +563,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { logWebSocketEvents: Option.none(), tailscaleServeEnabled: Option.none(), tailscaleServePort: Option.none(), + advertisedUrls: [], lan: Option.none(), }, Option.none(), @@ -607,6 +613,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { logWebSocketEvents: Option.none(), tailscaleServeEnabled: Option.none(), tailscaleServePort: Option.none(), + advertisedUrls: [], lan: Option.none(), }, Option.none(), @@ -648,6 +655,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { logWebSocketEvents: Option.none(), tailscaleServeEnabled: Option.none(), tailscaleServePort: Option.none(), + advertisedUrls: [], lan: Option.none(), }, Option.none(), @@ -711,6 +719,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { logWebSocketEvents: Option.none(), tailscaleServeEnabled: Option.none(), tailscaleServePort: Option.none(), + advertisedUrls: [], lan: Option.none(), }, Option.none(), @@ -751,6 +760,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { logWebSocketEvents: Option.none(), tailscaleServeEnabled: Option.none(), tailscaleServePort: Option.none(), + advertisedUrls: [], lan: Option.none(), }); @@ -791,6 +801,53 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { }), ); + it.effect("advertises only https origins and rejects anything else at startup", () => + Effect.gen(function* () { + const baseDir = yield* FileSystem.FileSystem.pipe( + Effect.flatMap((fs) => fs.makeTempDirectoryScoped({ prefix: "t3-cli-advertise-url-" })), + ); + const resolve = (env: Record, flag: ReadonlyArray = []) => + resolveServerConfig({ ...minimalWebFlags(baseDir), advertisedUrls: flag }, Option.none(), { + selectAutomaticPort: () => Effect.succeed(3773), + }).pipe( + Effect.map((config) => config.advertisedUrls), + Effect.provide( + Layer.merge(NetService.layer, ConfigProvider.layer(ConfigProvider.fromEnv({ env }))), + ), + ); + + expect(yield* resolve({})).toBeUndefined(); + const env = { + T3CODE_ADVERTISE_URL: + " https://T3.Example.test , https://other.example.test:8443/,https://t3.example.test", + }; + expect(yield* resolve(env)).toEqual([ + "https://t3.example.test/", + "https://other.example.test:8443/", + ]); + expect(yield* resolve(env, ["https://flag.example.test/"])).toEqual([ + "https://flag.example.test/", + ]); + + for (const rejected of [ + "http://t3.example.test", + "not a url", + // A proxy under a path prefix shares its origin with other apps. + "https://t3.example.test/app", + "https://t3.example.test?token=secret", + "https://user:secret@t3.example.test", + ]) { + const error = String( + yield* resolve({ T3CODE_ADVERTISE_URL: `https://t3.example.test,${rejected}` }).pipe( + Effect.flip, + ), + ); + expect(error).toContain("An advertised address must be an https origin"); + expect(error).not.toContain("secret"); + } + }), + ); + it.effect( "resolves each signal's endpoint through T3CODE_OTLP_*_URL, an OTEL endpoint, the bootstrap envelope, and persisted Settings, in that order", () => diff --git a/apps/server/src/cli/config.ts b/apps/server/src/cli/config.ts index a7b9584d1a7e6..71b24fc6c6438 100644 --- a/apps/server/src/cli/config.ts +++ b/apps/server/src/cli/config.ts @@ -28,6 +28,40 @@ import * as ServerConfig from "../config.ts"; import { expandHomePath, resolveBaseDir } from "../os-jank.ts"; import { isProcessAlive, readPersistedServerRuntimeState } from "../serverRuntimeState.ts"; +// Clients send credentials to these and keep only the origin, so a path would name another app. +const AdvertisedUrlsFromString = Schema.String.pipe( + Schema.decodeTo( + Schema.Array(Schema.String), + SchemaTransformation.transformEffect({ + decode: (value) => { + const origins = new Set(); + for (const entry of value.split(",").map((part) => part.trim())) { + if (entry.length === 0) continue; + const url = URL.parse(entry); + if ( + url?.protocol !== "https:" || + url.pathname !== "/" || + url.search !== "" || + url.hash !== "" || + url.username !== "" || + url.password !== "" + ) { + return Effect.fail( + new SchemaIssue.InvalidValue({ + message: + "An advertised address must be an https origin with no path, query or credentials, such as https://t3.example.com.", + }), + ); + } + origins.add(`${url.origin}/`); + } + return Effect.succeed>([...origins]); + }, + encode: (urls) => Effect.succeed(urls.join(",")), + }), + ), +); + const modeFlag = Flag.Literals("mode", ServerConfig.RuntimeMode.literals).pipe( Flag.withDescription("Runtime mode. `desktop` keeps loopback defaults unless overridden."), Flag.optional, @@ -85,6 +119,19 @@ const tailscaleServePortFlag = Flag.Int("tailscale-serve-port").pipe( Flag.withDescription("HTTPS port for Tailscale Serve when --tailscale-serve is enabled."), Flag.optional, ); +const decodeAdvertisedUrls = Schema.decodeEffect(AdvertisedUrlsFromString); +const advertiseUrlFlag = Flag.String("advertise-url").pipe( + Flag.mapEffect((value) => + decodeAdvertisedUrls(value).pipe( + Effect.mapError((error) => new CliError.ValidationError({ message: error.message })), + ), + ), + Flag.withDescription( + "HTTPS address that clients and linked environments reach this server at, such as a reverse proxy in front of it. Repeat for several, most preferred first (equivalent to T3CODE_ADVERTISE_URL, comma-separated).", + ), + Flag.atLeast(0), + Flag.map((urls): ReadonlyArray => [...new Set(urls.flat())]), +); const lanFlag = Flag.Boolean("lan").pipe( Flag.withDescription( "Accept connections from your local network and tailnet, not only this machine (equivalent to T3CODE_LAN).", @@ -171,6 +218,10 @@ const EnvServerConfig = Config.all({ Config.option, Config.map(Option.getOrUndefined), ), + advertisedUrls: Config.schema(AdvertisedUrlsFromString, "T3CODE_ADVERTISE_URL").pipe( + Config.option, + Config.map(Option.getOrUndefined), + ), lan: Config.Boolean("T3CODE_LAN").pipe(Config.option, Config.map(Option.getOrUndefined)), }); @@ -207,6 +258,7 @@ export interface CliServerFlags { readonly logWebSocketEvents: Option.Option; readonly tailscaleServeEnabled: Option.Option; readonly tailscaleServePort: Option.Option; + readonly advertisedUrls: ReadonlyArray; readonly lan: Option.Option; } @@ -242,6 +294,7 @@ export const sharedServerCommandFlags = { logWebSocketEvents: logWebSocketEventsFlag, tailscaleServeEnabled: tailscaleServeFlag, tailscaleServePort: tailscaleServePortFlag, + advertisedUrls: advertiseUrlFlag, lan: lanFlag, } as const; @@ -290,6 +343,7 @@ export const resolveServerConfig = Effect.fn("config.resolveServerConfig")(funct logWebSocketEvents: flags.logWebSocketEvents ?? Option.none(), tailscaleServeEnabled: flags.tailscaleServeEnabled ?? Option.none(), tailscaleServePort: flags.tailscaleServePort ?? Option.none(), + advertisedUrls: flags.advertisedUrls ?? [], lan: flags.lan ?? Option.none(), } satisfies CliServerFlags; const bootstrapFd = Option.getOrUndefined(normalizedFlags.bootstrapFd) ?? env.bootstrapFd; @@ -419,6 +473,10 @@ export const resolveServerConfig = Effect.fn("config.resolveServerConfig")(funct ), () => 443, ); + const advertisedUrls = + normalizedFlags.advertisedUrls.length > 0 + ? normalizedFlags.advertisedUrls + : (env.advertisedUrls ?? []); const staticDir = devUrl ? undefined : yield* ServerConfig.resolveStaticDir(); const explicitHost = resolveOptionPrecedence( normalizedFlags.host, @@ -525,6 +583,7 @@ export const resolveServerConfig = Effect.fn("config.resolveServerConfig")(funct logWebSocketEvents, tailscaleServeEnabled, tailscaleServePort, + ...(advertisedUrls.length > 0 ? { advertisedUrls } : {}), }; return config; @@ -549,6 +608,7 @@ export const resolveCliAuthConfig = ( logWebSocketEvents: Option.none(), tailscaleServeEnabled: Option.none(), tailscaleServePort: Option.none(), + advertisedUrls: [], lan: Option.none(), }, cliLogLevel, diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index c707c912d672d..8e15cd0a48195 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -109,6 +109,8 @@ export class ServerConfig extends Context.Service< readonly logWebSocketEvents: boolean; readonly tailscaleServeEnabled: boolean; readonly tailscaleServePort: number; + /** https origins that reach this server from outside, most preferred first. */ + readonly advertisedUrls?: ReadonlyArray; } >()("t3/config/ServerConfig") { static readonly layerTest = ( diff --git a/apps/server/src/environment/DirectEndpoints.test.ts b/apps/server/src/environment/DirectEndpoints.test.ts index 6548b2ccd8c0e..fd62cc77266dd 100644 --- a/apps/server/src/environment/DirectEndpoints.test.ts +++ b/apps/server/src/environment/DirectEndpoints.test.ts @@ -273,7 +273,7 @@ const layerServeProbe = (status: number) => HttpClient.make((request) => Effect.succeed(serveProbeResponse(request, status))), ); -const layerServeConfig = (host = "127.0.0.1") => +const layerServeConfig = (host = "127.0.0.1", advertisedUrls: ReadonlyArray = []) => Layer.effect( ServerConfig.ServerConfig, Effect.map(ServerConfig.ServerConfig, (config) => ({ @@ -282,6 +282,7 @@ const layerServeConfig = (host = "127.0.0.1") => port: 3773, tailscaleServeEnabled: true, tailscaleServePort: 443, + advertisedUrls, })), ).pipe( Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-direct-" })), @@ -375,3 +376,37 @@ describe("DirectEndpoints Tailscale Serve", () => { }), ); }); + +describe("DirectEndpoints advertised URLs", () => { + const resolveAdvertised = (host: string) => + Effect.flatMap(DirectEndpoints.DirectEndpoints, (service) => service.resolve()).pipe( + Effect.provide( + DirectEndpoints.layer.pipe( + Layer.provide( + Layer.mergeAll( + layerServeConfig(host, ["https://t3.example.test/", "https://t3.internal.test/"]), + layerTailscaleUp, + layerServeProbe(200), + ), + ), + ), + ), + ); + + effectIt.effect("lists them first, in the order given, whatever address the server binds", () => + Effect.gen(function* () { + const configured = [ + { kind: "configured", httpBaseUrl: "https://t3.example.test/" }, + { kind: "configured", httpBaseUrl: "https://t3.internal.test/" }, + ]; + const served = { kind: "tailnet", httpBaseUrl: "https://bb-1.tail1234.ts.net/" }; + expect(yield* resolveAdvertised("192.168.1.10")).toEqual([ + ...configured, + { kind: "lan", httpBaseUrl: "http://192.168.1.10:3773/" }, + served, + ]); + // A reverse proxy on this machine forwards to a loopback bind. + expect(yield* resolveAdvertised("127.0.0.1")).toEqual([...configured, served]); + }), + ); +}); diff --git a/apps/server/src/environment/DirectEndpoints.ts b/apps/server/src/environment/DirectEndpoints.ts index 1f7d9f705620c..9a23aa0730ff3 100644 --- a/apps/server/src/environment/DirectEndpoints.ts +++ b/apps/server/src/environment/DirectEndpoints.ts @@ -133,6 +133,10 @@ export const make = Effect.gen(function* () { const servedEndpoint = yield* Ref.make>(Option.none()); const resolveAvailable = Effect.fn("DirectEndpoints.resolveAvailable")(function* () { const endpoints = [ + ...(config.advertisedUrls ?? []).map((httpBaseUrl) => ({ + kind: "configured" as const, + httpBaseUrl, + })), ...resolveBoundEndpoints({ platform, host: config.host, diff --git a/apps/server/src/peer/PeerLinks.test.ts b/apps/server/src/peer/PeerLinks.test.ts index fcb682ab03faf..d9bfb663169f4 100644 --- a/apps/server/src/peer/PeerLinks.test.ts +++ b/apps/server/src/peer/PeerLinks.test.ts @@ -1,5 +1,7 @@ // @effect-diagnostics-next-line nodeBuiltinImport:off - The HTTPS test server needs certificate bytes. import * as NodeFS from "node:fs"; +// @effect-diagnostics-next-line nodeBuiltinImport:off - A proxy that sets the Host it forwards, which fetch cannot. +import * as NodeHttp from "node:http"; // @effect-diagnostics-next-line nodeBuiltinImport:off - A peer with a certificate this machine does not trust. import * as NodeHttps from "node:https"; import { NodeHttpServer } from "@effect/platform-node"; @@ -792,6 +794,76 @@ it.effect("linking a picked machine that answers nowhere names it and links noth ).pipe(Effect.provide(NodeHttpServer.layerTest)), ); +/** + * A proxy in front of `target` that forwards what an ingress terminating TLS for + * `host` forwards: that Host, and X-Forwarded-Proto. + */ +const serveBehindProxy = (target: string, host: string) => + Effect.acquireRelease( + Effect.callback((resume) => { + const server = NodeHttp.createServer((request, response) => { + const upstream = NodeHttp.request( + `${target}${request.url}`, + { + method: request.method, + headers: { ...request.headers, host, "x-forwarded-proto": "https" }, + }, + (answer) => { + response.writeHead(answer.statusCode ?? 502, answer.headers); + answer.pipe(response); + }, + ); + upstream.on("error", () => { + if (!response.headersSent) response.writeHead(502); + response.end(); + }); + request.pipe(upstream); + }); + server.listen(0, "127.0.0.1", () => resume(Effect.succeed(server))); + }), + (server) => + Effect.sync(() => { + server.closeAllConnections(); + server.close(); + }), + ).pipe( + Effect.map((server) => { + const address = server.address(); + if (address === null || typeof address === "string") + throw new Error("expected a TCP address"); + return `http://127.0.0.1:${address.port}`; + }), + ); + +it.effect("links a picked machine at the https address its proxy answers at", () => + Effect.scoped( + Effect.gen(function* () { + const b = yield* serveB(box); + const a = yield* makeA(laptop); + const advertised = "https://t3.example.test"; + const proxy = yield* serveBehindProxy(b.url, new URL(advertised).host); + // Stands in for DNS and TLS only: the proxy above decides what B sees. + const resolved = Effect.provideService(FetchHttpClient.Fetch, (input, init) => + // @effect-diagnostics-next-line globalFetch:off - Forwards the client's own request. + globalThis.fetch(String(input).replace(advertised, proxy), init), + ); + const pairing = yield* b.auth.issuePairingCredential(); + const linked = yield* a.links + .link({ + url: advertised, + alternateUrls: ["http://10.42.0.17:3773"], + pairingCode: pairing.credential, + access: "auto", + expectedEnvironmentId: box.environmentId, + }) + .pipe(resolved); + expect(linked).toMatchObject({ environmentId: box.environmentId, urls: [advertised] }); + expect(yield* probe(a).pipe(resolved)).toMatchObject({ caller: "T3 Code ยท Laptop" }); + expect((yield* linkedSessions(b)).length).toBe(1); + }), + ).pipe(Effect.provide(NodeHttpServer.layerTest)), +); + it.live("linking an address with nothing listening says so, unlike one that is not T3 Code", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/policy/managedLimits.test.ts b/apps/server/src/policy/managedLimits.test.ts index a09f11909abfc..1f8b0490231f4 100644 --- a/apps/server/src/policy/managedLimits.test.ts +++ b/apps/server/src/policy/managedLimits.test.ts @@ -82,6 +82,17 @@ it.effect("refuses Tailscale Serve when remote clients are not allowed", () => tailscaleServeEnabled: true, }).pipe(Effect.flip); assert.include(error.message, "Tailscale Serve"); + // A proxy on this machine reaches a loopback bind, so the address it answers at is remote. + const advertised = yield* ManagedLimits.checkServerReachability(desktopAndLocal, { + authPolicy: "loopback-browser", + host: undefined, + tailscaleServeEnabled: false, + advertisedUrls: ["https://t3.example.test/"], + }).pipe(Effect.flip); + assert.equal( + advertised.message, + "Example Corp's policy does not allow remote clients, so the server cannot advertise an address.", + ); }), ); diff --git a/apps/server/src/policy/managedLimits.ts b/apps/server/src/policy/managedLimits.ts index 88df7d63c27c2..dd8490536c7ea 100644 --- a/apps/server/src/policy/managedLimits.ts +++ b/apps/server/src/policy/managedLimits.ts @@ -214,6 +214,7 @@ export const checkServerReachability = Effect.fn("managedLimits.checkServerReach readonly authPolicy: ServerAuthPolicy; readonly host: string | undefined; readonly tailscaleServeEnabled: boolean; + readonly advertisedUrls?: ReadonlyArray | undefined; /** The background service chose `host` to reach private networks; the user did not. */ readonly privateNetworkOnly?: boolean | undefined; }, @@ -234,6 +235,9 @@ export const checkServerReachability = Effect.fn("managedLimits.checkServerReach if (input.tailscaleServeEnabled) { return yield* refuseRemoteClients(policy, "so Tailscale Serve cannot be turned on"); } + if (input.advertisedUrls !== undefined && input.advertisedUrls.length > 0) { + return yield* refuseRemoteClients(policy, "so the server cannot advertise an address"); + } return loopbackBindAddress(input.host) ?? (yield* cannotListen()); }, ); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 30f003bf12891..23793d9c87812 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -361,6 +361,7 @@ const layerHttpServer = Layer.unwrap( authPolicy: EnvironmentAuthPolicy.resolveServerAuthPolicy(config), host: config.host, tailscaleServeEnabled: config.tailscaleServeEnabled, + advertisedUrls: config.advertisedUrls, privateNetworkOnly: config.privateNetworkOnly, }); yield* ManagedLimits.closeTailscaleServeTo(policy, { host: bindHost, port: config.port }); diff --git a/docs/user/managed-policy.md b/docs/user/managed-policy.md index ab8d7133c5dbd..14776e7817cf0 100644 --- a/docs/user/managed-policy.md +++ b/docs/user/managed-policy.md @@ -226,7 +226,8 @@ desktop app, keep `desktop` in the list, or the app cannot start. Without `remote`, the server listens only on a loopback address, creates no pairing links, and signs out devices that paired earlier. It does not start with a host other than a loopback -address or `localhost`. A [background service](./background-service.md) installed before +address or `localhost`, or with an +[advertised address](./remote-access.md#behind-a-reverse-proxy). A [background service](./background-service.md) installed before `T3CODE_LAN` existed keeps private-network access, so without `remote` it refuses to start. Run `t3 up --no-lan` when its `service.env` sets no `T3CODE_HOST`, or set `T3CODE_HOST=127.0.0.1` in `service.env`, then run `t3 service restart`. The desktop app starts diff --git a/docs/user/remote-access.md b/docs/user/remote-access.md index 34c61e94a7c83..9d3aa6f181df7 100644 --- a/docs/user/remote-access.md +++ b/docs/user/remote-access.md @@ -144,7 +144,8 @@ Pairing again over an address the machine already has still works. A new route is placed by speed, in that order, and you can reorder routes at any time. While connected through T3 Connect or a paired address, T3 Code also learns the -machine's current LAN and Tailscale addresses and adds them as routes, so +machine's current LAN and Tailscale addresses, and any +[address it advertises](#behind-a-reverse-proxy), and adds them as routes, so pairing once through T3 Connect is enough to use the LAN at home. When the machine's LAN address changes, for example after it joins another Wi-Fi network, the learned route follows it. The machine must allow network access for its LAN @@ -219,6 +220,36 @@ tailscale serve --https=443 --set-path=/ off If that port is already in use, choose another with `--tailscale-serve-port`. See `t3 pair --help` for other pairing options. +### Behind a reverse proxy + +A server in a container, a Kubernetes Pod, or a VM behind an ingress cannot see +the address its proxy answers at. Tell it: + +```bash +t3 serve --advertise-url https://t3.example.com +``` + +Repeat the flag for several addresses, most preferred first. Or set +`T3CODE_ADVERTISE_URL` to a comma-separated list, for a background service in +its [`service.env`](./background-service.md#configure-the-service-environment). +Each address is `https` with no path, and the server does not start with +anything else. + +Clients that are already connected add the address as a route, and +[linking another environment](#link-another-environment) offers it when you pick +this machine, so you do not type it. `t3 pair` and the pairing link printed at +startup still use the address the server finds itself. For a pairing link at +the advertised address, run +`t3 auth pairing create --base-url https://t3.example.com`. + +Clients and linked environments send their credentials to an advertised +address, and the server does not check it first. List only addresses you +control, and remove one before you give up its domain. + +The setting does not set up the proxy or change where the server listens. The +proxy must pass the original `Host` header, set `X-Forwarded-Proto: https`, and +forward WebSocket connections. + ### Hosted web app [app.t3.codes](https://app.t3.codes) needs an HTTPS endpoint. It connects directly diff --git a/packages/client-runtime/src/connection/routes.test.ts b/packages/client-runtime/src/connection/routes.test.ts index e8dd244e043a6..e87b98495e33e 100644 --- a/packages/client-runtime/src/connection/routes.test.ts +++ b/packages/client-runtime/src/connection/routes.test.ts @@ -246,6 +246,37 @@ describe("learned routes", () => { ).toBeNull(); }); + it("learns a configured https address as an encrypted route, from an HTTPS page too", () => { + const reported = [ + { kind: "configured", httpBaseUrl: "https://t3.example.test/" }, + { kind: "lan", httpBaseUrl: "http://10.42.0.17:3773/" }, + ]; + const hosted = mergeLearnedRoutes({ + entry: relayOnly, + activeRoute: RELAY, + reported, + allowInsecure: false, + })!; + expect(hosted.map(connectionRouteLabel)).toEqual(["t3.example.test", "T3 Connect"]); + expect(profileOf(hosted[0]!)).toMatchObject({ + learned: true, + httpBaseUrl: "https://t3.example.test/", + wsBaseUrl: "wss://t3.example.test/", + }); + expect(profileOf(hosted[0]!)).not.toHaveProperty("network"); + + // A token paired over https reaches it, and still stays off the plain http address. + const paired = entryWithRoutes(relayOnly, [PUBLIC]); + const borrowed = mergeLearnedRoutes({ + entry: paired, + activeRoute: PUBLIC, + reported, + allowInsecure: true, + })!; + expect(borrowed.map(connectionRouteLabel)).toEqual(["desk.example.com", "t3.example.test"]); + expect(credentialConnectionId(connectionRouteId(borrowed[1]!.target))).toBe("public"); + }); + it("leaves user routes alone and does not learn an address already saved", () => { const entry: ConnectionCatalogEntry = { target: LAN.target, diff --git a/packages/client-runtime/src/linkRequest.test.ts b/packages/client-runtime/src/linkRequest.test.ts index dc8a1b94db9e9..6e8a56c87bf99 100644 --- a/packages/client-runtime/src/linkRequest.test.ts +++ b/packages/client-runtime/src/linkRequest.test.ts @@ -1,4 +1,10 @@ -import { EnvironmentId, type ServerConfig, ThreadId, TurnItemId } from "@t3tools/contracts"; +import { + EnvironmentId, + type ServerConfig, + type ServerDirectEndpoint, + ThreadId, + TurnItemId, +} from "@t3tools/contracts"; import type { RelayEnvironmentStatusResponse } from "@t3tools/contracts/relay"; import * as Option from "effect/Option"; import { describe, expect, it } from "vite-plus/test"; @@ -100,7 +106,7 @@ const presentation = ( label: string, options: { readonly saved?: ReadonlyArray; - readonly reported?: ReadonlyArray; + readonly reported?: ReadonlyArray; readonly connected?: boolean; } = {}, ): EnvironmentPresentation & { readonly environmentId: EnvironmentId } => { @@ -140,10 +146,9 @@ const presentation = ( ? null : ({ environment: { platform: { machine: "laptop" } }, - directEndpoints: options.reported.map((httpBaseUrl) => ({ - kind: "tailnet", - httpBaseUrl, - })), + directEndpoints: options.reported.map((endpoint) => + typeof endpoint === "string" ? { kind: "tailnet", httpBaseUrl: endpoint } : endpoint, + ), } as unknown as ServerConfig), }; }; @@ -265,6 +270,24 @@ describe("linkableMachines", () => { ]); }); + it("offers a configured https address first, ahead of the address the machine binds", () => { + const here = presentation("environment-here", "Here", { saved: ["http://localhost:3773"] }); + const pod = presentation("environment-pod", "Pod", { + saved: ["http://10.42.0.17:3773/"], + reported: [ + { kind: "configured", httpBaseUrl: "https://t3.example.test/" }, + { kind: "lan", httpBaseUrl: "http://10.42.0.17:3773/" }, + ], + }); + const [listed] = linkableMachines({ + environments: [here, pod], + threadEnvironmentId: here.environmentId, + links: [], + connect: new Map(), + }); + expect(listed?.urls).toEqual(["https://t3.example.test/", "http://10.42.0.17:3773/"]); + }); + it("no longer counts a machine as linked once its link expired", () => { const here = presentation("environment-here", "Here", { saved: ["http://localhost:3773"] }); const vps = presentation("environment-vps", "VPS", { saved: ["https://vps.ts.net"] }); diff --git a/packages/contracts/src/server.test.ts b/packages/contracts/src/server.test.ts index 570d99f045650..638bfcd1a4b70 100644 --- a/packages/contracts/src/server.test.ts +++ b/packages/contracts/src/server.test.ts @@ -6,6 +6,7 @@ import { applyServerProviderDeltas, OtlpEndpointCheckInput, resolveEnvironmentMachineKind, + ServerConfig, ServerConfigProviderStatusDeltasPayload, ServerObservability, ServerProvider, @@ -106,6 +107,18 @@ describe("server config forward compatibility", () => { { id: "primary", kind: "session", label: "Session", usedPercent: 12 }, ]); }); + + it("drops a direct endpoint of a kind this build does not know and keeps the rest", () => { + const decode = Schema.decodeUnknownSync(ServerConfig.fields.directEndpoints); + const known = [ + { kind: "configured", httpBaseUrl: "https://t3.example.test/" }, + { kind: "lan", httpBaseUrl: "http://192.168.1.10:3773/" }, + ]; + + expect( + decode([known[0], { kind: "some-future-kind", httpBaseUrl: "https://next.test/" }, known[1]]), + ).toEqual(known); + }); }); describe("ServerObservability", () => { diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index e2662bca07463..e47000a9d2bec 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -658,8 +658,11 @@ export function environmentThemeFileHasColors(file: EnvironmentThemeFile): boole ); } -/** tailnet means this machine's Tailscale interface; other private addresses are lan. */ -export const ServerDirectEndpointKind = Schema.Literals(["lan", "tailnet"]); +/** + * tailnet means this machine's Tailscale interface; other private addresses are lan. + * configured is an https address its operator set, such as a reverse proxy in front of it. + */ +export const ServerDirectEndpointKind = Schema.Literals(["lan", "tailnet", "configured"]); export type ServerDirectEndpointKind = typeof ServerDirectEndpointKind.Type; export const ServerDirectEndpoint = Schema.Struct({