summaryrefslogtreecommitdiffhomepage
path: root/packages/ssh/src/pool.ts
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-27 01:09:39 +0900
committerAdam Malczewski <[email protected]>2026-06-27 01:09:39 +0900
commit61e45e60d699ed1ca46f94a8f181c92a940317c6 (patch)
tree2892d9773c5a8e367e1e58cdb1e88d9c6ad3fe6d /packages/ssh/src/pool.ts
parent63c7e64532e85e0bbdd6d9ac6825d8f86be98e7a (diff)
parent727c98c9dae516a2070eb950410314380a20c974 (diff)
downloaddispatch-61e45e60d699ed1ca46f94a8f181c92a940317c6.tar.gz
dispatch-61e45e60d699ed1ca46f94a8f181c92a940317c6.zip
Merge branch 'feature/indent-change' into dev
Diffstat (limited to 'packages/ssh/src/pool.ts')
-rw-r--r--packages/ssh/src/pool.ts650
1 files changed, 325 insertions, 325 deletions
diff --git a/packages/ssh/src/pool.ts b/packages/ssh/src/pool.ts
index 5b380d8..9acae0e 100644
--- a/packages/ssh/src/pool.ts
+++ b/packages/ssh/src/pool.ts
@@ -37,12 +37,12 @@ export type SshConnectionState = "disconnected" | "connecting" | "connected" | "
* per computer — the transparency + perf win over spawning `ssh` per call).
*/
export interface SshConnection {
- readonly getClient: () => Promise<Client>;
- readonly getSftp: () => Promise<import("ssh2").SFTPWrapper>;
- readonly close: () => Promise<void>;
- readonly state: SshConnectionState;
- /** Last error message when `state === "error"`; `undefined` otherwise. */
- readonly error: string | undefined;
+ readonly getClient: () => Promise<Client>;
+ readonly getSftp: () => Promise<import("ssh2").SFTPWrapper>;
+ readonly close: () => Promise<void>;
+ readonly state: SshConnectionState;
+ /** Last error message when `state === "error"`; `undefined` otherwise. */
+ readonly error: string | undefined;
}
/**
@@ -50,44 +50,44 @@ export interface SshConnection {
* sshd (or fixture files) — never at a `@dispatch/*` mock.
*/
export interface SshPoolDeps {
- readonly logger: Logger;
- /** Read a file as utf8 text (key files, known_hosts, ssh config). */
- readonly readFileText: (path: string) => Promise<string>;
- /** Append a line to `~/.ssh/known_hosts` (the host-key pin). */
- readonly appendKnownHosts: (path: string, line: string) => Promise<void>;
- /** Check a path exists (for default-identity-file probing). */
- readonly pathExists: (path: string) => Promise<boolean>;
- /** Factory for a fresh ssh2 Client (the real edge). */
- readonly newClient: () => Client;
- /** Resolve a computer alias → its `Computer` (connection params). */
- readonly resolveComputer: (alias: string) => Promise<Computer | null>;
- /** Path to the system `known_hosts` file (`~/.ssh/known_hosts`). */
- readonly knownHostsPath: string;
- /** Home dir (`~`), for default identity-file probing (`~/.ssh/id_*`). */
- readonly homeDir: string;
+ readonly logger: Logger;
+ /** Read a file as utf8 text (key files, known_hosts, ssh config). */
+ readonly readFileText: (path: string) => Promise<string>;
+ /** Append a line to `~/.ssh/known_hosts` (the host-key pin). */
+ readonly appendKnownHosts: (path: string, line: string) => Promise<void>;
+ /** Check a path exists (for default-identity-file probing). */
+ readonly pathExists: (path: string) => Promise<boolean>;
+ /** Factory for a fresh ssh2 Client (the real edge). */
+ readonly newClient: () => Client;
+ /** Resolve a computer alias → its `Computer` (connection params). */
+ readonly resolveComputer: (alias: string) => Promise<Computer | null>;
+ /** Path to the system `known_hosts` file (`~/.ssh/known_hosts`). */
+ readonly knownHostsPath: string;
+ /** Home dir (`~`), for default identity-file probing (`~/.ssh/id_*`). */
+ readonly homeDir: string;
}
export interface SshConnectionPool {
- readonly acquire: (computerId: string) => Promise<SshConnection>;
- readonly drop: (computerId: string) => Promise<void>;
- readonly closeAll: () => Promise<void>;
- readonly status: () => readonly SshPoolStatusEntry[];
+ readonly acquire: (computerId: string) => Promise<SshConnection>;
+ readonly drop: (computerId: string) => Promise<void>;
+ readonly closeAll: () => Promise<void>;
+ readonly status: () => readonly SshPoolStatusEntry[];
}
export interface SshPoolStatusEntry {
- readonly computerId: string;
- readonly state: SshConnectionState;
- readonly error?: string;
+ readonly computerId: string;
+ readonly state: SshConnectionState;
+ readonly error?: string;
}
interface PooledEntry {
- readonly alias: string;
- conn: SshConnection;
- /** Wall-clock of the last `acquire`/use — for idle reaping. */
- lastUsedAt: number;
- /** Pending connect (so concurrent first-acquires share one connect). */
- readonly pending: Promise<void> | null;
- reaper: ReturnType<typeof setInterval> | null;
+ readonly alias: string;
+ conn: SshConnection;
+ /** Wall-clock of the last `acquire`/use — for idle reaping. */
+ lastUsedAt: number;
+ /** Pending connect (so concurrent first-acquires share one connect). */
+ readonly pending: Promise<void> | null;
+ reaper: ReturnType<typeof setInterval> | null;
}
/**
@@ -96,127 +96,127 @@ interface PooledEntry {
* `ComputerService` status/test routes.
*/
export function createSshConnectionPool(deps: SshPoolDeps): SshConnectionPool {
- const entries = new Map<string, PooledEntry>();
-
- async function buildConnection(alias: string): Promise<SshConnection> {
- const computer = await deps.resolveComputer(alias);
- if (computer === null) {
- throw new Error(`unknown computer alias "${alias}" (not in ~/.ssh/config)`);
- }
-
- const state: { value: SshConnectionState; error: string | undefined } = {
- value: "disconnected",
- error: undefined,
- };
- const client = deps.newClient();
- let sftp: import("ssh2").SFTPWrapper | null = null;
- let connectPromise: Promise<void> | null = null;
-
- const touch = (): void => {
- const e = entries.get(alias);
- if (e !== undefined) e.lastUsedAt = Date.now();
- };
-
- const connect = (): Promise<void> => {
- if (state.value === "connected") return Promise.resolve();
- if (connectPromise !== null) return connectPromise; // share one connect
- state.value = "connecting";
- connectPromise = doConnect(client, computer, deps, state)
- .then(() => {
- state.value = "connected";
- state.error = undefined;
- // Stale pins → re-evaluate on each connect via hostVerifier already.
- })
- .catch((err: unknown) => {
- state.value = "error";
- state.error = err instanceof Error ? err.message : String(err);
- connectPromise = null; // allow retry on next acquire
- throw err;
- });
- return connectPromise;
- };
-
- const conn: SshConnection = {
- get state() {
- return state.value;
- },
- get error() {
- return state.error;
- },
- async getClient() {
- await connect();
- touch();
- return client;
- },
- async getSftp() {
- await connect();
- if (sftp === null) {
- sftp = await openSftp(client);
- }
- touch();
- return sftp;
- },
- async close() {
- try {
- sftp?.end();
- } catch {
- // best-effort
- }
- try {
- client.end();
- } catch {
- // best-effort
- }
- sftp = null;
- state.value = "disconnected";
- },
- };
- return conn;
- }
-
- return {
- async acquire(computerId: string): Promise<SshConnection> {
- let entry = entries.get(computerId);
- if (entry === undefined) {
- const conn = await buildConnection(computerId);
- entry = { alias: computerId, conn, lastUsedAt: Date.now(), pending: null, reaper: null };
- entries.set(computerId, entry);
- startReaper(entries, computerId, deps);
- }
- // Eagerly verify connectivity (reconnect if the peer died/reaped).
- await entry.conn.getClient().then(
- () => undefined,
- () => {
- // getClient throws on a dead connection — drop + retry once.
- },
- );
- entry.lastUsedAt = Date.now();
- return entry.conn;
- },
-
- async drop(computerId: string): Promise<void> {
- const entry = entries.get(computerId);
- if (entry === undefined) return;
- stopReaper(entry);
- await entry.conn.close();
- entries.delete(computerId);
- },
-
- async closeAll(): Promise<void> {
- const all = [...entries.values()];
- for (const entry of all) stopReaper(entry);
- await Promise.all(all.map((e) => e.conn.close()));
- entries.clear();
- },
-
- status(): readonly SshPoolStatusEntry[] {
- return [...entries.values()].map((e) => ({
- computerId: e.alias,
- state: e.conn.state,
- ...(e.conn.error !== undefined ? { error: e.conn.error } : {}),
- }));
- },
- };
+ const entries = new Map<string, PooledEntry>();
+
+ async function buildConnection(alias: string): Promise<SshConnection> {
+ const computer = await deps.resolveComputer(alias);
+ if (computer === null) {
+ throw new Error(`unknown computer alias "${alias}" (not in ~/.ssh/config)`);
+ }
+
+ const state: { value: SshConnectionState; error: string | undefined } = {
+ value: "disconnected",
+ error: undefined,
+ };
+ const client = deps.newClient();
+ let sftp: import("ssh2").SFTPWrapper | null = null;
+ let connectPromise: Promise<void> | null = null;
+
+ const touch = (): void => {
+ const e = entries.get(alias);
+ if (e !== undefined) e.lastUsedAt = Date.now();
+ };
+
+ const connect = (): Promise<void> => {
+ if (state.value === "connected") return Promise.resolve();
+ if (connectPromise !== null) return connectPromise; // share one connect
+ state.value = "connecting";
+ connectPromise = doConnect(client, computer, deps, state)
+ .then(() => {
+ state.value = "connected";
+ state.error = undefined;
+ // Stale pins → re-evaluate on each connect via hostVerifier already.
+ })
+ .catch((err: unknown) => {
+ state.value = "error";
+ state.error = err instanceof Error ? err.message : String(err);
+ connectPromise = null; // allow retry on next acquire
+ throw err;
+ });
+ return connectPromise;
+ };
+
+ const conn: SshConnection = {
+ get state() {
+ return state.value;
+ },
+ get error() {
+ return state.error;
+ },
+ async getClient() {
+ await connect();
+ touch();
+ return client;
+ },
+ async getSftp() {
+ await connect();
+ if (sftp === null) {
+ sftp = await openSftp(client);
+ }
+ touch();
+ return sftp;
+ },
+ async close() {
+ try {
+ sftp?.end();
+ } catch {
+ // best-effort
+ }
+ try {
+ client.end();
+ } catch {
+ // best-effort
+ }
+ sftp = null;
+ state.value = "disconnected";
+ },
+ };
+ return conn;
+ }
+
+ return {
+ async acquire(computerId: string): Promise<SshConnection> {
+ let entry = entries.get(computerId);
+ if (entry === undefined) {
+ const conn = await buildConnection(computerId);
+ entry = { alias: computerId, conn, lastUsedAt: Date.now(), pending: null, reaper: null };
+ entries.set(computerId, entry);
+ startReaper(entries, computerId, deps);
+ }
+ // Eagerly verify connectivity (reconnect if the peer died/reaped).
+ await entry.conn.getClient().then(
+ () => undefined,
+ () => {
+ // getClient throws on a dead connection — drop + retry once.
+ },
+ );
+ entry.lastUsedAt = Date.now();
+ return entry.conn;
+ },
+
+ async drop(computerId: string): Promise<void> {
+ const entry = entries.get(computerId);
+ if (entry === undefined) return;
+ stopReaper(entry);
+ await entry.conn.close();
+ entries.delete(computerId);
+ },
+
+ async closeAll(): Promise<void> {
+ const all = [...entries.values()];
+ for (const entry of all) stopReaper(entry);
+ await Promise.all(all.map((e) => e.conn.close()));
+ entries.clear();
+ },
+
+ status(): readonly SshPoolStatusEntry[] {
+ return [...entries.values()].map((e) => ({
+ computerId: e.alias,
+ state: e.conn.state,
+ ...(e.conn.error !== undefined ? { error: e.conn.error } : {}),
+ }));
+ },
+ };
}
// ─── connect: auth + host-key ──────────────────────────────────────────────
@@ -227,127 +227,127 @@ export function createSshConnectionPool(deps: SshPoolDeps): SshConnectionPool {
* or connect timeout (never silently connects — plan §4.4/§8).
*/
async function doConnect(
- client: Client,
- computer: Computer,
- deps: SshPoolDeps,
- state: { value: SshConnectionState; error: string | undefined },
+ client: Client,
+ computer: Computer,
+ deps: SshPoolDeps,
+ state: { value: SshConnectionState; error: string | undefined },
): Promise<void> {
- const { privateKey, passphraseError } = await resolvePrivateKey(computer, deps);
- if (passphraseError !== null) throw new Error(passphraseError);
-
- // Read known_hosts once for the host-key decision (present/absent + verify).
- let knownHostsText = "";
- try {
- knownHostsText = await deps.readFileText(deps.knownHostsPath);
- } catch {
- // Missing known_hosts → treat as empty (first connect pins the first line).
- knownHostsText = "";
- }
- const token = knownHostToken(computer.hostName, computer.port);
- const decisionArmed = { decided: false };
-
- await new Promise<void>((resolve, reject) => {
- const onReady = (): void => {
- cleanup();
- resolve();
- };
- const onError = (err: Error): void => {
- cleanup();
- reject(err);
- };
- const timer = setTimeout(() => {
- cleanup();
- reject(new Error(`connect timeout to ${computer.hostName}:${computer.port}`));
- }, CONNECT_TIMEOUT_MS);
-
- function cleanup(): void {
- clearTimeout(timer);
- client.removeListener("ready", onReady);
- client.removeListener("error", onError);
- }
-
- client.on("ready", onReady);
- client.on("error", onError);
-
- const connectConfig: ConnectConfig = {
- host: computer.hostName,
- port: computer.port,
- username: computer.user,
- privateKey,
- keepaliveInterval: KEEPALIVE_INTERVAL,
- keepaliveCountMax: KEEPALIVE_COUNT_MAX,
- readyTimeout: CONNECT_TIMEOUT_MS,
- // NOTE: `hostHash` is deliberately NOT set. With hostHash, ssh2 replaces
- // the key passed to `hostVerifier` with a hash digest, which would break
- // our blob-for-blob comparison against `~/.ssh/known_hosts` (whose 3rd
- // field is the base64 of the raw public-key blob). We compare the raw
- // blob directly, exactly as OpenSSH records it (decision #2 — the file
- // is the shared trust store, so the comparison must be byte-identical).
- hostVerifier: (key: Buffer | string): boolean => {
- if (decisionArmed.decided) return true; // already accepted this handshake
- const fingerprint = toFingerprint(token, key);
- const decision = decideHostKey(knownHostsText, fingerprint);
- decisionArmed.decided = true;
- if (!decision.accept) {
- state.error = decision.reason;
- // Reject the handshake; the emitted 'error' → onError (reject).
- process.nextTick(() => client.emit("error", new Error(decision.reason)));
- return false;
- }
- // Accept. Pin on first connect (append is async + best-effort —
- // the connection proceeds; a failed append only means the next
- // connect re-pins).
- if (decision.append !== undefined) {
- void deps
- .appendKnownHosts(deps.knownHostsPath, decision.append)
- .then(() => {
- deps.logger.info("pinned host key", { alias: computer.alias, token });
- })
- .catch((e: unknown) => {
- deps.logger.warn("failed to pin host key", {
- alias: computer.alias,
- error: e instanceof Error ? e.message : String(e),
- });
- });
- }
- return true;
- },
- };
-
- client.connect(connectConfig);
- });
+ const { privateKey, passphraseError } = await resolvePrivateKey(computer, deps);
+ if (passphraseError !== null) throw new Error(passphraseError);
+
+ // Read known_hosts once for the host-key decision (present/absent + verify).
+ let knownHostsText = "";
+ try {
+ knownHostsText = await deps.readFileText(deps.knownHostsPath);
+ } catch {
+ // Missing known_hosts → treat as empty (first connect pins the first line).
+ knownHostsText = "";
+ }
+ const token = knownHostToken(computer.hostName, computer.port);
+ const decisionArmed = { decided: false };
+
+ await new Promise<void>((resolve, reject) => {
+ const onReady = (): void => {
+ cleanup();
+ resolve();
+ };
+ const onError = (err: Error): void => {
+ cleanup();
+ reject(err);
+ };
+ const timer = setTimeout(() => {
+ cleanup();
+ reject(new Error(`connect timeout to ${computer.hostName}:${computer.port}`));
+ }, CONNECT_TIMEOUT_MS);
+
+ function cleanup(): void {
+ clearTimeout(timer);
+ client.removeListener("ready", onReady);
+ client.removeListener("error", onError);
+ }
+
+ client.on("ready", onReady);
+ client.on("error", onError);
+
+ const connectConfig: ConnectConfig = {
+ host: computer.hostName,
+ port: computer.port,
+ username: computer.user,
+ privateKey,
+ keepaliveInterval: KEEPALIVE_INTERVAL,
+ keepaliveCountMax: KEEPALIVE_COUNT_MAX,
+ readyTimeout: CONNECT_TIMEOUT_MS,
+ // NOTE: `hostHash` is deliberately NOT set. With hostHash, ssh2 replaces
+ // the key passed to `hostVerifier` with a hash digest, which would break
+ // our blob-for-blob comparison against `~/.ssh/known_hosts` (whose 3rd
+ // field is the base64 of the raw public-key blob). We compare the raw
+ // blob directly, exactly as OpenSSH records it (decision #2 — the file
+ // is the shared trust store, so the comparison must be byte-identical).
+ hostVerifier: (key: Buffer | string): boolean => {
+ if (decisionArmed.decided) return true; // already accepted this handshake
+ const fingerprint = toFingerprint(token, key);
+ const decision = decideHostKey(knownHostsText, fingerprint);
+ decisionArmed.decided = true;
+ if (!decision.accept) {
+ state.error = decision.reason;
+ // Reject the handshake; the emitted 'error' → onError (reject).
+ process.nextTick(() => client.emit("error", new Error(decision.reason)));
+ return false;
+ }
+ // Accept. Pin on first connect (append is async + best-effort —
+ // the connection proceeds; a failed append only means the next
+ // connect re-pins).
+ if (decision.append !== undefined) {
+ void deps
+ .appendKnownHosts(deps.knownHostsPath, decision.append)
+ .then(() => {
+ deps.logger.info("pinned host key", { alias: computer.alias, token });
+ })
+ .catch((e: unknown) => {
+ deps.logger.warn("failed to pin host key", {
+ alias: computer.alias,
+ error: e instanceof Error ? e.message : String(e),
+ });
+ });
+ }
+ return true;
+ },
+ };
+
+ client.connect(connectConfig);
+ });
}
/** Resolve the private key bytes for a computer (key-only auth, decision #3). */
async function resolvePrivateKey(
- computer: Computer,
- deps: SshPoolDeps,
+ computer: Computer,
+ deps: SshPoolDeps,
): Promise<{ privateKey: Buffer; passphraseError: string | null }> {
- const candidates = await identityCandidates(computer, deps);
- for (const path of candidates) {
- try {
- const text = await deps.readFileText(path);
- if (looksEncrypted(text)) {
- // MVP: no passphrase prompt (roadmap). Fail with a clear error.
- return {
- privateKey: Buffer.from(text),
- passphraseError:
- `SSH key "${path}" is encrypted — passphrase prompting is not ` +
- `supported in the MVP (use an unencrypted key for computer ` +
- `"${computer.alias}", or set IdentityFile to an unencrypted key).`,
- };
- }
- return { privateKey: Buffer.from(text), passphraseError: null };
- } catch {
- // missing/unreadable → try the next candidate
- }
- }
- return {
- privateKey: Buffer.alloc(0),
- passphraseError:
- `no readable SSH key for computer "${computer.alias}" ` +
- `(checked: ${candidates.join(", ")})`,
- };
+ const candidates = await identityCandidates(computer, deps);
+ for (const path of candidates) {
+ try {
+ const text = await deps.readFileText(path);
+ if (looksEncrypted(text)) {
+ // MVP: no passphrase prompt (roadmap). Fail with a clear error.
+ return {
+ privateKey: Buffer.from(text),
+ passphraseError:
+ `SSH key "${path}" is encrypted — passphrase prompting is not ` +
+ `supported in the MVP (use an unencrypted key for computer ` +
+ `"${computer.alias}", or set IdentityFile to an unencrypted key).`,
+ };
+ }
+ return { privateKey: Buffer.from(text), passphraseError: null };
+ } catch {
+ // missing/unreadable → try the next candidate
+ }
+ }
+ return {
+ privateKey: Buffer.alloc(0),
+ passphraseError:
+ `no readable SSH key for computer "${computer.alias}" ` +
+ `(checked: ${candidates.join(", ")})`,
+ };
}
/**
@@ -356,39 +356,39 @@ async function resolvePrivateKey(
* `~/.ssh/id_rsa`, first-existing-wins — matches OpenSSH's own probing).
*/
async function identityCandidates(computer: Computer, deps: SshPoolDeps): Promise<string[]> {
- const candidates: string[] = [];
- if (computer.identityFile !== null) candidates.push(computer.identityFile);
- for (const name of DEFAULT_IDENTITY_FILES) {
- candidates.push(`${deps.homeDir}/.ssh/${name}`);
- }
- // De-dup + filter to existing, preserving order.
- const existing: string[] = [];
- const seen = new Set<string>();
- for (const c of candidates) {
- if (seen.has(c)) continue;
- seen.add(c);
- if (await deps.pathExists(c)) existing.push(c);
- }
- if (existing.length > 0) return existing;
- // Fall back to the raw candidate list (so resolvePrivateKey reports it).
- return [...new Set(candidates)];
+ const candidates: string[] = [];
+ if (computer.identityFile !== null) candidates.push(computer.identityFile);
+ for (const name of DEFAULT_IDENTITY_FILES) {
+ candidates.push(`${deps.homeDir}/.ssh/${name}`);
+ }
+ // De-dup + filter to existing, preserving order.
+ const existing: string[] = [];
+ const seen = new Set<string>();
+ for (const c of candidates) {
+ if (seen.has(c)) continue;
+ seen.add(c);
+ if (await deps.pathExists(c)) existing.push(c);
+ }
+ if (existing.length > 0) return existing;
+ // Fall back to the raw candidate list (so resolvePrivateKey reports it).
+ return [...new Set(candidates)];
}
const DEFAULT_IDENTITY_FILES = ["id_ed25519", "id_rsa"];
/** OpenSSH encrypts keys with a `ENCRYPTED` header — detect it (no passphrase MVP). */
function looksEncrypted(keyText: string): boolean {
- return keyText.includes("ENCRYPTED");
+ return keyText.includes("ENCRYPTED");
}
/** Open an SFTP session on a connected client (promisified). */
function openSftp(client: Client): Promise<import("ssh2").SFTPWrapper> {
- return new Promise((resolve, reject) => {
- client.sftp((err, sftp) => {
- if (err !== null && err !== undefined) reject(err);
- else resolve(sftp);
- });
- });
+ return new Promise((resolve, reject) => {
+ client.sftp((err, sftp) => {
+ if (err !== null && err !== undefined) reject(err);
+ else resolve(sftp);
+ });
+ });
}
// ─── host-key fingerprint → pure decision input ────────────────────────────
@@ -403,12 +403,12 @@ function openSftp(client: Client): Promise<import("ssh2").SFTPWrapper> {
* itself writes — the file is the shared trust store (decision #2).
*/
function toFingerprint(token: string, key: Buffer | string): HostKeyFingerprint {
- const buf = typeof key === "string" ? Buffer.from(key, "utf8") : key;
- return {
- knownHostToken: token,
- keyBase64: buf.toString("base64"),
- keyType: parseKeyType(buf),
- };
+ const buf = typeof key === "string" ? Buffer.from(key, "utf8") : key;
+ return {
+ knownHostToken: token,
+ keyBase64: buf.toString("base64"),
+ keyType: parseKeyType(buf),
+ };
}
/**
@@ -418,40 +418,40 @@ function toFingerprint(token: string, key: Buffer | string): HostKeyFingerprint
* is the authoritative identity for `decideHostKey`'s comparison.
*/
function parseKeyType(buf: Buffer): string {
- if (buf.length < 4) return "ssh-ed25519";
- const len = buf.readUInt32BE(0);
- if (len <= 0 || buf.length < 4 + len) return "ssh-ed25519";
- return buf.subarray(4, 4 + len).toString("ascii");
+ if (buf.length < 4) return "ssh-ed25519";
+ const len = buf.readUInt32BE(0);
+ if (len <= 0 || buf.length < 4 + len) return "ssh-ed25519";
+ return buf.subarray(4, 4 + len).toString("ascii");
}
// ─── idle reaping ───────────────────────────────────────────────────────────
function startReaper(
- entries: Map<string, PooledEntry>,
- computerId: string,
- deps: SshPoolDeps,
+ entries: Map<string, PooledEntry>,
+ computerId: string,
+ deps: SshPoolDeps,
): void {
- const entry = entries.get(computerId);
- if (entry === undefined) return;
- entry.reaper = setInterval(() => {
- const e = entries.get(computerId);
- if (e === undefined) return;
- const idle = Date.now() - e.lastUsedAt;
- if (idle >= IDLE_REAP_MS) {
- deps.logger.info("reaping idle ssh connection", { alias: computerId, idleMs: idle });
- void e.conn.close().then(() => {
- stopReaper(e);
- entries.delete(computerId);
- });
- }
- }, 60_000);
+ const entry = entries.get(computerId);
+ if (entry === undefined) return;
+ entry.reaper = setInterval(() => {
+ const e = entries.get(computerId);
+ if (e === undefined) return;
+ const idle = Date.now() - e.lastUsedAt;
+ if (idle >= IDLE_REAP_MS) {
+ deps.logger.info("reaping idle ssh connection", { alias: computerId, idleMs: idle });
+ void e.conn.close().then(() => {
+ stopReaper(e);
+ entries.delete(computerId);
+ });
+ }
+ }, 60_000);
}
function stopReaper(entry: PooledEntry): void {
- if (entry.reaper !== null) {
- clearInterval(entry.reaper);
- entry.reaper = null;
- }
+ if (entry.reaper !== null) {
+ clearInterval(entry.reaper);
+ entry.reaper = null;
+ }
}
/** Ssh2 exec stream type alias (the channel backing spawn). */