@linxin666/dsh-ssh 0.4.5 → 0.4.7

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/lib/index.js CHANGED
@@ -1452,6 +1452,24 @@ function fastGet(sftp, src, dst, concurrency, onProgress) {
1452
1452
  * to a pooled connection so an idle sweep never closes it, with per-tunnel
1453
1453
  * socket tracking and stop (single / alias-scoped / all).
1454
1454
  */
1455
+ /** Destroy one socket or channel stream, tolerating an already-dead peer. */
1456
+ function destroyQuietly(target) {
1457
+ try {
1458
+ target.destroy();
1459
+ } catch {}
1460
+ }
1461
+ /**
1462
+ * Tear down one tunnel because the SSH link behind it is gone. Idempotent: the
1463
+ * tunnel registry is the guard, so a socket callback or a link-death hook that
1464
+ * arrives after a manual stop does nothing instead of tearing down twice, and
1465
+ * no teardown error can escape into the net connection handler — an exception
1466
+ * there terminates the whole host process.
1467
+ */
1468
+ function abortTunnel(engine, id) {
1469
+ try {
1470
+ stopTunnel(engine, id);
1471
+ } catch {}
1472
+ }
1455
1473
  /** Start a local port-forward tunnel (listens on 127.0.0.1 only). */
1456
1474
  async function startTunnel(engine, alias, options) {
1457
1475
  if (!Number.isInteger(options.remotePort) || options.remotePort < 1 || options.remotePort > 65535) throw new Error("remotePort must be an integer in 1..65535");
@@ -1474,30 +1492,44 @@ async function startTunnel(engine, alias, options) {
1474
1492
  const record = engine.pool.get(alias) ?? await acquire(engine, alias);
1475
1493
  const client = record.client;
1476
1494
  const sockets = /* @__PURE__ */ new Set();
1495
+ const onLinkClosed = () => {
1496
+ abortTunnel(engine, id);
1497
+ };
1498
+ const detachLink = () => {
1499
+ client.off("close", onLinkClosed);
1500
+ };
1477
1501
  const server = createServer((socket) => {
1478
1502
  sockets.add(socket);
1479
1503
  socket.on("close", () => {
1480
1504
  sockets.delete(socket);
1481
1505
  });
1482
- client.forwardOut("127.0.0.1", 0, remoteHost, options.remotePort, (error, stream) => {
1483
- if (error !== void 0) {
1484
- socket.destroy();
1485
- return;
1486
- }
1487
- const destroy = () => {
1488
- try {
1489
- socket.destroy();
1490
- } catch {}
1491
- try {
1492
- stream.close();
1493
- } catch {}
1494
- };
1495
- stream.on("error", destroy);
1496
- socket.on("error", destroy);
1497
- stream.on("close", destroy);
1498
- socket.on("close", destroy);
1499
- stream.pipe(socket).pipe(stream);
1500
- });
1506
+ if (record.broken) {
1507
+ destroyQuietly(socket);
1508
+ abortTunnel(engine, id);
1509
+ return;
1510
+ }
1511
+ try {
1512
+ client.forwardOut("127.0.0.1", 0, remoteHost, options.remotePort, (error, stream) => {
1513
+ if (error !== void 0) {
1514
+ destroyQuietly(socket);
1515
+ return;
1516
+ }
1517
+ const destroy = () => {
1518
+ destroyQuietly(socket);
1519
+ try {
1520
+ stream.close();
1521
+ } catch {}
1522
+ };
1523
+ stream.on("error", destroy);
1524
+ socket.on("error", destroy);
1525
+ stream.on("close", destroy);
1526
+ socket.on("close", destroy);
1527
+ stream.pipe(socket).pipe(stream);
1528
+ });
1529
+ } catch {
1530
+ destroyQuietly(socket);
1531
+ abortTunnel(engine, id);
1532
+ }
1501
1533
  });
1502
1534
  try {
1503
1535
  await new Promise((resolve, reject) => {
@@ -1520,8 +1552,15 @@ async function startTunnel(engine, alias, options) {
1520
1552
  server,
1521
1553
  alias,
1522
1554
  record,
1523
- sockets
1555
+ sockets,
1556
+ detachLink
1524
1557
  });
1558
+ client.once("close", onLinkClosed);
1559
+ if (record.broken) {
1560
+ info.state = "failed";
1561
+ info.error = "ssh link closed while the tunnel was starting";
1562
+ abortTunnel(engine, id);
1563
+ }
1525
1564
  return info;
1526
1565
  }
1527
1566
  /** All active tunnels. */
@@ -1534,11 +1573,12 @@ function stopTunnel(engine, id) {
1534
1573
  if (tunnel === void 0) return false;
1535
1574
  engine.tunnels.delete(id);
1536
1575
  try {
1537
- tunnel.server.close();
1576
+ tunnel.detachLink?.();
1538
1577
  } catch {}
1539
- for (const socket of tunnel.sockets) try {
1540
- socket.destroy();
1578
+ try {
1579
+ tunnel.server.close();
1541
1580
  } catch {}
1581
+ for (const socket of tunnel.sockets) destroyQuietly(socket);
1542
1582
  tunnel.sockets.clear();
1543
1583
  if (![...engine.tunnels.values()].some((candidate) => candidate.record === tunnel.record)) if (engine.pool.get(tunnel.alias) === tunnel.record) disposeRecord(engine, tunnel.alias, tunnel.record);
1544
1584
  else endRecordChain(tunnel.record);
@@ -14,6 +14,8 @@ export interface TunnelRecord {
14
14
  /** The pooled connection this tunnel pins; siblings on one alias may share it. */
15
15
  record: PoolRecord;
16
16
  sockets: Set<Socket>;
17
+ /** Detach the link-death hook startTunnel installs on the pooled client. */
18
+ detachLink?: () => void;
17
19
  }
18
20
  /** The engine slice the tunnel module needs (adds the tunnel registry). */
19
21
  export interface TunnelEngine extends PoolEngine {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@linxin666/dsh-ssh",
3
3
  "description": "Remote SSH operations for the dsh web GUI: host config store (~/.dsh/dsh-ssh.json, import from ~/.ssh/config), a persistent ssh2 connection pool with jump-host support, exec / PTY web terminal / SFTP transfer / local port-forward tunnels / cluster execution, plus agent tools (ssh_list, ssh_exec, ssh_upload, ssh_download, ssh_tunnel, ssh_cluster). Hot-pluggable — mounted via ~/.dsh/cordis.patch.yml + a profile node_modules symlink, no dsh source changes.",
4
- "version": "0.4.5",
4
+ "version": "0.4.7",
5
5
  "icon": "icon.svg",
6
6
  "type": "module",
7
7
  "engines": {
@@ -14,10 +14,6 @@
14
14
  "types": "./lib/types/index.d.ts",
15
15
  "default": "./lib/index.js"
16
16
  },
17
- "./invariant": {
18
- "types": "./lib/types/invariant.d.ts",
19
- "default": "./lib/invariant.js"
20
- },
21
17
  "./client": {
22
18
  "types": "./lib/types/client/index.d.ts",
23
19
  "default": "./lib/client.js"
@@ -27,7 +23,7 @@
27
23
  },
28
24
  "dsh": {
29
25
  "engines": {
30
- "dsh": ">=0.2.0-rc.2"
26
+ "dsh": ">=0.2.1-rc.2"
31
27
  },
32
28
  "bundle": {
33
29
  "patch": "./cordis.patch.yml"
@@ -43,33 +39,33 @@
43
39
  }
44
40
  },
45
41
  "dependencies": {
46
- "@deepseek-ai/schemastery": "^3.18.4",
42
+ "@deepseek-ai/schemastery": "^3.18.5",
47
43
  "@xterm/addon-fit": "^0.11.0",
48
44
  "@xterm/xterm": "^6.0.0",
49
45
  "ssh2": "^1.17.0",
50
46
  "ws": "^8.18.0"
51
47
  },
52
48
  "peerDependencies": {
53
- "@deepseek-ai/dsh": ">=0.2.0-rc.2",
49
+ "@deepseek-ai/dsh": ">=0.2.1-rc.2",
54
50
  "react": "^18.2.0",
55
51
  "react-dom": "^18.2.0"
56
52
  },
57
53
  "devDependencies": {
58
- "@deepseek-ai/cordis": "^4.0.4",
59
- "@deepseek-ai/dsh-client-connection": "^0.2.0-rc.2",
60
- "@deepseek-ai/dsh-client-locale": "^0.2.0-rc.2",
61
- "@deepseek-ai/dsh-client-ui-renderer": "^0.2.0-rc.2",
62
- "@deepseek-ai/dsh-client-ui-layout": "^0.2.0-rc.2",
63
- "@deepseek-ai/dsh-client-ui-settings": "^0.2.0-rc.2",
64
- "@deepseek-ai/dsh-client-ui-sidebar": "^0.2.0-rc.2",
65
- "@deepseek-ai/dsh-client-ui-slots": "^0.2.0-rc.2",
66
- "@deepseek-ai/dsh-host-webserver": "^0.2.0-rc.2",
67
- "@deepseek-ai/dsh-llm": "^0.2.0-rc.2",
68
- "@deepseek-ai/dsh-scope": "^0.2.0-rc.2",
69
- "@deepseek-ai/dsh-settings": "^0.2.0-rc.2",
70
- "@deepseek-ai/dsh-system-prompt": "^0.2.0-rc.2",
71
- "@deepseek-ai/dsh-timeout": "^0.2.0-rc.2",
72
- "@deepseek-ai/dsh-tools": "^0.2.0-rc.2",
54
+ "@deepseek-ai/cordis": "^4.0.5",
55
+ "@deepseek-ai/dsh-client-connection": "^0.2.1-rc.2",
56
+ "@deepseek-ai/dsh-client-locale": "^0.2.1-rc.2",
57
+ "@deepseek-ai/dsh-client-ui-renderer": "^0.2.1-rc.2",
58
+ "@deepseek-ai/dsh-client-ui-layout": "^0.2.1-rc.2",
59
+ "@deepseek-ai/dsh-client-ui-settings": "^0.2.1-rc.2",
60
+ "@deepseek-ai/dsh-client-ui-sidebar": "^0.2.1-rc.2",
61
+ "@deepseek-ai/dsh-client-ui-slots": "^0.2.1-rc.2",
62
+ "@deepseek-ai/dsh-host-webserver": "^0.2.1-rc.2",
63
+ "@deepseek-ai/dsh-llm": "^0.2.1-rc.2",
64
+ "@deepseek-ai/dsh-scope": "^0.2.1-rc.2",
65
+ "@deepseek-ai/dsh-settings": "^0.2.1-rc.2",
66
+ "@deepseek-ai/dsh-system-prompt": "^0.2.1-rc.2",
67
+ "@deepseek-ai/dsh-timeout": "^0.2.1-rc.2",
68
+ "@deepseek-ai/dsh-tools": "^0.2.1-rc.2",
73
69
  "@types/node": "^22.20.0",
74
70
  "@types/react": "~18.3.1",
75
71
  "@types/react-dom": "^18.3.5",
@@ -99,10 +95,10 @@
99
95
  "url": "https://github.com/zhu1090093659/dsh-web.git"
100
96
  },
101
97
  "scripts": {
102
- "build": "tsc -p tsconfig.build.json && tsdown",
98
+ "build": "tsc -b tsconfig.build.json && tsdown",
103
99
  "bundle": "tsdown",
104
100
  "watch": "tsdown --watch",
105
- "typecheck": "tsc --noEmit",
101
+ "typecheck": "tsgo --noEmit",
106
102
  "test": "vitest run",
107
103
  "test:watch": "vitest"
108
104
  }
@@ -66,7 +66,6 @@ const PENDING_SNAPSHOT: ConfigFormSnapshot<never> = {
66
66
  user: undefined,
67
67
  revision: undefined,
68
68
  writable: false,
69
- mode: 'host',
70
69
  }
71
70
 
72
71
  /** Whether one Host namespace section carries a field this plugin's schema declares. */
@@ -16,6 +16,8 @@ export interface TunnelRecord {
16
16
  /** The pooled connection this tunnel pins; siblings on one alias may share it. */
17
17
  record: PoolRecord
18
18
  sockets: Set<Socket>
19
+ /** Detach the link-death hook startTunnel installs on the pooled client. */
20
+ detachLink?: () => void
19
21
  }
20
22
 
21
23
  /** The engine slice the tunnel module needs (adds the tunnel registry). */
@@ -24,6 +26,22 @@ export interface TunnelEngine extends PoolEngine {
24
26
  nextTunnelId: number
25
27
  }
26
28
 
29
+ /** Destroy one socket or channel stream, tolerating an already-dead peer. */
30
+ function destroyQuietly(target: { destroy(): void }): void {
31
+ try { target.destroy() } catch { /* already closed */ }
32
+ }
33
+
34
+ /**
35
+ * Tear down one tunnel because the SSH link behind it is gone. Idempotent: the
36
+ * tunnel registry is the guard, so a socket callback or a link-death hook that
37
+ * arrives after a manual stop does nothing instead of tearing down twice, and
38
+ * no teardown error can escape into the net connection handler — an exception
39
+ * there terminates the whole host process.
40
+ */
41
+ function abortTunnel(engine: TunnelEngine, id: string): void {
42
+ try { stopTunnel(engine, id) } catch { /* teardown already in progress */ }
43
+ }
44
+
27
45
  /** Start a local port-forward tunnel (listens on 127.0.0.1 only). */
28
46
  export async function startTunnel(
29
47
  engine: TunnelEngine,
@@ -58,26 +76,47 @@ export async function startTunnel(
58
76
  const record = engine.pool.get(alias) ?? (await acquire(engine, alias))
59
77
  const client = record.client
60
78
  const sockets = new Set<Socket>()
79
+ // A tunnel whose SSH link died can only refuse what it is handed, so every
80
+ // path that learns the link is gone tears the tunnel down as well instead
81
+ // of leaving a listener alive on a connection that forwards nothing.
82
+ const onLinkClosed = (): void => { abortTunnel(engine, id) }
83
+ const detachLink = (): void => { client.off('close', onLinkClosed) }
61
84
  const server = createServer((socket) => {
62
85
  sockets.add(socket)
63
86
  socket.on('close', () => { sockets.delete(socket) })
64
- client.forwardOut('127.0.0.1', 0, remoteHost, options.remotePort, (error, stream) => {
65
- if (error !== undefined) {
66
- socket.destroy()
67
- return
68
- }
69
- // Both ends of the pipe can die independently; destroy the pair so an
70
- // unhandled 'error' event can never crash the host process.
71
- const destroy = (): void => {
72
- try { socket.destroy() } catch { /* gone */ }
73
- try { stream.close() } catch { /* gone */ }
74
- }
75
- stream.on('error', destroy)
76
- socket.on('error', destroy)
77
- stream.on('close', destroy)
78
- socket.on('close', destroy)
79
- stream.pipe(socket).pipe(stream)
80
- })
87
+ // The link can die between taking the record above and this connection.
88
+ if (record.broken) {
89
+ destroyQuietly(socket)
90
+ abortTunnel(engine, id)
91
+ return
92
+ }
93
+ try {
94
+ client.forwardOut('127.0.0.1', 0, remoteHost, options.remotePort, (error, stream) => {
95
+ if (error !== undefined) {
96
+ // A refused remote channel is the remote service's answer, not a
97
+ // dead SSH link: the tunnel itself stays up for the next attempt.
98
+ destroyQuietly(socket)
99
+ return
100
+ }
101
+ // Both ends of the pipe can die independently; destroy the pair so an
102
+ // unhandled 'error' event can never crash the host process.
103
+ const destroy = (): void => {
104
+ destroyQuietly(socket)
105
+ try { stream.close() } catch { /* gone */ }
106
+ }
107
+ stream.on('error', destroy)
108
+ socket.on('error', destroy)
109
+ stream.on('close', destroy)
110
+ socket.on('close', destroy)
111
+ stream.pipe(socket).pipe(stream)
112
+ })
113
+ } catch {
114
+ // ssh2 reports 'Not connected' by throwing out of forwardOut rather than
115
+ // through the callback; an exception escaping this connection handler
116
+ // is an uncaught exception that takes the host process down with it.
117
+ destroyQuietly(socket)
118
+ abortTunnel(engine, id)
119
+ }
81
120
  })
82
121
  try {
83
122
  await new Promise<void>((resolve, reject) => {
@@ -96,7 +135,20 @@ export async function startTunnel(
96
135
  const address = server.address()
97
136
  info.localPort = typeof address === 'object' && address !== null ? address.port : 0
98
137
  info.state = 'forwarding'
99
- engine.tunnels.set(id, { info, server, alias, record, sockets })
138
+ engine.tunnels.set(id, { info, server, alias, record, sockets, detachLink })
139
+ // A dead link must not keep a listener alive: without this hook the tunnel
140
+ // outlives its connection and every later local connection hits the dead
141
+ // forwardOut path above.
142
+ client.once('close', onLinkClosed)
143
+ if (record.broken) {
144
+ // A link that died while the listener was binding already emitted 'close',
145
+ // so the pool's broken flag is the only signal left for that window. The
146
+ // tunnel retires right away, and the start reports that instead of
147
+ // handing the caller a forwarding tunnel that no longer exists.
148
+ info.state = 'failed'
149
+ info.error = 'ssh link closed while the tunnel was starting'
150
+ abortTunnel(engine, id)
151
+ }
100
152
  return info
101
153
  }
102
154
 
@@ -110,9 +162,12 @@ export function stopTunnel(engine: TunnelEngine, id: string): boolean {
110
162
  const tunnel = engine.tunnels.get(id)
111
163
  if (tunnel === undefined) return false
112
164
  engine.tunnels.delete(id)
165
+ // Drop the link-death hook first: a stop is never the link's death, and a
166
+ // long-lived pooled client outlives many tunnels.
167
+ try { tunnel.detachLink?.() } catch { /* already detached */ }
113
168
  try { tunnel.server.close() } catch { /* already closed */ }
114
169
  for (const socket of tunnel.sockets) {
115
- try { socket.destroy() } catch { /* already closed */ }
170
+ destroyQuietly(socket)
116
171
  }
117
172
  tunnel.sockets.clear()
118
173
  // Sibling tunnels on the same alias share the pinned connection: release it
package/lib/invariant.js DELETED
@@ -1,6 +0,0 @@
1
- //#region src/invariant.ts
2
- /** Invariant companion plugin (no assertions — nothing to check at runtime). */
3
- /** Provides no assertions: the ssh plugin owns no cross-package runtime invariants. */
4
- function apply() {}
5
- //#endregion
6
- export { apply };
@@ -1,3 +0,0 @@
1
- /** Invariant companion plugin (no assertions — nothing to check at runtime). */
2
- /** Provides no assertions: the ssh plugin owns no cross-package runtime invariants. */
3
- export declare function apply(): void;
package/src/invariant.ts DELETED
@@ -1,4 +0,0 @@
1
- /** Invariant companion plugin (no assertions — nothing to check at runtime). */
2
-
3
- /** Provides no assertions: the ssh plugin owns no cross-package runtime invariants. */
4
- export function apply(): void {}