Skip to content
Closed
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
43 changes: 41 additions & 2 deletions apps/desktop/src/electron/ElectronShell.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,26 @@ describe("ElectronShell", () => {
}).pipe(Effect.provide(ElectronShell.layer)),
);

it.effect("opens remote editor links that name an SSH login", () =>
Effect.gen(function* () {
openExternalMock.mockResolvedValue(undefined);

const electronShell = yield* ElectronShell.ElectronShell;
const results = yield* Effect.all([
electronShell.openExternal(
"vscode://vscode-remote/ssh-remote+theo@example.com/home/user/project",
),
electronShell.openExternal("zed://ssh/theo@example.com/home/user/project"),
]);

assert.deepEqual(results, [true, true]);
assert.deepEqual(openExternalMock.mock.calls, [
["vscode://vscode-remote/ssh-remote+theo@example.com/home/user/project"],
["zed://ssh/theo@example.com/home/user/project"],
]);
}).pipe(Effect.provide(ElectronShell.layer)),
);

it.effect("does not open editor URLs that mix up link shapes", () =>
Effect.gen(function* () {
openExternalMock.mockResolvedValue(undefined);
Expand Down Expand Up @@ -133,10 +153,29 @@ describe("ElectronShell", () => {
electronShell.openExternal(
"vscode://:secret@vscode-remote/ssh-remote+example.com/home/user/project",
),
electronShell.openExternal("zed://ssh/user@example.com/home/user/project"),
electronShell.openExternal("zed://user@ssh/example.com/home/user/project"),
electronShell.openExternal("zed://ssh/user:secret@example.com/home/user/project"),
]);

assert.deepEqual(results, [false, false, false, false]);
assert.equal(openExternalMock.mock.calls.length, 0);
}).pipe(Effect.provide(ElectronShell.layer)),
);

it.effect("does not open Zed links with anything beyond a plain login and host", () =>
Effect.gen(function* () {
openExternalMock.mockResolvedValue(undefined);

const electronShell = yield* ElectronShell.ElectronShell;
const results = yield* Effect.all([
electronShell.openExternal("zed://ssh/-oProxyCommand=calc@example.com/home/user/project"),
electronShell.openExternal("zed://ssh/@example.com/home/user/project"),
electronShell.openExternal("zed://ssh/theo@evil@example.com/home/user/project"),
electronShell.openExternal("zed://ssh/th%20eo@example.com/home/user/project"),
electronShell.openExternal("zed://ssh/example.com:2222/home/user/project"),
]);

assert.deepEqual(results, [false, false, false]);
assert.deepEqual(results, [false, false, false, false, false]);
assert.equal(openExternalMock.mock.calls.length, 0);
}).pipe(Effect.provide(ElectronShell.layer)),
);
Expand Down
19 changes: 16 additions & 3 deletions apps/desktop/src/electron/ElectronShell.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { MAC_PERMISSION_SETTINGS_URLS } from "../permissions/MacPermission.ts";
import {
REMOTE_CAPABLE_EDITOR_IDS,
REMOTE_OPEN_USER_PATTERN,
remoteSchemeForEditor,
type SystemSettingsPane,
} from "@t3tools/contracts";
Expand All @@ -22,15 +23,27 @@ const REMOTE_EDITOR_PROTOCOLS = new Set(
}),
);

// Zed's host sits in the first path segment, so it needs its own userinfo ban.
const ZED_SSH_PATHNAME = /^\/[^/@:]+\/.*$/;
// Zed's `[user@]host` sits in the first path segment, so it needs its own
// userinfo check: at most a login that passes REMOTE_OPEN_USER_PATTERN, the
// same `user@host` form vscode-remote authorities already take. Never a
// password or port.
const ZED_SSH_PATHNAME = /^\/(?:([^/@:]+)@)?[^/@:]+\/.*$/;

const isZedSshPathname = (pathname: string) => {
const match = ZED_SSH_PATHNAME.exec(pathname);
if (match === null) {
return false;
}
const user = match[1];
return user === undefined || REMOTE_OPEN_USER_PATTERN.test(user);
};

const isRemoteEditorUrl = (url: URL) =>
REMOTE_EDITOR_PROTOCOLS.has(url.protocol) &&
url.username.length === 0 &&
url.password.length === 0 &&
(url.protocol === "zed:"
? url.host === "ssh" && ZED_SSH_PATHNAME.test(url.pathname)
? url.host === "ssh" && isZedSshPathname(url.pathname)
: url.host === "vscode-remote" &&
url.pathname.startsWith("/ssh-remote+") &&
url.pathname.length > "/ssh-remote+".length);
Expand Down
221 changes: 206 additions & 15 deletions apps/server/src/environment/RemoteOpenTargets.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
import { it } from "@effect/vitest";
import { HostProcessHostname } from "@t3tools/shared/hostProcess";
import {
HostProcessHostname,
HostProcessPlatform,
HostProcessUsername,
} from "@t3tools/shared/hostProcess";
import * as NetService from "@t3tools/shared/Net";
import * as Deferred from "effect/Deferred";
import * as Effect from "effect/Effect";
import * as Fiber from "effect/Fiber";
import * as Layer from "effect/Layer";
import * as Sink from "effect/Sink";
import * as Stream from "effect/Stream";
Expand All @@ -16,26 +22,54 @@ 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 spawnerLayer = (input: { readonly exitCode: number; readonly stdout: string }) =>
interface CommandResult {
readonly exitCode: number;
readonly stdout: string;
}

// What a tailscale CLI without `tailscale get` (older than 1.102) returns.
const TAILSCALE_GET_UNSUPPORTED: CommandResult = { exitCode: 1, stdout: "" };

/**
* Spawner that answers `tailscale get` with `get` and every other command
* (`tailscale status --json`) with `status`, recording each subcommand it
* starts in `spawned`. With `stall`, the first command never exits and
* completes `stall` once it has started.
*/
const spawnerLayer = (input: {
readonly status: CommandResult;
readonly get: CommandResult;
readonly spawned: Array<string>;
readonly stall: Deferred.Deferred<void> | undefined;
}) =>
Layer.succeed(
ChildProcessSpawner.ChildProcessSpawner,
ChildProcessSpawner.make(() =>
Effect.succeed(
ChildProcessSpawner.makeHandle({
ChildProcessSpawner.make((command) =>
Effect.gen(function* () {
const { args } = command as unknown as { readonly args: ReadonlyArray<string> };
input.spawned.push(args[0] ?? "");
const result = args[0] === "get" ? input.get : input.status;
const stall = input.spawned.length === 1 ? input.stall : undefined;
if (stall !== undefined) {
yield* Deferred.succeed(stall, undefined);
}
return ChildProcessSpawner.makeHandle({
pid: ChildProcessSpawner.ProcessId(1),
exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(input.exitCode)),
exitCode:
stall !== undefined
? Effect.never
: 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,
}),
),
});
}),
),
);

Expand All @@ -48,22 +82,58 @@ const netLayer = (input: { readonly ipv4: boolean; readonly ipv6: boolean }) =>
findAvailablePort: (preferred) => Effect.succeed(preferred),
});

const resolveTargets = (input: {
interface MachineInput {
readonly sshd: { readonly ipv4: boolean; readonly ipv6: boolean };
readonly tailscale: { readonly exitCode: number; readonly stdout: string };
readonly tailscale: CommandResult;
readonly hostname: string;
}) =>
Effect.flatMap(RemoteOpenTargets.RemoteOpenTargets, (service) => service.resolveTargets()).pipe(
/** `tailscale get --json ssh`; omitted, the CLI predates `tailscale get`. */
readonly tailscaleSsh?: boolean;
readonly username?: string | undefined;
readonly platform?: NodeJS.Platform;
/** Collects the tailscale subcommands the resolver starts. */
readonly spawned?: Array<string>;
/** Holds the first tailscale command open; completed once it starts. */
readonly stall?: Deferred.Deferred<void>;
}

/** Runs `program` against one RemoteOpenTargets built for the given machine. */
const onMachine = <A, E>(
input: MachineInput,
program: Effect.Effect<A, E, RemoteOpenTargets.RemoteOpenTargets>,
) =>
program.pipe(
Effect.provideService(HostProcessHostname, input.hostname),
Effect.provideService(HostProcessUsername, "username" in input ? input.username : "theo"),
Effect.provideService(HostProcessPlatform, input.platform ?? "linux"),
Effect.provide(
RemoteOpenTargets.layer.pipe(
Layer.provide(Layer.mergeAll(netLayer(input.sshd), spawnerLayer(input.tailscale))),
Layer.provide(
Layer.mergeAll(
netLayer(input.sshd),
spawnerLayer({
status: input.tailscale,
get:
input.tailscaleSsh === undefined
? TAILSCALE_GET_UNSUPPORTED
: { exitCode: 0, stdout: JSON.stringify({ ssh: input.tailscaleSsh }) },
spawned: input.spawned ?? [],
stall: input.stall,
}),
),
),
),
),
);

const resolveTargets = (input: MachineInput) =>
onMachine(
input,
Effect.flatMap(RemoteOpenTargets.RemoteOpenTargets, (service) => service.resolveTargets()),
);

const TAILSCALE_UP = { exitCode: 0, stdout: TAILSCALE_STATUS_JSON };
const TAILSCALE_DOWN = { exitCode: 1, stdout: "" };
const NO_SSHD = { ipv4: false, ipv6: false };

describe("RemoteOpenTargets", () => {
it.effect("advertises nothing when no sshd accepts on either loopback", () =>
Expand Down Expand Up @@ -123,4 +193,125 @@ describe("RemoteOpenTargets", () => {
expect(targets).toEqual([{ kind: "mdns", host: "bb-1.local" }]);
}),
);

it.effect("advertises the tailnet name with this account when only Tailscale SSH answers", () =>
Effect.gen(function* () {
const targets = yield* resolveTargets({
sshd: NO_SSHD,
tailscale: TAILSCALE_UP,
tailscaleSsh: true,
hostname: "bb-1",
});
// No mDNS name: Tailscale SSH does not answer on the LAN address.
expect(targets).toEqual([
{ kind: "tailscale-ssh", host: "bb-1.tail1234.ts.net", user: "theo" },
]);
}),
);

it.effect("advertises nothing without sshd when Tailscale SSH is off", () =>
Effect.gen(function* () {
const targets = yield* resolveTargets({
sshd: NO_SSHD,
tailscale: TAILSCALE_UP,
tailscaleSsh: false,
hostname: "bb-1",
});
expect(targets).toEqual([]);
}),
);

it.effect("advertises nothing for Tailscale SSH without a MagicDNS name", () =>
Effect.gen(function* () {
const targets = yield* resolveTargets({
sshd: NO_SSHD,
tailscale: TAILSCALE_DOWN,
tailscaleSsh: true,
hostname: "bb-1",
});
expect(targets).toEqual([]);
}),
);

it.effect("does not start the tailscale CLI for Tailscale SSH on Windows hosts", () =>
Effect.gen(function* () {
const spawned: Array<string> = [];
const targets = yield* resolveTargets({
sshd: NO_SSHD,
tailscale: TAILSCALE_UP,
tailscaleSsh: true,
hostname: "bb-1",
platform: "win32",
spawned,
});
expect(targets).toEqual([]);
expect(spawned).toEqual([]);
}),
);

// A client that disconnects mid-discovery interrupts its own config load.
// The next load, from any client, must still get a real answer.
it.effect("answers the next config load after one is cut off mid-discovery", () =>
Effect.gen(function* () {
const spawned: Array<string> = [];
const stall = yield* Deferred.make<void>();
yield* onMachine(
{
sshd: NO_SSHD,
tailscale: TAILSCALE_UP,
tailscaleSsh: true,
hostname: "bb-1",
spawned,
stall,
},
Effect.gen(function* () {
const service = yield* RemoteOpenTargets.RemoteOpenTargets;
const cutOff = yield* Effect.forkChild(service.resolveTargets());
yield* Deferred.await(stall);
yield* Fiber.interrupt(cutOff);

expect(yield* service.resolveTargets()).toEqual([
{ kind: "tailscale-ssh", host: "bb-1.tail1234.ts.net", user: "theo" },
]);
expect(spawned.toSorted()).toEqual(["get", "get", "status", "status"]);
}),
);
}),
);

it.effect("leaves out an account name a link cannot carry", () =>
Effect.gen(function* () {
for (const username of [undefined, "-oProxyCommand=calc", "DOMAIN\\theo"]) {
const targets = yield* resolveTargets({
sshd: NO_SSHD,
tailscale: TAILSCALE_UP,
tailscaleSsh: true,
hostname: "bb-1",
username,
});
expect(targets).toEqual([{ kind: "tailscale-ssh", host: "bb-1.tail1234.ts.net" }]);
}
}),
);

// Tailscale SSH also answers the tailnet name when sshd runs, but those
// targets predate it; they stay exactly as older servers advertised them,
// from the same single `tailscale status` call.
it.effect("keeps sshd targets unchanged when Tailscale SSH is also on", () =>
Effect.gen(function* () {
const spawned: Array<string> = [];
const targets = yield* resolveTargets({
sshd: { ipv4: true, ipv6: false },
tailscale: TAILSCALE_UP,
tailscaleSsh: true,
hostname: "bb-1",
spawned,
});
expect(targets).toEqual([
{ kind: "tailscale", host: "bb-1.tail1234.ts.net" },
{ kind: "mdns", host: "bb-1.local" },
]);
expect(spawned).toEqual(["status"]);
}),
);
});
Loading
Loading