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
16 changes: 13 additions & 3 deletions apps/server/src/device/DeviceService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ import * as SynchronizedRef from "effect/SynchronizedRef";
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http";

import * as ServerSettings from "../serverSettings.ts";
import { isLocalSshDeviceHost, remoteSshDeviceHosts } from "./localSshDeviceHost.ts";

import { readDeviceDetail, runDeviceAction } from "./DeviceActions.ts";
import * as ProcessRunner from "../processRunner.ts";
Expand Down Expand Up @@ -892,11 +893,17 @@ export const make = Effect.gen(function* () {
};
const probeContext =
yield* Effect.context<Effect.Services<ReturnType<typeof SshDeviceHost.probe>>>();
const localTargetContext =
yield* Effect.context<Effect.Services<ReturnType<typeof isLocalSshDeviceHost>>>();
const service = yield* makeWithHosts(
hosts,
(host) =>
SshDeviceHost.probe(host).pipe(
Effect.provide(probeContext),
Effect.gen(function* () {
if (yield* isLocalSshDeviceHost(host).pipe(Effect.provide(localTargetContext))) {
return yield* localHost.summary;
}
return yield* SshDeviceHost.probe(host).pipe(Effect.provide(probeContext));
}).pipe(
Effect.mapError(
(error) =>
new DeviceOperationError({
Expand All @@ -911,8 +918,11 @@ export const make = Effect.gen(function* () {
const hostContext =
yield* Effect.context<Effect.Services<ReturnType<typeof SshDeviceHost.make>>>();
const configured = new Map<string, { config: SshDeviceHostConfig; scope: Scope.Closeable }>();
const reconcile = (next: ReadonlyArray<SshDeviceHostConfig>) =>
const reconcile = (configuredHosts: ReadonlyArray<SshDeviceHostConfig>) =>
Effect.gen(function* () {
const next = yield* remoteSshDeviceHosts(configuredHosts).pipe(
Effect.provide(localTargetContext),
);
const removed = yield* service.withLifecycleLock(
Effect.gen(function* () {
const removed: Array<{ id: string; scope: Scope.Closeable }> = [];
Expand Down
87 changes: 87 additions & 0 deletions apps/server/src/device/localSshDeviceHost.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { expect, it } from "@effect/vitest";
import * as NodeServices from "@effect/platform-node/NodeServices";
import * as Effect from "effect/Effect";
import * as Sink from "effect/Sink";
import * as Stream from "effect/Stream";
import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner";
import {
isLocalSshDeviceHost,
LocalDeviceHostAddresses,
remoteSshDeviceHosts,
} from "./localSshDeviceHost.ts";

const host = (target: string, port?: number) => ({
id: target,
label: target,
target,
...(port ? { port } : {}),
});
const spawner = ChildProcessSpawner.make((command) =>
Effect.gen(function* () {
if (command._tag !== "StandardCommand") return yield* Effect.die("Unexpected command");
// Any attempt to actually connect fails this test.
expect(command.args).toContain("-G");
const target = command.args.at(-1);
const configs: Record<string, string> = {
"mac-mini": "hostname 100.65.180.100\nport 22\n",
remote: "hostname 192.0.2.1\nport 22\n",
loopback: "hostname 127.0.1.1\nport 22\n",
ipv6: "hostname ::1\nport 22\n",
forwarded: "hostname 127.0.0.1\nport 2222\n",
proxy: "hostname 127.0.0.1\nport 22\nproxyjump bastion\n",
command: "hostname 127.0.0.1\nport 22\nproxycommand nc remote 22\n",
unresolved: "hostname example.invalid\nport 22\n",
};
return ChildProcessSpawner.makeHandle({
pid: ChildProcessSpawner.ProcessId(123),
stdout: Stream.make(new TextEncoder().encode(configs[target ?? ""] ?? "")),
stderr: Stream.empty,
all: Stream.empty,
exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(0)),
isRunning: Effect.succeed(false),
kill: () => Effect.void,
stdin: Sink.drain,
getInputFd: () => Sink.drain,
getOutputFd: () => Stream.empty,
unref: Effect.succeed(Effect.void),
});
}),
);
const provide = <A, E>(
effect: Effect.Effect<A, E, Effect.Services<ReturnType<typeof isLocalSshDeviceHost>>>,
) =>
effect.pipe(
Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
Effect.provideService(LocalDeviceHostAddresses, new Set(["100.65.180.100"])),
Effect.provide(NodeServices.layer),
);

it.effect("skips SSH aliases resolving to this machine, including loopback", () =>
provide(
Effect.gen(function* () {
for (const target of ["mac-mini", "loopback", "ipv6"]) {
expect(yield* isLocalSshDeviceHost(host(target))).toBe(true);
}
}),
),
);

it.effect("keeps remote, forwarded, proxied, and unresolved destinations", () =>
provide(
Effect.gen(function* () {
for (const target of ["remote", "forwarded", "proxy", "command", "unresolved"]) {
expect(yield* isLocalSshDeviceHost(host(target))).toBe(false);
}
}),
),
);

it.effect("removes only self targets from a fanned-out host list", () =>
provide(
Effect.gen(function* () {
expect(
yield* remoteSshDeviceHosts([host("mac-mini"), host("remote"), host("forwarded")]),
).toEqual([host("remote"), host("forwarded")]);
}),
),
);
71 changes: 71 additions & 0 deletions apps/server/src/device/localSshDeviceHost.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import * as NodeDnsPromises from "node:dns/promises";
import * as NodeNet from "node:net";
import type { SshDeviceHostConfig } from "@t3tools/contracts";
import * as NodeOS from "node:os";
import * as Context from "effect/Context";
import { runSshCommand } from "@t3tools/ssh/command";
import * as Effect from "effect/Effect";

export const LocalDeviceHostAddresses = Context.Reference<ReadonlySet<string>>(
"LocalDeviceHostAddresses",
{
defaultValue: () =>
new Set(
Object.values(NodeOS.networkInterfaces()).flatMap(
(entries) => entries?.map((entry) => entry.address) ?? [],
),
),
},
);

/** Resolve aliases on the owning environment without opening an SSH connection. */
export const isLocalSshDeviceHost = Effect.fn("isLocalSshDeviceHost")(function* (
host: SshDeviceHostConfig,
) {
const result = yield* runSshCommand(
{ alias: host.target, hostname: host.target, username: null, port: host.port ?? null },
{
preHostArgs: ["-G", ...(host.identityFile ? ["-i", host.identityFile] : [])],
timeoutMs: 5000,
},
).pipe(Effect.result);
if (result._tag === "Failure") return false;
const config = new Map(
result.success.stdout.split("\n").map((line) => {
const separator = line.indexOf(" ");
return [line.slice(0, separator), line.slice(separator + 1).trim()];
}),
);
// A local forwarded port or a proxy can lead to a different machine.
if (
config.get("port") !== "22" ||
["proxycommand", "proxyjump"].some((key) => config.has(key) && config.get(key) !== "none")
)
return false;
const hostname = config.get("hostname")?.replace(/^\[|\]$/g, "");
if (!hostname) return false;
const addresses = NodeNet.isIP(hostname)
? [hostname]
: yield* Effect.tryPromise(() => NodeDnsPromises.lookup(hostname, { all: true })).pipe(
Effect.map((entries) => entries.map((entry) => entry.address)),
Effect.timeout("2 seconds"),
Effect.orElseSucceed(() => [] as string[]),
);
const localAddresses = yield* LocalDeviceHostAddresses;
return (
addresses.length > 0 &&
addresses.every(
(address) => localAddresses.has(address) || address === "::1" || address.startsWith("127."),
)
);
});

export const remoteSshDeviceHosts = Effect.fn("remoteSshDeviceHosts")(function* (
hosts: ReadonlyArray<SshDeviceHostConfig>,
) {
return yield* Effect.filter(
hosts,
(host) => isLocalSshDeviceHost(host).pipe(Effect.map((local) => !local)),
{ concurrency: 4 },
);
});
18 changes: 15 additions & 3 deletions apps/server/src/ws.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ import * as ServerSettings from "./serverSettings.ts";
import * as TerminalManager from "./terminal/Manager.ts";
import * as PreviewAutomationBroker from "./mcp/PreviewAutomationBroker.ts";
import * as DeviceService from "./device/DeviceService.ts";
import { remoteSshDeviceHosts } from "./device/localSshDeviceHost.ts";
import * as PreviewManager from "./preview/Manager.ts";
import { issueAssetUrl } from "./assets/AssetAccess.ts";
import { deletePendingAttachment, issueAttachmentUploadUrl } from "./assets/AttachmentUpload.ts";
Expand Down Expand Up @@ -548,6 +549,8 @@ const makeWsRpcLayer = (
const terminalManager = yield* TerminalManager.TerminalManager;
const previewManager = yield* PreviewManager.PreviewManager;
const deviceService = yield* DeviceService.DeviceService;
const deviceHostContext =
yield* Effect.context<Effect.Services<ReturnType<typeof remoteSshDeviceHosts>>>();
const portDiscovery = yield* PortScanner.PortDiscovery;
const providerRegistry = yield* ProviderRegistry.ProviderRegistry;
const providerService = yield* ProviderService.ProviderService;
Expand Down Expand Up @@ -2305,9 +2308,18 @@ const makeWsRpcLayer = (
[WS_METHODS.serverUpdateSettings]: ({ patch }) =>
observeRpcEffect(
WS_METHODS.serverUpdateSettings,
serverSettings
.updateSettings(patch)
.pipe(Effect.map(ServerSettings.redactServerSettingsForClient)),
Effect.gen(function* () {
const deviceHosts = patch.deviceHosts
? yield* remoteSshDeviceHosts(patch.deviceHosts).pipe(
Effect.provide(deviceHostContext),
)
: undefined;
const settings = yield* serverSettings.updateSettings({
...patch,
...(deviceHosts ? { deviceHosts } : {}),
});
return ServerSettings.redactServerSettingsForClient(settings);
}),
{
"rpc.aggregate": "server",
},
Expand Down
Loading