@linxin666/dsh-ssh 0.1.18 → 0.1.20
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 +14 -11
- package/lib/client.js.map +1 -1
- package/lib/index.js +211 -111
- package/lib/types/client/locales.d.ts +127 -127
- package/lib/types/engine/connection-pool.d.ts +3 -1
- package/lib/types/engine/tunnel.d.ts +4 -2
- package/lib/types/engine.d.ts +7 -0
- package/lib/types/index.d.ts +3 -1
- package/lib/types/loopback.d.ts +24 -0
- package/lib/types/mount-once.d.ts +24 -0
- package/lib/types/protocol.d.ts +1 -1
- package/lib/types/routes.d.ts +2 -0
- package/lib/types/store.d.ts +6 -0
- package/package.json +1 -1
- package/src/client/locales.ts +2 -2
- package/src/client/panel/SshPanel.tsx +9 -1
- package/src/client/panel/panel.module.css +6 -0
- package/src/engine/connection-pool.ts +28 -8
- package/src/engine/sftp.ts +29 -38
- package/src/engine/tunnel.ts +19 -5
- package/src/engine.ts +11 -0
- package/src/index.ts +4 -1
- package/src/loopback.ts +63 -0
- package/src/mount-once.ts +48 -0
- package/src/routes.ts +24 -25
- package/src/store.ts +21 -2
|
@@ -2,133 +2,133 @@
|
|
|
2
2
|
* dsh-ssh surface copy: zh is the key source, en mirrors every key.
|
|
3
3
|
*/
|
|
4
4
|
export declare const zh: {
|
|
5
|
-
readonly 'entry.label':
|
|
6
|
-
readonly 'entry.tooltip':
|
|
7
|
-
readonly 'panel.title':
|
|
8
|
-
readonly '
|
|
9
|
-
readonly 'tab.
|
|
10
|
-
readonly 'tab.
|
|
11
|
-
readonly 'tab.
|
|
12
|
-
readonly 'tab.
|
|
13
|
-
readonly '
|
|
14
|
-
readonly 'hosts.
|
|
15
|
-
readonly 'hosts.
|
|
16
|
-
readonly 'hosts.
|
|
17
|
-
readonly 'hosts.
|
|
18
|
-
readonly 'hosts.col.
|
|
19
|
-
readonly 'hosts.col.
|
|
20
|
-
readonly 'hosts.col.
|
|
21
|
-
readonly 'hosts.col.
|
|
22
|
-
readonly 'hosts.col.
|
|
23
|
-
readonly 'hosts.col.
|
|
24
|
-
readonly 'hosts.col.
|
|
25
|
-
readonly 'hosts.
|
|
26
|
-
readonly 'hosts.
|
|
27
|
-
readonly 'hosts.
|
|
28
|
-
readonly 'hosts.
|
|
29
|
-
readonly 'hosts.
|
|
30
|
-
readonly 'hosts.
|
|
31
|
-
readonly 'hosts.
|
|
32
|
-
readonly 'hosts.
|
|
33
|
-
readonly 'hosts.
|
|
34
|
-
readonly 'hosts.
|
|
35
|
-
readonly '
|
|
36
|
-
readonly 'form.title.
|
|
37
|
-
readonly 'form.
|
|
38
|
-
readonly 'form.
|
|
39
|
-
readonly 'form.
|
|
40
|
-
readonly 'form.
|
|
41
|
-
readonly 'form.
|
|
42
|
-
readonly 'form.
|
|
43
|
-
readonly 'form.auth
|
|
44
|
-
readonly 'form.auth.
|
|
45
|
-
readonly 'form.
|
|
46
|
-
readonly 'form.
|
|
47
|
-
readonly 'form.
|
|
48
|
-
readonly 'form.
|
|
49
|
-
readonly 'form.
|
|
50
|
-
readonly 'form.
|
|
51
|
-
readonly 'form.
|
|
52
|
-
readonly 'form.
|
|
53
|
-
readonly 'form.
|
|
54
|
-
readonly 'form.
|
|
55
|
-
readonly 'form.
|
|
56
|
-
readonly 'form.
|
|
57
|
-
readonly 'form.
|
|
58
|
-
readonly 'form.
|
|
59
|
-
readonly 'form.
|
|
60
|
-
readonly 'form.
|
|
61
|
-
readonly 'form.
|
|
62
|
-
readonly 'form.
|
|
63
|
-
readonly '
|
|
64
|
-
readonly 'terminal.
|
|
65
|
-
readonly 'terminal.
|
|
66
|
-
readonly 'terminal.
|
|
67
|
-
readonly 'terminal.
|
|
68
|
-
readonly 'terminal.
|
|
69
|
-
readonly 'terminal.
|
|
70
|
-
readonly 'terminal.
|
|
71
|
-
readonly '
|
|
72
|
-
readonly 'transfer.
|
|
73
|
-
readonly 'transfer.
|
|
74
|
-
readonly 'transfer.
|
|
75
|
-
readonly 'transfer.
|
|
76
|
-
readonly 'transfer.
|
|
77
|
-
readonly 'transfer.
|
|
78
|
-
readonly 'transfer.
|
|
79
|
-
readonly 'transfer.
|
|
80
|
-
readonly 'transfer.
|
|
81
|
-
readonly 'transfer.
|
|
82
|
-
readonly 'transfer.
|
|
83
|
-
readonly 'transfer.
|
|
84
|
-
readonly 'transfer.
|
|
85
|
-
readonly 'transfer.
|
|
86
|
-
readonly 'transfer.
|
|
87
|
-
readonly 'transfer.
|
|
88
|
-
readonly 'transfer.
|
|
89
|
-
readonly 'transfer.
|
|
90
|
-
readonly 'transfer.
|
|
91
|
-
readonly 'transfer.
|
|
92
|
-
readonly 'transfer.
|
|
93
|
-
readonly 'transfer.
|
|
94
|
-
readonly '
|
|
95
|
-
readonly 'tunnel.
|
|
96
|
-
readonly 'tunnel.
|
|
97
|
-
readonly 'tunnel.
|
|
98
|
-
readonly 'tunnel.
|
|
99
|
-
readonly 'tunnel.
|
|
100
|
-
readonly 'tunnel.
|
|
101
|
-
readonly 'tunnel.
|
|
102
|
-
readonly 'tunnel.
|
|
103
|
-
readonly 'tunnel.
|
|
104
|
-
readonly 'tunnel.
|
|
105
|
-
readonly 'tunnel.
|
|
106
|
-
readonly 'tunnel.
|
|
107
|
-
readonly 'tunnel.
|
|
108
|
-
readonly '
|
|
109
|
-
readonly 'cluster.
|
|
110
|
-
readonly 'cluster.
|
|
111
|
-
readonly 'cluster.
|
|
112
|
-
readonly 'cluster.
|
|
113
|
-
readonly 'cluster.
|
|
114
|
-
readonly 'cluster.
|
|
115
|
-
readonly 'cluster.
|
|
116
|
-
readonly 'cluster.
|
|
117
|
-
readonly 'cluster.
|
|
118
|
-
readonly 'cluster.
|
|
119
|
-
readonly 'cluster.
|
|
120
|
-
readonly 'cluster.
|
|
121
|
-
readonly 'cluster.col.
|
|
122
|
-
readonly 'cluster.col.
|
|
123
|
-
readonly 'cluster.col.
|
|
124
|
-
readonly 'cluster.col.
|
|
125
|
-
readonly 'cluster.col.
|
|
126
|
-
readonly 'cluster.col.
|
|
127
|
-
readonly '
|
|
128
|
-
readonly 'common.
|
|
129
|
-
readonly 'common.
|
|
130
|
-
readonly 'common.
|
|
131
|
-
readonly 'common.confirm':
|
|
5
|
+
readonly 'entry.label': 'SSH';
|
|
6
|
+
readonly 'entry.tooltip': '远程 SSH 运维面板';
|
|
7
|
+
readonly 'panel.title': '远程 SSH';
|
|
8
|
+
readonly 'panel.backToConversation': '返回会话';
|
|
9
|
+
readonly 'tab.hosts': '主机';
|
|
10
|
+
readonly 'tab.terminal': '终端';
|
|
11
|
+
readonly 'tab.transfer': '传输';
|
|
12
|
+
readonly 'tab.tunnels': '隧道';
|
|
13
|
+
readonly 'tab.cluster': '集群';
|
|
14
|
+
readonly 'hosts.empty': '尚未配置主机。点击「新增主机」添加,或从 ~/.ssh/config 导入。';
|
|
15
|
+
readonly 'hosts.add': '新增主机';
|
|
16
|
+
readonly 'hosts.import': '导入 ~/.ssh/config';
|
|
17
|
+
readonly 'hosts.search': '搜索别名 / 描述 / 标签…';
|
|
18
|
+
readonly 'hosts.col.alias': '别名';
|
|
19
|
+
readonly 'hosts.col.host': '主机';
|
|
20
|
+
readonly 'hosts.col.user': '用户';
|
|
21
|
+
readonly 'hosts.col.auth': '认证';
|
|
22
|
+
readonly 'hosts.col.environment': '环境';
|
|
23
|
+
readonly 'hosts.col.tags': '标签';
|
|
24
|
+
readonly 'hosts.col.description': '备注';
|
|
25
|
+
readonly 'hosts.col.actions': '操作';
|
|
26
|
+
readonly 'hosts.test': '测试';
|
|
27
|
+
readonly 'hosts.edit': '编辑';
|
|
28
|
+
readonly 'hosts.delete': '删除';
|
|
29
|
+
readonly 'hosts.deleteConfirm': '确定删除主机「{alias}」?将同时停止其隧道。';
|
|
30
|
+
readonly 'hosts.imported': '解析 {parsed} 个配置块,新增 {added} 台主机,跳过 {skipped} 个。';
|
|
31
|
+
readonly 'hosts.testing': '连接测试中…';
|
|
32
|
+
readonly 'hosts.testOk': '连接成功({latency} ms)';
|
|
33
|
+
readonly 'hosts.testFail': '连接失败:{error}';
|
|
34
|
+
readonly 'hosts.connected': '连接';
|
|
35
|
+
readonly 'hosts.notConnected': '未连接';
|
|
36
|
+
readonly 'form.title.create': '新增主机';
|
|
37
|
+
readonly 'form.title.edit': '编辑主机 {alias}';
|
|
38
|
+
readonly 'form.alias': '别名';
|
|
39
|
+
readonly 'form.aliasHint': '小写字母、数字与单连字符,操作时使用';
|
|
40
|
+
readonly 'form.host': '主机地址';
|
|
41
|
+
readonly 'form.port': '端口';
|
|
42
|
+
readonly 'form.user': '用户名';
|
|
43
|
+
readonly 'form.auth': '认证方式';
|
|
44
|
+
readonly 'form.auth.key': '密钥';
|
|
45
|
+
readonly 'form.auth.password': '密码';
|
|
46
|
+
readonly 'form.authKeepHint': '编辑时密钥/密码留空将保留原认证方式与已存凭据;如需更换认证方式请填写新凭据。';
|
|
47
|
+
readonly 'form.keyPath': '私钥路径';
|
|
48
|
+
readonly 'form.keyPathHint': '如 ~/.ssh/id_ed25519';
|
|
49
|
+
readonly 'form.passphrase': '密钥口令';
|
|
50
|
+
readonly 'form.password': '密码';
|
|
51
|
+
readonly 'form.passwordHint': '明文保存在 ~/.dsh/dsh-ssh.json(本机私有文件)';
|
|
52
|
+
readonly 'form.proxyJump': '跳板机别名';
|
|
53
|
+
readonly 'form.proxyJumpHint': '逗号分隔的本地主机别名,按顺序连接';
|
|
54
|
+
readonly 'form.description': '备注';
|
|
55
|
+
readonly 'form.environment': '环境';
|
|
56
|
+
readonly 'form.environmentHint': '如 development / production';
|
|
57
|
+
readonly 'form.tags': '标签';
|
|
58
|
+
readonly 'form.tagsHint': '逗号分隔';
|
|
59
|
+
readonly 'form.location': '位置';
|
|
60
|
+
readonly 'form.save': '保存';
|
|
61
|
+
readonly 'form.cancel': '取消';
|
|
62
|
+
readonly 'form.required': '请填写必填字段:别名、主机地址、用户名。';
|
|
63
|
+
readonly 'form.portInvalid': '端口必须是 1-65535 之间的整数。';
|
|
64
|
+
readonly 'terminal.selectHost': '选择主机';
|
|
65
|
+
readonly 'terminal.connect': '连接';
|
|
66
|
+
readonly 'terminal.disconnect': '断开';
|
|
67
|
+
readonly 'terminal.placeholder': '选择主机后点击「连接」打开远程终端。';
|
|
68
|
+
readonly 'terminal.connecting': '正在连接…';
|
|
69
|
+
readonly 'terminal.ready': '终端已连接({alias})';
|
|
70
|
+
readonly 'terminal.exited': '终端已退出({alias})';
|
|
71
|
+
readonly 'terminal.error': '终端错误:{error}';
|
|
72
|
+
readonly 'transfer.selectHost': '选择主机';
|
|
73
|
+
readonly 'transfer.remotePath': '远程路径';
|
|
74
|
+
readonly 'transfer.remotePathHint': '如 /tmp/ 或 /home/user/app.tar.gz';
|
|
75
|
+
readonly 'transfer.upload': '上传';
|
|
76
|
+
readonly 'transfer.uploading': '正在上传 {file}…';
|
|
77
|
+
readonly 'transfer.download': '下载';
|
|
78
|
+
readonly 'transfer.downloading': '正在下载…';
|
|
79
|
+
readonly 'transfer.done': '完成:{bytes} 字节';
|
|
80
|
+
readonly 'transfer.failed': '传输失败:{error}';
|
|
81
|
+
readonly 'transfer.browseRemote': '浏览远程目录';
|
|
82
|
+
readonly 'transfer.refresh': '刷新';
|
|
83
|
+
readonly 'transfer.name': '名称';
|
|
84
|
+
readonly 'transfer.upLevel': '上级目录';
|
|
85
|
+
readonly 'transfer.size': '大小';
|
|
86
|
+
readonly 'transfer.modified': '修改时间';
|
|
87
|
+
readonly 'transfer.bytes': '{value} B';
|
|
88
|
+
readonly 'transfer.kib': '{value} KB';
|
|
89
|
+
readonly 'transfer.mib': '{value} MB';
|
|
90
|
+
readonly 'transfer.gib': '{value} GB';
|
|
91
|
+
readonly 'transfer.percent': '{value}%';
|
|
92
|
+
readonly 'transfer.speed': '{value}/s';
|
|
93
|
+
readonly 'transfer.dir': '目录';
|
|
94
|
+
readonly 'transfer.file': '文件';
|
|
95
|
+
readonly 'tunnel.empty': '没有活跃的隧道。';
|
|
96
|
+
readonly 'tunnel.start': '新建隧道';
|
|
97
|
+
readonly 'tunnel.alias': '主机别名';
|
|
98
|
+
readonly 'tunnel.remotePort': '远程端口';
|
|
99
|
+
readonly 'tunnel.remoteHost': '远程主机';
|
|
100
|
+
readonly 'tunnel.remoteHostHint': '默认 127.0.0.1(服务器自身)';
|
|
101
|
+
readonly 'tunnel.localPort': '本地端口';
|
|
102
|
+
readonly 'tunnel.localPortHint': '留空自动分配';
|
|
103
|
+
readonly 'tunnel.stop': '停止';
|
|
104
|
+
readonly 'tunnel.stopAll': '全部停止';
|
|
105
|
+
readonly 'tunnel.stopAllConfirm': '确定停止全部隧道吗?';
|
|
106
|
+
readonly 'tunnel.row': '{alias}: 127.0.0.1:{localPort} -> {remoteHost}:{remotePort}';
|
|
107
|
+
readonly 'tunnel.started': '隧道已建立:127.0.0.1:{localPort}';
|
|
108
|
+
readonly 'tunnel.failed': '隧道启动失败:{error}';
|
|
109
|
+
readonly 'cluster.command': '命令';
|
|
110
|
+
readonly 'cluster.run': '执行';
|
|
111
|
+
readonly 'cluster.confirm': '确定在匹配的主机上执行该命令吗?该操作会真实运行在远程服务器上。';
|
|
112
|
+
readonly 'cluster.aliases': '主机别名(逗号分隔,留空为全部)';
|
|
113
|
+
readonly 'cluster.environment': '环境过滤';
|
|
114
|
+
readonly 'cluster.tags': '标签过滤(逗号分隔)';
|
|
115
|
+
readonly 'cluster.results': '执行结果';
|
|
116
|
+
readonly 'cluster.empty': '输入命令后点击「执行」。';
|
|
117
|
+
readonly 'cluster.noMatch': '没有匹配的主机。';
|
|
118
|
+
readonly 'cluster.ok': '成功';
|
|
119
|
+
readonly 'cluster.fail': '失败';
|
|
120
|
+
readonly 'cluster.timeout': '超时';
|
|
121
|
+
readonly 'cluster.col.alias': '主机';
|
|
122
|
+
readonly 'cluster.col.status': '状态';
|
|
123
|
+
readonly 'cluster.col.exit': '退出码';
|
|
124
|
+
readonly 'cluster.col.stdout': '标准输出';
|
|
125
|
+
readonly 'cluster.col.stderr': '标准错误';
|
|
126
|
+
readonly 'cluster.col.error': '错误';
|
|
127
|
+
readonly 'cluster.col.duration': '耗时';
|
|
128
|
+
readonly 'common.loading': '加载中…';
|
|
129
|
+
readonly 'common.error': '出错:{error}';
|
|
130
|
+
readonly 'common.refresh': '刷新';
|
|
131
|
+
readonly 'common.confirm': '确定';
|
|
132
132
|
};
|
|
133
133
|
export declare const en: Record<keyof typeof zh, string>;
|
|
134
134
|
/** Locale key union. */
|
|
@@ -47,7 +47,7 @@ export interface PoolEngine {
|
|
|
47
47
|
readonly acquireQueue: Map<string, Promise<PoolRecord>>;
|
|
48
48
|
}
|
|
49
49
|
/** Build the ssh2 connect config for one entry (key read from disk). */
|
|
50
|
-
export declare function buildConnectConfig(entry: SshHostEntry, sock
|
|
50
|
+
export declare function buildConnectConfig(entry: SshHostEntry, sock: ConnectConfig['sock'] | undefined, opts: Required<EngineOptions>): ConnectConfig;
|
|
51
51
|
/** Connect one ssh2 client (resolve on ready, reject on error/close). */
|
|
52
52
|
export declare function connectClient(config: ConnectConfig): Promise<Client>;
|
|
53
53
|
/** Cap captured output at the configured byte budget (marks truncation). */
|
|
@@ -72,6 +72,8 @@ export declare function acquire(engine: PoolEngine, alias: string): Promise<Pool
|
|
|
72
72
|
* is torn down — the connection belongs to someone else now.
|
|
73
73
|
*/
|
|
74
74
|
export declare function disposeRecord(engine: PoolEngine, alias: string, record?: PoolRecord): void;
|
|
75
|
+
/** End one record's client and hop chain (best-effort, safe to repeat). */
|
|
76
|
+
export declare function endRecordChain(record: PoolRecord): void;
|
|
75
77
|
/** Close connections idle beyond the threshold (skips pinned and in-flight). */
|
|
76
78
|
export declare function sweepPool(engine: PoolEngine): void;
|
|
77
79
|
/**
|
|
@@ -5,12 +5,14 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { type Server as NetServer, type Socket } from 'node:net';
|
|
7
7
|
import type { TunnelInfo } from '../protocol.ts';
|
|
8
|
-
import { type PoolEngine } from './connection-pool.ts';
|
|
9
|
-
/** One active tunnel record (server + pinned
|
|
8
|
+
import { type PoolEngine, type PoolRecord } from './connection-pool.ts';
|
|
9
|
+
/** One active tunnel record (server + pinned connection + live sockets). */
|
|
10
10
|
export interface TunnelRecord {
|
|
11
11
|
info: TunnelInfo;
|
|
12
12
|
server: NetServer;
|
|
13
13
|
alias: string;
|
|
14
|
+
/** The pooled connection this tunnel pins; siblings on one alias may share it. */
|
|
15
|
+
record: PoolRecord;
|
|
14
16
|
sockets: Set<Socket>;
|
|
15
17
|
}
|
|
16
18
|
/** The engine slice the tunnel module needs (adds the tunnel registry). */
|
package/lib/types/engine.d.ts
CHANGED
|
@@ -74,6 +74,13 @@ export declare class SshEngine {
|
|
|
74
74
|
stopTunnel(id: string): boolean;
|
|
75
75
|
/** Stop all tunnels (optionally for one alias). */
|
|
76
76
|
stopAllTunnels(alias?: string): number;
|
|
77
|
+
/**
|
|
78
|
+
* Drop every live artifact bound to one alias: stop its tunnels and close
|
|
79
|
+
* the pooled connection. Host entries that are deleted or whose connection
|
|
80
|
+
* fields change must never keep serving a stale, previously authenticated
|
|
81
|
+
* connection — the next operation re-connects from the current config.
|
|
82
|
+
*/
|
|
83
|
+
dropAlias(alias: string): void;
|
|
77
84
|
/** Probe connectivity: connect, run `true`, close. */
|
|
78
85
|
test(alias: string): Promise<TestResult>;
|
|
79
86
|
/** Close every pooled connection and tunnel. */
|
package/lib/types/index.d.ts
CHANGED
|
@@ -37,4 +37,6 @@ export declare const SSH_GUIDANCE = "\u672C\u673A\u5DF2\u5B89\u88C5 dsh-ssh \u63
|
|
|
37
37
|
* @param ctx - host plugin context carrying webServer/tools/systemPrompt.
|
|
38
38
|
* @param config - resolved plugin config (schema defaults applied by the loader).
|
|
39
39
|
*/
|
|
40
|
-
export declare
|
|
40
|
+
export declare const apply: typeof applyImpl;
|
|
41
|
+
declare function applyImpl(ctx: Context, config?: Config): void;
|
|
42
|
+
export {};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Loopback trust fence shared by the host route families: socket address,
|
|
3
|
+
* Host header, and browser same-origin markers. Packages receive this file as
|
|
4
|
+
* a generated copy via scripts/sync-shared.mjs; edit the shared source and
|
|
5
|
+
* re-run the sync instead of editing a copy.
|
|
6
|
+
*
|
|
7
|
+
* Semantics: RFC 5735 IPv4 127/8, ::1, IPv4-mapped ::ffff:127/8 (matching the
|
|
8
|
+
* remote-web-ui gate), localhost hostnames, plus the browser same-origin
|
|
9
|
+
* markers (sec-fetch-site and Origin) for the request-level fence.
|
|
10
|
+
* @module dsh-web-ui-shared/host/loopback
|
|
11
|
+
*/
|
|
12
|
+
import type { IncomingMessage } from 'node:http';
|
|
13
|
+
/** IPv4 127/8 predicate (four decimal octets, first == 127). */
|
|
14
|
+
export declare function isIPv4Loopback(v4: string): boolean;
|
|
15
|
+
/** Whether a socket remote address names the loopback range (127/8, ::1, IPv4-mapped). */
|
|
16
|
+
export declare function isLoopbackAddress(address: string | undefined): boolean;
|
|
17
|
+
/** Whether a normalized URL hostname names the loopback authority (localhost, [::1], 127/8). */
|
|
18
|
+
export declare function isLoopbackHostname(hostname: string): boolean;
|
|
19
|
+
/**
|
|
20
|
+
* Request-level trust fence: a loopback socket address AND a loopback Host
|
|
21
|
+
* header, plus browser same-origin markers. The socket address is
|
|
22
|
+
* authoritative; X-Forwarded-For is never trusted.
|
|
23
|
+
*/
|
|
24
|
+
export declare function isLoopbackRequest(request: IncomingMessage): boolean;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host single-instance guard shared by the plugin family. The family bundle
|
|
3
|
+
* (dsh-web-ui-all / dsh-skins) namespaces every child row id (web-ui-*), so
|
|
4
|
+
* the loader accepts a standalone install of the same package side by side;
|
|
5
|
+
* without this guard the second instance would still re-register the same
|
|
6
|
+
* webserver routes, tools, settings namespaces, and system-prompt sections
|
|
7
|
+
* and fail the boot. mountOnce makes the second host apply a no-op for the
|
|
8
|
+
* lifetime of the first instance (the browser half is already deduped by
|
|
9
|
+
* package name in the client module host).
|
|
10
|
+
*
|
|
11
|
+
* The registry rides a global symbol so two module instances of the same
|
|
12
|
+
* package (npm copy vs repository link) still share one verdict. cordis
|
|
13
|
+
* `ctx.effect` runs its callback immediately and treats the callback's
|
|
14
|
+
* return value as the fiber disposer, so the unmarker is returned, not run.
|
|
15
|
+
*/
|
|
16
|
+
/**
|
|
17
|
+
* Wrap a cordis plugin apply so the package runs at most once per process.
|
|
18
|
+
* The first mount registers normally and unmarks when its fiber disposes;
|
|
19
|
+
* any later mount of the same package name is a no-op.
|
|
20
|
+
* @param packageName - npm package identity shared by every install source.
|
|
21
|
+
* @param fn - the original plugin apply.
|
|
22
|
+
* @returns an apply of the same shape.
|
|
23
|
+
*/
|
|
24
|
+
export declare function mountOnce<T extends (...args: any[]) => unknown>(packageName: string, fn: T): T;
|
package/lib/types/protocol.d.ts
CHANGED
|
@@ -164,7 +164,7 @@ export type TerminalClientFrame = {
|
|
|
164
164
|
rows: number;
|
|
165
165
|
};
|
|
166
166
|
/** Route paths the client calls (shared literals). */
|
|
167
|
-
export declare const SSH_API_BASE:
|
|
167
|
+
export declare const SSH_API_BASE: '/api/dsh-ssh';
|
|
168
168
|
export declare const SSH_API: {
|
|
169
169
|
readonly hosts: string;
|
|
170
170
|
readonly importSshConfig: string;
|
package/lib/types/routes.d.ts
CHANGED
|
@@ -17,6 +17,8 @@ export interface SshRoutesDeps {
|
|
|
17
17
|
engine: SshEngine;
|
|
18
18
|
/** Temp dir for upload/download staging (tests inject a sandbox). */
|
|
19
19
|
stagingDir?: string;
|
|
20
|
+
/** Upload byte cap override (tests); defaults to MAX_UPLOAD_BYTES. */
|
|
21
|
+
maxUploadBytes?: number;
|
|
20
22
|
}
|
|
21
23
|
/**
|
|
22
24
|
* Build every /api/dsh-ssh route (exact paths) plus the terminal upgrade.
|
package/lib/types/store.d.ts
CHANGED
|
@@ -47,6 +47,12 @@ export declare class HostStore {
|
|
|
47
47
|
*/
|
|
48
48
|
importFromSshConfig(): ImportResult;
|
|
49
49
|
private skippedNames;
|
|
50
|
+
/**
|
|
51
|
+
* Last parsed store keyed by file identity. list/find ride every acquire
|
|
52
|
+
* and GUI refresh; re-reading and re-parsing the whole file each call is
|
|
53
|
+
* wasted work when the file has not changed. Any save invalidates.
|
|
54
|
+
*/
|
|
55
|
+
private cache;
|
|
50
56
|
private load;
|
|
51
57
|
private save;
|
|
52
58
|
}
|
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.1.
|
|
4
|
+
"version": "0.1.20",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
7
7
|
"node": "^22.19.0 || >=24.0.0"
|
package/src/client/locales.ts
CHANGED
|
@@ -6,6 +6,7 @@ export const zh = {
|
|
|
6
6
|
'entry.label': 'SSH',
|
|
7
7
|
'entry.tooltip': '远程 SSH 运维面板',
|
|
8
8
|
'panel.title': '远程 SSH',
|
|
9
|
+
'panel.backToConversation': '返回会话',
|
|
9
10
|
'tab.hosts': '主机',
|
|
10
11
|
'tab.terminal': '终端',
|
|
11
12
|
'tab.transfer': '传输',
|
|
@@ -135,7 +136,6 @@ export const zh = {
|
|
|
135
136
|
'common.loading': '加载中…',
|
|
136
137
|
'common.error': '出错:{error}',
|
|
137
138
|
'common.refresh': '刷新',
|
|
138
|
-
'common.close': '关闭',
|
|
139
139
|
'common.confirm': '确定',
|
|
140
140
|
} as const
|
|
141
141
|
|
|
@@ -143,6 +143,7 @@ export const en: Record<keyof typeof zh, string> = {
|
|
|
143
143
|
'entry.label': 'SSH',
|
|
144
144
|
'entry.tooltip': 'Remote SSH operations panel',
|
|
145
145
|
'panel.title': 'Remote SSH',
|
|
146
|
+
'panel.backToConversation': 'Back to chat',
|
|
146
147
|
'tab.hosts': 'Hosts',
|
|
147
148
|
'tab.terminal': 'Terminal',
|
|
148
149
|
'tab.transfer': 'Transfer',
|
|
@@ -265,7 +266,6 @@ export const en: Record<keyof typeof zh, string> = {
|
|
|
265
266
|
'common.loading': 'Loading…',
|
|
266
267
|
'common.error': 'Error: {error}',
|
|
267
268
|
'common.refresh': 'Refresh',
|
|
268
|
-
'common.close': 'Close',
|
|
269
269
|
'common.confirm': 'Confirm',
|
|
270
270
|
}
|
|
271
271
|
|
|
@@ -55,8 +55,16 @@ export function SshPanel({ controller, api }: SshPanelProps) {
|
|
|
55
55
|
return (
|
|
56
56
|
<div className={css.panel}>
|
|
57
57
|
<div className={css.panelHeader}>
|
|
58
|
+
<button
|
|
59
|
+
type="button"
|
|
60
|
+
className={`${css.ghostButton} ${css.backButton}`}
|
|
61
|
+
aria-label={tt('panel.backToConversation')}
|
|
62
|
+
onClick={() => { controller.close() }}
|
|
63
|
+
>
|
|
64
|
+
<span aria-hidden="true">‹</span>
|
|
65
|
+
<span>{tt('panel.backToConversation')}</span>
|
|
66
|
+
</button>
|
|
58
67
|
<h2 className={css.panelTitle}>{tt('panel.title')}</h2>
|
|
59
|
-
<button type="button" className={css.iconButton} title={tt('common.close')} aria-label={tt('common.close')} onClick={() => { controller.close() }}>x</button>
|
|
60
68
|
</div>
|
|
61
69
|
<div className={css.tabBar} role="tablist">
|
|
62
70
|
{TABS.map(tab => (
|
|
@@ -133,6 +133,12 @@ html[data-dsh-ssh-active]:not([data-dsh-taskboard-active]) [class*='centerCol']
|
|
|
133
133
|
white-space: nowrap;
|
|
134
134
|
}
|
|
135
135
|
|
|
136
|
+
.backButton {
|
|
137
|
+
display: inline-flex;
|
|
138
|
+
align-items: center;
|
|
139
|
+
gap: 4px;
|
|
140
|
+
}
|
|
141
|
+
|
|
136
142
|
/* --- tab bar ------------------------------------------------------------------ */
|
|
137
143
|
|
|
138
144
|
.tabBar {
|
|
@@ -62,13 +62,13 @@ export interface PoolEngine {
|
|
|
62
62
|
}
|
|
63
63
|
|
|
64
64
|
/** Build the ssh2 connect config for one entry (key read from disk). */
|
|
65
|
-
export function buildConnectConfig(entry: SshHostEntry, sock
|
|
65
|
+
export function buildConnectConfig(entry: SshHostEntry, sock: ConnectConfig['sock'] | undefined, opts: Required<EngineOptions>): ConnectConfig {
|
|
66
66
|
const config: ConnectConfig = {
|
|
67
67
|
host: entry.host,
|
|
68
68
|
port: entry.port,
|
|
69
69
|
username: entry.user,
|
|
70
|
-
readyTimeout:
|
|
71
|
-
keepaliveInterval:
|
|
70
|
+
readyTimeout: opts.connectTimeoutMs,
|
|
71
|
+
keepaliveInterval: opts.keepaliveIntervalMs,
|
|
72
72
|
keepaliveCountMax: 3,
|
|
73
73
|
}
|
|
74
74
|
if (sock !== undefined) config.sock = sock
|
|
@@ -147,7 +147,7 @@ export async function connectChain(engine: PoolEngine, entry: SshHostEntry): Pro
|
|
|
147
147
|
for (const client of hops) client.end()
|
|
148
148
|
throw new Error('proxyJump alias \'' + hopAlias + '\' not found — create it first')
|
|
149
149
|
}
|
|
150
|
-
const hopClient = await connectClient(buildConnectConfig(hop, sock))
|
|
150
|
+
const hopClient = await connectClient(buildConnectConfig(hop, sock, engine.opts))
|
|
151
151
|
hops.push(hopClient)
|
|
152
152
|
const next = index + 1 < chain.length ? engine.store.find(chain[index + 1]) : undefined
|
|
153
153
|
const nextHost = next !== undefined ? next.host : entry.host
|
|
@@ -165,7 +165,7 @@ export async function connectChain(engine: PoolEngine, entry: SshHostEntry): Pro
|
|
|
165
165
|
}
|
|
166
166
|
let target: Client | undefined
|
|
167
167
|
try {
|
|
168
|
-
target = await connectClient(buildConnectConfig(entry, sock))
|
|
168
|
+
target = await connectClient(buildConnectConfig(entry, sock, engine.opts))
|
|
169
169
|
return { client: target, hops }
|
|
170
170
|
} catch (error) {
|
|
171
171
|
for (const client of hops) client.end()
|
|
@@ -213,8 +213,13 @@ export function disposeRecord(engine: PoolEngine, alias: string, record?: PoolRe
|
|
|
213
213
|
if (record !== undefined && current !== record) return
|
|
214
214
|
if (current === undefined) return
|
|
215
215
|
engine.pool.delete(alias)
|
|
216
|
-
|
|
217
|
-
|
|
216
|
+
endRecordChain(current)
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** End one record's client and hop chain (best-effort, safe to repeat). */
|
|
220
|
+
export function endRecordChain(record: PoolRecord): void {
|
|
221
|
+
try { record.client.end() } catch { /* already closed */ }
|
|
222
|
+
for (const hop of record.hops) {
|
|
218
223
|
try { hop.end() } catch { /* already closed */ }
|
|
219
224
|
}
|
|
220
225
|
}
|
|
@@ -247,6 +252,14 @@ export async function withClient<T>(engine: PoolEngine, alias: string, fn: (clie
|
|
|
247
252
|
const result = await fn(record.client)
|
|
248
253
|
record.idleAt = Date.now()
|
|
249
254
|
return result
|
|
255
|
+
} catch (error) {
|
|
256
|
+
lastError = error
|
|
257
|
+
// Retry only when the connection actually broke mid-flight: drop the
|
|
258
|
+
// corpse and let the next attempt reconnect (a reconnect may replay a
|
|
259
|
+
// non-idempotent command — the documented trade-off). A failure on a
|
|
260
|
+
// healthy connection is a logic error and is rethrown, not replayed.
|
|
261
|
+
if (!record.broken) throw error
|
|
262
|
+
disposeRecord(engine, alias, record)
|
|
250
263
|
} finally {
|
|
251
264
|
record.inFlight -= 1
|
|
252
265
|
}
|
|
@@ -297,8 +310,15 @@ export async function execCommand(engine: PoolEngine, alias: string, command: st
|
|
|
297
310
|
if (settled) return
|
|
298
311
|
settled = true
|
|
299
312
|
clearTimeout(timer)
|
|
313
|
+
if (typeof code !== 'number' && !timedOut) {
|
|
314
|
+
// The channel closed without an exit status: the connection
|
|
315
|
+
// dropped mid-flight. Reject so withClient can reconnect and
|
|
316
|
+
// retry within the attempt budget.
|
|
317
|
+
reject(new Error('ssh: connection lost mid-flight (channel closed without an exit status)'))
|
|
318
|
+
return
|
|
319
|
+
}
|
|
300
320
|
resolve({
|
|
301
|
-
success: code === 0
|
|
321
|
+
success: code === 0,
|
|
302
322
|
exitCode: code,
|
|
303
323
|
timedOut,
|
|
304
324
|
stdout: stdout.text,
|