@linxin666/dsh-ssh 0.4.5 → 0.4.6
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 +1 -0
- package/README.zh.md +1 -0
- package/lib/client.js +1 -1
- package/lib/index.js +63 -23
- package/lib/types/engine/tunnel.d.ts +2 -0
- package/package.json +2 -6
- 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/README.i18n.yaml
CHANGED
|
@@ -2,5 +2,5 @@
|
|
|
2
2
|
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
|
3
3
|
# after editing either side, bring the other along and re-record with:
|
|
4
4
|
# node scripts/verify-docs.mjs --write <dir>
|
|
5
|
-
README.md:
|
|
6
|
-
README.zh.md:
|
|
5
|
+
README.md: 9031d9df52f51011cc951cba61665da91b42c65b
|
|
6
|
+
README.zh.md: ddae66eb2e06138a8c9157dcfd07da00acdba0f1
|
package/README.md
CHANGED
|
@@ -40,6 +40,7 @@ The panel loads its contents on first open. Closing and reopening it preserves t
|
|
|
40
40
|
- ssh-agent auth stores only the agent socket path (or the special value `pageant`); it never reads or stores private-key material.
|
|
41
41
|
- Tunnels only listen on `127.0.0.1`.
|
|
42
42
|
- Deleting a host or changing its connection fields (host / port / user / auth / proxyJump / proxyCommand) immediately closes that alias's pooled connection and tunnels; later operations reconnect with the new configuration and never reuse a connection authenticated with the old credentials.
|
|
43
|
+
- When the SSH connection behind a tunnel drops, the tunnel retires itself: the local port stops accepting, the tunnel leaves the list, and the connection that arrives during the drop is closed without traffic instead of interrupting the host process.
|
|
43
44
|
- A `proxyCommand` value is a shell command executed by the DSH host process with its privileges — exactly the trust `ssh(1)` gives the same line in `~/.ssh/config`. It can only come from the user's own 0600 store file: the Agent cannot create or edit hosts, and `ssh_list` reports only whether a host has one, never the command text.
|
|
44
45
|
- Before the Agent uses a tool, the host must first be configured in the GUI (or imported from ~/.ssh/config).
|
|
45
46
|
- `ssh_upload` / `ssh_download` read/write arbitrary local paths on this machine with host-process privileges (not through the bash sandbox) — same host-local-path semantics as ssh-skill, be aware of that permission surface.
|
package/README.zh.md
CHANGED
|
@@ -40,6 +40,7 @@
|
|
|
40
40
|
- ssh-agent 认证仅保存 agent socket 路径(或 `pageant` 特殊值),不读取也不保存任何私钥材料。
|
|
41
41
|
- 隧道只监听 `127.0.0.1`。
|
|
42
42
|
- 删除主机或修改其连接字段(host / port / user / auth / proxyJump / proxyCommand)会立即断开该别名的池化连接与隧道,后续操作按新配置重新建连,不会复用旧凭据的已认证连接。
|
|
43
|
+
- 隧道背后的 SSH 连接一旦断开,隧道会自行退役:本地端口不再接受连接,隧道从列表中消失;断开期间到达的连接被直接关闭且没有流量,不会打断宿主进程。
|
|
43
44
|
- `proxyCommand` 是一条由 DSH 宿主进程以其自身权限执行的 shell 命令——与 `ssh(1)` 执行 `~/.ssh/config` 里同一行的信任模型一致。它只能来自用户自己的 0600 配置文件:Agent 无法创建或修改主机,`ssh_list` 只报告该主机是否配置了 ProxyCommand,不返回命令原文。
|
|
44
45
|
- Agent 使用工具前,主机需先在 GUI 中配置(或从 ~/.ssh/config 导入)。
|
|
45
46
|
- `ssh_upload` / `ssh_download` 以宿主进程权限直接读写本机任意路径(不经 bash 沙箱)——与 ssh-skill 的宿主本地路径语义一致,注意该权限面。
|
package/lib/client.js
CHANGED
|
@@ -16415,7 +16415,7 @@ window.__ModuleLoader__.load({
|
|
|
16415
16415
|
/** The building package's version, when the bundle carries it. */
|
|
16416
16416
|
function bakedVersion() {
|
|
16417
16417
|
try {
|
|
16418
|
-
return "0.4.
|
|
16418
|
+
return "0.4.6";
|
|
16419
16419
|
} catch {
|
|
16420
16420
|
return;
|
|
16421
16421
|
}
|
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.6",
|
|
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"
|
|
@@ -99,7 +95,7 @@
|
|
|
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
101
|
"typecheck": "tsc --noEmit",
|
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