Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
1 change: 1 addition & 0 deletions apps/desktop/src/backend/tailscaleEndpointProvider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,7 @@ describe("tailscale endpoint provider", () => {
return {
magicDnsName: "desktop.tail.ts.net",
tailnetIpv4Addresses: ["100.74.126.34"],
httpsServeEnabled: null,
};
}),
});
Expand Down
55 changes: 55 additions & 0 deletions apps/server/src/cli/pair.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,11 @@ import * as HostProcess from "@t3tools/shared/HostProcess";
import { assert, describe, 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 * as TestConsole from "effect/testing/TestConsole";
import { Command, CliError } from "effect/cli";
import { ChildProcessSpawner } from "effect/process";

import { cli } from "../binCli.ts";
import {
Expand Down Expand Up @@ -318,6 +321,58 @@ describe("t3 pair", () => {
assert.include(rendered, "No running T3 Code server found.");
}).pipe(Effect.provide(NodeServices.layer)),
);

// Without the capability `tailscale serve` blocks on an enablement URL, so
// pairing must stop at status instead of spawning it.
it.effect("explains a tailnet without HTTPS serve instead of running tailscale serve", () =>
withDescriptorServer((origin) =>
Effect.gen(function* () {
const baseDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-pair-ts-test-"));
yield* persistServerRuntimeState({
path: NodePath.join(baseDir, "userdata", "server-runtime.json"),
state: yield* makePersistedServerRuntimeState({
config: { host: "127.0.0.1", devUrl: undefined },
port: Number(new URL(origin).port),
}),
});
const tailscaleCalls: Array<ReadonlyArray<string>> = [];
const spawner = ChildProcessSpawner.make((command) => {
const { args } = command as unknown as { readonly args: ReadonlyArray<string> };
tailscaleCalls.push(args);
const stdout =
args[0] === "status"
? `{"Self":{"DNSName":"pair-test.invalid.","CapMap":{"funnel":null}}}`
: "";
return Effect.succeed(
ChildProcessSpawner.makeHandle({
pid: ChildProcessSpawner.ProcessId(1),
exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(args[0] === "status" ? 0 : 1)),
isRunning: Effect.succeed(false),
kill: () => Effect.void,
unref: Effect.succeed(Effect.void),
stdin: Sink.drain,
stdout: Stream.make(new TextEncoder().encode(stdout)),
stderr: Stream.empty,
all: Stream.empty,
getInputFd: () => Sink.drain,
getOutputFd: () => Stream.empty,
}),
);
});

const error = yield* provideCliTestLayers(
runCli(["pair", "--base-dir", baseDir, "--tailscale"]).pipe(
Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
Effect.flip,
),
);

assert.include(String(error), "Tailscale Serve is not enabled on your tailnet.");
assert.include(String(error), "https://login.tailscale.com/admin/dns");
assert.deepEqual(tailscaleCalls, [["status", "--json"]]);
}),
).pipe(Effect.provide(NodeServices.layer)),
);
});

describe("auth scope options", () => {
Expand Down
14 changes: 14 additions & 0 deletions apps/server/src/cli/pair.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,15 @@ export class MagicDnsNameMissingError extends Schema.TaggedError<MagicDnsNameMis
}
}

export class TailscaleServeNotEnabledError extends Schema.TaggedError<TailscaleServeNotEnabledError>()(
"TailscaleServeNotEnabledError",
{},
) {
override get message(): string {
return "Tailscale Serve is not enabled on your tailnet. Enable HTTPS certificates at https://login.tailscale.com/admin/dns, then run this command again.";
}
}

export class ServesOtherEnvironmentError extends Schema.TaggedError<ServesOtherEnvironmentError>()(
"ServesOtherEnvironmentError",
{ servePort: Schema.Number },
Expand Down Expand Up @@ -342,6 +351,11 @@ const resolveTailscalePairingBase = Effect.fn("pair.resolveTailscalePairingBase"
if (status.magicDnsName === null) {
return yield* new MagicDnsNameMissingError();
}
// Without the capability `tailscale serve` blocks on an enablement URL
// until it times out, which names neither the setting nor where to change it.
if (status.httpsServeEnabled === false) {
return yield* new TailscaleServeNotEnabledError();
}
const baseUrl = buildTailscaleHttpsBaseUrl({
magicDnsName: status.magicDnsName,
servePort: input.servePort,
Expand Down
14 changes: 14 additions & 0 deletions packages/tailscale/src/tailscale.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,10 +142,23 @@ describe("tailscale", () => {
assert.deepEqual(status, {
magicDnsName: "desktop.tail.ts.net",
tailnetIpv4Addresses: ["100.100.100.100"],
httpsServeEnabled: null,
});
}),
);

it.effect("reads the HTTPS serve capability from the status capability map", () =>
Effect.gen(function* () {
const enabled = yield* parseTailscaleStatus(
`{"Self":{"CapMap":{"https":null,"funnel":null}}}`,
);
assert.equal(enabled.httpsServeEnabled, true);

const disabled = yield* parseTailscaleStatus(`{"Self":{"CapMap":{"funnel":null}}}`);
assert.equal(disabled.httpsServeEnabled, false);
}),
);

it.effect("preserves status decoding failures without exposing cause text", () =>
Effect.gen(function* () {
const error = yield* parseTailscaleStatus("{not-json").pipe(Effect.flip);
Expand Down Expand Up @@ -203,6 +216,7 @@ describe("tailscale", () => {
assert.deepEqual(status, {
magicDnsName: "desktop.tail.ts.net",
tailnetIpv4Addresses: ["100.90.1.2"],
httpsServeEnabled: null,
});
});
});
Expand Down
12 changes: 12 additions & 0 deletions packages/tailscale/src/tailscale.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ export class TailscaleStatusParseError extends Schema.TaggedError<TailscaleStatu
const TailscaleStatusSelf = Schema.Struct({
DNSName: Schema.optional(Schema.Unknown),
TailscaleIPs: Schema.optional(Schema.Unknown),
CapMap: Schema.optional(Schema.Unknown),
});

const TailscaleStatusJson = Schema.Struct({
Expand All @@ -136,6 +137,12 @@ export type TailscaleStatusJson = typeof TailscaleStatusJson.Type;
export interface TailscaleStatus {
readonly magicDnsName: string | null;
readonly tailnetIpv4Addresses: readonly string[];
/**
* `tailscale serve --https` needs the `https` capability, which the control
* plane grants once the tailnet enables HTTPS certificates. `null` when the
* CLI reports no capability map (older CLI), so callers still attempt serve.
*/
readonly httpsServeEnabled: boolean | null;
}

const collectStdout = <E>(stream: Stream.Stream<Uint8Array, E>): Effect.Effect<string, E> =>
Expand Down Expand Up @@ -203,9 +210,14 @@ export const parseTailscaleStatus = (
}
}

const capMap = parsed.Self?.CapMap;
return {
magicDnsName: normalizeMagicDnsName(parsed),
tailnetIpv4Addresses,
httpsServeEnabled:
typeof capMap === "object" && capMap !== null && !Array.isArray(capMap)
? Object.hasOwn(capMap, "https")
: null,
};
}),
);
Expand Down
Loading