diff --git a/apps/server/src/cli/pair.test.ts b/apps/server/src/cli/pair.test.ts index fa7c6b7588fd..0077c3cc73e3 100644 --- a/apps/server/src/cli/pair.test.ts +++ b/apps/server/src/cli/pair.test.ts @@ -15,6 +15,7 @@ import * as TestConsole from "effect/testing/TestConsole"; import { Command, CliError } from "effect/cli"; import { cli } from "../binCli.ts"; +import { renderTerminalQrCode } from "../startupAccess.ts"; import { SERVICE_LAUNCHER_CONTEXT_ENV, SERVICE_LAUNCHER_PROTOCOL, @@ -145,6 +146,114 @@ const withDescriptorServer = (run: (origin: string) => Effect.Effect { + it.effect.each([ + { + baseUrl: "https://proxy.invalid", + publicOrigin: "https://proxy.invalid", + loopback: false, + variant: "userdata", + }, + { + baseUrl: "https://127.proxy.invalid", + publicOrigin: "https://127.proxy.invalid", + loopback: false, + variant: "userdata", + }, + { + baseUrl: "http://proxy.invalid:8080", + publicOrigin: "http://proxy.invalid:8080", + loopback: false, + variant: "userdata", + }, + { + baseUrl: "http://[::1]:5733", + publicOrigin: "http://[::1]:5733", + loopback: true, + variant: "userdata", + }, + { + baseUrl: "https://dev.invalid/prefix", + publicOrigin: "https://dev.invalid", + loopback: false, + variant: "dev", + }, + { + baseUrl: "http://[::ffff:127.0.0.1]:3773", + publicOrigin: "http://[::ffff:7f00:1]:3773", + loopback: true, + variant: "userdata", + }, + { + baseUrl: "http://[::ffff:192.168.1.42]:3773", + publicOrigin: "http://[::ffff:c0a8:12a]:3773", + loopback: false, + variant: "userdata", + }, + ] as const)( + "advertises $baseUrl while minting against the local $variant server", + ({ baseUrl, publicOrigin, loopback, variant }) => + withDescriptorServer((origin) => + Effect.gen(function* () { + const baseDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-pair-override-")); + yield* persistServerRuntimeState({ + path: NodePath.join(baseDir, variant, "server-runtime.json"), + state: yield* makePersistedServerRuntimeState({ + config: { host: "127.0.0.1", devUrl: undefined }, + port: Number(new URL(origin).port), + }), + }); + // The public address deliberately has no responder. Only local discovery should probe. + const output = yield* captureStdout( + runCli(["pair", "--base-dir", baseDir, "--base-url", baseUrl]), + ); + const pairingUrl = /Pairing URL: (\S+)/.exec(output)?.[1]; + if (pairingUrl === undefined) return yield* Effect.die("Missing pairing URL"); + assert.include(pairingUrl, `${publicOrigin}/pair#token=`); + assert.include(output, renderTerminalQrCode(pairingUrl)); + assert.include(output, `Pairing with pair-test (${origin})`); + assert.equal(output.includes("only reachable from this machine"), loopback); + assert.notInclude(output, "did not record its web URL"); + assert.notInclude(output, "Tailscale Serve now maps"); + if (variant === "userdata") { + const listed = yield* captureStdout( + runCli(["auth", "pairing", "list", "--base-dir", baseDir, "--json"]), + ); + assert.include(listed, '"label": "t3 pair"'); + } + }), + ).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect.each(["not-a-url", "/relative", "ftp://proxy.invalid", "file:///tmp/t3"])( + "rejects invalid pairing base URL %s", + (baseUrl) => + Effect.gen(function* () { + const error = yield* provideCliTestLayers( + runCli(["pair", "--base-url", baseUrl]).pipe(Effect.flip), + ); + if (!CliError.isCliError(error) || error._tag !== "ShowHelp") { + return yield* Effect.die("Expected CLI validation help"); + } + assert.isTrue(error.errors.some((entry) => entry._tag === "InvalidValue")); + const rendered = error.errors.map(String).join("\n"); + assert.include(rendered, "base-url"); + assert.notInclude(rendered, "No running T3 Code server"); + assert.notInclude(rendered, "TypeError"); + }), + ); + + it.effect("rejects --base-url with --tailscale before discovering or publishing a server", () => + Effect.gen(function* () { + const error = yield* provideCliTestLayers( + runCli(["pair", "--base-url", "https://proxy.invalid", "--tailscale"]).pipe(Effect.flip), + ); + const rendered = String( + typeof error === "object" && error !== null && "cause" in error ? error.cause : error, + ); + assert.include(rendered, "--base-url cannot be combined with --tailscale"); + }), + ); + it.effect("mints a token and prints a QR pairing URL for a live server", () => withDescriptorServer((origin) => Effect.gen(function* () { diff --git a/apps/server/src/cli/pair.ts b/apps/server/src/cli/pair.ts index debac218a983..6fd574ba895a 100644 --- a/apps/server/src/cli/pair.ts +++ b/apps/server/src/cli/pair.ts @@ -463,6 +463,32 @@ const labelFlag = Flag.String("label").pipe( Flag.optional, ); +const baseUrlFlag = Flag.String("base-url").pipe( + Flag.withSchema( + Schema.URLFromString.check( + Schema.makeFilter( + (url) => + url.protocol === "http:" || + url.protocol === "https:" || + "--base-url must be an absolute HTTP or HTTPS URL.", + ), + ), + ), + Flag.withDescription( + "Public HTTP(S) origin for the pairing link and QR code; must reach the client web origin. Does not configure or probe the proxy. Cannot combine with --tailscale.", + ), + Flag.optional, +); + +class ConflictingPairOptionsError extends Schema.TaggedError()( + "ConflictingPairOptionsError", + {}, +) { + override get message(): string { + return "--base-url cannot be combined with --tailscale."; + } +} + const tailscaleFlag = Flag.Boolean("tailscale").pipe( Flag.withDescription( "Publish the server over Tailscale Serve HTTPS and pair through the tailnet URL.", @@ -479,6 +505,7 @@ const tailscaleServePortFlag = Flag.Int("tailscale-serve-port").pipe( export const pairCommand = Command.make("pair", { baseDir: baseDirFlag, scopes: authScopesFlag(AuthStandardClientScopes), + baseUrl: baseUrlFlag, ttl: ttlFlag, label: labelFlag, tailscale: tailscaleFlag, @@ -489,6 +516,9 @@ export const pairCommand = Command.make("pair", { ), Command.withHandler((flags) => Effect.gen(function* () { + if (Option.isSome(flags.baseUrl) && flags.tailscale) { + return yield* new ConflictingPairOptionsError(); + } const cliLogLevel = yield* GlobalFlag.LogLevel; // Default to Warn so storage/migration chatter cannot bury the QR code; // an explicit --log-level still wins. @@ -506,13 +536,20 @@ export const pairCommand = Command.make("pair", { pairingBaseUrl = resolved.baseUrl; notes.push(...resolved.notes); } else { - pairingBaseUrl = resolveDirectPairingBaseUrl(target.state); + pairingBaseUrl = Option.match(flags.baseUrl, { + onNone: () => resolveDirectPairingBaseUrl(target.state), + onSome: (url) => url.toString(), + }); if (isLoopbackHost(new URL(pairingBaseUrl).hostname)) { notes.push( "This URL is only reachable from this machine. Re-run with --tailscale, or restart the server with a reachable --host.", ); } - if (target.variant === "dev" && target.state.devUrl === undefined) { + if ( + Option.isNone(flags.baseUrl) && + target.variant === "dev" && + target.state.devUrl === undefined + ) { notes.push( "This dev server did not record its web URL; restart it so pairing can go through the web origin.", ); diff --git a/apps/server/src/startupAccess.test.ts b/apps/server/src/startupAccess.test.ts index 03c01170f158..63a2bb848f64 100644 --- a/apps/server/src/startupAccess.test.ts +++ b/apps/server/src/startupAccess.test.ts @@ -2,6 +2,7 @@ import { assert, expect, it } from "@effect/vitest"; import { buildPairingUrl, + isLoopbackHost, formatHeadlessServeOutput, renderTerminalQrCode, resolveHeadlessConnectionHost, @@ -9,6 +10,33 @@ import { resolveListeningPort, } from "./startupAccess.ts"; +it.each([ + "localhost", + "127.0.0.1", + "127.255.255.254", + "::ffff:127.0.0.1", + "[::ffff:127.0.0.1]", + "[::ffff:7f00:1]", + "0:0:0:0:0:FFFF:7FFF:FFFF", + "::ffff:127.0.0.0", + "::1", + "[0:0:0:0:0:0:0:1]", +])("recognizes loopback host %s", (host) => { + expect(isLoopbackHost(host)).toBe(true); +}); + +it.each([ + "::ffff:126.255.255.255", + "::ffff:128.0.0.0", + "::ffff:192.168.1.42", + "::", + "invalid:host", + "127.proxy.example", + "127.0.0.1.example.com", +])("does not classify %s as loopback", (host) => { + expect(isLoopbackHost(host)).toBe(false); +}); + it("prefers localhost when no explicit host is configured", () => { expect(resolveHeadlessConnectionHost(undefined)).toBe("localhost"); expect(resolveHeadlessConnectionString(undefined, 3773)).toBe("http://localhost:3773"); diff --git a/apps/server/src/startupAccess.ts b/apps/server/src/startupAccess.ts index 4bc2578eeca7..60c936d162b9 100644 --- a/apps/server/src/startupAccess.ts +++ b/apps/server/src/startupAccess.ts @@ -1,3 +1,4 @@ +import * as NodeNet from "node:net"; import * as NodeOS from "node:os"; import { QrCode } from "@t3tools/shared/qrCode"; @@ -20,13 +21,18 @@ export const isLoopbackHost = (host: string | undefined): boolean => { return true; } - return ( - host === "localhost" || - host === "127.0.0.1" || - host === "::1" || - host === "[::1]" || - host.startsWith("127.") - ); + if (host.includes(":")) { + try { + // URL normalizes dotted and expanded IPv4-mapped IPv6 addresses to hex. + const hostname = new URL(`http://${formatHostForUrl(host)}`).hostname; + return hostname === "[::1]" || /^\[::ffff:7f[\da-f]{2}:[\da-f]{1,4}\]$/.test(hostname); + } catch { + return false; + } + } + + // A hostname such as 127.proxy.example is a name, not the loopback range. + return host === "localhost" || (NodeNet.isIPv4(host) && host.startsWith("127.")); }; export const isWildcardHost = (host: string | undefined): boolean => diff --git a/docs/user/remote-access.md b/docs/user/remote-access.md index 491470bfa2f0..163be5177223 100644 --- a/docs/user/remote-access.md +++ b/docs/user/remote-access.md @@ -51,6 +51,19 @@ If a server is already running, generate a fresh link without restarting it: t3 pair ``` +If an existing reverse proxy exposes the server at a different address, use that +public origin for the pairing link and QR code: + +```bash +t3 pair --base-url https://my-host.my-tailnet.ts.net +``` + +The command still finds the running local server and creates the token there. +It does not configure or probe your proxy. Use an absolute HTTP or HTTPS URL +that reaches the client web origin (the web port for a development server). +Pairing uses `/pair` at that origin; URL path prefixes are not retained. This +flag cannot be combined with `--tailscale`. + Scan the QR code on your phone or paste the pairing URL into **Add environment** in the receiving app. Connection settings are under **Settings → Connections** on web and desktop and **Settings → Environments** on mobile. A loopback address