@patimweb/pi-ssh 1.0.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 +154 -2
- package/index.ts +10 -0
- package/package.json +1 -1
- package/skills/ssh-key-setup/SKILL.md +21 -1
- package/skills/ssh-remote-work/SKILL.md +15 -0
- package/src/auto-key.ts +83 -0
- package/src/clients/ssh-client.ts +14 -1
- package/src/config.ts +1 -1
- package/src/doctor.ts +28 -0
- package/src/formatting/formatters.ts +76 -0
- package/src/tools/shared.ts +33 -0
- package/src/tools/ssh-download.ts +6 -3
- package/src/tools/ssh-exec.ts +6 -3
- package/src/tools/ssh-list.ts +8 -3
- package/src/tools/ssh-setup.ts +14 -3
- package/src/tools/ssh-status.ts +15 -5
- package/src/tools/ssh-tunnel.ts +251 -0
- package/src/tools/ssh-upload.ts +6 -3
- package/src/tunnels.ts +340 -0
- package/src/types.ts +55 -0
package/README.md
CHANGED
|
@@ -31,7 +31,23 @@ ssh_exec:
|
|
|
31
31
|
command: systemctl status nginx --no-pager
|
|
32
32
|
```
|
|
33
33
|
|
|
34
|
-
|
|
34
|
+
The password is only used once. On the **first connection** the extension installs a key on the host and removes the password from the config — see below.
|
|
35
|
+
|
|
36
|
+
## The password is not kept
|
|
37
|
+
|
|
38
|
+
A password in a config file stays there for as long as the profile does. So the first time a password-only profile is actually used — the first `ssh_exec`, `ssh_list`, tunnel, anything that connects — the extension:
|
|
39
|
+
|
|
40
|
+
1. generates an ed25519 key,
|
|
41
|
+
2. installs its public key in the remote `~/.ssh/authorized_keys`,
|
|
42
|
+
3. opens a second connection using **only** the key to prove it works,
|
|
43
|
+
4. writes the key path into the profile and **deletes the stored password**,
|
|
44
|
+
5. and then does what you actually asked for.
|
|
45
|
+
|
|
46
|
+
The tool output says so when this happened, including the fingerprint and where the key was written.
|
|
47
|
+
|
|
48
|
+
If the upgrade fails — a host with `PubkeyAuthentication no`, an unwritable home directory — the password is kept and the work continues, with the reason in the output. The upgrade is attempted, not enforced: a server that refuses keys would otherwise become unusable.
|
|
49
|
+
|
|
50
|
+
To keep using a password, set `autoKey: false` in `ssh_setup`. `ssh_authorize` then remains available to do the switch by hand.
|
|
35
51
|
|
|
36
52
|
## Tools
|
|
37
53
|
|
|
@@ -47,6 +63,7 @@ After `ssh_authorize` the password is no longer needed. It stays in the profile
|
|
|
47
63
|
| `ssh_keygen` | Create an ed25519 key pair in process. |
|
|
48
64
|
| `ssh_authorize` | Install a key on a host and stop needing the password. |
|
|
49
65
|
| `ssh_doctor` | Report what this machine can do and what needs fixing. |
|
|
66
|
+
| `ssh_tunnel` | Open, close and list port forwards, and store named ones in a profile. |
|
|
50
67
|
|
|
51
68
|
### Passwordless login
|
|
52
69
|
|
|
@@ -87,6 +104,133 @@ ssh_upload: { localPath: ./dist/app.tar.gz, remotePath: /tmp/app.tar.gz }
|
|
|
87
104
|
|
|
88
105
|
Missing local directories are created on download. Prefer these over `cat` through `ssh_exec`: SFTP handles binary content and does not push the file through the model.
|
|
89
106
|
|
|
107
|
+
### Tunnels
|
|
108
|
+
|
|
109
|
+
A tunnel is the one thing here that keeps running after its tool call returns — that is what a tunnel is for. It stays up until it is stopped, its time limit expires, or the pi session ends, at which point all of them are closed.
|
|
110
|
+
|
|
111
|
+
There are two directions, and the difference is whose machine each side refers to:
|
|
112
|
+
|
|
113
|
+
| Kind | Who listens | Who reaches the destination | OpenSSH equivalent |
|
|
114
|
+
|------|-------------|-----------------------------|--------------------|
|
|
115
|
+
| `local` | this machine, on `bind:listenPort` | the server, to `destHost:destPort` | `ssh -L` |
|
|
116
|
+
| `remote` | the server, on `bind:listenPort` | this machine, to `destHost:destPort` | `ssh -R` |
|
|
117
|
+
|
|
118
|
+
**Local** is the common case: reach a database, admin interface or internal service that only the server can see.
|
|
119
|
+
|
|
120
|
+
```yaml
|
|
121
|
+
ssh_tunnel:
|
|
122
|
+
action: start
|
|
123
|
+
name: db
|
|
124
|
+
kind: local
|
|
125
|
+
listenPort: 5432 # 0 picks a free port
|
|
126
|
+
destHost: db.internal # resolved from the server
|
|
127
|
+
destPort: 5432
|
|
128
|
+
# durationSeconds: 3600 # close automatically after an hour
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
Then connect to `127.0.0.1:5432` on this machine as if the database were local.
|
|
132
|
+
|
|
133
|
+
**Remote** goes the other way: make something running here reachable from the server.
|
|
134
|
+
|
|
135
|
+
```yaml
|
|
136
|
+
ssh_tunnel:
|
|
137
|
+
action: start
|
|
138
|
+
name: preview
|
|
139
|
+
kind: remote
|
|
140
|
+
listenPort: 8080 # the server listens on this
|
|
141
|
+
destHost: 127.0.0.1 # resolved from this machine
|
|
142
|
+
destPort: 3000
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
#### Named tunnels in the profile
|
|
146
|
+
|
|
147
|
+
Rather than repeating ports, store a tunnel under a name and start it by that name later. Definitions live in the profile in `~/.pi/ssh-config.json`.
|
|
148
|
+
|
|
149
|
+
```yaml
|
|
150
|
+
ssh_tunnel:
|
|
151
|
+
action: define
|
|
152
|
+
name: db
|
|
153
|
+
kind: local
|
|
154
|
+
listenPort: 5432
|
|
155
|
+
destHost: db.internal
|
|
156
|
+
destPort: 5432
|
|
157
|
+
description: production database, read replica
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
```yaml
|
|
161
|
+
ssh_tunnel: { action: start, name: db } # everything else comes from the profile
|
|
162
|
+
ssh_tunnel: { action: stop, name: db }
|
|
163
|
+
ssh_tunnel: { action: forget, name: db } # remove the definition
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
The stored form is plain JSON, so it can be written by hand too:
|
|
167
|
+
|
|
168
|
+
```json
|
|
169
|
+
{
|
|
170
|
+
"profiles": {
|
|
171
|
+
"staging": {
|
|
172
|
+
"host": "staging.example.com",
|
|
173
|
+
"port": 22,
|
|
174
|
+
"user": "deploy",
|
|
175
|
+
"privateKeyPath": "/home/pat/.ssh/id_ed25519_pi_staging",
|
|
176
|
+
"tunnels": {
|
|
177
|
+
"db": {
|
|
178
|
+
"kind": "local",
|
|
179
|
+
"listenPort": 5432,
|
|
180
|
+
"bind": "127.0.0.1",
|
|
181
|
+
"destHost": "db.internal",
|
|
182
|
+
"destPort": 5432,
|
|
183
|
+
"description": "production database, read replica"
|
|
184
|
+
},
|
|
185
|
+
"preview": {
|
|
186
|
+
"kind": "remote",
|
|
187
|
+
"listenPort": 8080,
|
|
188
|
+
"destHost": "127.0.0.1",
|
|
189
|
+
"destPort": 3000
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
},
|
|
194
|
+
"activeProfile": "staging"
|
|
195
|
+
}
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
| Field | Meaning |
|
|
199
|
+
|-------|---------|
|
|
200
|
+
| `kind` | `local` or `remote`, per the table above. |
|
|
201
|
+
| `listenPort` | Port the tunnel accepts connections on. `0` picks a free one. |
|
|
202
|
+
| `bind` | Interface that port binds to. Defaults to `127.0.0.1`. |
|
|
203
|
+
| `destHost` / `destPort` | Where traffic is delivered. |
|
|
204
|
+
| `description` | Free text, shown in listings. |
|
|
205
|
+
|
|
206
|
+
#### Seeing and stopping them
|
|
207
|
+
|
|
208
|
+
```yaml
|
|
209
|
+
ssh_tunnel: { action: list } # running tunnels and stored definitions
|
|
210
|
+
ssh_tunnel: { action: "stop-all" }
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
`ssh_status` also lists anything currently running, so a forgotten tunnel does not stay invisible.
|
|
214
|
+
|
|
215
|
+
#### Binding to something other than loopback
|
|
216
|
+
|
|
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
|
+
|
|
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
|
+
|
|
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.
|
|
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
|
+
|
|
90
234
|
## Commands
|
|
91
235
|
|
|
92
236
|
| Command | Description |
|
|
@@ -131,6 +275,14 @@ Profiles live in `~/.pi/ssh-config.json`, written atomically with mode `0600` be
|
|
|
131
275
|
|
|
132
276
|
Every tool also takes a one-off `profile` parameter, so several hosts can be used in one session without switching.
|
|
133
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
|
+
|
|
134
286
|
## Development
|
|
135
287
|
|
|
136
288
|
```bash
|
|
@@ -139,7 +291,7 @@ npm test
|
|
|
139
291
|
npm run test:coverage
|
|
140
292
|
```
|
|
141
293
|
|
|
142
|
-
The suite runs end-to-end against a real OpenSSH server: it starts `sshd` on a loopback port with a host key generated by this package, then exercises the handshake, host key verification (including a simulated key change), exit codes, SFTP,
|
|
294
|
+
The suite runs end-to-end against a real OpenSSH server: it starts `sshd` on a loopback port with a host key generated by this package, then exercises the handshake, host key verification (including a simulated key change), exit codes, SFTP, the full key bootstrap, and both tunnel directions with real traffic flowing through them. Those tests skip themselves on machines without `sshd` rather than failing.
|
|
143
295
|
|
|
144
296
|
## License
|
|
145
297
|
|
package/index.ts
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
* - ssh_keygen: Create an ed25519 key pair in process
|
|
16
16
|
* - ssh_authorize: Install a key on a host and stop needing the password
|
|
17
17
|
* - ssh_doctor: Report what the environment can do and what needs fixing
|
|
18
|
+
* - ssh_tunnel: Open, close and list port forwards, and store named ones
|
|
18
19
|
*
|
|
19
20
|
* Nothing here shells out: ssh2 is a pure JavaScript SSH implementation and
|
|
20
21
|
* keys are generated with Node's own crypto, so Windows, macOS and Linux all
|
|
@@ -40,6 +41,8 @@ import { SshDownloadTool } from "./src/tools/ssh-download.ts";
|
|
|
40
41
|
import { SshKeygenTool } from "./src/tools/ssh-keygen.ts";
|
|
41
42
|
import { SshAuthorizeTool } from "./src/tools/ssh-authorize.ts";
|
|
42
43
|
import { SshDoctorTool } from "./src/tools/ssh-doctor.ts";
|
|
44
|
+
import { SshTunnelTool } from "./src/tools/ssh-tunnel.ts";
|
|
45
|
+
import { stopAllTunnels } from "./src/tunnels.ts";
|
|
43
46
|
|
|
44
47
|
export default function (pi: ExtensionAPI) {
|
|
45
48
|
// Load saved hosts on startup
|
|
@@ -56,6 +59,13 @@ export default function (pi: ExtensionAPI) {
|
|
|
56
59
|
pi.registerTool(SshKeygenTool);
|
|
57
60
|
pi.registerTool(SshAuthorizeTool);
|
|
58
61
|
pi.registerTool(SshDoctorTool);
|
|
62
|
+
pi.registerTool(SshTunnelTool);
|
|
63
|
+
|
|
64
|
+
// A tunnel is the one thing here that outlives its tool call, so it must
|
|
65
|
+
// not outlive the session that opened it.
|
|
66
|
+
pi.on("session_shutdown", async () => {
|
|
67
|
+
await stopAllTunnels();
|
|
68
|
+
});
|
|
59
69
|
|
|
60
70
|
// Run something on the active host without spelling out the tool call
|
|
61
71
|
pi.registerCommand("ssh", {
|
package/package.json
CHANGED
|
@@ -9,7 +9,22 @@ allowed-tools: ssh_setup, ssh_status, ssh_keygen, ssh_authorize, ssh_exec, ssh_d
|
|
|
9
9
|
A password in a config file is a password on disk, and typing one into every
|
|
10
10
|
session is friction. A key fixes both. The whole switch is one tool call.
|
|
11
11
|
|
|
12
|
-
##
|
|
12
|
+
## It usually happens by itself
|
|
13
|
+
|
|
14
|
+
A profile configured with only a password upgrades itself on first use: the
|
|
15
|
+
first tool that connects installs a key, verifies it, and **removes the
|
|
16
|
+
password from the config**. Nothing needs to be called for this, and the tool
|
|
17
|
+
output reports it. Say so when relaying that output - the user should know
|
|
18
|
+
their password is no longer stored and where the key went.
|
|
19
|
+
|
|
20
|
+
If the upgrade failed, the note explains why and the password is still in
|
|
21
|
+
place. That is worth surfacing rather than glossing over: it means the host
|
|
22
|
+
refused key authentication, and the password is still on disk.
|
|
23
|
+
|
|
24
|
+
`autoKey: false` on `ssh_setup` turns the automatic switch off for hosts where
|
|
25
|
+
it is not wanted.
|
|
26
|
+
|
|
27
|
+
## Doing it by hand
|
|
13
28
|
|
|
14
29
|
1. `ssh_setup` with host, user and the password.
|
|
15
30
|
2. `ssh_authorize` on that profile.
|
|
@@ -34,6 +49,11 @@ run `ssh_doctor` and show them the report rather than guessing.
|
|
|
34
49
|
The keys produced are ordinary OpenSSH ed25519 keys, so `ssh -i` and any other
|
|
35
50
|
SSH client can use the same file.
|
|
36
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
|
+
|
|
37
57
|
## Things worth getting right
|
|
38
58
|
|
|
39
59
|
- **Never overwrite an existing key.** Every host that already trusts it would
|
|
@@ -74,6 +74,21 @@ The error usually says which layer failed. Work from it rather than retrying:
|
|
|
74
74
|
- Something about the local environment - run `ssh_doctor`, which reports what
|
|
75
75
|
is missing and what to do about it.
|
|
76
76
|
|
|
77
|
+
## Tunnels
|
|
78
|
+
|
|
79
|
+
`ssh_tunnel` is the one tool here whose effect outlives the call: a forward
|
|
80
|
+
keeps running until stopped, its time limit expires, or the session ends.
|
|
81
|
+
|
|
82
|
+
- Start one only when something actually needs it, and tell the user it is
|
|
83
|
+
running and how to reach it.
|
|
84
|
+
- `ssh_tunnel action list`, and `ssh_status`, show what is open. Check there
|
|
85
|
+
before starting another one with the same purpose.
|
|
86
|
+
- Stop tunnels when the work that needed them is done rather than leaving them
|
|
87
|
+
open for the rest of the session.
|
|
88
|
+
- `bind` defaults to loopback. Do not set it to `0.0.0.0` unless the user asked
|
|
89
|
+
for the service to be reachable from other machines, and say plainly what
|
|
90
|
+
that exposes when you do.
|
|
91
|
+
|
|
77
92
|
## What to report back
|
|
78
93
|
|
|
79
94
|
Give the user the command's actual output and its exit code. When a command
|
package/src/auto-key.ts
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* First contact upgrades a password login to a key login.
|
|
3
|
+
*
|
|
4
|
+
* A password sitting in a config file is a password on disk, and it stays
|
|
5
|
+
* there for as long as the profile exists. So the first time a profile that
|
|
6
|
+
* has only a password is actually used, the key bootstrap runs first and the
|
|
7
|
+
* password is replaced by the key it just installed.
|
|
8
|
+
*
|
|
9
|
+
* The upgrade is attempted, not enforced: a host that refuses public key
|
|
10
|
+
* authentication entirely would otherwise become unusable. When it fails the
|
|
11
|
+
* password is kept and the caller is told, rather than the work being blocked.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { SshProfile } from "./types.ts";
|
|
15
|
+
import { authorizeKey } from "./authorize.ts";
|
|
16
|
+
import { getProfile } from "./config.ts";
|
|
17
|
+
|
|
18
|
+
export interface UpgradeOutcome {
|
|
19
|
+
/** The profile to actually connect with. */
|
|
20
|
+
readonly profile: SshProfile;
|
|
21
|
+
/** Set when an upgrade was attempted, whether or not it worked. */
|
|
22
|
+
readonly note?: string;
|
|
23
|
+
readonly upgraded: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Does this profile still log in with a password and nothing else? */
|
|
27
|
+
export function needsKeyUpgrade(profile: SshProfile): boolean {
|
|
28
|
+
if (profile.autoKey === false) return false;
|
|
29
|
+
return Boolean(profile.password) && !profile.privateKeyPath;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface UpgradeOptions {
|
|
33
|
+
readonly acceptNewHostKey?: boolean;
|
|
34
|
+
readonly signal?: AbortSignal;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Install a key and drop the password, then return the profile to connect
|
|
39
|
+
* with. A profile that already has a key, or has opted out, is returned
|
|
40
|
+
* untouched without opening any connection.
|
|
41
|
+
*/
|
|
42
|
+
export async function ensureKeyAuthentication(
|
|
43
|
+
profileName: string,
|
|
44
|
+
profile: SshProfile,
|
|
45
|
+
options: UpgradeOptions = {},
|
|
46
|
+
): Promise<UpgradeOutcome> {
|
|
47
|
+
if (!needsKeyUpgrade(profile)) {
|
|
48
|
+
return { profile, upgraded: false };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
try {
|
|
52
|
+
const result = await authorizeKey({
|
|
53
|
+
profileName,
|
|
54
|
+
profile,
|
|
55
|
+
acceptNewHostKey: options.acceptNewHostKey,
|
|
56
|
+
signal: options.signal,
|
|
57
|
+
// The point of the upgrade is that the password stops being stored.
|
|
58
|
+
removePassword: true,
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
if (!result.verified) {
|
|
62
|
+
return {
|
|
63
|
+
profile,
|
|
64
|
+
upgraded: false,
|
|
65
|
+
note: `A key was installed on ${profile.host}, but a key-only login could not be verified, so the password is still being used. Fingerprint: ${result.fingerprint}`,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// authorizeKey rewrote the stored profile; use what is on disk now.
|
|
70
|
+
const updated = getProfile(profileName) ?? profile;
|
|
71
|
+
return {
|
|
72
|
+
profile: updated,
|
|
73
|
+
upgraded: true,
|
|
74
|
+
note: `First connection to ${profile.host}: installed a key (${result.fingerprint}) at ${result.keyPath} and removed the stored password. Logins from now on use the key.`,
|
|
75
|
+
};
|
|
76
|
+
} catch (err) {
|
|
77
|
+
return {
|
|
78
|
+
profile,
|
|
79
|
+
upgraded: false,
|
|
80
|
+
note: `Could not switch ${profile.host} to key authentication (${(err as Error).message}). Continuing with the password.`,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
}
|
|
@@ -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(),
|
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
|
|
8
8
|
import type {
|
|
9
9
|
AuthorizeResult,
|
|
10
|
+
RunningTunnel,
|
|
11
|
+
TunnelDefinition,
|
|
10
12
|
ExecResult,
|
|
11
13
|
RemoteEntry,
|
|
12
14
|
ServerIdentity,
|
|
@@ -131,3 +133,77 @@ export function formatAuthorizeResult(result: AuthorizeResult): string {
|
|
|
131
133
|
];
|
|
132
134
|
return lines.join("\n");
|
|
133
135
|
}
|
|
136
|
+
|
|
137
|
+
/** One line describing where a forward starts and where it ends. */
|
|
138
|
+
export function describeTunnel(definition: TunnelDefinition): string {
|
|
139
|
+
const bind = definition.bind ?? "127.0.0.1";
|
|
140
|
+
const listen = `${bind}:${definition.listenPort === 0 ? "(free port)" : definition.listenPort}`;
|
|
141
|
+
const dest = `${definition.destHost}:${definition.destPort}`;
|
|
142
|
+
|
|
143
|
+
return definition.kind === "local"
|
|
144
|
+
? `local ${listen} -> ${dest} (reached from the server)`
|
|
145
|
+
: `remote ${listen} on the server -> ${dest} (reached from this machine)`;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function formatTunnelStarted(
|
|
149
|
+
tunnel: RunningTunnel,
|
|
150
|
+
profile: SshProfile,
|
|
151
|
+
): string {
|
|
152
|
+
const bind = tunnel.definition.bind ?? "127.0.0.1";
|
|
153
|
+
const lines = [
|
|
154
|
+
`Tunnel "${tunnel.name}" is up: ${describeTunnel(tunnel.definition)}`,
|
|
155
|
+
tunnel.definition.kind === "local"
|
|
156
|
+
? `Connect to ${tunnel.listenAddress} on this machine.`
|
|
157
|
+
: `On ${profile.host}, connect to ${tunnel.listenAddress}.`,
|
|
158
|
+
"",
|
|
159
|
+
"It keeps running in the background until you stop it with ssh_tunnel action stop"
|
|
160
|
+
+ (tunnel.expiresAt ? `, or automatically at ${tunnel.expiresAt.slice(11, 19)} UTC.` : ", or the pi session ends."),
|
|
161
|
+
];
|
|
162
|
+
|
|
163
|
+
if (bind !== "127.0.0.1" && bind !== "localhost") {
|
|
164
|
+
lines.push(
|
|
165
|
+
"",
|
|
166
|
+
`Note: this binds ${bind}, not loopback, so anything that can reach that interface can use the tunnel.`,
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
return lines.join("\n");
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function formatTunnelList(
|
|
173
|
+
running: ReadonlyArray<RunningTunnel>,
|
|
174
|
+
defined: ReadonlyArray<{ profile: string; name: string; definition: TunnelDefinition }>,
|
|
175
|
+
): string {
|
|
176
|
+
const sections: string[] = [];
|
|
177
|
+
|
|
178
|
+
if (running.length === 0) {
|
|
179
|
+
sections.push("No tunnels are running.");
|
|
180
|
+
} else {
|
|
181
|
+
sections.push(`Running tunnels (${running.length}):`);
|
|
182
|
+
for (const tunnel of running) {
|
|
183
|
+
sections.push(
|
|
184
|
+
`- ${tunnel.profile}/${tunnel.name}: ${describeTunnel(tunnel.definition)}`,
|
|
185
|
+
` listening on ${tunnel.listenAddress}, ${tunnel.connections} connection(s) since ${tunnel.startedAt.slice(11, 19)} UTC`
|
|
186
|
+
+ (tunnel.expiresAt ? `, closes at ${tunnel.expiresAt.slice(11, 19)} UTC` : ""),
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (defined.length > 0) {
|
|
192
|
+
const runningIds = new Set(running.map((tunnel) => `${tunnel.profile}:${tunnel.name}`));
|
|
193
|
+
sections.push("", `Defined in profiles (${defined.length}):`);
|
|
194
|
+
for (const entry of defined) {
|
|
195
|
+
const state = runningIds.has(`${entry.profile}:${entry.name}`) ? " [running]" : "";
|
|
196
|
+
const purpose = entry.definition.description ? ` -- ${entry.definition.description}` : "";
|
|
197
|
+
sections.push(
|
|
198
|
+
`- ${entry.profile}/${entry.name}${state}: ${describeTunnel(entry.definition)}${purpose}`,
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
} else if (running.length === 0) {
|
|
202
|
+
sections.push(
|
|
203
|
+
"",
|
|
204
|
+
"Define one with ssh_tunnel action define, so it can be started by name later.",
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return sections.join("\n");
|
|
209
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What every tool that opens a connection does first.
|
|
3
|
+
*
|
|
4
|
+
* Resolving the profile and upgrading it to key authentication belong
|
|
5
|
+
* together: the upgrade has to happen before the connection the tool actually
|
|
6
|
+
* wants, and its outcome has to reach the user, so both are done in one place
|
|
7
|
+
* rather than repeated in each tool.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { resolveProfile } from "../config.ts";
|
|
11
|
+
import { ensureKeyAuthentication } from "../auto-key.ts";
|
|
12
|
+
import type { SshProfile } from "../types.ts";
|
|
13
|
+
|
|
14
|
+
export interface ConnectionContext {
|
|
15
|
+
readonly name: string;
|
|
16
|
+
readonly profile: SshProfile;
|
|
17
|
+
/** Worth showing the user, e.g. that the password was just replaced. */
|
|
18
|
+
readonly note?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function resolveForConnection(
|
|
22
|
+
profileName: string | undefined,
|
|
23
|
+
options: { acceptNewHostKey?: boolean; signal?: AbortSignal } = {},
|
|
24
|
+
): Promise<ConnectionContext> {
|
|
25
|
+
const { name, profile } = resolveProfile(profileName);
|
|
26
|
+
const outcome = await ensureKeyAuthentication(name, profile, options);
|
|
27
|
+
return { name, profile: outcome.profile, note: outcome.note };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Append a note to a tool's text output, when there is one. */
|
|
31
|
+
export function withNote(text: string, note?: string): string {
|
|
32
|
+
return note ? `${text}\n\n${note}` : text;
|
|
33
|
+
}
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import { Type } from "typebox";
|
|
6
|
-
import {
|
|
6
|
+
import { resolveForConnection, withNote } from "./shared.ts";
|
|
7
7
|
import { downloadFile, withConnection } from "../clients/ssh-client.ts";
|
|
8
8
|
import { formatTransfer } from "../formatting/formatters.ts";
|
|
9
9
|
|
|
@@ -35,7 +35,10 @@ export const SshDownloadTool = {
|
|
|
35
35
|
},
|
|
36
36
|
signal: AbortSignal,
|
|
37
37
|
) {
|
|
38
|
-
const { name, profile } =
|
|
38
|
+
const { name, profile, note } = await resolveForConnection(params.profile, {
|
|
39
|
+
acceptNewHostKey: params.acceptNewHostKey,
|
|
40
|
+
signal,
|
|
41
|
+
});
|
|
39
42
|
|
|
40
43
|
const result = await withConnection(
|
|
41
44
|
profile,
|
|
@@ -44,7 +47,7 @@ export const SshDownloadTool = {
|
|
|
44
47
|
);
|
|
45
48
|
|
|
46
49
|
return {
|
|
47
|
-
content: [{ type: "text" as const, text: formatTransfer(result, "down") }],
|
|
50
|
+
content: [{ type: "text" as const, text: withNote(formatTransfer(result, "down"), note) }],
|
|
48
51
|
details: { profile: name, ...result },
|
|
49
52
|
};
|
|
50
53
|
},
|
package/src/tools/ssh-exec.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import { Type } from "typebox";
|
|
6
|
-
import {
|
|
6
|
+
import { resolveForConnection, withNote } from "./shared.ts";
|
|
7
7
|
import { execCommand, withConnection } from "../clients/ssh-client.ts";
|
|
8
8
|
import { formatExecResult } from "../formatting/formatters.ts";
|
|
9
9
|
|
|
@@ -45,7 +45,10 @@ export const SshExecTool = {
|
|
|
45
45
|
) {
|
|
46
46
|
if (!params.command?.trim()) throw new Error("command must not be empty.");
|
|
47
47
|
|
|
48
|
-
const { name, profile } =
|
|
48
|
+
const { name, profile, note } = await resolveForConnection(params.profile, {
|
|
49
|
+
acceptNewHostKey: params.acceptNewHostKey,
|
|
50
|
+
signal,
|
|
51
|
+
});
|
|
49
52
|
const timeoutMs = Math.min(Math.max(params.timeoutSeconds ?? 120, 1), 3600) * 1000;
|
|
50
53
|
|
|
51
54
|
const result = await withConnection(
|
|
@@ -60,7 +63,7 @@ export const SshExecTool = {
|
|
|
60
63
|
);
|
|
61
64
|
|
|
62
65
|
return {
|
|
63
|
-
content: [{ type: "text" as const, text: formatExecResult(result) }],
|
|
66
|
+
content: [{ type: "text" as const, text: withNote(formatExecResult(result), note) }],
|
|
64
67
|
details: {
|
|
65
68
|
profile: name,
|
|
66
69
|
host: profile.host,
|
package/src/tools/ssh-list.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import { Type } from "typebox";
|
|
6
|
-
import {
|
|
6
|
+
import { resolveForConnection, withNote } from "./shared.ts";
|
|
7
7
|
import { listDirectory, withConnection } from "../clients/ssh-client.ts";
|
|
8
8
|
import { formatDirectory } from "../formatting/formatters.ts";
|
|
9
9
|
|
|
@@ -27,7 +27,10 @@ export const SshListTool = {
|
|
|
27
27
|
params: { path: string; profile?: string; acceptNewHostKey?: boolean },
|
|
28
28
|
signal: AbortSignal,
|
|
29
29
|
) {
|
|
30
|
-
const { name, profile } =
|
|
30
|
+
const { name, profile, note } = await resolveForConnection(params.profile, {
|
|
31
|
+
acceptNewHostKey: params.acceptNewHostKey,
|
|
32
|
+
signal,
|
|
33
|
+
});
|
|
31
34
|
const remotePath = params.path?.trim() || ".";
|
|
32
35
|
|
|
33
36
|
const entries = await withConnection(
|
|
@@ -37,7 +40,9 @@ export const SshListTool = {
|
|
|
37
40
|
);
|
|
38
41
|
|
|
39
42
|
return {
|
|
40
|
-
content: [
|
|
43
|
+
content: [
|
|
44
|
+
{ type: "text" as const, text: withNote(formatDirectory(entries, remotePath), note) },
|
|
45
|
+
],
|
|
41
46
|
details: {
|
|
42
47
|
profile: name,
|
|
43
48
|
path: remotePath,
|