@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.
@@ -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 = profile.password && !profile.privateKeyPath
76
- ? "\n\nThis profile logs in with a password. Run ssh_authorize to generate a key, install it on the host, and stop needing the password."
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: [
@@ -3,9 +3,11 @@
3
3
  */
4
4
 
5
5
  import { Type } from "typebox";
6
- import { getActiveProfile, getProfiles, resolveProfile } from "../config.ts";
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 overview = formatProfileStatus(profiles, getActiveProfile());
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 } = resolveProfile(params.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: [{ type: "text" as const, text: `${overview}\n\n${connection}` }],
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
+ };
@@ -3,7 +3,7 @@
3
3
  */
4
4
 
5
5
  import { Type } from "typebox";
6
- import { resolveProfile } from "../config.ts";
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 } = resolveProfile(params.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,340 @@
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
+ stopped: boolean;
33
+ stop(): Promise<void>;
34
+ }
35
+
36
+ const running = new Map<string, TunnelHandle>();
37
+
38
+ function tunnelId(profile: string, name: string): string {
39
+ return `${profile}:${name}`;
40
+ }
41
+
42
+ /** Reject a definition before anything is opened. */
43
+ export function validateDefinition(definition: TunnelDefinition): void {
44
+ if (definition.kind !== "local" && definition.kind !== "remote") {
45
+ throw new TunnelError(`Unknown tunnel kind "${definition.kind}". Use local or remote.`);
46
+ }
47
+ for (const [label, port] of [
48
+ ["listenPort", definition.listenPort],
49
+ ["destPort", definition.destPort],
50
+ ] as const) {
51
+ // 0 is allowed for listenPort only: it means "pick a free one".
52
+ const min = label === "listenPort" ? 0 : 1;
53
+ if (!Number.isInteger(port) || port < min || port > 65535) {
54
+ throw new TunnelError(`${label} must be an integer between ${min} and 65535.`);
55
+ }
56
+ }
57
+ if (!definition.destHost?.trim()) {
58
+ throw new TunnelError("destHost must not be empty.");
59
+ }
60
+ }
61
+
62
+ /** Pipe two streams together and tear both down when either ends. */
63
+ function join(a: NodeJS.ReadWriteStream & { destroy?: () => void }, b: NodeJS.ReadWriteStream & { destroy?: () => void }): void {
64
+ a.pipe(b);
65
+ b.pipe(a);
66
+
67
+ const close = () => {
68
+ try {
69
+ a.destroy?.();
70
+ } catch {
71
+ /* already gone */
72
+ }
73
+ try {
74
+ b.destroy?.();
75
+ } catch {
76
+ /* already gone */
77
+ }
78
+ };
79
+
80
+ a.on("error", close);
81
+ b.on("error", close);
82
+ a.on("close", close);
83
+ b.on("close", close);
84
+ }
85
+
86
+ /**
87
+ * ssh -L: listen here, let the server reach the destination.
88
+ */
89
+ async function startLocal(
90
+ connection: Connection,
91
+ definition: TunnelDefinition,
92
+ handle: TunnelHandle,
93
+ ): Promise<string> {
94
+ const bind = definition.bind ?? DEFAULT_BIND;
95
+ const sockets = new Set<net.Socket>();
96
+
97
+ const server = net.createServer((socket) => {
98
+ sockets.add(socket);
99
+ socket.on("close", () => sockets.delete(socket));
100
+
101
+ connection.client.forwardOut(
102
+ socket.remoteAddress ?? "127.0.0.1",
103
+ socket.remotePort ?? 0,
104
+ definition.destHost,
105
+ definition.destPort,
106
+ (err, stream) => {
107
+ if (err) {
108
+ // The destination refused or does not resolve from the server. The
109
+ // tunnel itself stays up; only this connection fails.
110
+ socket.destroy();
111
+ return;
112
+ }
113
+ handle.connections += 1;
114
+ join(socket, stream);
115
+ },
116
+ );
117
+ });
118
+
119
+ const address = await new Promise<string>((resolve, reject) => {
120
+ server.once("error", (err: NodeJS.ErrnoException) => {
121
+ if (err.code === "EADDRINUSE") {
122
+ reject(
123
+ new TunnelError(
124
+ `Local port ${definition.listenPort} is already in use. Pick another listenPort, or 0 to let the system choose.`,
125
+ ),
126
+ );
127
+ return;
128
+ }
129
+ if (err.code === "EACCES") {
130
+ reject(
131
+ new TunnelError(
132
+ `Not allowed to listen on port ${definition.listenPort}. Ports below 1024 need elevated rights.`,
133
+ ),
134
+ );
135
+ return;
136
+ }
137
+ reject(err);
138
+ });
139
+ server.listen(definition.listenPort, bind, () => {
140
+ const info = server.address() as net.AddressInfo;
141
+ resolve(`${bind}:${info.port}`);
142
+ });
143
+ });
144
+
145
+ const originalStop = handle.stop;
146
+ handle.stop = async () => {
147
+ for (const socket of sockets) socket.destroy();
148
+ await new Promise<void>((resolve) => server.close(() => resolve()));
149
+ await originalStop();
150
+ };
151
+
152
+ return address;
153
+ }
154
+
155
+ /**
156
+ * ssh -R: the server listens, and connections come back here.
157
+ */
158
+ async function startRemote(
159
+ connection: Connection,
160
+ definition: TunnelDefinition,
161
+ handle: TunnelHandle,
162
+ ): Promise<string> {
163
+ const bind = definition.bind ?? DEFAULT_BIND;
164
+ const sockets = new Set<net.Socket>();
165
+
166
+ const boundPort = await new Promise<number>((resolve, reject) => {
167
+ connection.client.forwardIn(bind, definition.listenPort, (err, port) => {
168
+ if (err) {
169
+ reject(
170
+ new TunnelError(
171
+ [
172
+ `The server refused to listen on ${bind}:${definition.listenPort}: ${err.message}.`,
173
+ "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.",
174
+ ].join(" "),
175
+ ),
176
+ );
177
+ return;
178
+ }
179
+ resolve(definition.listenPort === 0 ? (port as number) : definition.listenPort);
180
+ });
181
+ });
182
+
183
+ connection.client.on("tcp connection", (details, accept, reject) => {
184
+ // One connection carries one tunnel, but the server still reports which
185
+ // binding a connection arrived on.
186
+ if (details.destPort !== boundPort) {
187
+ reject();
188
+ return;
189
+ }
190
+
191
+ const stream = accept();
192
+ handle.connections += 1;
193
+
194
+ const socket = net.connect(definition.destPort, definition.destHost, () => {
195
+ join(socket, stream);
196
+ });
197
+ sockets.add(socket);
198
+ socket.on("close", () => sockets.delete(socket));
199
+ socket.on("error", () => {
200
+ // Nothing is listening on our side; drop the forwarded connection.
201
+ stream.destroy();
202
+ socket.destroy();
203
+ });
204
+ });
205
+
206
+ const originalStop = handle.stop;
207
+ handle.stop = async () => {
208
+ for (const socket of sockets) socket.destroy();
209
+ await new Promise<void>((resolve) => {
210
+ try {
211
+ connection.client.unforwardIn(bind, boundPort, () => resolve());
212
+ } catch {
213
+ resolve();
214
+ }
215
+ });
216
+ await originalStop();
217
+ };
218
+
219
+ return `${bind}:${boundPort}`;
220
+ }
221
+
222
+ export interface StartTunnelOptions {
223
+ readonly profileName: string;
224
+ readonly profile: SshProfile;
225
+ readonly name: string;
226
+ readonly definition: TunnelDefinition;
227
+ readonly acceptNewHostKey?: boolean;
228
+ /** Close the tunnel automatically after this long. */
229
+ readonly durationSeconds?: number;
230
+ readonly signal?: AbortSignal;
231
+ }
232
+
233
+ export async function startTunnel(options: StartTunnelOptions): Promise<RunningTunnel> {
234
+ const { definition, name, profileName } = options;
235
+ validateDefinition(definition);
236
+
237
+ const id = tunnelId(profileName, name);
238
+ if (running.has(id)) {
239
+ throw new TunnelError(
240
+ `A tunnel called "${name}" is already running for profile "${profileName}". Stop it first, or give this one another name.`,
241
+ );
242
+ }
243
+
244
+ const connection = await connect(options.profile, {
245
+ acceptNewHostKey: options.acceptNewHostKey,
246
+ signal: options.signal,
247
+ });
248
+
249
+ const handle: TunnelHandle = {
250
+ id,
251
+ profile: profileName,
252
+ name,
253
+ definition,
254
+ startedAt: new Date(),
255
+ listenAddress: "",
256
+ connections: 0,
257
+ stopped: false,
258
+ stop: async () => {
259
+ connection.client.end();
260
+ running.delete(id);
261
+ },
262
+ };
263
+
264
+ try {
265
+ handle.listenAddress =
266
+ definition.kind === "local"
267
+ ? await startLocal(connection, definition, handle)
268
+ : await startRemote(connection, definition, handle);
269
+ } catch (err) {
270
+ connection.client.end();
271
+ throw err;
272
+ }
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.
278
+ connection.client.on("close", () => {
279
+ void teardown(handle);
280
+ });
281
+
282
+ if (options.durationSeconds && options.durationSeconds > 0) {
283
+ handle.expiresAt = new Date(Date.now() + options.durationSeconds * 1000);
284
+ const timer = setTimeout(() => {
285
+ void teardown(handle);
286
+ }, options.durationSeconds * 1000);
287
+ timer.unref?.();
288
+ }
289
+
290
+ running.set(id, handle);
291
+ return describe(handle);
292
+ }
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
+
305
+ function describe(handle: TunnelHandle): RunningTunnel {
306
+ return {
307
+ id: handle.id,
308
+ profile: handle.profile,
309
+ name: handle.name,
310
+ definition: handle.definition,
311
+ listenAddress: handle.listenAddress,
312
+ startedAt: handle.startedAt.toISOString(),
313
+ connections: handle.connections,
314
+ expiresAt: handle.expiresAt?.toISOString(),
315
+ };
316
+ }
317
+
318
+ export function listTunnels(): RunningTunnel[] {
319
+ return [...running.values()].map(describe).sort((a, b) => a.id.localeCompare(b.id));
320
+ }
321
+
322
+ export async function stopTunnel(profileName: string, name: string): Promise<boolean> {
323
+ const handle = running.get(tunnelId(profileName, name));
324
+ if (!handle) return false;
325
+ await teardown(handle);
326
+ return true;
327
+ }
328
+
329
+ /** Close everything. Called when the pi session ends. */
330
+ export async function stopAllTunnels(): Promise<number> {
331
+ const handles = [...running.values()];
332
+ await Promise.all(handles.map((handle) => teardown(handle).catch(() => undefined)));
333
+ running.clear();
334
+ return handles.length;
335
+ }
336
+
337
+ /** @internal for tests */
338
+ export function _runningCount(): number {
339
+ return running.size;
340
+ }