Skip to content
Source
Sign inSign up
Open History to see the last commit

config.ts

431 lines13.6 KB

1import type { DesktopDiscoveredSshHost } from "@t3tools/contracts";
2​
3import * as Config from "effect/Config";
4import * as Effect from "effect/Effect";
5import * as FileSystem from "effect/FileSystem";
6import * as Option from "effect/Option";
7import * as Path from "effect/Path";
8import * as PlatformError from "effect/PlatformError";
9​
10import { SshHostDiscoveryError } from "./errors.ts";
11​
12const NO_HOSTS: ReadonlyArray<string> = [] as const;
13​
14// Mirrors OpenSSH readconf: the keyword may be followed by one optional "=",
15// then the arguments are split like misc.c argv_split() with quotes, the
16// escapes \\ \" \' and unquoted "\ ", and "#" starting a comment only at
17// the start of a token. Invalid quoting leaves hostname resolution uncertain.
18function parseSshConfigLine(line: string) {
19 const match = /^[ \t\r]*([^ \t\r=]+)[ \t\r]*=?[ \t\r]*(.*)$/su.exec(
20 line.replace(/[ \t\r\f]+$/u, ""),
21 );
22 if (!match) {
23 return null;
24 }
25​
26 const directive = match[1]!.toLowerCase();
27 const args: Array<string> = [];
28 const rest = match[2]!;
29 let token: string | null = null;
30 let quote: string | null = null;
31 for (let index = 0; index < rest.length; index += 1) {
32 const char = rest[index]!;
33 const next = rest[index + 1];
34 if (
35 char === "\\" &&
36 (next === '"' || next === "'" || next === "\\" || (quote === null && next === " "))
37 ) {
38 token = (token ?? "") + next;
39 index += 1;
40 } else if (quote !== null) {
41 if (char === quote) {
42 quote = null;
43 } else {
44 token = (token ?? "") + char;
45 }
46 } else if (char === '"' || char === "'") {
47 quote = char;
48 token ??= "";
49 } else if (/[ \t]/u.test(char)) {
50 if (token !== null) {
51 args.push(token);
52 token = null;
53 }
54 } else if (char === "#" && token === null) {
55 break;
56 } else {
57 token = (token ?? "") + char;
58 }
59 }
60 if (quote !== null) {
61 return { directive, args: null };
62 }
63 if (token !== null) {
64 args.push(token);
65 }
66 return { directive, args };
68​
69function expandHomePath(input: string, homeDir: string): string {
70 return input.replace(/^~(?=$|\/|\\)/u, homeDir);
72​
73const resolveSshConfigIncludePattern = Effect.fnUntraced(function* (
74 includePattern: string,
75 _directory: string,
76 homeDir: string,
77) {
78 const path = yield* Path.Path;
79 const expandedPattern = expandHomePath(includePattern, homeDir);
80 return path.isAbsolute(expandedPattern)
81 ? expandedPattern
82 : path.resolve(path.join(homeDir, ".ssh"), expandedPattern);
83});
84​
85function hasSshPattern(value: string): boolean {
86 return value.includes("*") || value.includes("?") || value.startsWith("!");
88​
89function escapeRegex(value: string): string {
90 return value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
92​
93function globToRegExp(pattern: string): RegExp {
94 return new RegExp(
95 `^${escapeRegex(pattern).replace(/\\\*/gu, ".*").replace(/\\\?/gu, ".")}$`,
96 "u",
97 );
99​
100function hasGlobWildcard(value: string): boolean {
101 return value.includes("*") || value.includes("?");
103​
104const expandGlob = Effect.fnUntraced(function* (
105 pattern: string,
106): Effect.fn.Return<
107 ReadonlyArray<string>,
108 PlatformError.PlatformError,
109 FileSystem.FileSystem | Path.Path
110> {
111 const fs = yield* FileSystem.FileSystem;
112 const path = yield* Path.Path;
113 if (!hasGlobWildcard(pattern)) {
114 return (yield* fs.exists(pattern)) ? [pattern] : NO_HOSTS;
115 }
116​
117 const directoryPattern = path.dirname(pattern);
118 const basePattern = path.basename(pattern);
119 const directoryIsGlob = hasGlobWildcard(directoryPattern);
120 const directories = directoryIsGlob ? yield* expandGlob(directoryPattern) : [directoryPattern];
121​
122 const matcher = globToRegExp(basePattern);
123 const matchedPaths: string[] = [];
124 for (const directory of directories) {
125 if (!(yield* fs.exists(directory))) continue;
126 if ((yield* fs.stat(directory)).type !== "Directory") continue;
127 // Like OpenSSH's glob, a wildcard-matched directory we cannot read matches nothing.
128 const entries = directoryIsGlob
129 ? yield* fs.readDirectory(directory).pipe(Effect.orElseSucceed(() => NO_HOSTS))
130 : yield* fs.readDirectory(directory);
131 for (const entry of entries) {
132 if (entry.startsWith(".") && !basePattern.startsWith(".")) continue;
133 if (!matcher.test(entry)) {
134 continue;
135 }
136 const entryPath = path.join(directory, entry);
137 if (yield* fs.exists(entryPath)) {
138 matchedPaths.push(entryPath);
139 }
140 }
141 }
142 return matchedPaths.toSorted();
143});
144​
145type HostCondition = ReadonlyArray<string> | null;
146​
147interface HostRule {
148 readonly conditions: ReadonlyArray<HostCondition>;
149 // null: an unreadable Include that may set any directive.
150 readonly directive: "hostname" | "port" | null;
151 readonly value: string | null;
153​
154function matchesHostPatterns(alias: string, patterns: ReadonlyArray<string>): boolean {
155 let matched = false;
156 for (const pattern of patterns) {
157 const negated = pattern.startsWith("!");
158 const hostPattern = negated ? pattern.slice(1) : pattern;
159 const matches =
160 hostPattern === "*" ||
161 (hasSshPattern(hostPattern) ? globToRegExp(hostPattern).test(alias) : hostPattern === alias);
162 if (!matches) continue;
163 if (negated) return false;
164 matched = true;
165 }
166 return matched;
168​
169function resolveConfiguredValue(
170 alias: string,
171 directive: "hostname" | "port",
172 rules: ReadonlyArray<HostRule>,
173) {
174 for (const rule of rules) {
175 if (
176 (rule.directive !== null && rule.directive !== directive) ||
177 rule.conditions.some(
178 (condition) => condition !== null && !matchesHostPatterns(alias, condition),
179 )
180 ) {
181 continue;
182 }
183 // SSH takes the first value; an uncertain earlier value blocks later fallbacks.
184 if (rule.conditions.includes(null) || rule.value === null) return null;
185 return rule.value;
186 }
187 return undefined;
189​
190function resolveConfiguredHostname(alias: string, rules: ReadonlyArray<HostRule>) {
191 const hostname = resolveConfiguredValue(alias, "hostname", rules) ?? null;
192 if (hostname === null || /%(?!h)/u.test(hostname) || hostname.includes("$")) return null;
193 return hostname.replaceAll("%h", alias);
195​
196// "*" when the port is uncertain, so the alias still covers every known_hosts port.
197function resolveConfiguredPort(alias: string, rules: ReadonlyArray<HostRule>) {
198 const port = resolveConfiguredValue(alias, "port", rules);
199 return port === undefined ? "22" : (port ?? "*");
201​
202const collectSshConfigAliasesFromFile = Effect.fnUntraced(function* (
203 filePath: string,
204 visited: Set<string>,
205 homeDir: string,
206 aliases: Set<string>,
207 hostNameRules: Array<HostRule>,
208 inheritedConditions: ReadonlyArray<HostCondition>,
209): Effect.fn.Return<void, PlatformError.PlatformError, FileSystem.FileSystem | Path.Path> {
210 const fs = yield* FileSystem.FileSystem;
211 const path = yield* Path.Path;
212 const resolvedPath = path.resolve(filePath);
213 if (visited.has(resolvedPath)) {
214 // Read each file once, but never reuse its rules under a different Include context.
215 hostNameRules.push({ conditions: inheritedConditions, directive: null, value: null });
216 return;
217 }
218 if (!(yield* fs.exists(resolvedPath))) return;
219 if ((yield* fs.stat(resolvedPath)).type === "Directory") return;
220 visited.add(resolvedPath);
221​
222 const directory = path.dirname(resolvedPath);
223 const raw = yield* fs.readFileString(resolvedPath);
224 let condition: HostCondition = ["*"];
225​
226 for (const line of raw.split(/\r?\n/u)) {
227 const parsed = parseSshConfigLine(line);
228 if (!parsed) continue;
229 const { directive, args } = parsed;
230 const conditions = [...inheritedConditions, condition];
231​
232 if (directive === "host") {
233 condition = args;
234 for (const alias of args ?? []) {
235 if (alias.length > 0 && !hasSshPattern(alias)) aliases.add(alias);
236 }
237 } else if (directive === "match") {
238 condition = null;
239 } else if (directive === "hostname") {
240 hostNameRules.push({
241 conditions,
242 directive: "hostname",
243 value: args?.length === 1 ? args[0]! : null,
244 });
245 } else if (directive === "port") {
246 const port = args?.length === 1 ? Number(args[0]) : Number.NaN;
247 hostNameRules.push({
248 conditions,
249 directive: "port",
250 value: Number.isInteger(port) && port >= 1 && port <= 65535 ? String(port) : null,
251 });
252 } else if (directive === "include") {
253 if (args === null) {
254 hostNameRules.push({ conditions, directive: null, value: null });
255 continue;
256 }
257 for (const includePattern of args) {
258 const resolvedPattern = yield* resolveSshConfigIncludePattern(
259 includePattern,
260 directory,
261 homeDir,
262 );
263 if (
264 /%|\$\{|\[/u.test(includePattern) ||
265 (includePattern.startsWith("~") && !/^~(?=$|\/|\\)/u.test(includePattern))
266 ) {
267 hostNameRules.push({ conditions, directive: null, value: null });
268 continue;
269 }
270 const includedPaths = yield* expandGlob(resolvedPattern);
271 for (const includedPath of includedPaths) {
272 yield* collectSshConfigAliasesFromFile(
273 includedPath,
274 visited,
275 homeDir,
276 aliases,
277 hostNameRules,
278 conditions,
279 );
280 }
281 }
282 }
283 }
284});
285​
286interface KnownHost {
287 readonly hostname: string;
288 readonly port: number | null;
290​
291function parseKnownHostsHost(rawHost: string): KnownHost | null {
292 const bracketMatch = /^\[([^\]]+)\]:(\d+)$/u.exec(rawHost);
293 if (bracketMatch?.[1]) {
294 const port = Number(bracketMatch[2]);
295 return port >= 1 && port <= 65535 ? { hostname: bracketMatch[1], port } : null;
296 }
297​
298 if (!rawHost.includes(":")) {
299 return { hostname: rawHost, port: null };
300 }
301​
302 const firstColonIndex = rawHost.indexOf(":");
303 const lastColonIndex = rawHost.lastIndexOf(":");
304 return {
305 hostname: firstColonIndex === lastColonIndex ? rawHost.slice(0, lastColonIndex) : rawHost,
306 port: null,
307 };
309​
310export function parseKnownHosts(raw: string): ReadonlyArray<KnownHost> {
311 const hosts = new Map<string, KnownHost>();
312​
313 for (const line of raw.split(/\r?\n/u)) {
314 const trimmed = line.trim();
315 if (trimmed.length === 0 || trimmed.startsWith("#")) {
316 continue;
317 }
318​
319 const withoutMarker = trimmed.startsWith("@")
320 ? trimmed.split(/\s+/u).slice(1).join(" ")
321 : trimmed;
322 const [hostField = ""] = withoutMarker.split(/\s+/u);
323 if (hostField.length === 0 || hostField.startsWith("|")) {
324 continue;
325 }
326​
327 for (const rawHost of hostField.split(",")) {
328 const parsed = parseKnownHostsHost(rawHost.trim());
329 if (parsed === null) continue;
330 const hostname = parsed.hostname.trim();
331 if (hostname.length === 0 || hasSshPattern(hostname)) {
332 continue;
333 }
334 hosts.set(`${hostname}\u0000${parsed.port ?? ""}`, { hostname, port: parsed.port });
335 }
336 }
337​
338 return [...hosts.values()].toSorted(
339 (left, right) =>
340 left.hostname.localeCompare(right.hostname) || (left.port ?? 0) - (right.port ?? 0),
341 );
343​
344const readKnownHosts = Effect.fnUntraced(function* (filePath: string) {
345 const fs = yield* FileSystem.FileSystem;
346 if (!(yield* fs.exists(filePath))) {
347 return [];
348 }
349 return parseKnownHosts(yield* fs.readFileString(filePath));
350});
351​
352export const discoverSshHosts = Effect.fnUntraced(
353 function* (input: { readonly homeDir?: string }) {
354 const path = yield* Path.Path;
355 const env = yield* Config.all({
356 home: Config.String("HOME").pipe(Config.option),
357 userProfile: Config.String("USERPROFILE").pipe(Config.option),
358 });
359 const homeDir =
360 input?.homeDir ??
361 Option.getOrUndefined(env.home) ??
362 Option.getOrUndefined(env.userProfile) ??
363 "";
364 if (homeDir.trim().length === 0) {
365 return [];
366 }
367​
368 const sshDirectory = path.join(homeDir, ".ssh");
369 const configAliases = new Set<string>();
370 const hostNameRules: Array<HostRule> = [];
371 yield* collectSshConfigAliasesFromFile(
372 path.join(sshDirectory, "config"),
373 new Set<string>(),
374 homeDir,
375 configAliases,
376 hostNameRules,
377 [],
378 );
379 const knownHosts = yield* readKnownHosts(path.join(sshDirectory, "known_hosts"));
380 const discovered = new Map<string, DesktopDiscoveredSshHost>();
381​
382 const configuredNames = new Set<string>();
383 const configuredDestinations = new Set<string>();
384 for (const alias of configAliases) {
385 const hostname = resolveConfiguredHostname(alias, hostNameRules);
386 const port = resolveConfiguredPort(alias, hostNameRules);
387 for (const name of hostname === null ? [alias] : [alias, hostname]) {
388 configuredNames.add(name.toLowerCase());
389 configuredDestinations.add(`${name.toLowerCase()}\u0000${port}`);
390 }
391 discovered.set(alias, {
392 alias,
393 hostname: hostname ?? alias,
394 username: null,
395 port: null,
396 source: "ssh-config",
397 });
398 }
399​
400 for (const { hostname, port } of knownHosts) {
401 const name = hostname.toLowerCase();
402 // A port-less entry connects as `ssh <host>`, so a matching alias's Port applies to it.
403 if (
404 port === null
405 ? configuredNames.has(name)
406 : configuredDestinations.has(`${name}\u0000${port}`) ||
407 configuredDestinations.has(`${name}\u0000*`)
408 ) {
409 continue;
410 }
411 discovered.set(`${hostname}\u0000${port ?? ""}`, {
412 alias: hostname,
413 hostname,
414 username: null,
415 port,
416 source: "known-hosts",
417 });
418 }
419​
420 return [...discovered.values()].toSorted((left, right) =>
421 left.alias.localeCompare(right.alias),
422 );
423 },
424 Effect.mapError(
425 (cause) =>
426 new SshHostDiscoveryError({
427 message: "Failed to discover SSH hosts.",
428 cause,
429 }),
430 ),
431);