@patimweb/pi-ssh 1.1.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -216,8 +216,21 @@ ssh_tunnel: { action: "stop-all" }
216
216
 
217
217
  `bind` defaults to `127.0.0.1`, which means only this machine (or only the server, for a remote tunnel) can use the tunnel. Setting it to `0.0.0.0` publishes the forwarded service to the whole network — the tool output says so when you do. For a remote tunnel, binding anything but loopback additionally needs `GatewayPorts` enabled in the server's `sshd_config`; without it sshd silently binds loopback instead.
218
218
 
219
+ #### How long it stays up
220
+
221
+ A tunnel holds its own SSH connection open and keeps listening after the tool call returns. It ends when:
222
+
223
+ - you stop it (`action: stop` or `stop-all`),
224
+ - its `durationSeconds` expires,
225
+ - the underlying SSH connection drops, or
226
+ - the pi session ends.
227
+
219
228
  Each tunnel owns its own SSH connection. Sharing one would be tidier on the wire, but a single dropped connection would take every tunnel down with it.
220
229
 
230
+ SSH-level keepalives are enabled (every 15s, four unanswered probes before giving up). ssh2 sends none by default, and without them an idle tunnel behind a NAT or a stateful firewall keeps looking alive long after the path has been dropped. With them, a dead connection is noticed within about a minute, the local listener is closed, and the tunnel disappears from `ssh_tunnel list` — rather than accepting connections that silently go nowhere.
231
+
232
+ There is no automatic reconnect: a tunnel that dies stays dead and has to be started again.
233
+
221
234
  ## Commands
222
235
 
223
236
  | Command | Description |
@@ -262,6 +275,14 @@ Profiles live in `~/.pi/ssh-config.json`, written atomically with mode `0600` be
262
275
 
263
276
  Every tool also takes a one-off `profile` parameter, so several hosts can be used in one session without switching.
264
277
 
278
+ ## Platform support
279
+
280
+ Windows, macOS and Linux behave the same. Nothing in this package spawns a process — SSH comes from [ssh2](https://github.com/mscdex/ssh2) in pure JavaScript, keys are generated with Node's crypto, and there are no POSIX-only paths. A test asserts all three, so a change that introduces one fails the suite rather than only failing on someone else's machine.
281
+
282
+ One difference is real and worth knowing: **file permissions are not enforced on Windows.** The config file and private keys are written with mode `0600`, but Windows governs access through ACLs and `chmod` only toggles the read-only bit. `ssh_doctor` reports this as a note on Windows rather than staying quiet about it. The practical answer is the same as everywhere: let the first connection replace the stored password with a key, so the file stops holding a secret at all.
283
+
284
+ The end-to-end tests need an `sshd` to run against and skip themselves where there is none, which includes Windows.
285
+
265
286
  ## Development
266
287
 
267
288
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@patimweb/pi-ssh",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "SSH client extension for the pi coding agent. Run commands, transfer files over SFTP, and set up key-based login on remote hosts.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -49,6 +49,11 @@ run `ssh_doctor` and show them the report rather than guessing.
49
49
  The keys produced are ordinary OpenSSH ed25519 keys, so `ssh -i` and any other
50
50
  SSH client can use the same file.
51
51
 
52
+ On Windows there is one caveat worth passing on: the owner-only file modes
53
+ this extension sets are not enforced there, because access is governed by
54
+ ACLs. That makes replacing a stored password with a key more valuable, not
55
+ less. `ssh_doctor` says so on that platform.
56
+
52
57
  ## Things worth getting right
53
58
 
54
59
  - **Never overwrite an existing key.** Every host that already trusts it would
@@ -40,6 +40,17 @@ import {
40
40
  import { expandPath } from "../config.ts";
41
41
 
42
42
  const DEFAULT_CONNECT_TIMEOUT_MS = 20_000;
43
+ /**
44
+ * SSH-level keepalives.
45
+ *
46
+ * ssh2 sends none by default. A connection that carries a tunnel can sit idle
47
+ * for a long time, and anything doing NAT or stateful filtering in between
48
+ * will eventually drop an idle flow without telling either end -- the tunnel
49
+ * then looks alive and silently is not. Four unanswered probes at 15s means a
50
+ * dead connection is noticed within about a minute and closed properly.
51
+ */
52
+ const KEEPALIVE_INTERVAL_MS = 15_000;
53
+ const KEEPALIVE_COUNT_MAX = 4;
43
54
  const DEFAULT_EXEC_TIMEOUT_MS = 120_000;
44
55
  /** Enough to be useful, small enough not to swamp a context window. */
45
56
  const MAX_OUTPUT_CHARS = 200_000;
@@ -71,7 +82,7 @@ function readPrivateKey(profile: SshProfile): Buffer | null {
71
82
  }
72
83
 
73
84
  /** The authentication methods to offer, in the order they should be tried. */
74
- function buildAuthMethods(
85
+ export function buildAuthMethods(
75
86
  profile: SshProfile,
76
87
  only: ConnectOptions["only"],
77
88
  ): Array<{ type: string; label: string; key?: Buffer; passphrase?: string; password?: string }> {
@@ -143,6 +154,8 @@ export function connect(
143
154
  port: profile.port,
144
155
  username: profile.user,
145
156
  readyTimeout: profile.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS,
157
+ keepaliveInterval: KEEPALIVE_INTERVAL_MS,
158
+ keepaliveCountMax: KEEPALIVE_COUNT_MAX,
146
159
 
147
160
  hostVerifier: (key: Buffer, verify: (ok: boolean) => void) => {
148
161
  const check = checkHostKey(profile.host, profile.port, key, knownHostsFile);
package/src/config.ts CHANGED
@@ -18,7 +18,7 @@ let profiles: Record<string, SshProfile> = {};
18
18
  let activeProfile: string | null = null;
19
19
 
20
20
  function configPath(): string {
21
- const home = process.env.HOME || process.env.USERPROFILE || "~";
21
+ const home = process.env.HOME || process.env.USERPROFILE || os.homedir();
22
22
  return path.join(home, ".pi", "ssh-config.json");
23
23
  }
24
24
 
package/src/doctor.ts CHANGED
@@ -25,6 +25,33 @@ export interface Check {
25
25
  readonly remedy?: string;
26
26
  }
27
27
 
28
+ /**
29
+ * Whether file permissions mean anything on this platform.
30
+ *
31
+ * The config file can hold a password and the private keys are secrets, so
32
+ * they are written 0600. On Windows chmod only toggles the read-only bit and
33
+ * access is governed by ACLs instead, so those modes are not enforced. Saying
34
+ * nothing would be the wrong kind of quiet: the situation there is weaker,
35
+ * not stronger.
36
+ */
37
+ export function describePermissionSupport(platform: string): Check {
38
+ if (platform !== "win32") {
39
+ return {
40
+ name: "File permissions",
41
+ status: "ok",
42
+ detail: "secrets are written owner-only (0600) and that is enforced",
43
+ };
44
+ }
45
+ return {
46
+ name: "File permissions",
47
+ status: "note",
48
+ detail:
49
+ "Windows ignores the owner-only modes this extension sets; access is governed by ACLs instead",
50
+ remedy:
51
+ "~/.pi/ssh-config.json can hold a password and ~/.ssh holds private keys. Make sure your user profile directory is not shared, or let ssh_authorize replace the password with a key.",
52
+ };
53
+ }
54
+
28
55
  function checkNodeVersion(): Check {
29
56
  const major = Number.parseInt(process.versions.node.split(".")[0], 10);
30
57
  return major >= 20
@@ -189,6 +216,7 @@ export function runChecks(): Check[] {
189
216
  checkSsh2(),
190
217
  checkKeyGeneration(),
191
218
  checkOpenSshClient(),
219
+ describePermissionSupport(process.platform),
192
220
  checkSshDirectory(),
193
221
  checkKnownHosts(),
194
222
  checkConfigDirectory(),
package/src/tunnels.ts CHANGED
@@ -29,6 +29,7 @@ interface TunnelHandle {
29
29
  listenAddress: string;
30
30
  connections: number;
31
31
  expiresAt?: Date;
32
+ stopped: boolean;
32
33
  stop(): Promise<void>;
33
34
  }
34
35
 
@@ -253,6 +254,7 @@ export async function startTunnel(options: StartTunnelOptions): Promise<RunningT
253
254
  startedAt: new Date(),
254
255
  listenAddress: "",
255
256
  connections: 0,
257
+ stopped: false,
256
258
  stop: async () => {
257
259
  connection.client.end();
258
260
  running.delete(id);
@@ -269,16 +271,18 @@ export async function startTunnel(options: StartTunnelOptions): Promise<RunningT
269
271
  throw err;
270
272
  }
271
273
 
272
- // A dropped SSH connection means the tunnel is dead; do not leave a stale
273
- // entry claiming otherwise.
274
+ // A dropped SSH connection means the tunnel is dead. Removing it from the
275
+ // registry is not enough: the local listener would keep accepting
276
+ // connections that can no longer go anywhere, so it has to be torn down
277
+ // too.
274
278
  connection.client.on("close", () => {
275
- running.delete(id);
279
+ void teardown(handle);
276
280
  });
277
281
 
278
282
  if (options.durationSeconds && options.durationSeconds > 0) {
279
283
  handle.expiresAt = new Date(Date.now() + options.durationSeconds * 1000);
280
284
  const timer = setTimeout(() => {
281
- void handle.stop();
285
+ void teardown(handle);
282
286
  }, options.durationSeconds * 1000);
283
287
  timer.unref?.();
284
288
  }
@@ -287,6 +291,17 @@ export async function startTunnel(options: StartTunnelOptions): Promise<RunningT
287
291
  return describe(handle);
288
292
  }
289
293
 
294
+ /** Stop a tunnel once, whether the caller asked or the connection died. */
295
+ async function teardown(handle: TunnelHandle): Promise<void> {
296
+ if (handle.stopped) return;
297
+ handle.stopped = true;
298
+ try {
299
+ await handle.stop();
300
+ } finally {
301
+ running.delete(handle.id);
302
+ }
303
+ }
304
+
290
305
  function describe(handle: TunnelHandle): RunningTunnel {
291
306
  return {
292
307
  id: handle.id,
@@ -307,15 +322,14 @@ export function listTunnels(): RunningTunnel[] {
307
322
  export async function stopTunnel(profileName: string, name: string): Promise<boolean> {
308
323
  const handle = running.get(tunnelId(profileName, name));
309
324
  if (!handle) return false;
310
- await handle.stop();
311
- running.delete(handle.id);
325
+ await teardown(handle);
312
326
  return true;
313
327
  }
314
328
 
315
329
  /** Close everything. Called when the pi session ends. */
316
330
  export async function stopAllTunnels(): Promise<number> {
317
331
  const handles = [...running.values()];
318
- await Promise.all(handles.map((handle) => handle.stop().catch(() => undefined)));
332
+ await Promise.all(handles.map((handle) => teardown(handle).catch(() => undefined)));
319
333
  running.clear();
320
334
  return handles.length;
321
335
  }