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