Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion apps/server/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -585,7 +585,11 @@ export const makeServerLayer = Layer.unwrap(
}),
(configured) =>
configured
? disableTailscaleServe({ servePort: configured.servePort }).pipe(
? disableTailscaleServe({
localPort: configured.localPort,
servePort: configured.servePort,
localHost: "127.0.0.1",
}).pipe(
Effect.tap(() =>
Effect.logInfo("Tailscale Serve disabled", {
servePort: configured.servePort,
Expand Down
5 changes: 3 additions & 2 deletions apps/web/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import "vite-plus/test/config";
import { defineConfig, type Connect, type Plugin } from "vite-plus";
import pkg from "./package.json" with { type: "json" };

import { DEV_PROXIED_PATH_PREFIXES } from "@t3tools/shared/devProxy";
import { DEV_PROXIED_PATH_PREFIXES, resolveWebDevServerHost } from "@t3tools/shared/devProxy";

import { loadRepoEnv } from "../../scripts/lib/public-config";

Expand All @@ -28,7 +28,8 @@ const isSingleOriginDev = process.env.T3CODE_SINGLE_ORIGIN_DEV === "1";

const port = Number(process.env.PORT ?? 5733);
const explicitHost = process.env.HOST?.trim();
const host = explicitHost || "localhost";
const sharedBindHost = process.env.T3CODE_WEB_BIND_HOST?.trim();
const host = resolveWebDevServerHost({ explicitHost, sharedBindHost });
const configuredWsUrl = isSingleOriginDev ? undefined : process.env.VITE_WS_URL?.trim();
const configuredHttpUrl = isSingleOriginDev ? undefined : process.env.VITE_HTTP_URL?.trim();
const configuredRelayUrl = repoEnv.VITE_T3CODE_RELAY_URL?.trim() || "";
Expand Down
6 changes: 5 additions & 1 deletion docs/internals/remote.md
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,11 @@ Worker itself. See [t3-connect.md](./t3-connect.md).

A T3-managed `tailscale serve` mapping exposes the server on the tailnet over HTTPS, and the
resulting private-network endpoints are advertised for pairing. Connection then follows the ordinary
bearer path.
bearer path. Serve configuration, rather than backend health, determines port ownership: T3 reuses
only an exact root handler for its loopback target, refuses to replace any other configured handler,
and removes a handler only while it still exactly matches the target being stopped. This lets other
applications keep additive handlers on separate HTTPS ports, including while their backends are
temporarily unavailable.

### Desktop-managed SSH access

Expand Down
2 changes: 1 addition & 1 deletion docs/user/remote-access.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ If the server is only bound to loopback, the printed URL is not reachable from a
npx t3 pair --tailscale
```

This publishes the server over Tailscale Serve HTTPS (configuring the mapping if needed — it persists until you run `tailscale serve --https=443 off`) and pairs through the `https://machine.tailnet.ts.net/` URL. Use `--tailscale-serve-port` for a different HTTPS port, `--ttl` to change the token lifetime, and `--base-dir` to target a specific data directory.
This publishes the server over Tailscale Serve HTTPS (configuring the mapping if needed — it persists until you run `tailscale serve --https=443 off`) and pairs through the `https://machine.tailnet.ts.net/` URL. Use `--tailscale-serve-port` for a different HTTPS port, `--ttl` to change the token lifetime, and `--base-dir` to target a specific data directory. If that HTTPS port already has a different Serve handler, T3 leaves it unchanged and asks you to choose another port; an unavailable backend does not make an occupied port safe to reuse.

If no server is running, `t3 pair` says so and points you at `npx t3 serve` or `npx t3 connect`.

Expand Down
29 changes: 29 additions & 0 deletions packages/shared/src/devProxy.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { assert, describe, it } from "@effect/vitest";

import { resolveWebDevServerHost, SHARED_DEV_LOOPBACK_HOST } from "./devProxy.ts";

describe("resolveWebDevServerHost", () => {
it("uses the same IPv4 loopback selected for Tailscale sharing", () => {
assert.equal(
resolveWebDevServerHost({
explicitHost: undefined,
sharedBindHost: SHARED_DEV_LOOPBACK_HOST,
}),
"127.0.0.1",
);
});

it("keeps ordinary local development and explicit desktop hosts unchanged", () => {
assert.equal(
resolveWebDevServerHost({ explicitHost: undefined, sharedBindHost: undefined }),
"localhost",
);
assert.equal(
resolveWebDevServerHost({
explicitHost: "192.0.2.10",
sharedBindHost: SHARED_DEV_LOOPBACK_HOST,
}),
"192.0.2.10",
);
});
});
9 changes: 9 additions & 0 deletions packages/shared/src/devProxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,15 @@
*/
export const DEV_PROXIED_PATH_PREFIXES = ["/api", "/oauth", "/.well-known", "/ws"] as const;

export const SHARED_DEV_LOOPBACK_HOST = "127.0.0.1";

export function resolveWebDevServerHost(input: {
readonly explicitHost: string | undefined;
readonly sharedBindHost: string | undefined;
}): string {
return input.explicitHost || input.sharedBindHost || "localhost";
Comment thread
lastobelus marked this conversation as resolved.
Resolved
}

export function isDevProxiedPath(pathname: string): boolean {
return DEV_PROXIED_PATH_PREFIXES.some(
(prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`),
Expand Down
105 changes: 98 additions & 7 deletions packages/tailscale/src/tailscale.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
TailscaleCommandExitError,
TailscaleCommandSpawnError,
TailscaleCommandTimeoutError,
TailscaleServePortOccupiedError,
TailscaleStatusParseError,
} from "./tailscale.ts";

Expand Down Expand Up @@ -65,6 +66,14 @@ function assertCarriesNoSecret(error: object, secret: string): void {
}
const tailscaleStatusJson = `{"Self":{"DNSName":"desktop.tail.ts.net.","TailscaleIPs":["100.100.100.100","fd7a:115c:a1e0::1","192.168.1.20"]}}`;
const tailscaleStatusWithSingleIpJson = `{"Self":{"DNSName":"desktop.tail.ts.net.","TailscaleIPs":["100.90.1.2"]}}`;
const emptyServeStatusJson = `{"TCP":{},"Web":{}}`;
const serveStatusJson = (servePort: number, proxy: string) =>
JSON.stringify({
TCP: { [servePort]: { HTTPS: true } },
Web: {
[`desktop.tail.ts.net:${String(servePort)}`]: { Handlers: { "/": { Proxy: proxy } } },
},
});

function mockHandle(result: { stdout?: string; stderr?: string; code?: number }) {
return ChildProcessSpawner.makeHandle({
Expand Down Expand Up @@ -320,21 +329,81 @@ describe("tailscale", () => {
});

it.effect("configures tailscale serve through the process spawner service", () => {
const commands: ReadonlyArray<string>[] = [];
const layer = mockSpawnerLayer((command, args) => {
assert.equal(command, "tailscale");
assert.deepEqual(args, ["serve", "--bg", "--https=8443", "http://127.0.0.1:13773"]);
commands.push(args);
if (args[1] === "status") {
return { stdout: emptyServeStatusJson };
}
return {};
});

return ensureTailscaleServe({ localPort: 13773, servePort: 8443 }).pipe(Effect.provide(layer));
return Effect.gen(function* () {
yield* ensureTailscaleServe({ localPort: 13773, servePort: 8443 }).pipe(
Effect.provide(layer),
);
assert.deepEqual(commands, [
["serve", "status", "--json"],
["serve", "--bg", "--https=8443", "http://127.0.0.1:13773"],
]);
});
});

it.effect("retains tailscale serve exit diagnostics", () => {
it.effect("reuses only the exact existing handler", () => {
const commands: ReadonlyArray<string>[] = [];
const layer = mockSpawnerLayer((_command, args) => {
commands.push(args);
return { stdout: serveStatusJson(8443, "http://127.0.0.1:13773") };
});

return Effect.gen(function* () {
yield* ensureTailscaleServe({ localPort: 13773, servePort: 8443 }).pipe(
Effect.provide(layer),
);
assert.deepEqual(commands, [["serve", "status", "--json"]]);
});
});

it.effect("refuses to replace a configured port even when its backend differs", () => {
const layer = mockSpawnerLayer(() => ({
code: 1,
stderr: "serve permission denied tskey-auth-secret-token-value",
stdout: serveStatusJson(8443, "http://127.0.0.1:39831"),
}));

return Effect.gen(function* () {
const error = yield* ensureTailscaleServe({ localPort: 13773, servePort: 8443 }).pipe(
Effect.flip,
Effect.provide(layer),
);
assert.instanceOf(error, TailscaleServePortOccupiedError);
assert.equal(error.servePort, 8443);
});
});

it.effect("treats a non-web listener on the selected port as occupied", () => {
const layer = mockSpawnerLayer(() => ({
stdout: JSON.stringify({ TCP: { 8443: { TCPForward: "127.0.0.1:39831" } }, Web: {} }),
}));

return Effect.gen(function* () {
const error = yield* ensureTailscaleServe({ localPort: 13773, servePort: 8443 }).pipe(
Effect.flip,
Effect.provide(layer),
);
assert.instanceOf(error, TailscaleServePortOccupiedError);
});
});

it.effect("retains tailscale serve exit diagnostics", () => {
const layer = mockSpawnerLayer((_command, args) =>
args[1] === "status"
? { stdout: emptyServeStatusJson }
: {
code: 1,
stderr: "serve permission denied tskey-auth-secret-token-value",
},
);

return Effect.gen(function* () {
const error = yield* ensureTailscaleServe({ localPort: 13773, servePort: 8443 }).pipe(
Effect.flip,
Expand Down Expand Up @@ -365,15 +434,37 @@ describe("tailscale", () => {
const layer = mockSpawnerLayer((command, args) => {
commands.push({ command, args });
assert.equal(command, "tailscale");
assert.deepEqual(args, ["serve", "--https=8443", "off"]);
if (args[1] === "status") {
return { stdout: serveStatusJson(8443, "http://127.0.0.1:13773") };
}
return {};
});

return Effect.gen(function* () {
yield* disableTailscaleServe({ servePort: 8443 }).pipe(Effect.provide(layer));
yield* disableTailscaleServe({ localPort: 13773, servePort: 8443 }).pipe(
Effect.provide(layer),
);
assert.deepEqual(commands, [
{ command: "tailscale", args: ["serve", "status", "--json"] },
{ command: "tailscale", args: ["serve", "--https=8443", "off"] },
]);
});
});

it.effect("refuses to disable a handler owned by another service", () => {
const commands: ReadonlyArray<string>[] = [];
const layer = mockSpawnerLayer((_command, args) => {
commands.push(args);
return { stdout: serveStatusJson(8443, "http://127.0.0.1:39831") };
});

return Effect.gen(function* () {
const error = yield* disableTailscaleServe({ localPort: 13773, servePort: 8443 }).pipe(
Effect.flip,
Effect.provide(layer),
);
assert.equal(error._tag, "TailscaleServePortOccupiedError");
assert.deepEqual(commands, [["serve", "status", "--json"]]);
});
});
});
Loading