@patimweb/pi-ssh 1.0.0 → 1.1.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 +133 -2
- package/index.ts +10 -0
- package/package.json +1 -1
- package/skills/ssh-key-setup/SKILL.md +16 -1
- package/skills/ssh-remote-work/SKILL.md +15 -0
- package/src/auto-key.ts +83 -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 +326 -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,120 @@ 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
|
+
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
|
+
|
|
90
221
|
## Commands
|
|
91
222
|
|
|
92
223
|
| Command | Description |
|
|
@@ -139,7 +270,7 @@ npm test
|
|
|
139
270
|
npm run test:coverage
|
|
140
271
|
```
|
|
141
272
|
|
|
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,
|
|
273
|
+
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
274
|
|
|
144
275
|
## License
|
|
145
276
|
|
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.
|
|
@@ -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
|
+
}
|
|
@@ -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,
|
package/src/tools/ssh-setup.ts
CHANGED
|
@@ -35,6 +35,13 @@ export const SshSetupTool = {
|
|
|
35
35
|
passphrase: Type.Optional(
|
|
36
36
|
Type.String({ description: "Passphrase for that private key, if it has one." }),
|
|
37
37
|
),
|
|
38
|
+
autoKey: Type.Optional(
|
|
39
|
+
Type.Boolean({
|
|
40
|
+
description:
|
|
41
|
+
"On the first connection, install an SSH key on the host and replace the stored password with it. Default true. Set false to keep using the password.",
|
|
42
|
+
default: true,
|
|
43
|
+
}),
|
|
44
|
+
),
|
|
38
45
|
strictHostKey: Type.Optional(
|
|
39
46
|
Type.Boolean({
|
|
40
47
|
description:
|
|
@@ -67,14 +74,18 @@ export const SshSetupTool = {
|
|
|
67
74
|
? { privateKeyPath: expandPath(params.privateKeyPath) }
|
|
68
75
|
: {}),
|
|
69
76
|
...(params.passphrase ? { passphrase: params.passphrase } : {}),
|
|
77
|
+
...(params.autoKey === false ? { autoKey: false } : {}),
|
|
70
78
|
...(params.strictHostKey === false ? { strictHostKey: false } : {}),
|
|
71
79
|
};
|
|
72
80
|
|
|
73
81
|
saveProfile(params.name, profile);
|
|
74
82
|
|
|
75
|
-
const advice =
|
|
76
|
-
|
|
77
|
-
|
|
83
|
+
const advice =
|
|
84
|
+
profile.password && !profile.privateKeyPath
|
|
85
|
+
? params.autoKey === false
|
|
86
|
+
? "\n\nThis profile logs in with a password and will keep doing so. Run ssh_authorize to switch to a key."
|
|
87
|
+
: "\n\nThis profile logs in with a password. On the first connection a key will be installed on the host and the password removed from the config; pass autoKey: false to prevent that."
|
|
88
|
+
: "";
|
|
78
89
|
|
|
79
90
|
return {
|
|
80
91
|
content: [
|
package/src/tools/ssh-status.ts
CHANGED
|
@@ -3,9 +3,11 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import { Type } from "typebox";
|
|
6
|
-
import { getActiveProfile, getProfiles
|
|
6
|
+
import { getActiveProfile, getProfiles } from "../config.ts";
|
|
7
|
+
import { resolveForConnection, withNote } from "./shared.ts";
|
|
7
8
|
import { withConnection } from "../clients/ssh-client.ts";
|
|
8
|
-
import { formatIdentity, formatProfileStatus } from "../formatting/formatters.ts";
|
|
9
|
+
import { formatIdentity, formatProfileStatus, formatTunnelList } from "../formatting/formatters.ts";
|
|
10
|
+
import { listTunnels } from "../tunnels.ts";
|
|
9
11
|
|
|
10
12
|
export const SshStatusTool = {
|
|
11
13
|
name: "ssh_status",
|
|
@@ -30,7 +32,13 @@ export const SshStatusTool = {
|
|
|
30
32
|
signal: AbortSignal,
|
|
31
33
|
) {
|
|
32
34
|
const profiles = getProfiles();
|
|
33
|
-
const
|
|
35
|
+
const running = listTunnels();
|
|
36
|
+
// A tunnel outlives the call that opened it, so status is where it has to
|
|
37
|
+
// become visible again.
|
|
38
|
+
const overview = [
|
|
39
|
+
formatProfileStatus(profiles, getActiveProfile()),
|
|
40
|
+
...(running.length > 0 ? ["", formatTunnelList(running, [])] : []),
|
|
41
|
+
].join("\n");
|
|
34
42
|
|
|
35
43
|
if (Object.keys(profiles).length === 0 || !params.connect) {
|
|
36
44
|
return {
|
|
@@ -43,7 +51,7 @@ export const SshStatusTool = {
|
|
|
43
51
|
};
|
|
44
52
|
}
|
|
45
53
|
|
|
46
|
-
const { name, profile } =
|
|
54
|
+
const { name, profile, note } = await resolveForConnection(params.profile, { signal });
|
|
47
55
|
let connection: string;
|
|
48
56
|
let reachable = false;
|
|
49
57
|
try {
|
|
@@ -57,7 +65,9 @@ export const SshStatusTool = {
|
|
|
57
65
|
}
|
|
58
66
|
|
|
59
67
|
return {
|
|
60
|
-
content: [
|
|
68
|
+
content: [
|
|
69
|
+
{ type: "text" as const, text: withNote(`${overview}\n\n${connection}`, note) },
|
|
70
|
+
],
|
|
61
71
|
details: {
|
|
62
72
|
count: Object.keys(profiles).length,
|
|
63
73
|
profiles: Object.keys(profiles),
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ssh_tunnel tool -- Port forwarding, and the named forwards a profile keeps.
|
|
3
|
+
*
|
|
4
|
+
* Both the running tunnels and their stored definitions live here because
|
|
5
|
+
* they are the same subject from a user's point of view: define a forward
|
|
6
|
+
* once, then start and stop it by name.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { Type } from "typebox";
|
|
10
|
+
import { getProfiles, resolveProfile, updateProfile } from "../config.ts";
|
|
11
|
+
import { resolveForConnection, withNote } from "./shared.ts";
|
|
12
|
+
import {
|
|
13
|
+
listTunnels,
|
|
14
|
+
startTunnel,
|
|
15
|
+
stopAllTunnels,
|
|
16
|
+
stopTunnel,
|
|
17
|
+
validateDefinition,
|
|
18
|
+
} from "../tunnels.ts";
|
|
19
|
+
import { formatTunnelList, formatTunnelStarted } from "../formatting/formatters.ts";
|
|
20
|
+
import type { TunnelDefinition } from "../types.ts";
|
|
21
|
+
import { TunnelError } from "../types.ts";
|
|
22
|
+
|
|
23
|
+
const ACTIONS = new Set(["start", "stop", "stop-all", "list", "define", "forget"]);
|
|
24
|
+
|
|
25
|
+
function buildDefinition(params: {
|
|
26
|
+
kind?: string;
|
|
27
|
+
listenPort?: number;
|
|
28
|
+
bind?: string;
|
|
29
|
+
destHost?: string;
|
|
30
|
+
destPort?: number;
|
|
31
|
+
description?: string;
|
|
32
|
+
}): TunnelDefinition {
|
|
33
|
+
const definition: TunnelDefinition = {
|
|
34
|
+
kind: (params.kind ?? "local") as TunnelDefinition["kind"],
|
|
35
|
+
listenPort: params.listenPort ?? 0,
|
|
36
|
+
destHost: params.destHost ?? "",
|
|
37
|
+
destPort: params.destPort ?? 0,
|
|
38
|
+
...(params.bind ? { bind: params.bind } : {}),
|
|
39
|
+
...(params.description ? { description: params.description } : {}),
|
|
40
|
+
};
|
|
41
|
+
validateDefinition(definition);
|
|
42
|
+
return definition;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export const SshTunnelTool = {
|
|
46
|
+
name: "ssh_tunnel",
|
|
47
|
+
label: "SSH Tunnel",
|
|
48
|
+
description:
|
|
49
|
+
"Open, close and list SSH port forwards, and store named ones in a profile. A local tunnel makes a service behind the server reachable on this machine (like ssh -L); a remote tunnel makes something on this machine reachable from the server (like ssh -R). Unlike the other tools, a tunnel keeps running after the call returns, until it is stopped, its time limit expires, or the pi session ends.",
|
|
50
|
+
parameters: Type.Object({
|
|
51
|
+
action: Type.Optional(
|
|
52
|
+
Type.String({
|
|
53
|
+
description:
|
|
54
|
+
"One of: start, stop, stop-all, list, define, forget. Defaults to list. 'define' stores a named tunnel in the profile; 'start' runs one, by name or from the ports given here.",
|
|
55
|
+
default: "list",
|
|
56
|
+
}),
|
|
57
|
+
),
|
|
58
|
+
name: Type.Optional(
|
|
59
|
+
Type.String({
|
|
60
|
+
description:
|
|
61
|
+
"Name of the tunnel. Required for define, forget and stop; for start it selects a stored definition.",
|
|
62
|
+
}),
|
|
63
|
+
),
|
|
64
|
+
kind: Type.Optional(
|
|
65
|
+
Type.String({
|
|
66
|
+
description:
|
|
67
|
+
"local (this machine listens, the server reaches the destination) or remote (the server listens, this machine reaches the destination). Default local.",
|
|
68
|
+
default: "local",
|
|
69
|
+
}),
|
|
70
|
+
),
|
|
71
|
+
listenPort: Type.Optional(
|
|
72
|
+
Type.Number({
|
|
73
|
+
description:
|
|
74
|
+
"Port the tunnel accepts connections on: local to this machine for a local tunnel, on the server for a remote one. 0 picks a free port.",
|
|
75
|
+
}),
|
|
76
|
+
),
|
|
77
|
+
bind: Type.Optional(
|
|
78
|
+
Type.String({
|
|
79
|
+
description:
|
|
80
|
+
"Interface that port binds to. Default 127.0.0.1. Using 0.0.0.0 exposes the forwarded service to the whole network, and for a remote tunnel the server also needs GatewayPorts enabled.",
|
|
81
|
+
}),
|
|
82
|
+
),
|
|
83
|
+
destHost: Type.Optional(
|
|
84
|
+
Type.String({
|
|
85
|
+
description:
|
|
86
|
+
"Host the traffic is delivered to, resolved from the server for a local tunnel and from this machine for a remote one.",
|
|
87
|
+
}),
|
|
88
|
+
),
|
|
89
|
+
destPort: Type.Optional(Type.Number({ description: "Port on destHost." })),
|
|
90
|
+
description: Type.Optional(
|
|
91
|
+
Type.String({ description: "What this tunnel is for, shown in listings." }),
|
|
92
|
+
),
|
|
93
|
+
durationSeconds: Type.Optional(
|
|
94
|
+
Type.Number({
|
|
95
|
+
description: "Close the tunnel automatically after this long. Default: no limit.",
|
|
96
|
+
}),
|
|
97
|
+
),
|
|
98
|
+
profile: Type.Optional(
|
|
99
|
+
Type.String({ description: "SSH profile to use. Defaults to the active one." }),
|
|
100
|
+
),
|
|
101
|
+
acceptNewHostKey: Type.Optional(
|
|
102
|
+
Type.Boolean({ description: "Record an unknown host key.", default: false }),
|
|
103
|
+
),
|
|
104
|
+
}),
|
|
105
|
+
|
|
106
|
+
async execute(
|
|
107
|
+
_toolCallId: string,
|
|
108
|
+
params: {
|
|
109
|
+
action?: string;
|
|
110
|
+
name?: string;
|
|
111
|
+
kind?: string;
|
|
112
|
+
listenPort?: number;
|
|
113
|
+
bind?: string;
|
|
114
|
+
destHost?: string;
|
|
115
|
+
destPort?: number;
|
|
116
|
+
description?: string;
|
|
117
|
+
durationSeconds?: number;
|
|
118
|
+
profile?: string;
|
|
119
|
+
acceptNewHostKey?: boolean;
|
|
120
|
+
},
|
|
121
|
+
signal: AbortSignal,
|
|
122
|
+
) {
|
|
123
|
+
const action = (params.action ?? "list").toLowerCase();
|
|
124
|
+
if (!ACTIONS.has(action)) {
|
|
125
|
+
throw new TunnelError(
|
|
126
|
+
`Unknown action "${params.action}". Use start, stop, stop-all, list, define or forget.`,
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (action === "list") {
|
|
131
|
+
const running = listTunnels();
|
|
132
|
+
const defined = Object.entries(getProfiles()).flatMap(([profileName, profile]) =>
|
|
133
|
+
Object.entries(profile.tunnels ?? {}).map(([name, definition]) => ({
|
|
134
|
+
profile: profileName,
|
|
135
|
+
name,
|
|
136
|
+
definition,
|
|
137
|
+
})),
|
|
138
|
+
);
|
|
139
|
+
return {
|
|
140
|
+
content: [{ type: "text" as const, text: formatTunnelList(running, defined) }],
|
|
141
|
+
details: { running: running.length, defined: defined.length, tunnels: running },
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (action === "stop-all") {
|
|
146
|
+
const stopped = await stopAllTunnels();
|
|
147
|
+
return {
|
|
148
|
+
content: [
|
|
149
|
+
{
|
|
150
|
+
type: "text" as const,
|
|
151
|
+
text: stopped === 0 ? "No tunnels were running." : `Stopped ${stopped} tunnel(s).`,
|
|
152
|
+
},
|
|
153
|
+
],
|
|
154
|
+
details: { stopped },
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (!params.name) {
|
|
159
|
+
throw new TunnelError(`The "${action}" action requires a tunnel name.`);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (action === "define") {
|
|
163
|
+
const { name: profileName, profile } = resolveProfile(params.profile);
|
|
164
|
+
const definition = buildDefinition(params);
|
|
165
|
+
updateProfile(profileName, {
|
|
166
|
+
tunnels: { ...(profile.tunnels ?? {}), [params.name]: definition },
|
|
167
|
+
});
|
|
168
|
+
return {
|
|
169
|
+
content: [
|
|
170
|
+
{
|
|
171
|
+
type: "text" as const,
|
|
172
|
+
text: `Tunnel "${params.name}" defined for profile "${profileName}". Start it with ssh_tunnel action start, name ${params.name}.`,
|
|
173
|
+
},
|
|
174
|
+
],
|
|
175
|
+
details: { profile: profileName, name: params.name, definition },
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (action === "forget") {
|
|
180
|
+
const { name: profileName, profile } = resolveProfile(params.profile);
|
|
181
|
+
const tunnels = { ...(profile.tunnels ?? {}) };
|
|
182
|
+
const existed = params.name in tunnels;
|
|
183
|
+
delete tunnels[params.name];
|
|
184
|
+
updateProfile(profileName, { tunnels });
|
|
185
|
+
return {
|
|
186
|
+
content: [
|
|
187
|
+
{
|
|
188
|
+
type: "text" as const,
|
|
189
|
+
text: existed
|
|
190
|
+
? `Tunnel "${params.name}" removed from profile "${profileName}".`
|
|
191
|
+
: `Profile "${profileName}" has no tunnel called "${params.name}".`,
|
|
192
|
+
},
|
|
193
|
+
],
|
|
194
|
+
details: { profile: profileName, name: params.name, removed: existed },
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (action === "stop") {
|
|
199
|
+
const { name: profileName } = resolveProfile(params.profile);
|
|
200
|
+
const stopped = await stopTunnel(profileName, params.name);
|
|
201
|
+
return {
|
|
202
|
+
content: [
|
|
203
|
+
{
|
|
204
|
+
type: "text" as const,
|
|
205
|
+
text: stopped
|
|
206
|
+
? `Tunnel "${params.name}" stopped.`
|
|
207
|
+
: `No running tunnel called "${params.name}" for profile "${profileName}".`,
|
|
208
|
+
},
|
|
209
|
+
],
|
|
210
|
+
details: { profile: profileName, name: params.name, stopped },
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// start
|
|
215
|
+
const { name: profileName, profile, note } = await resolveForConnection(params.profile, {
|
|
216
|
+
acceptNewHostKey: params.acceptNewHostKey,
|
|
217
|
+
signal,
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
const stored = profile.tunnels?.[params.name];
|
|
221
|
+
const definition =
|
|
222
|
+
params.destHost || params.destPort ? buildDefinition(params) : stored;
|
|
223
|
+
|
|
224
|
+
if (!definition) {
|
|
225
|
+
const available = Object.keys(profile.tunnels ?? {});
|
|
226
|
+
throw new TunnelError(
|
|
227
|
+
`No tunnel called "${params.name}" is defined for profile "${profileName}"${
|
|
228
|
+
available.length > 0 ? ` (available: ${available.join(", ")})` : ""
|
|
229
|
+
}, and no destHost/destPort were given to build one.`,
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
validateDefinition(definition);
|
|
233
|
+
|
|
234
|
+
const running = await startTunnel({
|
|
235
|
+
profileName,
|
|
236
|
+
profile,
|
|
237
|
+
name: params.name,
|
|
238
|
+
definition,
|
|
239
|
+
acceptNewHostKey: params.acceptNewHostKey,
|
|
240
|
+
durationSeconds: params.durationSeconds,
|
|
241
|
+
signal,
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
return {
|
|
245
|
+
content: [
|
|
246
|
+
{ type: "text" as const, text: withNote(formatTunnelStarted(running, profile), note) },
|
|
247
|
+
],
|
|
248
|
+
details: { profile: profileName, ...running },
|
|
249
|
+
};
|
|
250
|
+
},
|
|
251
|
+
};
|
package/src/tools/ssh-upload.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 { uploadFile, withConnection } from "../clients/ssh-client.ts";
|
|
8
8
|
import { formatTransfer } from "../formatting/formatters.ts";
|
|
9
9
|
|
|
@@ -35,7 +35,10 @@ export const SshUploadTool = {
|
|
|
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 SshUploadTool = {
|
|
|
44
47
|
);
|
|
45
48
|
|
|
46
49
|
return {
|
|
47
|
-
content: [{ type: "text" as const, text: formatTransfer(result, "up") }],
|
|
50
|
+
content: [{ type: "text" as const, text: withNote(formatTransfer(result, "up"), note) }],
|
|
48
51
|
details: { profile: name, ...result },
|
|
49
52
|
};
|
|
50
53
|
},
|
package/src/tunnels.ts
ADDED
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Port forwarding.
|
|
3
|
+
*
|
|
4
|
+
* This is the one place where something deliberately outlives the tool call
|
|
5
|
+
* that created it: a tunnel is only useful while it is open, so it runs in the
|
|
6
|
+
* background and is stopped explicitly. Everything else about the design is
|
|
7
|
+
* there to keep that from becoming a leak -- every tunnel is in a registry
|
|
8
|
+
* that `ssh_tunnel list` and `ssh_status` show, each one can carry a time
|
|
9
|
+
* limit, and the extension closes all of them when the pi session ends.
|
|
10
|
+
*
|
|
11
|
+
* Each tunnel owns its own SSH connection. Sharing one would be tidier on the
|
|
12
|
+
* wire, but a single dropped connection would then take every tunnel with it.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import * as net from "node:net";
|
|
16
|
+
import type { Connection } from "./clients/ssh-client.ts";
|
|
17
|
+
import { connect } from "./clients/ssh-client.ts";
|
|
18
|
+
import type { RunningTunnel, SshProfile, TunnelDefinition } from "./types.ts";
|
|
19
|
+
import { TunnelError } from "./types.ts";
|
|
20
|
+
|
|
21
|
+
const DEFAULT_BIND = "127.0.0.1";
|
|
22
|
+
|
|
23
|
+
interface TunnelHandle {
|
|
24
|
+
readonly id: string;
|
|
25
|
+
readonly profile: string;
|
|
26
|
+
readonly name: string;
|
|
27
|
+
readonly definition: TunnelDefinition;
|
|
28
|
+
readonly startedAt: Date;
|
|
29
|
+
listenAddress: string;
|
|
30
|
+
connections: number;
|
|
31
|
+
expiresAt?: Date;
|
|
32
|
+
stop(): Promise<void>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const running = new Map<string, TunnelHandle>();
|
|
36
|
+
|
|
37
|
+
function tunnelId(profile: string, name: string): string {
|
|
38
|
+
return `${profile}:${name}`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Reject a definition before anything is opened. */
|
|
42
|
+
export function validateDefinition(definition: TunnelDefinition): void {
|
|
43
|
+
if (definition.kind !== "local" && definition.kind !== "remote") {
|
|
44
|
+
throw new TunnelError(`Unknown tunnel kind "${definition.kind}". Use local or remote.`);
|
|
45
|
+
}
|
|
46
|
+
for (const [label, port] of [
|
|
47
|
+
["listenPort", definition.listenPort],
|
|
48
|
+
["destPort", definition.destPort],
|
|
49
|
+
] as const) {
|
|
50
|
+
// 0 is allowed for listenPort only: it means "pick a free one".
|
|
51
|
+
const min = label === "listenPort" ? 0 : 1;
|
|
52
|
+
if (!Number.isInteger(port) || port < min || port > 65535) {
|
|
53
|
+
throw new TunnelError(`${label} must be an integer between ${min} and 65535.`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
if (!definition.destHost?.trim()) {
|
|
57
|
+
throw new TunnelError("destHost must not be empty.");
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Pipe two streams together and tear both down when either ends. */
|
|
62
|
+
function join(a: NodeJS.ReadWriteStream & { destroy?: () => void }, b: NodeJS.ReadWriteStream & { destroy?: () => void }): void {
|
|
63
|
+
a.pipe(b);
|
|
64
|
+
b.pipe(a);
|
|
65
|
+
|
|
66
|
+
const close = () => {
|
|
67
|
+
try {
|
|
68
|
+
a.destroy?.();
|
|
69
|
+
} catch {
|
|
70
|
+
/* already gone */
|
|
71
|
+
}
|
|
72
|
+
try {
|
|
73
|
+
b.destroy?.();
|
|
74
|
+
} catch {
|
|
75
|
+
/* already gone */
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
a.on("error", close);
|
|
80
|
+
b.on("error", close);
|
|
81
|
+
a.on("close", close);
|
|
82
|
+
b.on("close", close);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* ssh -L: listen here, let the server reach the destination.
|
|
87
|
+
*/
|
|
88
|
+
async function startLocal(
|
|
89
|
+
connection: Connection,
|
|
90
|
+
definition: TunnelDefinition,
|
|
91
|
+
handle: TunnelHandle,
|
|
92
|
+
): Promise<string> {
|
|
93
|
+
const bind = definition.bind ?? DEFAULT_BIND;
|
|
94
|
+
const sockets = new Set<net.Socket>();
|
|
95
|
+
|
|
96
|
+
const server = net.createServer((socket) => {
|
|
97
|
+
sockets.add(socket);
|
|
98
|
+
socket.on("close", () => sockets.delete(socket));
|
|
99
|
+
|
|
100
|
+
connection.client.forwardOut(
|
|
101
|
+
socket.remoteAddress ?? "127.0.0.1",
|
|
102
|
+
socket.remotePort ?? 0,
|
|
103
|
+
definition.destHost,
|
|
104
|
+
definition.destPort,
|
|
105
|
+
(err, stream) => {
|
|
106
|
+
if (err) {
|
|
107
|
+
// The destination refused or does not resolve from the server. The
|
|
108
|
+
// tunnel itself stays up; only this connection fails.
|
|
109
|
+
socket.destroy();
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
handle.connections += 1;
|
|
113
|
+
join(socket, stream);
|
|
114
|
+
},
|
|
115
|
+
);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
const address = await new Promise<string>((resolve, reject) => {
|
|
119
|
+
server.once("error", (err: NodeJS.ErrnoException) => {
|
|
120
|
+
if (err.code === "EADDRINUSE") {
|
|
121
|
+
reject(
|
|
122
|
+
new TunnelError(
|
|
123
|
+
`Local port ${definition.listenPort} is already in use. Pick another listenPort, or 0 to let the system choose.`,
|
|
124
|
+
),
|
|
125
|
+
);
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
if (err.code === "EACCES") {
|
|
129
|
+
reject(
|
|
130
|
+
new TunnelError(
|
|
131
|
+
`Not allowed to listen on port ${definition.listenPort}. Ports below 1024 need elevated rights.`,
|
|
132
|
+
),
|
|
133
|
+
);
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
reject(err);
|
|
137
|
+
});
|
|
138
|
+
server.listen(definition.listenPort, bind, () => {
|
|
139
|
+
const info = server.address() as net.AddressInfo;
|
|
140
|
+
resolve(`${bind}:${info.port}`);
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
const originalStop = handle.stop;
|
|
145
|
+
handle.stop = async () => {
|
|
146
|
+
for (const socket of sockets) socket.destroy();
|
|
147
|
+
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
148
|
+
await originalStop();
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
return address;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* ssh -R: the server listens, and connections come back here.
|
|
156
|
+
*/
|
|
157
|
+
async function startRemote(
|
|
158
|
+
connection: Connection,
|
|
159
|
+
definition: TunnelDefinition,
|
|
160
|
+
handle: TunnelHandle,
|
|
161
|
+
): Promise<string> {
|
|
162
|
+
const bind = definition.bind ?? DEFAULT_BIND;
|
|
163
|
+
const sockets = new Set<net.Socket>();
|
|
164
|
+
|
|
165
|
+
const boundPort = await new Promise<number>((resolve, reject) => {
|
|
166
|
+
connection.client.forwardIn(bind, definition.listenPort, (err, port) => {
|
|
167
|
+
if (err) {
|
|
168
|
+
reject(
|
|
169
|
+
new TunnelError(
|
|
170
|
+
[
|
|
171
|
+
`The server refused to listen on ${bind}:${definition.listenPort}: ${err.message}.`,
|
|
172
|
+
"Usually the port is taken, or sshd only allows loopback binds -- binding to anything other than 127.0.0.1 needs GatewayPorts in its sshd_config.",
|
|
173
|
+
].join(" "),
|
|
174
|
+
),
|
|
175
|
+
);
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
resolve(definition.listenPort === 0 ? (port as number) : definition.listenPort);
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
connection.client.on("tcp connection", (details, accept, reject) => {
|
|
183
|
+
// One connection carries one tunnel, but the server still reports which
|
|
184
|
+
// binding a connection arrived on.
|
|
185
|
+
if (details.destPort !== boundPort) {
|
|
186
|
+
reject();
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const stream = accept();
|
|
191
|
+
handle.connections += 1;
|
|
192
|
+
|
|
193
|
+
const socket = net.connect(definition.destPort, definition.destHost, () => {
|
|
194
|
+
join(socket, stream);
|
|
195
|
+
});
|
|
196
|
+
sockets.add(socket);
|
|
197
|
+
socket.on("close", () => sockets.delete(socket));
|
|
198
|
+
socket.on("error", () => {
|
|
199
|
+
// Nothing is listening on our side; drop the forwarded connection.
|
|
200
|
+
stream.destroy();
|
|
201
|
+
socket.destroy();
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
const originalStop = handle.stop;
|
|
206
|
+
handle.stop = async () => {
|
|
207
|
+
for (const socket of sockets) socket.destroy();
|
|
208
|
+
await new Promise<void>((resolve) => {
|
|
209
|
+
try {
|
|
210
|
+
connection.client.unforwardIn(bind, boundPort, () => resolve());
|
|
211
|
+
} catch {
|
|
212
|
+
resolve();
|
|
213
|
+
}
|
|
214
|
+
});
|
|
215
|
+
await originalStop();
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
return `${bind}:${boundPort}`;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export interface StartTunnelOptions {
|
|
222
|
+
readonly profileName: string;
|
|
223
|
+
readonly profile: SshProfile;
|
|
224
|
+
readonly name: string;
|
|
225
|
+
readonly definition: TunnelDefinition;
|
|
226
|
+
readonly acceptNewHostKey?: boolean;
|
|
227
|
+
/** Close the tunnel automatically after this long. */
|
|
228
|
+
readonly durationSeconds?: number;
|
|
229
|
+
readonly signal?: AbortSignal;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export async function startTunnel(options: StartTunnelOptions): Promise<RunningTunnel> {
|
|
233
|
+
const { definition, name, profileName } = options;
|
|
234
|
+
validateDefinition(definition);
|
|
235
|
+
|
|
236
|
+
const id = tunnelId(profileName, name);
|
|
237
|
+
if (running.has(id)) {
|
|
238
|
+
throw new TunnelError(
|
|
239
|
+
`A tunnel called "${name}" is already running for profile "${profileName}". Stop it first, or give this one another name.`,
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const connection = await connect(options.profile, {
|
|
244
|
+
acceptNewHostKey: options.acceptNewHostKey,
|
|
245
|
+
signal: options.signal,
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
const handle: TunnelHandle = {
|
|
249
|
+
id,
|
|
250
|
+
profile: profileName,
|
|
251
|
+
name,
|
|
252
|
+
definition,
|
|
253
|
+
startedAt: new Date(),
|
|
254
|
+
listenAddress: "",
|
|
255
|
+
connections: 0,
|
|
256
|
+
stop: async () => {
|
|
257
|
+
connection.client.end();
|
|
258
|
+
running.delete(id);
|
|
259
|
+
},
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
try {
|
|
263
|
+
handle.listenAddress =
|
|
264
|
+
definition.kind === "local"
|
|
265
|
+
? await startLocal(connection, definition, handle)
|
|
266
|
+
: await startRemote(connection, definition, handle);
|
|
267
|
+
} catch (err) {
|
|
268
|
+
connection.client.end();
|
|
269
|
+
throw err;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// A dropped SSH connection means the tunnel is dead; do not leave a stale
|
|
273
|
+
// entry claiming otherwise.
|
|
274
|
+
connection.client.on("close", () => {
|
|
275
|
+
running.delete(id);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
if (options.durationSeconds && options.durationSeconds > 0) {
|
|
279
|
+
handle.expiresAt = new Date(Date.now() + options.durationSeconds * 1000);
|
|
280
|
+
const timer = setTimeout(() => {
|
|
281
|
+
void handle.stop();
|
|
282
|
+
}, options.durationSeconds * 1000);
|
|
283
|
+
timer.unref?.();
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
running.set(id, handle);
|
|
287
|
+
return describe(handle);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function describe(handle: TunnelHandle): RunningTunnel {
|
|
291
|
+
return {
|
|
292
|
+
id: handle.id,
|
|
293
|
+
profile: handle.profile,
|
|
294
|
+
name: handle.name,
|
|
295
|
+
definition: handle.definition,
|
|
296
|
+
listenAddress: handle.listenAddress,
|
|
297
|
+
startedAt: handle.startedAt.toISOString(),
|
|
298
|
+
connections: handle.connections,
|
|
299
|
+
expiresAt: handle.expiresAt?.toISOString(),
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
export function listTunnels(): RunningTunnel[] {
|
|
304
|
+
return [...running.values()].map(describe).sort((a, b) => a.id.localeCompare(b.id));
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
export async function stopTunnel(profileName: string, name: string): Promise<boolean> {
|
|
308
|
+
const handle = running.get(tunnelId(profileName, name));
|
|
309
|
+
if (!handle) return false;
|
|
310
|
+
await handle.stop();
|
|
311
|
+
running.delete(handle.id);
|
|
312
|
+
return true;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** Close everything. Called when the pi session ends. */
|
|
316
|
+
export async function stopAllTunnels(): Promise<number> {
|
|
317
|
+
const handles = [...running.values()];
|
|
318
|
+
await Promise.all(handles.map((handle) => handle.stop().catch(() => undefined)));
|
|
319
|
+
running.clear();
|
|
320
|
+
return handles.length;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** @internal for tests */
|
|
324
|
+
export function _runningCount(): number {
|
|
325
|
+
return running.size;
|
|
326
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -25,6 +25,53 @@ export interface SshProfile {
|
|
|
25
25
|
*/
|
|
26
26
|
readonly strictHostKey?: boolean;
|
|
27
27
|
readonly connectTimeoutMs?: number;
|
|
28
|
+
/**
|
|
29
|
+
* Upgrade this profile to key authentication on first use, replacing the
|
|
30
|
+
* stored password. On by default; set to false to keep using the password.
|
|
31
|
+
*/
|
|
32
|
+
readonly autoKey?: boolean;
|
|
33
|
+
/** Named port forwards that ssh_tunnel can start by name. */
|
|
34
|
+
readonly tunnels?: Record<string, TunnelDefinition>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** A tunnel that is currently running. */
|
|
38
|
+
export interface RunningTunnel {
|
|
39
|
+
readonly id: string;
|
|
40
|
+
readonly profile: string;
|
|
41
|
+
readonly name: string;
|
|
42
|
+
readonly definition: TunnelDefinition;
|
|
43
|
+
/** Where it actually listens, which matters when listenPort was 0. */
|
|
44
|
+
readonly listenAddress: string;
|
|
45
|
+
readonly startedAt: string;
|
|
46
|
+
readonly connections: number;
|
|
47
|
+
/** Set when the tunnel closes itself after a fixed time. */
|
|
48
|
+
readonly expiresAt?: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* A port forward, stored by name in a profile.
|
|
53
|
+
*
|
|
54
|
+
* The same shape describes both directions, and `kind` decides whose machine
|
|
55
|
+
* each side refers to:
|
|
56
|
+
*
|
|
57
|
+
* local (ssh -L): this machine listens on bind:listenPort, and the server
|
|
58
|
+
* opens the connection to destHost:destPort.
|
|
59
|
+
* remote (ssh -R): the server listens on bind:listenPort, and this machine
|
|
60
|
+
* opens the connection to destHost:destPort.
|
|
61
|
+
*/
|
|
62
|
+
export interface TunnelDefinition {
|
|
63
|
+
readonly kind: "local" | "remote";
|
|
64
|
+
/** Port the tunnel accepts connections on. */
|
|
65
|
+
readonly listenPort: number;
|
|
66
|
+
/**
|
|
67
|
+
* Interface to bind that port to. Defaults to 127.0.0.1: binding to
|
|
68
|
+
* 0.0.0.0 publishes the forwarded service to the whole network.
|
|
69
|
+
*/
|
|
70
|
+
readonly bind?: string;
|
|
71
|
+
/** Where traffic is delivered. */
|
|
72
|
+
readonly destHost: string;
|
|
73
|
+
readonly destPort: number;
|
|
74
|
+
readonly description?: string;
|
|
28
75
|
}
|
|
29
76
|
|
|
30
77
|
export interface SshProfiles {
|
|
@@ -95,6 +142,7 @@ export interface SetupParams {
|
|
|
95
142
|
readonly privateKeyPath?: string;
|
|
96
143
|
readonly passphrase?: string;
|
|
97
144
|
readonly strictHostKey?: boolean;
|
|
145
|
+
readonly autoKey?: boolean;
|
|
98
146
|
}
|
|
99
147
|
|
|
100
148
|
// Errors
|
|
@@ -133,6 +181,13 @@ export class SshAuthError extends Error {
|
|
|
133
181
|
}
|
|
134
182
|
}
|
|
135
183
|
|
|
184
|
+
export class TunnelError extends Error {
|
|
185
|
+
constructor(message: string) {
|
|
186
|
+
super(message);
|
|
187
|
+
this.name = "TunnelError";
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
136
191
|
export class RemoteCommandError extends Error {
|
|
137
192
|
readonly result: ExecResult;
|
|
138
193
|
|