@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/README.i18n.yaml +2 -2
- package/README.md +2 -1
- package/README.zh.md +2 -1
- package/lib/client.js +2 -3
- package/lib/client.js.map +1 -1
- package/lib/index.js +63 -23
- package/lib/types/engine/tunnel.d.ts +2 -0
- package/package.json +21 -25
- package/src/client/settings-binding.ts +0 -1
- package/src/engine/tunnel.ts +74 -19
- package/lib/invariant.js +0 -6
- package/lib/types/invariant.d.ts +0 -3
- package/src/invariant.ts +0 -4
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
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
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.
|
|
1576
|
+
tunnel.detachLink?.();
|
|
1538
1577
|
} catch {}
|
|
1539
|
-
|
|
1540
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
59
|
-
"@deepseek-ai/dsh-client-connection": "^0.2.
|
|
60
|
-
"@deepseek-ai/dsh-client-locale": "^0.2.
|
|
61
|
-
"@deepseek-ai/dsh-client-ui-renderer": "^0.2.
|
|
62
|
-
"@deepseek-ai/dsh-client-ui-layout": "^0.2.
|
|
63
|
-
"@deepseek-ai/dsh-client-ui-settings": "^0.2.
|
|
64
|
-
"@deepseek-ai/dsh-client-ui-sidebar": "^0.2.
|
|
65
|
-
"@deepseek-ai/dsh-client-ui-slots": "^0.2.
|
|
66
|
-
"@deepseek-ai/dsh-host-webserver": "^0.2.
|
|
67
|
-
"@deepseek-ai/dsh-llm": "^0.2.
|
|
68
|
-
"@deepseek-ai/dsh-scope": "^0.2.
|
|
69
|
-
"@deepseek-ai/dsh-settings": "^0.2.
|
|
70
|
-
"@deepseek-ai/dsh-system-prompt": "^0.2.
|
|
71
|
-
"@deepseek-ai/dsh-timeout": "^0.2.
|
|
72
|
-
"@deepseek-ai/dsh-tools": "^0.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 -
|
|
98
|
+
"build": "tsc -b tsconfig.build.json && tsdown",
|
|
103
99
|
"bundle": "tsdown",
|
|
104
100
|
"watch": "tsdown --watch",
|
|
105
|
-
"typecheck": "
|
|
101
|
+
"typecheck": "tsgo --noEmit",
|
|
106
102
|
"test": "vitest run",
|
|
107
103
|
"test:watch": "vitest"
|
|
108
104
|
}
|
package/src/engine/tunnel.ts
CHANGED
|
@@ -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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
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
package/lib/types/invariant.d.ts
DELETED
package/src/invariant.ts
DELETED