authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-13 22:13:34-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-13 22:13:34-07:00
log1ced38a6647b7f466b535b4b413ca4a74303da8e
tree4cfd06fbc118961b7fd8e8e5323c80240065f552
parent73b206f4bf99e7e98ae144a9d9edbf7cb2700fc1
signature Signed by PGP key B5690EEEBB952194

fix(server): skip device hosts that resolve to the local machine (#11698)


4 files changed, 186 insertions(+), 6 deletions(-)

apps/server/src/device/DeviceService.ts+13-3
......@@ -58,6 +58,7 @@ import * as SynchronizedRef from "effect/SynchronizedRef";
5858import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http";
5959
6060import * as ServerSettings from "../serverSettings.ts";
61import { isLocalSshDeviceHost, remoteSshDeviceHosts } from "./localSshDeviceHost.ts";
6162
6263import { readDeviceDetail, runDeviceAction } from "./DeviceActions.ts";
6364import * as ProcessRunner from "../processRunner.ts";
......@@ -892,11 +893,17 @@ export const make = Effect.gen(function* () {
892893 };
893894 const probeContext =
894895 yield* Effect.context<Effect.Services<ReturnType<typeof SshDeviceHost.probe>>>();
896 const localTargetContext =
897 yield* Effect.context<Effect.Services<ReturnType<typeof isLocalSshDeviceHost>>>();
895898 const service = yield* makeWithHosts(
896899 hosts,
897900 (host) =>
898 SshDeviceHost.probe(host).pipe(
899 Effect.provide(probeContext),
901 Effect.gen(function* () {
902 if (yield* isLocalSshDeviceHost(host).pipe(Effect.provide(localTargetContext))) {
903 return yield* localHost.summary;
904 }
905 return yield* SshDeviceHost.probe(host).pipe(Effect.provide(probeContext));
906 }).pipe(
900907 Effect.mapError(
901908 (error) =>
902909 new DeviceOperationError({
......@@ -911,8 +918,11 @@ export const make = Effect.gen(function* () {
911918 const hostContext =
912919 yield* Effect.context<Effect.Services<ReturnType<typeof SshDeviceHost.make>>>();
913920 const configured = new Map<string, { config: SshDeviceHostConfig; scope: Scope.Closeable }>();
914 const reconcile = (next: ReadonlyArray<SshDeviceHostConfig>) =>
921 const reconcile = (configuredHosts: ReadonlyArray<SshDeviceHostConfig>) =>
915922 Effect.gen(function* () {
923 const next = yield* remoteSshDeviceHosts(configuredHosts).pipe(
924 Effect.provide(localTargetContext),
925 );
916926 const removed = yield* service.withLifecycleLock(
917927 Effect.gen(function* () {
918928 const removed: Array<{ id: string; scope: Scope.Closeable }> = [];
apps/server/src/device/localSshDeviceHost.test.ts created+87
......@@ -0,0 +1,87 @@
1import { expect, it } from "@effect/vitest";
2import * as NodeServices from "@effect/platform-node/NodeServices";
3import * as Effect from "effect/Effect";
4import * as Sink from "effect/Sink";
5import * as Stream from "effect/Stream";
6import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner";
7import {
8 isLocalSshDeviceHost,
9 LocalDeviceHostAddresses,
10 remoteSshDeviceHosts,
11} from "./localSshDeviceHost.ts";
12
13const host = (target: string, port?: number) => ({
14 id: target,
15 label: target,
16 target,
17 ...(port ? { port } : {}),
18});
19const spawner = ChildProcessSpawner.make((command) =>
20 Effect.gen(function* () {
21 if (command._tag !== "StandardCommand") return yield* Effect.die("Unexpected command");
22 // Any attempt to actually connect fails this test.
23 expect(command.args).toContain("-G");
24 const target = command.args.at(-1);
25 const configs: Record<string, string> = {
26 "mac-mini": "hostname 100.65.180.100\nport 22\n",
27 remote: "hostname 192.0.2.1\nport 22\n",
28 loopback: "hostname 127.0.1.1\nport 22\n",
29 ipv6: "hostname ::1\nport 22\n",
30 forwarded: "hostname 127.0.0.1\nport 2222\n",
31 proxy: "hostname 127.0.0.1\nport 22\nproxyjump bastion\n",
32 command: "hostname 127.0.0.1\nport 22\nproxycommand nc remote 22\n",
33 unresolved: "hostname example.invalid\nport 22\n",
34 };
35 return ChildProcessSpawner.makeHandle({
36 pid: ChildProcessSpawner.ProcessId(123),
37 stdout: Stream.make(new TextEncoder().encode(configs[target ?? ""] ?? "")),
38 stderr: Stream.empty,
39 all: Stream.empty,
40 exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(0)),
41 isRunning: Effect.succeed(false),
42 kill: () => Effect.void,
43 stdin: Sink.drain,
44 getInputFd: () => Sink.drain,
45 getOutputFd: () => Stream.empty,
46 unref: Effect.succeed(Effect.void),
47 });
48 }),
49);
50const provide = <A, E>(
51 effect: Effect.Effect<A, E, Effect.Services<ReturnType<typeof isLocalSshDeviceHost>>>,
52) =>
53 effect.pipe(
54 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
55 Effect.provideService(LocalDeviceHostAddresses, new Set(["100.65.180.100"])),
56 Effect.provide(NodeServices.layer),
57 );
58
59it.effect("skips SSH aliases resolving to this machine, including loopback", () =>
60 provide(
61 Effect.gen(function* () {
62 for (const target of ["mac-mini", "loopback", "ipv6"]) {
63 expect(yield* isLocalSshDeviceHost(host(target))).toBe(true);
64 }
65 }),
66 ),
67);
68
69it.effect("keeps remote, forwarded, proxied, and unresolved destinations", () =>
70 provide(
71 Effect.gen(function* () {
72 for (const target of ["remote", "forwarded", "proxy", "command", "unresolved"]) {
73 expect(yield* isLocalSshDeviceHost(host(target))).toBe(false);
74 }
75 }),
76 ),
77);
78
79it.effect("removes only self targets from a fanned-out host list", () =>
80 provide(
81 Effect.gen(function* () {
82 expect(
83 yield* remoteSshDeviceHosts([host("mac-mini"), host("remote"), host("forwarded")]),
84 ).toEqual([host("remote"), host("forwarded")]);
85 }),
86 ),
87);
apps/server/src/device/localSshDeviceHost.ts created+71
......@@ -0,0 +1,71 @@
1import * as NodeDnsPromises from "node:dns/promises";
2import * as NodeNet from "node:net";
3import type { SshDeviceHostConfig } from "@t3tools/contracts";
4import * as NodeOS from "node:os";
5import * as Context from "effect/Context";
6import { runSshCommand } from "@t3tools/ssh/command";
7import * as Effect from "effect/Effect";
8
9export const LocalDeviceHostAddresses = Context.Reference<ReadonlySet<string>>(
10 "LocalDeviceHostAddresses",
11 {
12 defaultValue: () =>
13 new Set(
14 Object.values(NodeOS.networkInterfaces()).flatMap(
15 (entries) => entries?.map((entry) => entry.address) ?? [],
16 ),
17 ),
18 },
19);
20
21/** Resolve aliases on the owning environment without opening an SSH connection. */
22export const isLocalSshDeviceHost = Effect.fn("isLocalSshDeviceHost")(function* (
23 host: SshDeviceHostConfig,
24) {
25 const result = yield* runSshCommand(
26 { alias: host.target, hostname: host.target, username: null, port: host.port ?? null },
27 {
28 preHostArgs: ["-G", ...(host.identityFile ? ["-i", host.identityFile] : [])],
29 timeoutMs: 5000,
30 },
31 ).pipe(Effect.result);
32 if (result._tag === "Failure") return false;
33 const config = new Map(
34 result.success.stdout.split("\n").map((line) => {
35 const separator = line.indexOf(" ");
36 return [line.slice(0, separator), line.slice(separator + 1).trim()];
37 }),
38 );
39 // A local forwarded port or a proxy can lead to a different machine.
40 if (
41 config.get("port") !== "22" ||
42 ["proxycommand", "proxyjump"].some((key) => config.has(key) && config.get(key) !== "none")
43 )
44 return false;
45 const hostname = config.get("hostname")?.replace(/^\[|\]$/g, "");
46 if (!hostname) return false;
47 const addresses = NodeNet.isIP(hostname)
48 ? [hostname]
49 : yield* Effect.tryPromise(() => NodeDnsPromises.lookup(hostname, { all: true })).pipe(
50 Effect.map((entries) => entries.map((entry) => entry.address)),
51 Effect.timeout("2 seconds"),
52 Effect.orElseSucceed(() => [] as string[]),
53 );
54 const localAddresses = yield* LocalDeviceHostAddresses;
55 return (
56 addresses.length > 0 &&
57 addresses.every(
58 (address) => localAddresses.has(address) || address === "::1" || address.startsWith("127."),
59 )
60 );
61});
62
63export const remoteSshDeviceHosts = Effect.fn("remoteSshDeviceHosts")(function* (
64 hosts: ReadonlyArray<SshDeviceHostConfig>,
65) {
66 return yield* Effect.filter(
67 hosts,
68 (host) => isLocalSshDeviceHost(host).pipe(Effect.map((local) => !local)),
69 { concurrency: 4 },
70 );
71});
apps/server/src/ws.ts+15-3
......@@ -117,6 +117,7 @@ import * as ServerSettings from "./serverSettings.ts";
117117import * as TerminalManager from "./terminal/Manager.ts";
118118import * as PreviewAutomationBroker from "./mcp/PreviewAutomationBroker.ts";
119119import * as DeviceService from "./device/DeviceService.ts";
120import { remoteSshDeviceHosts } from "./device/localSshDeviceHost.ts";
120121import * as PreviewManager from "./preview/Manager.ts";
121122import { issueAssetUrl } from "./assets/AssetAccess.ts";
122123import { deletePendingAttachment, issueAttachmentUploadUrl } from "./assets/AttachmentUpload.ts";
......@@ -548,6 +549,8 @@ const makeWsRpcLayer = (
548549 const terminalManager = yield* TerminalManager.TerminalManager;
549550 const previewManager = yield* PreviewManager.PreviewManager;
550551 const deviceService = yield* DeviceService.DeviceService;
552 const deviceHostContext =
553 yield* Effect.context<Effect.Services<ReturnType<typeof remoteSshDeviceHosts>>>();
551554 const portDiscovery = yield* PortScanner.PortDiscovery;
552555 const providerRegistry = yield* ProviderRegistry.ProviderRegistry;
553556 const providerService = yield* ProviderService.ProviderService;
......@@ -2305,9 +2308,18 @@ const makeWsRpcLayer = (
23052308 [WS_METHODS.serverUpdateSettings]: ({ patch }) =>
23062309 observeRpcEffect(
23072310 WS_METHODS.serverUpdateSettings,
2308 serverSettings
2309 .updateSettings(patch)
2310 .pipe(Effect.map(ServerSettings.redactServerSettingsForClient)),
2311 Effect.gen(function* () {
2312 const deviceHosts = patch.deviceHosts
2313 ? yield* remoteSshDeviceHosts(patch.deviceHosts).pipe(
2314 Effect.provide(deviceHostContext),
2315 )
2316 : undefined;
2317 const settings = yield* serverSettings.updateSettings({
2318 ...patch,
2319 ...(deviceHosts ? { deviceHosts } : {}),
2320 });
2321 return ServerSettings.redactServerSettingsForClient(settings);
2322 }),
23112323 {
23122324 "rpc.aggregate": "server",
23132325 },