/** * Computer discovery — pure resolution of `Computer`s from SSH config + * `known_hosts`. * * Per decision #4: computers are DISCOVERED read-only (no CRUD). Discovery * has TWO sources, in precedence order: * * 1. **`~/.ssh/config`** — named (non-wildcard) `Host` aliases with full * connection params (HostName, Port, User, IdentityFile). These are the * "explicitly configured" computers. * 2. **`~/.ssh/known_hosts`** — every hostname you've ever connected to. These * are the "discovered" computers, added with defaulted params (User= * defaultUser, IdentityFile=null → the pool probes default keys, Port * parsed from `[host]:port` notation or 22). A hostname already present in * config is NOT duplicated (config takes precedence). * * A **reject list** (glob patterns from `dispatch.toml` `[ssh].reject`) * filters the final list — e.g. `github.com`, `*.ts.net`, raw IPs — so noise * from `known_hosts` doesn't pollute the computer catalog. * * This module is the PURE half: it takes the config TEXT + known_hosts TEXT + * reject patterns (the I/O of reading the files lives in the shell) and * resolves each to a `Computer`. Uses the `ssh-config` package for correct * parsing (wildcards, `Include`, first-match-wins) rather than a hand-rolled * parser (decision #8). * * Pure: zero I/O, zero mocks — a test feeds fixture strings. The shell * (`service.ts`) injects the file contents. */ import type { Computer } from "@dispatch/wire"; import SSHConfig, { type Directive, type Section } from "ssh-config"; import { isKnownHost } from "./hostkey.js"; /** Injected environment for the pure resolver (no ambient process access). */ export interface SshConfigResolveEnv { /** The raw `~/.ssh/config` text (may be empty — no file). */ readonly configText: string; /** The raw `~/.ssh/known_hosts` text (drives `knownHost` + discovery). */ readonly knownHostsText: string; /** Fallback user when the config sets none (the current OS user). */ readonly defaultUser: string; /** Home dir, for resolving `~` in `IdentityFile` (already-expanded by caller). */ readonly homeDir: string; /** * Glob patterns (e.g. `github.com`, `*.ts.net`) to exclude from the * computer catalog. Sourced from `dispatch.toml` `[ssh].reject`. Absent * or empty → no filtering. */ readonly rejectPatterns?: readonly string[]; } /** * Discover `Computer`s from `~/.ssh/config` + `~/.ssh/known_hosts`, returning * one per unique hostname (config aliases first, then known_hosts entries not * already in config), filtered by the reject list. * * **Sources, in precedence order:** * 1. `~/.ssh/config` — named (non-wildcard) `Host` aliases with full params. * 2. `~/.ssh/known_hosts` — hostnames you've connected to before, with * defaulted params (User=defaultUser, IdentityFile=null, Port from * `[host]:port` or 22). Not duplicated when already in config. * * Wildcard hosts (`*`, `?.example.com`) are NOT computers. The reject list * (glob patterns) filters the final set. Sorted by `alias`. * * `knownHost` reflects whether the resolved HostName appears in * `~/.ssh/known_hosts` (drives the FE "known/new" indicator). * * Pure: `SshConfigResolveEnv` → `readonly Computer[]`. */ export function resolveComputers(env: SshConfigResolveEnv): readonly Computer[] { const config = SSHConfig.parse(env.configText); const computers: Computer[] = []; // Source 1: ~/.ssh/config — full-param aliases. for (const line of config) { // Only `Host` sections define aliases; `Match`/standalone directives aren't // selectable computers. if (!isHostSection(line)) continue; const aliases = readAliasValues(line); for (const alias of aliases) { if (isWildcardAlias(alias)) continue; // patterns, not targets const computer = resolveOne(config, alias, env); if (computer !== null) computers.push(computer); } } const configAliases = new Set(computers.map((c) => c.alias)); // Source 2: ~/.ssh/known_hosts — discovered hostnames not already in config. for (const { hostname, port } of parseKnownHosts(env.knownHostsText)) { if (configAliases.has(hostname)) continue; // config takes precedence computers.push({ alias: hostname, hostName: hostname, port, user: env.defaultUser, identityFile: null, // pool probes default keys (~/.ssh/id_ed25519, etc.) knownHost: true, // it's in known_hosts by definition }); } // De-dup by alias (a host may be listed in multiple `Host` lines or appear // in both config + known_hosts; first wins), then sort for stable FE ordering. const seen = new Set(); const unique = computers.filter((c) => { if (seen.has(c.alias)) return false; seen.add(c.alias); return true; }); // Filter out rejected hostnames (glob patterns from dispatch.toml). const filtered = unique.filter((c) => !isRejected(c.alias, env.rejectPatterns)); filtered.sort((a, b) => (a.alias < b.alias ? -1 : a.alias > b.alias ? 1 : 0)); return filtered; } /** * Resolve a single alias to a `Computer` (or `null` when the alias isn't a * known computer). Checks `~/.ssh/config` first (full params), then * `~/.ssh/known_hosts` (defaulted params). Does NOT apply the reject list — * a specific lookup always resolves (reject is a discovery/catalog filter, * not access control). * Pure. `compute()` applies OpenSSH first-match-wins + wildcards. */ export function resolveComputer(alias: string, env: SshConfigResolveEnv): Computer | null { // Source 1: ~/.ssh/config. const config = SSHConfig.parse(env.configText); if (aliasExistsAsNamedHost(config, alias)) { return resolveOne(config, alias, env); } // Source 2: ~/.ssh/known_hosts (defaulted params). const knownHosts = parseKnownHosts(env.knownHostsText); const entry = knownHosts.find((h) => h.hostname === alias); if (entry !== undefined) { return { alias: entry.hostname, hostName: entry.hostname, port: entry.port, user: env.defaultUser, identityFile: null, knownHost: true, }; } return null; } /** Resolve one alias using a parsed config. Pure. */ function resolveOne(config: SSHConfig, alias: string, env: SshConfigResolveEnv): Computer | null { const computed = config.compute(alias); const hostName = stringValue(computed.HostName) ?? alias; // falls back to alias const port = numberValue(computed.Port) ?? 22; const user = stringValue(computed.User) ?? env.defaultUser; const identityFile = identityFileValue(computed.IdentityFile, env); // `knownHost` is keyed by the HostName (the actual connect target) — that is // what ssh2 connects to and what OpenSSH records in known_hosts. const knownHost = isKnownHost(env.knownHostsText, knownHostToken(hostName, port)); return { alias, hostName, port, user, identityFile, knownHost }; } // ─── ssh-config line helpers ────────────────────────────────────────────── function isHostSection(line: SSHConfig[number]): line is Section { return "param" in line && (line as Directive).param.toLowerCase() === "host"; } /** The alias values declared on a `Host` line (space-separated, may be quoted). */ function readAliasValues(section: Section): string[] { const value = section.value; if (typeof value === "string") return value.split(/\s+/).filter((s) => s.length > 0); // Quoted/structured value: array of { val } objects. if (Array.isArray(value)) { return value.map((v) => (typeof v === "string" ? v : v.val)).filter((s) => s.length > 0); } return []; } /** A `Host` alias is a selectable computer only if it contains no wildcard chars. */ function isWildcardAlias(alias: string): boolean { return alias.includes("*") || alias.includes("?"); } function aliasExistsAsNamedHost(config: SSHConfig, alias: string): boolean { for (const line of config) { if (!isHostSection(line)) continue; const aliases = readAliasValues(line); if (aliases.includes(alias) && !aliases.some(isWildcardAlias)) return true; } return false; } // ─── value coercion (ssh-config returns string | string[]) ──────────────── function stringValue(v: string | string[] | undefined): string | undefined { if (v === undefined) return undefined; return Array.isArray(v) ? v[0] : v; } function numberValue(v: string | string[] | undefined): number | undefined { const s = stringValue(v); if (s === undefined) return undefined; const n = Number.parseInt(s, 10); return Number.isNaN(n) ? undefined : n; } function identityFileValue( v: string | string[] | undefined, env: SshConfigResolveEnv, ): string | null { const raw = stringValue(v); if (raw === undefined) return null; // caller falls back to default probing return expandPath(raw, env.homeDir); } /** Expand a leading `~` to the home dir. (Other $VARs left to the shell.) */ function expandPath(p: string, homeDir: string): string { if (p === "~") return homeDir; if (p.startsWith("~/")) return `${homeDir}/${p.slice(2)}`; return p; } /** * The token used to key `known_hosts` for a host:port. Mirrors OpenSSH — a * non-default port is recorded as `[host]:port`; the default port (22) is just * `host`. Used both for the `knownHost` view and by the pool's host-verifier. */ export function knownHostToken(hostName: string, port: number): string { if (port === 22) return hostName; return `[${hostName}]:${port}`; } // ─── known_hosts discovery ───────────────────────────────────────────────── /** Find the index of the first space or tab, or -1 if none. */ function findSpace(line: string): number { for (let i = 0; i < line.length; i++) { const ch = line.charCodeAt(i); if (ch === 32 || ch === 9) return i; // space or tab } return -1; } /** A hostname + port extracted from a `~/.ssh/known_hosts` line. */ export interface KnownHostEntry { readonly hostname: string; readonly port: number; } /** * Parse `~/.ssh/known_hosts` and return one entry per unique hostname with * its port. Skips hashed entries (`|1|...` — can't reverse the hash), comment * lines, and entries with no parseable hostname. Deduplicates by hostname * (first port wins — so a host with both `host` and `[host]:2222` entries * keeps whichever appears first). * * Each known_hosts line is: `hostmarkers keytype key [comment]` * where `hostmarkers` is comma-separated, each marker being: * - `hostname` (e.g. `myserver`) → port 22 * - `[hostname]:port` (e.g. `[myserver]:2222`) * - `|1|hash|hash` (hashed — skipped) * * Pure: `knownHostsText` → `readonly KnownHostEntry[]`. */ export function parseKnownHosts(knownHostsText: string): readonly KnownHostEntry[] { const entries: KnownHostEntry[] = []; const seen = new Set(); for (const raw of knownHostsText.split("\n")) { const line = raw.trim(); if (line === "" || line.startsWith("#")) continue; // First whitespace-delimited field is the host markers (comma-list). const firstSpace = findSpace(line); const firstField = firstSpace === -1 ? line : line.slice(0, firstSpace); for (const marker of firstField.split(",")) { const trimmed = marker.trim(); if (trimmed === "" || trimmed.startsWith("|")) continue; // skip hashed let hostname: string; let port = 22; if (trimmed.startsWith("[")) { // [hostname]:port or [hostname] const bracketEnd = trimmed.indexOf("]"); if (bracketEnd === -1) continue; // malformed hostname = trimmed.slice(1, bracketEnd); const afterBracket = trimmed.slice(bracketEnd + 1); if (afterBracket.startsWith(":")) { const n = Number.parseInt(afterBracket.slice(1), 10); if (Number.isFinite(n) && n > 0) port = n; } } else { hostname = trimmed; } // Dedup by hostname — first port wins (a host with entries on // multiple ports gets one computer; use config for a specific port). if (seen.has(hostname)) continue; seen.add(hostname); entries.push({ hostname, port }); } } return entries; } // ─── reject-list glob matching ───────────────────────────────────────────── /** * Test whether a hostname should be rejected (hidden from the catalog). * Patterns support `*` (any chars) and `?` (single char), matching SSH's * own wildcard semantics. A bare hostname pattern matches exactly. * * Pure: `alias` + `patterns` → `boolean`. */ export function isRejected(alias: string, patterns?: readonly string[]): boolean { if (patterns === undefined || patterns.length === 0) return false; return patterns.some((p) => globMatch(p, alias)); } /** * Minimal glob matcher: `*` matches any sequence (including empty), `?` * matches a single character. Case-insensitive (hostnames are). All other * characters match literally. */ function globMatch(pattern: string, input: string): boolean { const p = pattern.toLowerCase(); const s = input.toLowerCase(); return globMatchImpl(p, 0, s, 0); } function globMatchImpl(p: string, pi: number, s: string, si: number): boolean { while (pi < p.length) { const pc = p[pi]; if (pc === "*") { // Skip consecutive * (they're equivalent to one). while (pi + 1 < p.length && p[pi + 1] === "*") pi++; // If * is the last char, match everything remaining. if (pi + 1 === p.length) return true; // Try to match the rest of the pattern at every position in s. for (let i = si; i <= s.length; i++) { if (globMatchImpl(p, pi + 1, s, i)) return true; } return false; } if (pc === "?") { if (si >= s.length) return false; pi++; si++; continue; } // Literal char. if (si >= s.length || p[pi] !== s[si]) return false; pi++; si++; } return si === s.length; }