@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 +21 -0
- package/package.json +1 -1
- package/skills/ssh-key-setup/SKILL.md +5 -0
- package/src/clients/ssh-client.ts +14 -1
- package/src/config.ts +1 -1
- package/src/doctor.ts +28 -0
- package/src/tunnels.ts +21 -7
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
|
@@ -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
|
|
273
|
-
//
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
332
|
+
await Promise.all(handles.map((handle) => teardown(handle).catch(() => undefined)));
|
|
319
333
|
running.clear();
|
|
320
334
|
return handles.length;
|
|
321
335
|
}
|