| 1 | import type { DesktopDiscoveredSshHost } from "@t3tools/contracts"; |
| 2 | |
| 3 | import * as Config from "effect/Config"; |
| 4 | import * as Effect from "effect/Effect"; |
| 5 | import * as FileSystem from "effect/FileSystem"; |
| 6 | import * as Option from "effect/Option"; |
| 7 | import * as Path from "effect/Path"; |
| 8 | import * as PlatformError from "effect/PlatformError"; |
| 9 | |
| 10 | import { SshHostDiscoveryError } from "./errors.ts"; |
| 11 | |
| 12 | const NO_HOSTS: ReadonlyArray<string> = [] as const; |
| 13 | |
| 14 | function stripInlineComment(line: string): string { |
| 15 | const hashIndex = line.indexOf("#"); |
| 16 | return (hashIndex >= 0 ? line.slice(0, hashIndex) : line).trim(); |
| 17 | } |
| 18 | |
| 19 | function splitDirectiveArgs(value: string): ReadonlyArray<string> { |
| 20 | const args: Array<string> = []; |
| 21 | for (const rawEntry of value |
| 22 | .replace(/=(?!=)/gu, " ") |
| 23 | .trim() |
| 24 | .split(/\s+/u)) { |
| 25 | const entry = rawEntry.trim(); |
| 26 | if (entry.length > 0) { |
| 27 | args.push(entry); |
| 28 | } |
| 29 | } |
| 30 | return args; |
| 31 | } |
| 32 | |
| 33 | function expandHomePath(input: string, homeDir: string): string { |
| 34 | return input.replace(/^~(?=$|\/|\\)/u, homeDir); |
| 35 | } |
| 36 | |
| 37 | export const resolveSshConfigIncludePattern = Effect.fnUntraced(function* ( |
| 38 | includePattern: string, |
| 39 | _directory: string, |
| 40 | homeDir: string, |
| 41 | ) { |
| 42 | const path = yield* Path.Path; |
| 43 | const expandedPattern = expandHomePath(includePattern, homeDir); |
| 44 | return path.isAbsolute(expandedPattern) |
| 45 | ? expandedPattern |
| 46 | : path.resolve(path.join(homeDir, ".ssh"), expandedPattern); |
| 47 | }); |
| 48 | |
| 49 | function hasSshPattern(value: string): boolean { |
| 50 | return value.includes("*") || value.includes("?") || value.startsWith("!"); |
| 51 | } |
| 52 | |
| 53 | function escapeRegex(value: string): string { |
| 54 | return value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"); |
| 55 | } |
| 56 | |
| 57 | function globToRegExp(pattern: string): RegExp { |
| 58 | return new RegExp( |
| 59 | `^${escapeRegex(pattern).replace(/\\\*/gu, ".*").replace(/\\\?/gu, ".")}$`, |
| 60 | "u", |
| 61 | ); |
| 62 | } |
| 63 | |
| 64 | const expandGlob = Effect.fnUntraced(function* (pattern: string) { |
| 65 | const fs = yield* FileSystem.FileSystem; |
| 66 | const path = yield* Path.Path; |
| 67 | if (!pattern.includes("*") && !pattern.includes("?")) { |
| 68 | return (yield* fs.exists(pattern)) ? [pattern] : NO_HOSTS; |
| 69 | } |
| 70 | |
| 71 | const directory = path.dirname(pattern); |
| 72 | const basePattern = path.basename(pattern); |
| 73 | if (!(yield* fs.exists(directory))) { |
| 74 | return NO_HOSTS; |
| 75 | } |
| 76 | |
| 77 | const matcher = globToRegExp(basePattern); |
| 78 | const entries = yield* fs.readDirectory(directory); |
| 79 | const matchedPaths: string[] = []; |
| 80 | for (const entry of entries) { |
| 81 | if (!matcher.test(entry)) { |
| 82 | continue; |
| 83 | } |
| 84 | const entryPath = path.join(directory, entry); |
| 85 | if (yield* fs.exists(entryPath)) { |
| 86 | matchedPaths.push(entryPath); |
| 87 | } |
| 88 | } |
| 89 | return matchedPaths.toSorted((left, right) => left.localeCompare(right)); |
| 90 | }); |
| 91 | |
| 92 | const collectSshConfigAliasesFromFile = Effect.fnUntraced(function* ( |
| 93 | filePath: string, |
| 94 | visited = new Set<string>(), |
| 95 | homeDir: string, |
| 96 | ): Effect.fn.Return< |
| 97 | ReadonlyArray<string>, |
| 98 | PlatformError.PlatformError, |
| 99 | FileSystem.FileSystem | Path.Path |
| 100 | > { |
| 101 | const fs = yield* FileSystem.FileSystem; |
| 102 | const path = yield* Path.Path; |
| 103 | const resolvedPath = path.resolve(filePath); |
| 104 | if (visited.has(resolvedPath) || !(yield* fs.exists(resolvedPath))) { |
| 105 | return NO_HOSTS; |
| 106 | } |
| 107 | visited.add(resolvedPath); |
| 108 | |
| 109 | const aliases = new Set<string>(); |
| 110 | const directory = path.dirname(resolvedPath); |
| 111 | const raw = yield* fs.readFileString(resolvedPath); |
| 112 | |
| 113 | for (const line of raw.split(/\r?\n/u)) { |
| 114 | const stripped = stripInlineComment(line); |
| 115 | if (stripped.length === 0) { |
| 116 | continue; |
| 117 | } |
| 118 | |
| 119 | const [directive = "", ...rawArgs] = splitDirectiveArgs(stripped); |
| 120 | const normalizedDirective = directive.toLowerCase(); |
| 121 | if (normalizedDirective === "include") { |
| 122 | for (const includePattern of rawArgs) { |
| 123 | const resolvedPattern = yield* resolveSshConfigIncludePattern( |
| 124 | includePattern, |
| 125 | directory, |
| 126 | homeDir, |
| 127 | ); |
| 128 | const includedPaths = yield* expandGlob(resolvedPattern); |
| 129 | for (const includedPath of includedPaths) { |
| 130 | const includedAliases = yield* collectSshConfigAliasesFromFile( |
| 131 | includedPath, |
| 132 | visited, |
| 133 | homeDir, |
| 134 | ); |
| 135 | for (const alias of includedAliases) { |
| 136 | aliases.add(alias); |
| 137 | } |
| 138 | } |
| 139 | } |
| 140 | continue; |
| 141 | } |
| 142 | |
| 143 | if (normalizedDirective !== "host") { |
| 144 | continue; |
| 145 | } |
| 146 | |
| 147 | for (const alias of rawArgs) { |
| 148 | if (alias.length === 0 || hasSshPattern(alias)) { |
| 149 | continue; |
| 150 | } |
| 151 | aliases.add(alias); |
| 152 | } |
| 153 | } |
| 154 | |
| 155 | return [...aliases].toSorted((left, right) => left.localeCompare(right)); |
| 156 | }); |
| 157 | |
| 158 | function normalizeKnownHostsHostname(rawHost: string): string { |
| 159 | const bracketMatch = /^\[([^\]]+)\]:(\d+)$/u.exec(rawHost); |
| 160 | if (bracketMatch?.[1]) { |
| 161 | return bracketMatch[1]; |
| 162 | } |
| 163 | |
| 164 | if (!rawHost.includes(":")) { |
| 165 | return rawHost; |
| 166 | } |
| 167 | |
| 168 | const firstColonIndex = rawHost.indexOf(":"); |
| 169 | const lastColonIndex = rawHost.lastIndexOf(":"); |
| 170 | return firstColonIndex === lastColonIndex ? rawHost.slice(0, lastColonIndex) : rawHost; |
| 171 | } |
| 172 | |
| 173 | export function parseKnownHostsHostnames(raw: string): ReadonlyArray<string> { |
| 174 | const hostnames = new Set<string>(); |
| 175 | |
| 176 | for (const line of raw.split(/\r?\n/u)) { |
| 177 | const trimmed = line.trim(); |
| 178 | if (trimmed.length === 0 || trimmed.startsWith("#")) { |
| 179 | continue; |
| 180 | } |
| 181 | |
| 182 | const withoutMarker = trimmed.startsWith("@") |
| 183 | ? trimmed.split(/\s+/u).slice(1).join(" ") |
| 184 | : trimmed; |
| 185 | const [hostField = ""] = withoutMarker.split(/\s+/u); |
| 186 | if (hostField.length === 0 || hostField.startsWith("|")) { |
| 187 | continue; |
| 188 | } |
| 189 | |
| 190 | for (const rawHost of hostField.split(",")) { |
| 191 | const host = normalizeKnownHostsHostname(rawHost).trim(); |
| 192 | if (host.length === 0 || hasSshPattern(host)) { |
| 193 | continue; |
| 194 | } |
| 195 | hostnames.add(host); |
| 196 | } |
| 197 | } |
| 198 | |
| 199 | return [...hostnames].toSorted((left, right) => left.localeCompare(right)); |
| 200 | } |
| 201 | |
| 202 | const readKnownHostsHostnames = Effect.fnUntraced(function* (filePath: string) { |
| 203 | const fs = yield* FileSystem.FileSystem; |
| 204 | if (!(yield* fs.exists(filePath))) { |
| 205 | return NO_HOSTS; |
| 206 | } |
| 207 | return parseKnownHostsHostnames(yield* fs.readFileString(filePath)); |
| 208 | }); |
| 209 | |
| 210 | export const discoverSshHosts = Effect.fnUntraced( |
| 211 | function* (input: { readonly homeDir?: string }) { |
| 212 | const path = yield* Path.Path; |
| 213 | const env = yield* Config.all({ |
| 214 | home: Config.String("HOME").pipe(Config.option), |
| 215 | userProfile: Config.String("USERPROFILE").pipe(Config.option), |
| 216 | }); |
| 217 | const homeDir = |
| 218 | input?.homeDir ?? |
| 219 | Option.getOrUndefined(env.home) ?? |
| 220 | Option.getOrUndefined(env.userProfile) ?? |
| 221 | ""; |
| 222 | if (homeDir.trim().length === 0) { |
| 223 | return []; |
| 224 | } |
| 225 | |
| 226 | const sshDirectory = path.join(homeDir, ".ssh"); |
| 227 | const configAliases = yield* collectSshConfigAliasesFromFile( |
| 228 | path.join(sshDirectory, "config"), |
| 229 | new Set<string>(), |
| 230 | homeDir, |
| 231 | ); |
| 232 | const knownHosts = yield* readKnownHostsHostnames(path.join(sshDirectory, "known_hosts")); |
| 233 | const discovered = new Map<string, DesktopDiscoveredSshHost>(); |
| 234 | |
| 235 | for (const alias of configAliases) { |
| 236 | discovered.set(alias, { |
| 237 | alias, |
| 238 | hostname: alias, |
| 239 | username: null, |
| 240 | port: null, |
| 241 | source: "ssh-config", |
| 242 | }); |
| 243 | } |
| 244 | |
| 245 | for (const hostname of knownHosts) { |
| 246 | if (discovered.has(hostname)) { |
| 247 | continue; |
| 248 | } |
| 249 | discovered.set(hostname, { |
| 250 | alias: hostname, |
| 251 | hostname, |
| 252 | username: null, |
| 253 | port: null, |
| 254 | source: "known-hosts", |
| 255 | }); |
| 256 | } |
| 257 | |
| 258 | return [...discovered.values()].toSorted((left, right) => |
| 259 | left.alias.localeCompare(right.alias), |
| 260 | ); |
| 261 | }, |
| 262 | Effect.mapError( |
| 263 | (cause) => |
| 264 | new SshHostDiscoveryError({ |
| 265 | message: "Failed to discover SSH hosts.", |
| 266 | cause, |
| 267 | }), |
| 268 | ), |
| 269 | ); |