terminal-bridge-setup 2.0.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.
- package/README.md +65 -0
- package/bin/setup.mjs +205 -0
- package/files/extension/background.js +505 -0
- package/files/extension/content.js +134 -0
- package/files/extension/manifest.json +24 -0
- package/files/extension/popup.html +216 -0
- package/files/extension/popup.js +185 -0
- package/files/native/com.wssniffer.host.json.template +9 -0
- package/files/native/host.js +168 -0
- package/files/native/host.sh.template +5 -0
- package/files/native/install.sh +89 -0
- package/files/proxy/arthas-guard.js +194 -0
- package/files/proxy/client-example.mjs +189 -0
- package/files/proxy/package.json +15 -0
- package/files/proxy/server.js +529 -0
- package/package.json +33 -0
|
@@ -0,0 +1,529 @@
|
|
|
1
|
+
// JumpServer xterm 桥接代理
|
|
2
|
+
//
|
|
3
|
+
// 在 Agent 和 Chrome 插件之间做"请求-响应"配对:
|
|
4
|
+
// Agent 发 run{cmd} → 代理把 cmd 包成 `cmd; printf 哨兵\r` → 发给插件
|
|
5
|
+
// 插件注入 xterm → 远端 SSH 执行 → koko WS recv 帧 → 代理在帧流里
|
|
6
|
+
// 匹配到哨兵 → 截取哨兵前的内容作为这条命令的输出 → 返回给 Agent
|
|
7
|
+
//
|
|
8
|
+
// 端点:ws://127.0.0.1:8787/ssh
|
|
9
|
+
// 两类客户端连这个端点:
|
|
10
|
+
// - 插件 background:上报 ws-recv/ws-send 帧,接收 run-cmd 指令
|
|
11
|
+
// - Agent:发 run 请求,接收 result 响应
|
|
12
|
+
// (它们用同一端点,靠消息 type 区分角色)
|
|
13
|
+
|
|
14
|
+
import { WebSocketServer } from "ws";
|
|
15
|
+
import { randomBytes } from "node:crypto";
|
|
16
|
+
import {
|
|
17
|
+
auditArthasCommand,
|
|
18
|
+
isArthasCommand,
|
|
19
|
+
} from "./arthas-guard.js";
|
|
20
|
+
|
|
21
|
+
const PORT = Number(process.env.PORT || 8787);
|
|
22
|
+
const HOST = process.env.HOST || "127.0.0.1";
|
|
23
|
+
const DEFAULT_TIMEOUT_MS = Number(process.env.DEFAULT_TIMEOUT_MS || 10000);
|
|
24
|
+
const PROBE_LOG = process.env.PROBE_LOG !== "0"; // 默认开探针日志
|
|
25
|
+
|
|
26
|
+
const TAG = "[proxy]";
|
|
27
|
+
|
|
28
|
+
// ===================== 客户端管理 =====================
|
|
29
|
+
// 一个端点两类客户端:插件(唯一)和 Agent(多个)。
|
|
30
|
+
// 我们不严格区分谁连进来,靠消息 type 路由。
|
|
31
|
+
const clients = new Set(); // 所有连进来的 ws
|
|
32
|
+
let extensionWs = null; // 最新一个发过 hello/role:extension 的连接
|
|
33
|
+
|
|
34
|
+
function broadcast(obj, except = null) {
|
|
35
|
+
const line = JSON.stringify(obj);
|
|
36
|
+
for (const c of clients) {
|
|
37
|
+
if (c === except) continue;
|
|
38
|
+
if (c.readyState !== c.OPEN) continue;
|
|
39
|
+
try { c.send(line); } catch {}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function sendToExtension(obj) {
|
|
44
|
+
if (extensionWs && extensionWs.readyState === extensionWs.OPEN) {
|
|
45
|
+
try { extensionWs.send(JSON.stringify(obj)); } catch {}
|
|
46
|
+
return true;
|
|
47
|
+
}
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ===================== 请求-响应配对 =====================
|
|
52
|
+
// pending: reqId -> { resolve, timer, buffer, cmd, sentAt }
|
|
53
|
+
//
|
|
54
|
+
// 设计要点:
|
|
55
|
+
// - 同一时刻只让一个 pending 跑(SSH 单会话命令会交错,必须串行)。
|
|
56
|
+
// 后来的 run 进入 queue,前一个完成(命中哨兵或超时)后才放行。
|
|
57
|
+
// - 每个 recv 帧追加到当前 pending 的 buffer;buffer 里出现该 reqId 的
|
|
58
|
+
// 哨兵时,切出哨兵之前的内容当输出,resolve 掉。
|
|
59
|
+
const pending = new Map();
|
|
60
|
+
const queue = [];
|
|
61
|
+
let running = false;
|
|
62
|
+
|
|
63
|
+
function genReqId() {
|
|
64
|
+
return randomBytes(4).toString("hex");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Agent 发来的 run 请求
|
|
68
|
+
function handleRun(ws, msg) {
|
|
69
|
+
const reqId = msg.reqId || genReqId();
|
|
70
|
+
const cmd = (msg.cmd || "").toString();
|
|
71
|
+
const timeoutMs = Number(msg.timeoutMs || DEFAULT_TIMEOUT_MS);
|
|
72
|
+
|
|
73
|
+
if (!cmd) {
|
|
74
|
+
ws.send(JSON.stringify({ type: "result", reqId, ok: false, error: "empty cmd" }));
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ====== Arthas 安全基线审计 ======
|
|
79
|
+
// 只对 Arthas 命令做拦截(shell 命令不拦)。拦截结果三种:
|
|
80
|
+
// allow —— 放行
|
|
81
|
+
// transform —— 自动补安全参数后放行(对 Agent 透明)
|
|
82
|
+
// deny —— 拒绝(高风险命令无条件禁用;中风险超限或缺参数在严格模式下拒绝)
|
|
83
|
+
let finalCmd = cmd;
|
|
84
|
+
if (isArthasCommand(cmd)) {
|
|
85
|
+
const audit = auditArthasCommand(cmd);
|
|
86
|
+
|
|
87
|
+
if (audit.action === "deny") {
|
|
88
|
+
console.warn(TAG, `[guard] 拒绝命令: ${cmd.slice(0, 80)} → ${audit.error}`);
|
|
89
|
+
ws.send(JSON.stringify({
|
|
90
|
+
type: "result",
|
|
91
|
+
reqId,
|
|
92
|
+
ok: false,
|
|
93
|
+
error: audit.error,
|
|
94
|
+
suggest: audit.suggest,
|
|
95
|
+
message: audit.message,
|
|
96
|
+
}));
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (audit.action === "transform") {
|
|
101
|
+
// 自动改写:用补了安全参数的命令替换原命令
|
|
102
|
+
console.log(TAG, `[guard] 改写命令: ${cmd.slice(0, 60)} → 补参数 (${audit.reason})`);
|
|
103
|
+
finalCmd = audit.cmd;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const job = { ws, reqId, cmd: finalCmd, timeoutMs, sentAt: Date.now() };
|
|
108
|
+
queue.push(job);
|
|
109
|
+
maybeRunNext();
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function maybeRunNext() {
|
|
113
|
+
if (running) return;
|
|
114
|
+
const job = queue.shift();
|
|
115
|
+
if (!job) return;
|
|
116
|
+
|
|
117
|
+
running = true;
|
|
118
|
+
|
|
119
|
+
// ====== prompt 锚点方案 ======
|
|
120
|
+
// kitty 终端逐字符注入会重绘,把任何标记字符串(BEGIN/END/哨兵)打散,
|
|
121
|
+
// 标记方案不可靠。改用 shell prompt 作为命令完成的锚点(expect/pexpect 经典做法)。
|
|
122
|
+
//
|
|
123
|
+
// 你的 PS1=[\u@\h \w]\$ 渲染成 [root@k8s-master-test /home/operation]#
|
|
124
|
+
// 关键:prompt 是服务端 shell 在命令完成后输出的,不受 kitty 输入重绘影响。
|
|
125
|
+
//
|
|
126
|
+
// 流程:
|
|
127
|
+
// 1. 发 cmd + \r
|
|
128
|
+
// 2. 在 ws-recv 流里累积,找 prompt 正则匹配(]\s*[#$]\s*$ 在行尾)
|
|
129
|
+
// 3. 第一次匹配到 prompt:说明之前的 buffer 含"上一条命令的尾部 prompt + cmd 回显",
|
|
130
|
+
// 从这个 prompt 之后开始才是本命令的输出区域
|
|
131
|
+
// 4. 第二次匹配到 prompt:命令执行完毕,两个 prompt 之间就是输出
|
|
132
|
+
const wrapped = `${job.cmd}\r`;
|
|
133
|
+
|
|
134
|
+
// 状态机:
|
|
135
|
+
// phase 0 (wait_prompt_1) : 等 prompt 第 1 次出现(命令回显前的 prompt)
|
|
136
|
+
// phase 1 (wait_prompt_2) : 等 prompt 第 2 次出现(命令完成后的 prompt)
|
|
137
|
+
const entry = {
|
|
138
|
+
resolve: (result) => {
|
|
139
|
+
if (pending.has(job.reqId)) {
|
|
140
|
+
clearTimeout(entry.timer);
|
|
141
|
+
pending.delete(job.reqId);
|
|
142
|
+
try {
|
|
143
|
+
job.ws.send(JSON.stringify({ type: "result", reqId: job.reqId, ...result }));
|
|
144
|
+
} catch {}
|
|
145
|
+
}
|
|
146
|
+
running = false;
|
|
147
|
+
setImmediate(maybeRunNext);
|
|
148
|
+
},
|
|
149
|
+
timer: null,
|
|
150
|
+
phase: 0,
|
|
151
|
+
buffer: "",
|
|
152
|
+
cmd: job.cmd,
|
|
153
|
+
promptCount: 0
|
|
154
|
+
};
|
|
155
|
+
pending.set(job.reqId, entry);
|
|
156
|
+
entry.sentAt = job.sentAt;
|
|
157
|
+
|
|
158
|
+
// 超时:resolve 失败 + 发 Ctrl+C 复位终端
|
|
159
|
+
entry.timer = setTimeout(() => {
|
|
160
|
+
console.warn(TAG, `run [${job.reqId}] timeout (phase=${entry.phase}), sending Ctrl+C to reset terminal`);
|
|
161
|
+
sendCtrlC();
|
|
162
|
+
entry.resolve({
|
|
163
|
+
ok: false,
|
|
164
|
+
error: "timeout",
|
|
165
|
+
output: cleanOutput(entry.buffer, entry.cmd),
|
|
166
|
+
elapsedMs: Date.now() - job.sentAt
|
|
167
|
+
});
|
|
168
|
+
}, job.timeoutMs);
|
|
169
|
+
|
|
170
|
+
const ok = sendToExtension({ type: "run-cmd", text: wrapped, reqId: job.reqId });
|
|
171
|
+
if (!ok) {
|
|
172
|
+
entry.resolve({ ok: false, error: "extension not connected" });
|
|
173
|
+
} else {
|
|
174
|
+
console.log(TAG, `run [${job.reqId}] dispatched: ${job.cmd.slice(0, 80)}`);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// 发 Ctrl+C 复位终端(超时/交互卡死时调用)
|
|
179
|
+
function sendCtrlC() {
|
|
180
|
+
const ok = sendToExtension({ type: "run-cmd", text: "\x03", reqId: "__ctrlc__" });
|
|
181
|
+
if (!ok) console.warn(TAG, "Ctrl+C 发送失败:插件未连接");
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// ===================== 终端类型感知 =====================
|
|
185
|
+
// 截断根因:原来用单一 PROMPT_RE = /\]\s*[#$]\s*$|>\s*$/,其中 >\s*$ 太宽——
|
|
186
|
+
// JumpServer 输出里只要某行以 > 结尾(JSON 片段、shell 重定向、日志)就会被误判成
|
|
187
|
+
// Arthas prompt,导致代理提前认为命令结束、resolve 返回,后面的输出全丢。
|
|
188
|
+
//
|
|
189
|
+
// 根治:自动探测终端类型,按类型选 prompt 正则。
|
|
190
|
+
// - jumpserver (koko/SSH): prompt = [user@host dir]# 或 ]$,只认 ]\s*[#$]\s*$
|
|
191
|
+
// - arthas: prompt = arthas@pid>,只认 arthas@\S+>\s*$
|
|
192
|
+
// - unknown(探测未出结果前): 用宽松兜底,保持向后兼容
|
|
193
|
+
//
|
|
194
|
+
// 探测依据:koko 和 Arthas 的 prompt 特征泾渭分明。
|
|
195
|
+
// 每条 ws-recv 帧喂给 detectTerminalType(),命中特征即锁定类型(一旦锁定不再改)。
|
|
196
|
+
|
|
197
|
+
let terminalType = "unknown"; // "unknown" | "jumpserver" | "arthas"
|
|
198
|
+
|
|
199
|
+
// 各类型的 prompt 正则
|
|
200
|
+
const PROMPT_RE_BY_TYPE = {
|
|
201
|
+
// JumpServer shell prompt:[user@host /dir]# 或 ]$
|
|
202
|
+
// 注意:不匹配裸 >,避免输出内容里的 > 行误触发
|
|
203
|
+
jumpserver: /\]\s*[#$]\s*$/,
|
|
204
|
+
// Arthas prompt:arthas@pid>(带 arthas@ 前缀,不匹配裸 >)
|
|
205
|
+
arthas: /arthas@\S+>\s*$/,
|
|
206
|
+
// 未知类型:宽松兜底(探测完成前的窗口期,行为同旧版)
|
|
207
|
+
unknown: /\]\s*[#$]\s*$|>\s*$/,
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
// 旧名保留(cleanOutput 等处仍引用),指向宽松兜底,仅用于"删 prompt 行"的清理逻辑
|
|
211
|
+
const PROMPT_RE = PROMPT_RE_BY_TYPE.unknown;
|
|
212
|
+
|
|
213
|
+
// 探测终端类型:扫描 ANSI 清理后的文本,按 prompt 特征判定
|
|
214
|
+
// 支持切换:如果已锁定类型 A,但检测到明确的类型 B 特征,则切换到 B
|
|
215
|
+
// (用户在 popup 切 tab 从 Arthas 切到 JumpServer 时,代理靠这条路径纠正)
|
|
216
|
+
function detectTerminalType(text) {
|
|
217
|
+
const clean = text
|
|
218
|
+
.replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, "")
|
|
219
|
+
.replace(/\x1b\][^\x07\x1b]*(\x07|\x1b\\)/g, "");
|
|
220
|
+
|
|
221
|
+
// Arthas prompt 特征:arthas@<pid>> (出现在任意行尾)
|
|
222
|
+
if (/arthas@\S+>\s*$/m.test(clean)) {
|
|
223
|
+
if (terminalType !== "arthas") {
|
|
224
|
+
terminalType = "arthas";
|
|
225
|
+
console.log(TAG, "[probe] 终端类型切换 → arthas(检测到 arthas@pid> prompt)");
|
|
226
|
+
}
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// JumpServer shell prompt 特征:[user@host /dir]# 或 ]$
|
|
231
|
+
if (/\[[^\]\n]+@[^\]\n]+\][^\n]*[#$]\s*$/m.test(clean)) {
|
|
232
|
+
if (terminalType !== "jumpserver") {
|
|
233
|
+
terminalType = "jumpserver";
|
|
234
|
+
console.log(TAG, "[probe] 终端类型切换 → jumpserver(检测到 [user@host]# prompt)");
|
|
235
|
+
}
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
// 未命中任一特征,保持当前类型不变
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// 插件上报的 ws-recv 帧
|
|
242
|
+
function handleWsRecv(payload) {
|
|
243
|
+
const data = payload && payload.data;
|
|
244
|
+
const opcode = payload && payload.opcode;
|
|
245
|
+
|
|
246
|
+
// ---------- 探针日志 ----------
|
|
247
|
+
// 第一次见到 recv 帧时,把原始数据形态打到日志,便于判断 koko 走文本还是二进制
|
|
248
|
+
if (PROBE_LOG && !probeSeen) {
|
|
249
|
+
probeSeen = true;
|
|
250
|
+
probeLog(data, opcode);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
if (data == null || data === "") return;
|
|
254
|
+
|
|
255
|
+
// 关键:opcode=2(二进制帧)时,CDP 返回的 payloadData 是 base64 字符串,
|
|
256
|
+
// 解码后才是真实的终端明文(koko 走二进制帧,但 payload 是普通 SSH PTY 文本)。
|
|
257
|
+
// opcode=1(文本帧)时 data 本身就是明文。
|
|
258
|
+
// 探针实测:koko 是 opcode=2,base64 解码后能看到 echo 回显、ANSI、哨兵等明文。
|
|
259
|
+
let text;
|
|
260
|
+
if (opcode === 2 && typeof data === "string") {
|
|
261
|
+
try {
|
|
262
|
+
text = Buffer.from(data, "base64").toString("utf8");
|
|
263
|
+
} catch {
|
|
264
|
+
text = data; // 解码失败兜底,至少能匹配 base64 形式的哨兵(几乎不会发生)
|
|
265
|
+
}
|
|
266
|
+
} else {
|
|
267
|
+
text = typeof data === "string" ? data : String(data);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// 给当前 pending(同时只会有一个)喂帧,按 prompt 锚点状态机处理
|
|
271
|
+
for (const [reqId, entry] of pending) {
|
|
272
|
+
entry.buffer += text;
|
|
273
|
+
|
|
274
|
+
// 终端类型探测:每帧喂一次,一旦锁定就不再改
|
|
275
|
+
// 在 prompt 匹配之前做,确保本帧用到的正则已是正确类型
|
|
276
|
+
detectTerminalType(text);
|
|
277
|
+
|
|
278
|
+
// 按探测到的终端类型选 prompt 正则(截断根治的核心)
|
|
279
|
+
const activePromptRE = PROMPT_RE_BY_TYPE[terminalType];
|
|
280
|
+
|
|
281
|
+
// 在 ANSI 清理后的文本上找 prompt。注意:kitty 可能逐字符推送,
|
|
282
|
+
// prompt 可能跨多个 ws-recv 帧才完整,所以每次都重新扫整个 buffer 尾部。
|
|
283
|
+
// 为了避免重复计数同一个 prompt,记录上次扫描的长度。
|
|
284
|
+
if (entry.lastScanLen === undefined) entry.lastScanLen = 0;
|
|
285
|
+
if (entry.buffer.length <= entry.lastScanLen) continue;
|
|
286
|
+
|
|
287
|
+
// 只扫描新增部分 + 一点重叠(prompt 可能跨帧,重叠 64 字符足够覆盖一个 prompt)
|
|
288
|
+
const scanFrom = Math.max(0, entry.lastScanLen - 64);
|
|
289
|
+
const newPart = entry.buffer.slice(scanFrom);
|
|
290
|
+
|
|
291
|
+
// ANSI 清理后判断行尾是否是 prompt
|
|
292
|
+
// 用"找换行后的 prompt 模式":prompt 总是出现在某行行尾
|
|
293
|
+
const cleanNew = newPart
|
|
294
|
+
.replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, "")
|
|
295
|
+
.replace(/\x1b\][^\x07\x1b]*(\x07|\x1b\\)/g, "");
|
|
296
|
+
|
|
297
|
+
// 找所有以 ]# 或 ]$ 结尾的位置(prompt)
|
|
298
|
+
// cleanNew 是新片段,它的行尾可能是 prompt
|
|
299
|
+
// 但更可靠:检查清理后整个 buffer 的尾部
|
|
300
|
+
const cleanFull = entry.buffer
|
|
301
|
+
.replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, "")
|
|
302
|
+
.replace(/\x1b\][^\x07\x1b]*(\x07|\x1b\\)/g, "");
|
|
303
|
+
|
|
304
|
+
// ====== sudo 密码提示实时检测(优先于 prompt 检测)======
|
|
305
|
+
// 一旦发现 [sudo] password for / Sorry, try again / [sudo] password for root
|
|
306
|
+
// 立即失败 + Ctrl+C 复位,返回特殊错误让 Agent 询问用户是否切 root。
|
|
307
|
+
// 不等超时——sudo 提示是交互式的,等 10s 没意义。
|
|
308
|
+
const sudoPatterns = [
|
|
309
|
+
/\[sudo\] password for /,
|
|
310
|
+
/Sorry, try again\./,
|
|
311
|
+
/sudo: /,
|
|
312
|
+
];
|
|
313
|
+
const sudoHit = sudoPatterns.some(re => re.test(cleanFull));
|
|
314
|
+
if (sudoHit) {
|
|
315
|
+
console.warn(TAG, `[DEBUG ${reqId}] sudo prompt detected, auto Ctrl+C + return sudo-required`);
|
|
316
|
+
sendCtrlC();
|
|
317
|
+
entry.resolve({
|
|
318
|
+
ok: false,
|
|
319
|
+
error: "sudo-required",
|
|
320
|
+
suggest: "sudo su root",
|
|
321
|
+
message: "命令触发了 sudo 密码提示(可能是 alias 劫持)。是否切换到 root 后重试?",
|
|
322
|
+
elapsedMs: Date.now() - entry.sentAt
|
|
323
|
+
});
|
|
324
|
+
continue; // 已 resolve,跳过后续 prompt 检测
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// 检查清理后 buffer 的尾部是否以 prompt 结尾
|
|
328
|
+
// 用按终端类型选出的正则(截断根治:jumpserver 不再被裸 > 误触发)
|
|
329
|
+
const tail = cleanFull.slice(-200);
|
|
330
|
+
const promptMatch = tail.match(activePromptRE);
|
|
331
|
+
if (promptMatch) {
|
|
332
|
+
entry.promptCount = (entry.promptCount || 0) + 1;
|
|
333
|
+
entry.lastScanLen = entry.buffer.length;
|
|
334
|
+
|
|
335
|
+
if (entry.phase === 0) {
|
|
336
|
+
// 注:终端在我们注入命令前就已经处于 prompt 状态,但那个 prompt 字节
|
|
337
|
+
// 早已流过(attach 之前)。我们注入 cmd 后,第一个出现的 prompt 就是
|
|
338
|
+
// 命令完成后的 prompt。所以只需等 1 个 prompt。
|
|
339
|
+
// buffer 里 = kitty 重绘碎片 + cmd 回显 + 真实输出 + 最终 prompt
|
|
340
|
+
// 切掉末尾 prompt,前面的内容交给 cleanOutput 清理(删 prompt 行、碎片)
|
|
341
|
+
const output = cleanOutput(entry.buffer, entry.cmd);
|
|
342
|
+
entry.resolve({
|
|
343
|
+
ok: true,
|
|
344
|
+
output,
|
|
345
|
+
elapsedMs: Date.now() - entry.sentAt
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// ===================== 探针 =====================
|
|
353
|
+
let probeSeen = false;
|
|
354
|
+
function probeLog(data, opcode) {
|
|
355
|
+
console.log("=".repeat(60));
|
|
356
|
+
console.log(TAG, "[PROBE] 首个 ws-recv 帧已到达");
|
|
357
|
+
console.log(TAG, `[PROBE] opcode = ${opcode} (${opcode === 2 ? "二进制帧" : opcode === 1 ? "文本帧" : "其他"})`);
|
|
358
|
+
console.log(TAG, `[PROBE] 原始 payloadData(前 120 字符): ${JSON.stringify(typeof data === "string" ? data.slice(0, 120) : String(data))}`);
|
|
359
|
+
|
|
360
|
+
// 对二进制帧展示 base64 解码后的内容
|
|
361
|
+
if (opcode === 2 && typeof data === "string") {
|
|
362
|
+
try {
|
|
363
|
+
const decoded = Buffer.from(data, "base64").toString("utf8");
|
|
364
|
+
const sample = decoded.slice(0, 200);
|
|
365
|
+
const looksText = /^[\x09\x0a\x0d\x1b\x20-\x7e]*$/.test(sample);
|
|
366
|
+
console.log(TAG, `[PROBE] base64 解码后(前 200 字符): ${JSON.stringify(sample)}`);
|
|
367
|
+
console.log(TAG, `[PROBE] 解码后是否可打印 ASCII(含 ANSI): ${looksText}`);
|
|
368
|
+
console.log(TAG, looksText
|
|
369
|
+
? "[PROBE] 结论:二进制帧,但 payload 是明文终端流(已自动解码)"
|
|
370
|
+
: "[PROBE] 结论:真正的二进制协议,需要专门的解析器");
|
|
371
|
+
} catch (e) {
|
|
372
|
+
console.log(TAG, "[PROBE] base64 解码失败:", e.message);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
console.log("=".repeat(60));
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// ===================== 输出清理 =====================
|
|
379
|
+
// prompt 锚点方案:buffer = kitty 重绘碎片 + cmd 回显 + 真实输出 + 最终 prompt
|
|
380
|
+
// 清理策略:
|
|
381
|
+
// 1. 去 ANSI(颜色、OSC 标题)
|
|
382
|
+
// 2. 删含 prompt 模式的行(命令回显行,含粘在前面的碎片)
|
|
383
|
+
// 注:kitty 重绘碎片(cmd 文本的片段)可能残留几行,但不影响 Agent 理解输出。
|
|
384
|
+
// 不做 cmd 子串清理——它会误删真实输出(如 cmd="echo hello",输出"hello"会被删)。
|
|
385
|
+
function cleanOutput(text, cmd) {
|
|
386
|
+
if (!text) return "";
|
|
387
|
+
let out = text
|
|
388
|
+
// ANSI CSI 序列:ESC [ ... 字母(颜色、光标移动、清行等)
|
|
389
|
+
.replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, "")
|
|
390
|
+
// ANSI OSC 序列:ESC ] ... BEL 或 ESC \(标题设置等)
|
|
391
|
+
.replace(/\x1b\][^\x07\x1b]*(\x07|\x1b\\)/g, "")
|
|
392
|
+
// \r\n 和孤立 \r → \n
|
|
393
|
+
.replace(/\r\n?/g, "\n");
|
|
394
|
+
|
|
395
|
+
// 删除 prompt 行:
|
|
396
|
+
// - shell 风格:行里出现 [user@host /cwd]# 或 ]$ → 整行删
|
|
397
|
+
// (删 "[root@host ~]# echo hello" 这种命令回显行,含粘在前面的碎片)
|
|
398
|
+
// - Arthas 风格:行尾是 arthas@xxx> 或单独的 > → 整行删
|
|
399
|
+
out = out.split("\n").filter(line => {
|
|
400
|
+
if (/\[[^\]\n]+@[^\]\n]+\][^\n]*[#$]/.test(line)) return false;
|
|
401
|
+
// Arthas / 通用 REPL prompt:行尾 > (允许前面有 arthas@xxx 等前缀)
|
|
402
|
+
if (/>\s*$/.test(line) && line.trim().length <= 60) return false;
|
|
403
|
+
return true;
|
|
404
|
+
}).join("\n");
|
|
405
|
+
|
|
406
|
+
// 删除 koko 控制消息:koko 偶尔在终端流里发送 JSON 控制消息
|
|
407
|
+
// (TERMINAL_RESIZE、PING 等),特征是以 {"id": 开头的 JSON 行(可能前面带 # )
|
|
408
|
+
out = out.split("\n").filter(line => {
|
|
409
|
+
const t = line.trim();
|
|
410
|
+
if (/^#?\s*\{"id":/.test(t) && /"type":/.test(t)) return false;
|
|
411
|
+
return true;
|
|
412
|
+
}).join("\n");
|
|
413
|
+
|
|
414
|
+
// 删除命令回显行:整行(去空白后)等于 cmd 的行
|
|
415
|
+
// 注意是精确匹配整行,不是子串——避免误删真实输出
|
|
416
|
+
if (cmd) {
|
|
417
|
+
const cmdNoWs = cmd.replace(/\s+/g, "");
|
|
418
|
+
out = out.split("\n").filter(line => {
|
|
419
|
+
const lineNoWs = line.replace(/\s+/g, "");
|
|
420
|
+
return lineNoWs !== cmdNoWs;
|
|
421
|
+
}).join("\n");
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
return out
|
|
425
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
426
|
+
.trim();
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// ===================== WebSocket 服务 =====================
|
|
430
|
+
const wss = new WebSocketServer({ host: HOST, port: PORT });
|
|
431
|
+
|
|
432
|
+
wss.on("connection", (ws, req) => {
|
|
433
|
+
const url = req.url || "/ssh";
|
|
434
|
+
if (!url.startsWith("/ssh")) {
|
|
435
|
+
// 本代理只暴露 /ssh 端点,其他路径直接关
|
|
436
|
+
ws.close(4000, "unknown endpoint");
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
clients.add(ws);
|
|
441
|
+
console.log(TAG, `client connected (total=${clients.size}) url=${url}`);
|
|
442
|
+
|
|
443
|
+
ws.on("message", (raw) => {
|
|
444
|
+
const text = raw.toString("utf8").trim();
|
|
445
|
+
if (!text) return;
|
|
446
|
+
let msg;
|
|
447
|
+
try { msg = JSON.parse(text); } catch {
|
|
448
|
+
console.error(TAG, "无法解析:", text.slice(0, 200));
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// --- 插件 hello ---
|
|
453
|
+
if (msg.type === "hello") {
|
|
454
|
+
if (msg.payload && msg.payload.role === "extension") {
|
|
455
|
+
extensionWs = ws;
|
|
456
|
+
console.log(TAG, "extension 已连接");
|
|
457
|
+
}
|
|
458
|
+
ws.send(JSON.stringify({ type: "hello-ack", payload: { ok: true } }));
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// --- Agent 发来的 run 请求 ---
|
|
463
|
+
if (msg.type === "run") {
|
|
464
|
+
handleRun(ws, msg);
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// --- 插件上报的 WS 帧 ---
|
|
469
|
+
if (msg.type === "ws-recv") {
|
|
470
|
+
handleWsRecv(msg.payload);
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
if (msg.type === "ws-send") {
|
|
474
|
+
// 调试用,暂不处理(命令是我们自己注入的,不用回放)
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
if (msg.type === "ws-open") {
|
|
478
|
+
console.log(TAG, "koko WS 连接已建立:", msg.payload && msg.payload.url);
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
if (msg.type === "inject-failed") {
|
|
482
|
+
console.error(TAG, "插件注入失败:", msg.payload);
|
|
483
|
+
// 把对应 pending 失败掉
|
|
484
|
+
const reqId = msg.payload && msg.payload.reqId;
|
|
485
|
+
if (reqId && pending.has(reqId)) {
|
|
486
|
+
pending.get(reqId).resolve({
|
|
487
|
+
ok: false,
|
|
488
|
+
error: msg.payload.error || "inject failed"
|
|
489
|
+
});
|
|
490
|
+
}
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// 未知消息:忽略(避免 ping/pong 等噪音刷屏)
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
ws.on("close", () => {
|
|
498
|
+
clients.delete(ws);
|
|
499
|
+
if (ws === extensionWs) {
|
|
500
|
+
extensionWs = null;
|
|
501
|
+
console.log(TAG, "extension 已断开");
|
|
502
|
+
// 失败所有 pending(没有插件就没法拿输出了)
|
|
503
|
+
for (const [, entry] of pending) {
|
|
504
|
+
entry.resolve({ ok: false, error: "extension disconnected" });
|
|
505
|
+
}
|
|
506
|
+
} else {
|
|
507
|
+
console.log(TAG, `client disconnected (total=${clients.size})`);
|
|
508
|
+
}
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
ws.on("error", (err) => console.error(TAG, "ws error:", err.message));
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
wss.on("listening", () => {
|
|
515
|
+
const { address, port } = wss.address();
|
|
516
|
+
console.log(TAG, `监听 ws://${address}:${port}/ssh`);
|
|
517
|
+
console.log(TAG, "等待插件连接(role: extension)和 Agent 连接(发 run 请求)");
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
function shutdown() {
|
|
521
|
+
console.error(TAG, "shutting down");
|
|
522
|
+
wss.clients.forEach((c) => c.close(1001, "shutting down"));
|
|
523
|
+
for (const [, entry] of pending) {
|
|
524
|
+
clearTimeout(entry.timer);
|
|
525
|
+
}
|
|
526
|
+
setTimeout(() => process.exit(0), 100);
|
|
527
|
+
}
|
|
528
|
+
process.on("SIGINT", shutdown);
|
|
529
|
+
process.on("SIGTERM", shutdown);
|
package/package.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "terminal-bridge-setup",
|
|
3
|
+
"version": "2.0.0",
|
|
4
|
+
"description": "一次性安装器:释放终端桥接(JumpServer / Arthas)的本地代理 + Chrome 插件,并注册 native messaging host。让 Agent 能通过浏览器 xterm 终端执行命令并拿回输出。",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "encorearon",
|
|
7
|
+
"homepage": "https://github.com/encorearon/terminal-bridge",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/encorearon/terminal-bridge.git"
|
|
11
|
+
},
|
|
12
|
+
"keywords": [
|
|
13
|
+
"jumpserver",
|
|
14
|
+
"arthas",
|
|
15
|
+
"xterm",
|
|
16
|
+
"terminal",
|
|
17
|
+
"bridge",
|
|
18
|
+
"chrome-extension",
|
|
19
|
+
"native-messaging",
|
|
20
|
+
"agent"
|
|
21
|
+
],
|
|
22
|
+
"type": "module",
|
|
23
|
+
"bin": {
|
|
24
|
+
"terminal-bridge-setup": "bin/setup.mjs"
|
|
25
|
+
},
|
|
26
|
+
"files": [
|
|
27
|
+
"bin",
|
|
28
|
+
"files"
|
|
29
|
+
],
|
|
30
|
+
"engines": {
|
|
31
|
+
"node": ">=18"
|
|
32
|
+
}
|
|
33
|
+
}
|