From 4c019e81e036fe2f78aa6d3226b6195cefb4776e Mon Sep 17 00:00:00 2001 From: John Lombardo Date: Thu, 28 May 2026 11:19:08 +0800 Subject: [PATCH] Add Tailscale Serve advertised URL support --- REMOTE.md | 13 ++- apps/server/src/cli/config.test.ts | 17 +++ apps/server/src/cli/config.ts | 20 ++++ apps/server/src/config.ts | 2 + apps/server/src/server.ts | 20 +++- apps/server/src/serverRuntimeStartup.ts | 9 +- apps/server/src/startupAccess.test.ts | 128 +++++++++++++++++++++++ apps/server/src/startupAccess.ts | 47 ++++++++- apps/server/src/tailscaleServeRuntime.ts | 29 +++++ 9 files changed, 275 insertions(+), 10 deletions(-) create mode 100644 apps/server/src/tailscaleServeRuntime.ts diff --git a/REMOTE.md b/REMOTE.md index 56510e62890..1c878149631 100644 --- a/REMOTE.md +++ b/REMOTE.md @@ -89,8 +89,17 @@ For hosted web pairing over Tailscale HTTPS, opt in to Tailscale Serve: npx t3 serve --tailscale-serve ``` -By default this configures Tailscale Serve on HTTPS port 443 and advertises -`https://machine.tailnet.ts.net/`. Advanced users can choose a different HTTPS port: +By default this configures Tailscale Serve on HTTPS port 443, discovers the machine's MagicDNS +name, and advertises `https://machine.tailnet.ts.net/`. If MagicDNS discovery is unavailable, the +CLI falls back to the normal headless pairing URL. + +Use `--tailscale-serve-host` when the advertised HTTPS host should be fixed instead of discovered: + +```bash +npx t3 serve --tailscale-serve --tailscale-serve-host machine.tailnet.ts.net +``` + +Advanced users can also choose a different HTTPS port: ```bash npx t3 serve --tailscale-serve --tailscale-serve-port 8443 diff --git a/apps/server/src/cli/config.test.ts b/apps/server/src/cli/config.test.ts index 9e73773d5a5..8356f6910e0 100644 --- a/apps/server/src/cli/config.test.ts +++ b/apps/server/src/cli/config.test.ts @@ -75,6 +75,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { logWebSocketEvents: Option.none(), tailscaleServeEnabled: Option.none(), tailscaleServePort: Option.none(), + tailscaleServeHost: Option.none(), }, Option.none(), ).pipe( @@ -92,6 +93,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { T3CODE_NO_BROWSER: "true", T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD: "false", T3CODE_LOG_WS_EVENTS: "true", + T3CODE_TAILSCALE_SERVE_HOST: "env-tailnet.example.ts.net", }, }), ), @@ -118,6 +120,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { logWebSocketEvents: true, tailscaleServeEnabled: false, tailscaleServePort: 443, + tailscaleServeHost: "env-tailnet.example.ts.net", }); }), ); @@ -141,6 +144,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { logWebSocketEvents: Option.some(true), tailscaleServeEnabled: Option.some(true), tailscaleServePort: Option.some(8443), + tailscaleServeHost: Option.some("cli-tailnet.example.ts.net"), }, Option.some("Debug"), ).pipe( @@ -158,6 +162,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { T3CODE_NO_BROWSER: "false", T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD: "false", T3CODE_LOG_WS_EVENTS: "false", + T3CODE_TAILSCALE_SERVE_HOST: "ignored-env-tailnet.example.ts.net", }, }), ), @@ -184,6 +189,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { logWebSocketEvents: true, tailscaleServeEnabled: true, tailscaleServePort: 8443, + tailscaleServeHost: "cli-tailnet.example.ts.net", }); }), ); @@ -215,6 +221,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { logWebSocketEvents: Option.some(false), tailscaleServeEnabled: Option.none(), tailscaleServePort: Option.none(), + tailscaleServeHost: Option.none(), }, Option.none(), ).pipe( @@ -253,6 +260,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { logWebSocketEvents: false, tailscaleServeEnabled: false, tailscaleServePort: 443, + tailscaleServeHost: undefined, }); }), ); @@ -290,6 +298,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { logWebSocketEvents: Option.none(), tailscaleServeEnabled: Option.none(), tailscaleServePort: Option.none(), + tailscaleServeHost: Option.none(), }, Option.none(), ).pipe( @@ -327,6 +336,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { logWebSocketEvents: false, tailscaleServeEnabled: false, tailscaleServePort: 443, + tailscaleServeHost: undefined, }); assert.equal(join(baseDir, "userdata"), resolved.stateDir); }), @@ -353,6 +363,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { logWebSocketEvents: Option.none(), tailscaleServeEnabled: Option.none(), tailscaleServePort: Option.none(), + tailscaleServeHost: Option.none(), }, Option.none(), ).pipe( @@ -412,6 +423,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { logWebSocketEvents: Option.none(), tailscaleServeEnabled: Option.none(), tailscaleServePort: Option.none(), + tailscaleServeHost: Option.none(), }, Option.some("Debug"), ).pipe( @@ -452,6 +464,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { logWebSocketEvents: true, tailscaleServeEnabled: false, tailscaleServePort: 443, + tailscaleServeHost: undefined, }); }), ); @@ -488,6 +501,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { logWebSocketEvents: Option.none(), tailscaleServeEnabled: Option.none(), tailscaleServePort: Option.none(), + tailscaleServeHost: Option.none(), }, Option.none(), ).pipe( @@ -521,6 +535,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { logWebSocketEvents: false, tailscaleServeEnabled: false, tailscaleServePort: 443, + tailscaleServeHost: undefined, }); }), ); @@ -545,6 +560,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { logWebSocketEvents: Option.none(), tailscaleServeEnabled: Option.none(), tailscaleServePort: Option.none(), + tailscaleServeHost: Option.none(), }, Option.none(), { @@ -584,6 +600,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { logWebSocketEvents: false, tailscaleServeEnabled: false, tailscaleServePort: 443, + tailscaleServeHost: undefined, }); }), ); diff --git a/apps/server/src/cli/config.ts b/apps/server/src/cli/config.ts index 7182854e18c..13ee56a2af6 100644 --- a/apps/server/src/cli/config.ts +++ b/apps/server/src/cli/config.ts @@ -80,6 +80,10 @@ export const tailscaleServePortFlag = Flag.integer("tailscale-serve-port").pipe( Flag.withDescription("HTTPS port for Tailscale Serve when --tailscale-serve is enabled."), Flag.optional, ); +export const tailscaleServeHostFlag = Flag.string("tailscale-serve-host").pipe( + Flag.withDescription("Host name for Tailscale Serve when --tailscale-serve is enabled."), + Flag.optional, +); const EnvServerConfig = Config.all({ logLevel: Config.logLevel("T3CODE_LOG_LEVEL").pipe(Config.withDefault("Info")), @@ -136,6 +140,10 @@ const EnvServerConfig = Config.all({ Config.option, Config.map(Option.getOrUndefined), ), + tailscaleServeHost: Config.string("T3CODE_TAILSCALE_SERVE_HOST").pipe( + Config.option, + Config.map(Option.getOrUndefined), + ), }); export interface CliServerFlags { @@ -151,6 +159,7 @@ export interface CliServerFlags { readonly logWebSocketEvents: Option.Option; readonly tailscaleServeEnabled: Option.Option; readonly tailscaleServePort: Option.Option; + readonly tailscaleServeHost: Option.Option; } export interface CliAuthLocationFlags { @@ -185,6 +194,7 @@ export const sharedServerCommandFlags = { logWebSocketEvents: logWebSocketEventsFlag, tailscaleServeEnabled: tailscaleServeFlag, tailscaleServePort: tailscaleServePortFlag, + tailscaleServeHost: tailscaleServeHostFlag, } as const; export const authLocationFlags = sharedServerLocationFlags; @@ -230,6 +240,7 @@ export const resolveServerConfig = ( logWebSocketEvents: flags.logWebSocketEvents ?? Option.none(), tailscaleServeEnabled: flags.tailscaleServeEnabled ?? Option.none(), tailscaleServePort: flags.tailscaleServePort ?? Option.none(), + tailscaleServeHost: flags.tailscaleServeHost ?? Option.none(), } satisfies CliServerFlags; const bootstrapFd = Option.getOrUndefined(normalizedFlags.bootstrapFd) ?? env.bootstrapFd; const bootstrapEnvelope = @@ -330,6 +341,13 @@ export const resolveServerConfig = ( ), () => 443, ); + const tailscaleServeHost = Option.getOrElse( + resolveOptionPrecedence( + normalizedFlags.tailscaleServeHost, + Option.fromUndefinedOr(env.tailscaleServeHost), + ), + () => undefined, + ); const staticDir = devUrl ? undefined : yield* resolveStaticDir(); const host = Option.getOrElse( resolveOptionPrecedence( @@ -374,6 +392,7 @@ export const resolveServerConfig = ( logWebSocketEvents, tailscaleServeEnabled, tailscaleServePort, + tailscaleServeHost, }; return config; @@ -397,6 +416,7 @@ export const resolveCliAuthConfig = ( logWebSocketEvents: Option.none(), tailscaleServeEnabled: Option.none(), tailscaleServePort: Option.none(), + tailscaleServeHost: Option.none(), }, cliLogLevel, ); diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index b0a23cb273c..0c6d8996d33 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -73,6 +73,7 @@ export interface ServerConfigShape extends ServerDerivedPaths { readonly logWebSocketEvents: boolean; readonly tailscaleServeEnabled: boolean; readonly tailscaleServePort: number; + readonly tailscaleServeHost?: string | undefined; } export const deriveServerPaths = Effect.fn(function* ( @@ -168,6 +169,7 @@ export class ServerConfig extends Context.Service tailscaleServeRuntime.markConfigured), Effect.tap(() => Effect.logInfo("Tailscale Serve configured", { localPort, @@ -372,11 +376,16 @@ export const makeServerLayer = Layer.unwrap( }), ), Effect.catch((cause) => - Effect.logWarning("Failed to configure Tailscale Serve", { - cause, - localPort, - servePort: config.tailscaleServePort, - }).pipe(Effect.as(null)), + tailscaleServeRuntime.markUnavailable.pipe( + Effect.andThen( + Effect.logWarning("Failed to configure Tailscale Serve", { + cause, + localPort, + servePort: config.tailscaleServePort, + }), + ), + Effect.as(null), + ), ), ); }), @@ -411,6 +420,7 @@ export const makeServerLayer = Layer.unwrap( return serverApplicationLayer.pipe( Layer.provideMerge(RuntimeServicesLive), + Layer.provideMerge(TailscaleServeRuntimeLive), Layer.provideMerge(HttpServerLive), Layer.provide(ObservabilityLive), Layer.provideMerge(FetchHttpClient.layer), diff --git a/apps/server/src/serverRuntimeStartup.ts b/apps/server/src/serverRuntimeStartup.ts index 9ec536105c8..aa84b77e652 100644 --- a/apps/server/src/serverRuntimeStartup.ts +++ b/apps/server/src/serverRuntimeStartup.ts @@ -38,6 +38,7 @@ import { formatHostForUrl, isWildcardHost, issueHeadlessServeAccessInfo, + resolveAdvertisedStartupBaseUrl, } from "./startupAccess.ts"; export class ServerRuntimeStartupError extends Data.TaggedError("ServerRuntimeStartupError")<{ @@ -247,7 +248,13 @@ const resolveStartupBrowserTarget = Effect.gen(function* () { serverConfig.host && !isWildcardHost(serverConfig.host) ? `http://${formatHostForUrl(serverConfig.host)}:${serverConfig.port}` : localUrl; - const baseTarget = serverConfig.devUrl?.toString() ?? bindUrl; + const httpBaseTarget = serverConfig.devUrl?.toString() ?? bindUrl; + const baseTarget = yield* resolveAdvertisedStartupBaseUrl({ + httpBaseUrl: httpBaseTarget, + tailscaleServeEnabled: serverConfig.tailscaleServeEnabled, + tailscaleServePort: serverConfig.tailscaleServePort, + tailscaleServeHost: serverConfig.tailscaleServeHost, + }); return yield* Effect.succeed(serverConfig.mode === "desktop" ? baseTarget : undefined).pipe( Effect.flatMap((target) => target ? Effect.succeed(target) : serverAuth.issueStartupPairingUrl(baseTarget), diff --git a/apps/server/src/startupAccess.test.ts b/apps/server/src/startupAccess.test.ts index 03c01170f15..a1391aaf3fa 100644 --- a/apps/server/src/startupAccess.test.ts +++ b/apps/server/src/startupAccess.test.ts @@ -1,13 +1,60 @@ import { assert, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Sink from "effect/Sink"; +import * as Stream from "effect/Stream"; +import { ChildProcessSpawner } from "effect/unstable/process"; import { buildPairingUrl, formatHeadlessServeOutput, renderTerminalQrCode, + resolveAdvertisedStartupBaseUrl, resolveHeadlessConnectionHost, resolveHeadlessConnectionString, resolveListeningPort, } from "./startupAccess.ts"; +import { TailscaleServeRuntime } from "./tailscaleServeRuntime.ts"; + +const encoder = new TextEncoder(); + +function mockHandle(result: { + readonly stdout?: string; + readonly stderr?: string; + readonly code?: number; +}) { + return ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(result.code ?? 0)), + isRunning: Effect.succeed(false), + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + stdin: Sink.drain, + stdout: Stream.make(encoder.encode(result.stdout ?? "")), + stderr: Stream.make(encoder.encode(result.stderr ?? "")), + all: Stream.empty, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }); +} + +function mockSpawnerLayer(result: { + readonly stdout?: string; + readonly stderr?: string; + readonly code?: number; +}) { + return Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => Effect.succeed(mockHandle(result))), + ); +} + +const mockTailscaleServeRuntimeLayer = (configured: boolean) => + Layer.succeed(TailscaleServeRuntime, { + awaitConfigured: Effect.succeed(configured), + markConfigured: Effect.void, + markUnavailable: Effect.void, + }); it("prefers localhost when no explicit host is configured", () => { expect(resolveHeadlessConnectionHost(undefined)).toBe("localhost"); @@ -52,6 +99,87 @@ it("prefers the actual bound port when an http server address is available", () expect(resolveListeningPort(null, 3773)).toBe(3773); }); +it.effect("uses an explicit Tailscale Serve host for the advertised startup base URL", () => + Effect.gen(function* () { + const baseUrl = yield* resolveAdvertisedStartupBaseUrl({ + httpBaseUrl: "http://192.168.1.42:3773", + tailscaleServeEnabled: true, + tailscaleServePort: 443, + tailscaleServeHost: "desktop.tail.ts.net", + }).pipe( + Effect.provide(Layer.mergeAll(mockSpawnerLayer({}), mockTailscaleServeRuntimeLayer(true))), + ); + + expect(baseUrl).toBe("https://desktop.tail.ts.net/"); + }), +); + +it.effect( + "resolves the advertised startup base URL from Tailscale status when no host is set", + () => + Effect.gen(function* () { + const baseUrl = yield* resolveAdvertisedStartupBaseUrl({ + httpBaseUrl: "http://192.168.1.42:3773", + tailscaleServeEnabled: true, + tailscaleServePort: 8443, + tailscaleServeHost: undefined, + }).pipe( + Effect.provide( + Layer.mergeAll( + mockSpawnerLayer({ + stdout: `{"Self":{"DNSName":"desktop.tail.ts.net.","TailscaleIPs":["100.100.100.100"]}}`, + }), + mockTailscaleServeRuntimeLayer(true), + ), + ), + ); + + expect(baseUrl).toBe("https://desktop.tail.ts.net:8443/"); + }), +); + +it.effect("falls back to the HTTP startup base URL when Tailscale status cannot be resolved", () => + Effect.gen(function* () { + const baseUrl = yield* resolveAdvertisedStartupBaseUrl({ + httpBaseUrl: "http://192.168.1.42:3773", + tailscaleServeEnabled: true, + tailscaleServePort: 443, + tailscaleServeHost: undefined, + }).pipe( + Effect.provide( + Layer.mergeAll( + mockSpawnerLayer({ code: 1, stderr: "not running" }), + mockTailscaleServeRuntimeLayer(true), + ), + ), + ); + + expect(baseUrl).toBe("http://192.168.1.42:3773"); + }), +); + +it.effect("falls back to HTTP when Tailscale Serve setup did not complete", () => + Effect.gen(function* () { + const baseUrl = yield* resolveAdvertisedStartupBaseUrl({ + httpBaseUrl: "http://192.168.1.42:3773", + tailscaleServeEnabled: true, + tailscaleServePort: 443, + tailscaleServeHost: "desktop.tail.ts.net", + }).pipe( + Effect.provide( + Layer.mergeAll( + mockSpawnerLayer({ + stdout: `{"Self":{"DNSName":"desktop.tail.ts.net.","TailscaleIPs":["100.100.100.100"]}}`, + }), + mockTailscaleServeRuntimeLayer(false), + ), + ), + ); + + expect(baseUrl).toBe("http://192.168.1.42:3773"); + }), +); + it("builds a pairing URL that embeds the token in the hash", () => { expect(buildPairingUrl("http://192.168.1.42:3773", "PAIRCODE")).toBe( "http://192.168.1.42:3773/pair#token=PAIRCODE", diff --git a/apps/server/src/startupAccess.ts b/apps/server/src/startupAccess.ts index d3b6898d75b..34a12840df9 100644 --- a/apps/server/src/startupAccess.ts +++ b/apps/server/src/startupAccess.ts @@ -1,11 +1,13 @@ import { networkInterfaces } from "node:os"; import { QrCode } from "@t3tools/shared/qrCode"; +import { buildTailscaleHttpsBaseUrl, resolveTailscaleHttpsBaseUrl } from "@t3tools/tailscale"; import * as Effect from "effect/Effect"; import { HttpServer } from "effect/unstable/http"; import { ServerConfig } from "./config.ts"; import { ServerAuth } from "./auth/Services/ServerAuth.ts"; +import { TailscaleServeRuntime } from "./tailscaleServeRuntime.ts"; export interface HeadlessServeAccessInfo { readonly connectionString: string; @@ -77,6 +79,41 @@ export const resolveHeadlessConnectionString = ( return `http://${formatHostForUrl(connectionHost)}:${port}`; }; +export const resolveAdvertisedStartupBaseUrl = (input: { + readonly httpBaseUrl: string; + readonly tailscaleServeEnabled: boolean; + readonly tailscaleServePort: number; + readonly tailscaleServeHost: string | undefined; +}) => { + if (!input.tailscaleServeEnabled) { + return Effect.succeed(input.httpBaseUrl); + } + + return Effect.gen(function* () { + const tailscaleServeRuntime = yield* TailscaleServeRuntime; + if (!(yield* tailscaleServeRuntime.awaitConfigured)) { + return input.httpBaseUrl; + } + + const explicitTailscaleHost = input.tailscaleServeHost?.trim(); + if (explicitTailscaleHost) { + return buildTailscaleHttpsBaseUrl({ + magicDnsName: explicitTailscaleHost, + servePort: input.tailscaleServePort, + }); + } + + return yield* resolveTailscaleHttpsBaseUrl({ servePort: input.tailscaleServePort }).pipe( + Effect.catch((cause) => + Effect.logDebug("failed to resolve tailscale https startup url", { cause }).pipe( + Effect.as(null), + ), + ), + Effect.map((tailscaleBaseUrl) => tailscaleBaseUrl ?? input.httpBaseUrl), + ); + }); +}; + export const resolveListeningPort = (address: unknown, fallbackPort: number): number => { if ( typeof address === "object" && @@ -138,11 +175,17 @@ export const issueHeadlessServeAccessInfo = Effect.fn("issueHeadlessServeAccessI serverConfig.host, resolveListeningPort(httpServer.address, serverConfig.port), ); + const advertisedBaseUrl = yield* resolveAdvertisedStartupBaseUrl({ + httpBaseUrl: connectionString, + tailscaleServeEnabled: serverConfig.tailscaleServeEnabled, + tailscaleServePort: serverConfig.tailscaleServePort, + tailscaleServeHost: serverConfig.tailscaleServeHost, + }); const issued = yield* serverAuth.issuePairingCredential({ role: "owner" }); return { - connectionString, + connectionString: advertisedBaseUrl, token: issued.credential, - pairingUrl: buildPairingUrl(connectionString, issued.credential), + pairingUrl: buildPairingUrl(advertisedBaseUrl, issued.credential), } satisfies HeadlessServeAccessInfo; }); diff --git a/apps/server/src/tailscaleServeRuntime.ts b/apps/server/src/tailscaleServeRuntime.ts new file mode 100644 index 00000000000..edad4bdcd93 --- /dev/null +++ b/apps/server/src/tailscaleServeRuntime.ts @@ -0,0 +1,29 @@ +import * as Context from "effect/Context"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +export interface TailscaleServeRuntimeShape { + readonly awaitConfigured: Effect.Effect; + readonly markConfigured: Effect.Effect; + readonly markUnavailable: Effect.Effect; +} + +export class TailscaleServeRuntime extends Context.Service< + TailscaleServeRuntime, + TailscaleServeRuntimeShape +>()("t3/tailscaleServeRuntime") {} + +export const TailscaleServeRuntimeLive = Layer.effect( + TailscaleServeRuntime, + Effect.gen(function* () { + const configured = yield* Deferred.make(); + const complete = (value: boolean) => Deferred.succeed(configured, value).pipe(Effect.ignore); + + return { + awaitConfigured: Deferred.await(configured), + markConfigured: complete(true), + markUnavailable: complete(false), + } satisfies TailscaleServeRuntimeShape; + }), +);