1import * as NodeDnsPromises from "node:dns/promises";
2import * as NodeNet from "node:net";
3import type { DeviceHostConfig, 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";
8import { HostnameLookupError } from "@t3tools/shared/HostProcess";
9
10export const LocalDeviceHostAddresses = Context.Reference<ReadonlySet<string>>(
11 "LocalDeviceHostAddresses",
12 {
13 defaultValue: () =>
14 new Set(
15 Object.values(NodeOS.networkInterfaces()).flatMap(
16 (entries) => entries?.map((entry) => entry.address) ?? [],
17 ),
18 ),
19 },
20);
21
22/** Unwraps IPv4-mapped IPv6 (`::ffff:127.0.0.1`, `::ffff:7f00:1`) to plain IPv4. */
23const unmapIPv4 = (address: string) => {
24 if (NodeNet.isIP(address) !== 6) return address;
25 let canonical: string;
26 try {
27 canonical = new URL(`http://[${address}]`).hostname;
28 } catch {
29 return address;
30 }
31 const match = /^\[::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})\]$/.exec(canonical);
32 if (!match) return address;
33 const [high, low] = [parseInt(match[1]!, 16), parseInt(match[2]!, 16)];
34 return [high >> 8, high & 0xff, low >> 8, low & 0xff].join(".");
35};
36
37export const isLocalSshDeviceHost = Effect.fn("isLocalSshDeviceHost")(function* (
38 host: SshDeviceHostConfig,
39) {
40 const result = yield* runSshCommand(
41 { alias: host.target, hostname: host.target, username: null, port: host.port ?? null },
42 {
43 preHostArgs: ["-G", ...(host.identityFile ? ["-i", host.identityFile] : [])],
44 timeoutMs: 5000,
45 },
46 ).pipe(Effect.result);
47 if (result._tag === "Failure") return false;
48 const config = new Map(
49 result.success.stdout.split("\n").map((line) => {
50 const separator = line.indexOf(" ");
51 return [line.slice(0, separator), line.slice(separator + 1).trim()];
52 }),
53 );
54 // A local forwarded port or a proxy can lead to a different machine.
55 if (
56 config.get("port") !== "22" ||
57 ["proxycommand", "proxyjump"].some((key) => config.has(key) && config.get(key) !== "none")
58 )
59 return false;
60 const hostname = config.get("hostname")?.replace(/^\[|\]$/g, "");
61 if (!hostname) return false;
62 const addresses = NodeNet.isIP(hostname)
63 ? [hostname]
64 : yield* Effect.tryPromise({
65 try: () => NodeDnsPromises.lookup(hostname, { all: true }),
66 catch: (cause) => new HostnameLookupError({ hostname, cause }),
67 }).pipe(
68 Effect.map((entries) => entries.map((entry) => entry.address)),
69 Effect.timeout("2 seconds"),
70 Effect.orElseSucceed(() => [] as string[]),
71 );
72 const localAddresses = yield* LocalDeviceHostAddresses;
73 return (
74 addresses.length > 0 &&
75 addresses
76 .map(unmapIPv4)
77 .every(
78 (address) => localAddresses.has(address) || address === "::1" || address.startsWith("127."),
79 )
80 );
81});
82
83/** Drops SSH hosts that resolve to this machine; other host kinds pass through. */
84export const remoteSshDeviceHosts = Effect.fn("remoteSshDeviceHosts")(
85 <Host extends DeviceHostConfig>(hosts: ReadonlyArray<Host>) =>
86 Effect.filter(
87 hosts,
88 (host) =>
89 host.kind === "ssh"
90 ? isLocalSshDeviceHost(host).pipe(Effect.map((local) => !local))
91 : Effect.succeed(true),
92 { concurrency: 4 },
93 ),
94);