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
66 changes: 54 additions & 12 deletions apps/server/src/environment/RemoteOpenTargets.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ 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 ChildProcess from "effect/process/ChildProcess";
import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
import { describe, expect } from "vite-plus/test";

Expand All @@ -16,27 +17,34 @@ const TAILSCALE_STATUS_JSON = JSON.stringify({
Self: { DNSName: "bb-1.tail1234.ts.net.", TailscaleIPs: ["100.64.1.2"] },
});

/** Spawner whose `tailscale status --json` exits with the given output. */
const layerSpawner = (input: { readonly exitCode: number; readonly stdout: string }) =>
interface CommandResult {
readonly exitCode: number;
readonly stdout: string;
}

/** Spawner answering `tailscale status --json` and `tailscale debug prefs`. */
const layerSpawner = (input: { readonly status: CommandResult; readonly prefs: CommandResult }) =>
Layer.succeed(
ChildProcessSpawner.ChildProcessSpawner,
ChildProcessSpawner.make(() =>
Effect.succeed(
ChildProcessSpawner.make((command) => {
const args = ChildProcess.isStandardCommand(command) ? command.args : [];
const result = args[0] === "debug" ? input.prefs : input.status;
return Effect.succeed(
ChildProcessSpawner.makeHandle({
pid: ChildProcessSpawner.ProcessId(1),
exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(input.exitCode)),
exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(result.exitCode)),
isRunning: Effect.succeed(false),
kill: () => Effect.void,
unref: Effect.succeed(Effect.void),
stdin: Sink.drain,
stdout: Stream.make(encoder.encode(input.stdout)),
stdout: Stream.make(encoder.encode(result.stdout)),
stderr: Stream.empty,
all: Stream.empty,
getInputFd: () => Sink.drain,
getOutputFd: () => Stream.empty,
}),
),
),
);
}),
);

const layerNet = (input: { readonly ipv4: boolean; readonly ipv6: boolean }) =>
Expand All @@ -50,7 +58,7 @@ const layerNet = (input: { readonly ipv4: boolean; readonly ipv6: boolean }) =>

const resolveTargets = (input: {
readonly sshd: { readonly ipv4: boolean; readonly ipv6: boolean };
readonly tailscale: { readonly exitCode: number; readonly stdout: string };
readonly tailscale: { readonly status: CommandResult; readonly prefs: CommandResult };
readonly hostname: string;
}) =>
Effect.flatMap(RemoteOpenTargets.RemoteOpenTargets, (service) => service.resolveTargets()).pipe(
Expand All @@ -62,11 +70,23 @@ const resolveTargets = (input: {
),
);

const TAILSCALE_UP = { exitCode: 0, stdout: TAILSCALE_STATUS_JSON };
const TAILSCALE_DOWN = { exitCode: 1, stdout: "" };
// `tailscale debug prefs` output, trimmed. `tailscale up --ssh` sets RunSSH.
const prefsJson = (prefs: { readonly RunSSH: boolean; readonly WantRunning: boolean }) => ({
exitCode: 0,
stdout: JSON.stringify(
{ ControlURL: "https://controlplane.tailscale.com", ...prefs },
null,
"\t",
),
});

const STATUS_UP = { exitCode: 0, stdout: TAILSCALE_STATUS_JSON };
const TAILSCALE_UP = { status: STATUS_UP, prefs: prefsJson({ RunSSH: false, WantRunning: true }) };
const TAILSCALE_SSH = { status: STATUS_UP, prefs: prefsJson({ RunSSH: true, WantRunning: true }) };
const TAILSCALE_DOWN = { status: { exitCode: 1, stdout: "" }, prefs: { exitCode: 1, stdout: "" } };

describe("RemoteOpenTargets", () => {
it.effect("advertises nothing when no sshd accepts on either loopback", () =>
it.effect("advertises nothing when no sshd accepts and Tailscale SSH is off", () =>
Effect.gen(function* () {
const targets = yield* resolveTargets({
sshd: { ipv4: false, ipv6: false },
Expand All @@ -77,6 +97,28 @@ describe("RemoteOpenTargets", () => {
}),
);

it.effect("advertises only the tailnet name when Tailscale SSH serves without sshd", () =>
Effect.gen(function* () {
const targets = yield* resolveTargets({
sshd: { ipv4: false, ipv6: false },
tailscale: TAILSCALE_SSH,
hostname: "bb-1",
});
expect(targets).toEqual([{ kind: "tailscale", host: "bb-1.tail1234.ts.net" }]);
}),
);

it.effect("ignores a stored RunSSH after `tailscale down`", () =>
Effect.gen(function* () {
const targets = yield* resolveTargets({
sshd: { ipv4: false, ipv6: false },
tailscale: { status: STATUS_UP, prefs: prefsJson({ RunSSH: true, WantRunning: false }) },
hostname: "bb-1",
});
expect(targets).toEqual([]);
}),
);

it.effect("orders the tailnet name before the mDNS name", () =>
Effect.gen(function* () {
const targets = yield* resolveTargets({
Expand Down
34 changes: 24 additions & 10 deletions apps/server/src/environment/RemoteOpenTargets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,16 @@
* for remote open-in-editor deep links (`vscode://vscode-remote/ssh-remote+…`).
*
* The server can only check itself: sshd listening locally, tailscaled
* reporting a MagicDNS name, and the machine hostname for mDNS. Whether a
* given name resolves from the viewer's machine is inherently client-side.
* reporting a MagicDNS name and whether Tailscale SSH is on, and the machine
* hostname for mDNS. Whether a given name resolves from the viewer's machine
* is inherently client-side.
* Targets are ordered most-reachable first (tailnet name works from anywhere
* on the tailnet; `<hostname>.local` only on the same LAN).
*/
import { type RemoteOpenTarget } from "@t3tools/contracts";
import * as HostProcess from "@t3tools/shared/HostProcess";
import * as NetService from "@t3tools/shared/Net";
import { readTailscaleStatus } from "@t3tools/tailscale";
import { readTailscaleSshEnabled, readTailscaleStatus } from "@t3tools/tailscale";
import * as Context from "effect/Context";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
Expand All @@ -32,26 +33,39 @@ export const make = Effect.gen(function* () {
const net = yield* NetService.NetService;

const resolveTargets = Effect.gen(function* () {
// No local sshd means no name can work; advertise nothing so clients
// render a clear "no SSH route" state instead of links that hang.
// Check both loopback families: sshd can be bound IPv6-only.
const sshdListening = yield* Effect.zipWith(
net.hasListenerOnHost(SSH_PORT, "127.0.0.1"),
net.hasListenerOnHost(SSH_PORT, "::1"),
(ipv4, ipv6) => ipv4 || ipv6,
);
if (!sshdListening) {
return [];
}

const targets: Array<RemoteOpenTarget> = [];

// Tailscale absent or down is the common case, not an error.
const magicDnsName = yield* readTailscaleStatus.pipe(
Effect.map((status) => status.magicDnsName),
Effect.orElseSucceed(() => null),
Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
);

const targets: Array<RemoteOpenTarget> = [];

if (!sshdListening) {
// Tailscale SSH answers on the tailnet name with no local sshd, so only
// that name can work. A running tailscaled alone is not an SSH route.
// With neither, advertise nothing so clients render a clear "no SSH
// route" state instead of links that hang.
if (
magicDnsName !== null &&
(yield* readTailscaleSshEnabled.pipe(
Effect.orElseSucceed(() => false),
Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
))
) {
targets.push({ kind: "tailscale", host: magicDnsName });
}
return targets;
}

if (magicDnsName !== null) {
targets.push({ kind: "tailscale", host: magicDnsName });
}
Expand Down
22 changes: 22 additions & 0 deletions packages/tailscale/src/tailscale.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,9 @@ import {
ensureTailscaleServe,
isTailscaleIpv4Address,
parseTailscaleMagicDnsName,
parseTailscaleSshEnabled,
parseTailscaleStatus,
readTailscaleSshEnabled,
readTailscaleStatus,
TAILSCALE_STATUS_TIMEOUT,
TailscaleCommandExitError,
Expand Down Expand Up @@ -164,6 +166,26 @@ describe("tailscale", () => {
}),
);

it.effect("reads Tailscale SSH enablement from local prefs", () => {
// `tailscale debug prefs` output, trimmed.
const prefsJson =
'{\n\t"ControlURL": "https://controlplane.tailscale.com",\n\t"RunSSH": true,\n\t"WantRunning": true\n}\n';
const layer = layerMockSpawner((command, args) => {
assert.equal(command, "tailscale");
assert.deepEqual(args, ["debug", "prefs"]);
return { stdout: prefsJson };
});

return Effect.gen(function* () {
assert.isTrue(yield* readTailscaleSshEnabled.pipe(Effect.provide(layer)));
assert.isFalse(yield* parseTailscaleSshEnabled('{"RunSSH":false,"WantRunning":true}'));
assert.isFalse(yield* parseTailscaleSshEnabled('{"RunSSH":true,"WantRunning":false}'));
assert.isFalse(yield* parseTailscaleSshEnabled("{}"));
const error = yield* parseTailscaleSshEnabled("{not-json").pipe(Effect.flip);
assert.instanceOf(error, TailscaleStatusParseError);
});
});

it.effect("builds clean HTTPS base URLs", () =>
Effect.sync(() => {
assert.equal(
Expand Down
141 changes: 85 additions & 56 deletions packages/tailscale/src/tailscale.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ const tailscaleCommandForPlatform = (platform: NodeJS.Platform): "tailscale" | "

const TailscaleCommandContext = {
executable: Schema.Literals(["tailscale", "tailscale.exe"]),
subcommand: Schema.Literals(["status", "serve"]),
subcommand: Schema.Literals(["status", "serve", "debug"]),
argumentCount: Schema.Number,
};

Expand Down Expand Up @@ -214,64 +214,93 @@ export const parseTailscaleStatus = (
}),
);

export const readTailscaleStatus = Effect.gen(function* () {
const args = ["status", "--json"];
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
const hostPlatform = yield* HostProcess.Platform;
const executable = tailscaleCommandForPlatform(hostPlatform);
const commandContext = {
executable,
subcommand: "status" as const,
argumentCount: args.length,
};
return yield* Effect.gen(function* () {
const child = yield* spawner.spawn(ChildProcess.make(executable, args)).pipe(
Effect.mapError((cause) => new TailscaleCommandSpawnError({ ...commandContext, cause })),
// Spawning can also fail as a defect rather than a typed error - a
// non-directory entry on PATH makes node throw ENOTDIR synchronously.
// `mapError` never sees that, so it would escape as an uncaught error.
Effect.catchDefect((cause) =>
Effect.fail(new TailscaleCommandSpawnError({ ...commandContext, cause })),
),
);
const [stdout, stderr, exitCode] = yield* Effect.all(
[
collectStdout(child.stdout),
collectStderr(child.stderr),
child.exitCode.pipe(Effect.map(Number)),
],
{ concurrency: "unbounded" },
).pipe(
Effect.mapError((cause) => new TailscaleCommandOutputError({ ...commandContext, cause })),
);
if (exitCode !== 0) {
return yield* new TailscaleCommandExitError({
...commandContext,
exitCode,
stdoutLength: stdout.length,
stderrLength: stderr.length,
...(stderrDiagnosticOf(stderr) !== undefined
? { stderrDiagnostic: stderrDiagnosticOf(stderr) }
: {}),
});
}
return yield* parseTailscaleStatus(stdout);
}).pipe(
Effect.scoped,
Effect.timeout(TAILSCALE_STATUS_TIMEOUT),
Effect.catchTags({
TimeoutError: (cause) =>
Effect.fail(
new TailscaleCommandTimeoutError({
...commandContext,
timeoutMs: Duration.toMillis(TAILSCALE_STATUS_TIMEOUT),
cause,
}),
/** Runs a read-only tailscale command and returns its stdout. */
const readTailscaleOutput = (subcommand: "status" | "debug", args: readonly string[]) =>
Effect.gen(function* () {
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
const hostPlatform = yield* HostProcess.Platform;
const executable = tailscaleCommandForPlatform(hostPlatform);
const commandContext = {
executable,
subcommand,
argumentCount: args.length,
};
return yield* Effect.gen(function* () {
const child = yield* spawner.spawn(ChildProcess.make(executable, [...args])).pipe(
Effect.mapError((cause) => new TailscaleCommandSpawnError({ ...commandContext, cause })),
// Spawning can also fail as a defect rather than a typed error - a
// non-directory entry on PATH makes node throw ENOTDIR synchronously.
// `mapError` never sees that, so it would escape as an uncaught error.
Effect.catchDefect((cause) =>
Effect.fail(new TailscaleCommandSpawnError({ ...commandContext, cause })),
),
}),
);
);
const [stdout, stderr, exitCode] = yield* Effect.all(
[
collectStdout(child.stdout),
collectStderr(child.stderr),
child.exitCode.pipe(Effect.map(Number)),
],
{ concurrency: "unbounded" },
).pipe(
Effect.mapError((cause) => new TailscaleCommandOutputError({ ...commandContext, cause })),
);
if (exitCode !== 0) {
return yield* new TailscaleCommandExitError({
...commandContext,
exitCode,
stdoutLength: stdout.length,
stderrLength: stderr.length,
...(stderrDiagnosticOf(stderr) !== undefined
? { stderrDiagnostic: stderrDiagnosticOf(stderr) }
: {}),
});
}
return stdout;
}).pipe(
Effect.scoped,
Effect.timeout(TAILSCALE_STATUS_TIMEOUT),
Effect.catchTags({
TimeoutError: (cause) =>
Effect.fail(
new TailscaleCommandTimeoutError({
...commandContext,
timeoutMs: Duration.toMillis(TAILSCALE_STATUS_TIMEOUT),
cause,
}),
),
}),
);
});

export const readTailscaleStatus = readTailscaleOutput("status", ["status", "--json"]).pipe(
Effect.flatMap(parseTailscaleStatus),
);

const TailscalePrefsJson = Schema.Struct({
RunSSH: Schema.optional(Schema.Unknown),
WantRunning: Schema.optional(Schema.Unknown),
});

const decodeTailscalePrefsJson = Schema.decodeEffect(Schema.fromJsonString(TailscalePrefsJson));

/**
* Whether this node serves Tailscale SSH (`tailscale up --ssh`), read from its
* prefs. `tailscale status --json` does not report this for the local node.
* `tailscale down` clears WantRunning but keeps RunSSH, so check both.
*/
export const parseTailscaleSshEnabled = (
rawPrefsJson: string,
): Effect.Effect<boolean, TailscaleStatusParseError> =>
decodeTailscalePrefsJson(rawPrefsJson).pipe(
Effect.mapError((cause) => new TailscaleStatusParseError({ cause })),
Effect.map((prefs) => prefs.RunSSH === true && prefs.WantRunning !== false),
);

export const readTailscaleSshEnabled = readTailscaleOutput("debug", ["debug", "prefs"]).pipe(
Effect.flatMap(parseTailscaleSshEnabled),
);

export function buildTailscaleHttpsBaseUrl(input: {
readonly magicDnsName: string;
readonly servePort?: number;
Expand Down
Loading