From 0521ecbfbc8824a84674864e2f8c97022c0ef67c Mon Sep 17 00:00:00 2001 From: David Ilie Date: Mon, 9 Nov 2026 20:57:29 +0000 Subject: [PATCH 1/4] feat(server): a server behind a reverse proxy can advertise its https address A server in a Pod, container or VM behind an ingress reports only the addresses on its own interfaces, such as http://:3773/. Clients never learn the https address the proxy answers at, and linking two such machines means typing that address by hand, because linking refuses plain http outside loopback and the tailnet. `--advertise-url` (repeatable) and `T3CODE_ADVERTISE_URL` (comma-separated) take https origins. The server refuses to start on plain http, a path, a query or credentials, since clients keep only the origin and a path would name another app there. DirectEndpoints reports them first, as a new `configured` kind, ahead of the interface addresses it already reports. A managed policy without remote clients refuses the setting, as it refuses Tailscale Serve. Clients need no code change. `directEndpoints` is already a forward compatible array, so a client that predates the kind drops the entry and keeps the rest. Route learning adds the address as an https route, and `linkableMachines` offers it first because it keeps the server's order. `peerOrigin` and the interface addresses are unchanged. Claude Fable 5.2, Claude Code in T3 Code --- apps/server/src/cli/config.test.ts | 57 +++++++++++++++ apps/server/src/cli/config.ts | 55 +++++++++++++++ apps/server/src/config.ts | 2 + .../src/environment/DirectEndpoints.test.ts | 37 +++++++++- .../server/src/environment/DirectEndpoints.ts | 4 ++ apps/server/src/peer/PeerLinks.test.ts | 69 +++++++++++++++++++ apps/server/src/policy/managedLimits.test.ts | 11 +++ apps/server/src/policy/managedLimits.ts | 4 ++ apps/server/src/server.ts | 1 + docs/user/managed-policy.md | 3 +- docs/user/remote-access.md | 33 ++++++++- .../src/connection/routes.test.ts | 31 +++++++++ .../client-runtime/src/linkRequest.test.ts | 35 ++++++++-- packages/contracts/src/server.test.ts | 13 ++++ packages/contracts/src/server.ts | 7 +- 15 files changed, 351 insertions(+), 11 deletions(-) diff --git a/apps/server/src/cli/config.test.ts b/apps/server/src/cli/config.test.ts index 5e09dda2c164f..6425755abfa12 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, shown] of [ + ["http://t3.example.test", "http://t3.example.test"], + ["not a url", "not a url"], + // A proxy under a path prefix shares its origin with other apps. + ["https://t3.example.test/app", "https://t3.example.test/app"], + ["https://t3.example.test?x=1", "https://t3.example.test?x=1"], + ["https://user:secret@t3.example.test", "t3.example.test"], + ]) { + const error = String( + yield* resolve({ T3CODE_ADVERTISE_URL: `https://t3.example.test,${rejected}` }).pipe( + Effect.flip, + ), + ); + expect(error).toContain(`"${shown}" is not 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 00aae0f3133b7..c067587374144 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({ + // Credentials written into an address stay out of the error. + message: `"${entry.slice(entry.lastIndexOf("@") + 1)}" is not an https origin. An advertised address is https 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,14 @@ const tailscaleServePortFlag = Flag.Int("tailscale-serve-port").pipe( Flag.withDescription("HTTPS port for Tailscale Serve when --tailscale-serve is enabled."), Flag.optional, ); +const advertiseUrlFlag = Flag.String("advertise-url").pipe( + Flag.withSchema(AdvertisedUrlsFromString), + 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 +213,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 +253,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 +289,7 @@ export const sharedServerCommandFlags = { logWebSocketEvents: logWebSocketEventsFlag, tailscaleServeEnabled: tailscaleServeFlag, tailscaleServePort: tailscaleServePortFlag, + advertisedUrls: advertiseUrlFlag, lan: lanFlag, } as const; @@ -290,6 +338,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; @@ -418,6 +467,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, @@ -523,6 +576,7 @@ export const resolveServerConfig = Effect.fn("config.resolveServerConfig")(funct logWebSocketEvents, tailscaleServeEnabled, tailscaleServePort, + ...(advertisedUrls.length > 0 ? { advertisedUrls } : {}), }; return config; @@ -547,6 +601,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 d4883688c08ef..c530767b67842 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -107,6 +107,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 def0434140c32..9694ba7d7b3b0 100644 --- a/apps/server/src/environment/DirectEndpoints.test.ts +++ b/apps/server/src/environment/DirectEndpoints.test.ts @@ -258,7 +258,7 @@ const layerServeProbe = (status: number) => ), ); -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) => ({ @@ -267,6 +267,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-" })), @@ -356,3 +357,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 47e9772e20fe1..385ccc2180ee9 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 5198fc443f768..1f244f02fa8b1 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"; @@ -649,6 +651,73 @@ 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) => { + request.pipe( + 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); + }, + ), + ); + }); + 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)), +); + /** An https listener whose certificate this machine does not trust. */ const serveUntrusted = Layer.build( Layer.fresh( 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 47190f60dcf51..c87b069a045e4 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -346,6 +346,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 75910d9405c39..0145a2a986a07 100644 --- a/docs/user/remote-access.md +++ b/docs/user/remote-access.md @@ -120,7 +120,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 @@ -195,6 +196,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 da9c631638cc4..5bd8f50284856 100644 --- a/packages/client-runtime/src/connection/routes.test.ts +++ b/packages/client-runtime/src/connection/routes.test.ts @@ -215,6 +215,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 25647abe5aa27..cb74758774fba 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"; @@ -97,7 +103,7 @@ const presentation = ( label: string, options: { readonly saved?: ReadonlyArray; - readonly reported?: ReadonlyArray; + readonly reported?: ReadonlyArray; readonly connected?: boolean; } = {}, ): EnvironmentPresentation & { readonly environmentId: EnvironmentId } => { @@ -137,10 +143,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), }; }; @@ -261,6 +266,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 a79c499afa3ac..55836681befce 100644 --- a/packages/contracts/src/server.test.ts +++ b/packages/contracts/src/server.test.ts @@ -5,6 +5,7 @@ import { ExecutionEnvironmentDescriptor } from "./environment.ts"; import { OtlpEndpointCheckInput, resolveEnvironmentMachineKind, + ServerConfig, ServerObservability, ServerProvider, ServerProviders, @@ -70,6 +71,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 40a1aa8b2ca86..21352aad84c17 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -584,8 +584,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({ From 266b02d9f884378797fc4748251697efb5111123 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sun, 15 Nov 2026 04:08:56 +0000 Subject: [PATCH 2/4] fix(server): keep rejected advertised urls out of diagnostics --- apps/server/src/cli/config.test.ts | 14 +++++++------- apps/server/src/cli/config.ts | 4 ++-- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/apps/server/src/cli/config.test.ts b/apps/server/src/cli/config.test.ts index 6425755abfa12..cf14a474cce51 100644 --- a/apps/server/src/cli/config.test.ts +++ b/apps/server/src/cli/config.test.ts @@ -829,20 +829,20 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { "https://flag.example.test/", ]); - for (const [rejected, shown] of [ - ["http://t3.example.test", "http://t3.example.test"], - ["not a url", "not a url"], + 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/app"], - ["https://t3.example.test?x=1", "https://t3.example.test?x=1"], - ["https://user:secret@t3.example.test", "t3.example.test"], + "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(`"${shown}" is not an https origin`); + expect(error).toContain("An advertised address must be an https origin"); expect(error).not.toContain("secret"); } }), diff --git a/apps/server/src/cli/config.ts b/apps/server/src/cli/config.ts index 042228caf26ff..bad506902be57 100644 --- a/apps/server/src/cli/config.ts +++ b/apps/server/src/cli/config.ts @@ -48,8 +48,8 @@ const AdvertisedUrlsFromString = Schema.String.pipe( ) { return Effect.fail( new SchemaIssue.InvalidValue({ - // Credentials written into an address stay out of the error. - message: `"${entry.slice(entry.lastIndexOf("@") + 1)}" is not an https origin. An advertised address is https with no path, query or credentials, such as https://t3.example.com.`, + message: + "An advertised address must be an https origin with no path, query or credentials, such as https://t3.example.com.", }), ); } From af29fbb16fbd2ff970017473b492c231d487d23f Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sun, 15 Nov 2026 04:10:52 +0000 Subject: [PATCH 3/4] fix(server): redact advertised url flag validation errors --- apps/server/src/cli/config.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/server/src/cli/config.ts b/apps/server/src/cli/config.ts index bad506902be57..e11ea2599cffe 100644 --- a/apps/server/src/cli/config.ts +++ b/apps/server/src/cli/config.ts @@ -120,7 +120,11 @@ const tailscaleServePortFlag = Flag.Int("tailscale-serve-port").pipe( Flag.optional, ); const advertiseUrlFlag = Flag.String("advertise-url").pipe( - Flag.withSchema(AdvertisedUrlsFromString), + Flag.mapEffect((value) => + Schema.decodeUnknownEffect(AdvertisedUrlsFromString)(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).", ), From 7f34361a80584ae84b49e805196988be916db957 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sun, 15 Nov 2026 04:13:00 +0000 Subject: [PATCH 4/4] fix(server): compile advertised url decoder once --- apps/server/src/cli/config.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/server/src/cli/config.ts b/apps/server/src/cli/config.ts index e11ea2599cffe..71b24fc6c6438 100644 --- a/apps/server/src/cli/config.ts +++ b/apps/server/src/cli/config.ts @@ -119,9 +119,10 @@ 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) => - Schema.decodeUnknownEffect(AdvertisedUrlsFromString)(value).pipe( + decodeAdvertisedUrls(value).pipe( Effect.mapError((error) => new CliError.ValidationError({ message: error.message })), ), ),