rdsh-gateway 0.8.3 → 0.8.5
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/dist/config.d.ts +7 -0
- package/dist/config.js +10 -2
- package/dist/http-encoding.d.ts +6 -0
- package/dist/http-encoding.js +51 -0
- package/dist/join.d.ts +19 -0
- package/dist/join.js +167 -28
- package/dist/lock.d.ts +17 -4
- package/dist/lock.js +129 -24
- package/dist/proxy.d.ts +6 -0
- package/dist/proxy.js +32 -0
- package/dist/serve.js +1 -0
- package/dist/server.d.ts +7 -0
- package/dist/server.js +14 -2
- package/package.json +1 -1
package/dist/config.d.ts
CHANGED
|
@@ -43,6 +43,13 @@ export interface GatewayConfig {
|
|
|
43
43
|
export interface DshUiCompat {
|
|
44
44
|
/** E2EE 激活(或宿主启用)时 patch JS;false = 保持 DSH 原样(共享 host/敏感场景) */
|
|
45
45
|
trustE2EEAsLoopback?: boolean;
|
|
46
|
+
/**
|
|
47
|
+
* LAN / 已配对会话是否同样视同 loopback(默认 true)。
|
|
48
|
+
* 背景:DSH 的设置与"持久化凭据"界面只对 loopback 开放,而 LAN 访问的 hostname 是局域网 IP,
|
|
49
|
+
* 不满足 DSH 的 loopback 判定 ⇒ 不打开时 LAN 用户无法在界面里输入 API key。
|
|
50
|
+
* false = 严格模式(LAN 用户需自行在 `~/.dsh/.credentials.yaml` 配置)。
|
|
51
|
+
*/
|
|
52
|
+
trustPairedAsLoopback?: boolean;
|
|
46
53
|
}
|
|
47
54
|
export declare const DEFAULT_HOST_CONFIG_PATH: string;
|
|
48
55
|
/** 解析配置文件路径(--config > $RDSH_CONFIG > 默认 host.json)。 */
|
package/dist/config.js
CHANGED
|
@@ -21,7 +21,7 @@ const DEFAULTS = {
|
|
|
21
21
|
behindProxy: false,
|
|
22
22
|
allowFrom: [],
|
|
23
23
|
auth: DEFAULT_AUTH,
|
|
24
|
-
dshUiCompat: { trustE2EEAsLoopback: true },
|
|
24
|
+
dshUiCompat: { trustE2EEAsLoopback: true, trustPairedAsLoopback: true },
|
|
25
25
|
gateway: { accessCode: null },
|
|
26
26
|
};
|
|
27
27
|
/** 解析配置文件路径(--config > $RDSH_CONFIG > 默认 host.json)。 */
|
|
@@ -182,12 +182,20 @@ export function normalizeConfig(raw, source = "config") {
|
|
|
182
182
|
throw new Error(`${source}: "dshUiCompat" must be an object`);
|
|
183
183
|
}
|
|
184
184
|
const compat = cfg.dshUiCompat;
|
|
185
|
+
const compatOut = {};
|
|
185
186
|
if (compat.trustE2EEAsLoopback !== undefined) {
|
|
186
187
|
if (typeof compat.trustE2EEAsLoopback !== "boolean") {
|
|
187
188
|
throw new Error(`${source}: "dshUiCompat.trustE2EEAsLoopback" must be boolean`);
|
|
188
189
|
}
|
|
189
|
-
|
|
190
|
+
compatOut.trustE2EEAsLoopback = compat.trustE2EEAsLoopback;
|
|
190
191
|
}
|
|
192
|
+
if (compat.trustPairedAsLoopback !== undefined) {
|
|
193
|
+
if (typeof compat.trustPairedAsLoopback !== "boolean") {
|
|
194
|
+
throw new Error(`${source}: "dshUiCompat.trustPairedAsLoopback" must be boolean`);
|
|
195
|
+
}
|
|
196
|
+
compatOut.trustPairedAsLoopback = compat.trustPairedAsLoopback;
|
|
197
|
+
}
|
|
198
|
+
out.dshUiCompat = compatOut;
|
|
191
199
|
}
|
|
192
200
|
// ---- gateway(访问口令;缺省 accessCode null = gate off)----
|
|
193
201
|
if (cfg.gateway !== undefined) {
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** 取首个 content-encoding(小写;identity/空 → "")。 */
|
|
2
|
+
export declare function firstEncoding(value: string | string[] | undefined): string;
|
|
3
|
+
/** 解码为明文;不支持/损坏 → null。 */
|
|
4
|
+
export declare function decodeBody(body: Buffer, encoding: string): Buffer | null;
|
|
5
|
+
/** 按原编码重新压缩;identity → 原样;不支持/失败 → null。 */
|
|
6
|
+
export declare function encodeBody(body: Buffer, encoding: string): Buffer | null;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* http-encoding.ts — 响应体 `content-encoding` 的解码/重编码(供 loopback JS 补丁使用)。
|
|
3
|
+
*
|
|
4
|
+
* 背景(2026-09-14 实测事故):dsh 在客户端声明 `accept-encoding` 时会对 JS **gzip 压缩**,
|
|
5
|
+
* 而 `patchLoopbackJs()` 是在字节里做字面量替换 —— 在压缩体上必然 miss(fail-open 静默失效),
|
|
6
|
+
* 导致前端 `isLoopback` 判定没被改写、DSH 设置页/API key 界面不可用。
|
|
7
|
+
*
|
|
8
|
+
* 约定:只处理 identity/gzip/deflate/br;**未知或多重编码返回 null**,调用方 fail-open(原样透传)。
|
|
9
|
+
*/
|
|
10
|
+
import { brotliCompressSync, brotliDecompressSync, deflateSync, gunzipSync, gzipSync, inflateSync } from "node:zlib";
|
|
11
|
+
/** 取首个 content-encoding(小写;identity/空 → "")。 */
|
|
12
|
+
export function firstEncoding(value) {
|
|
13
|
+
const raw = Array.isArray(value) ? value[0] : value;
|
|
14
|
+
const enc = (raw ?? "").split(",")[0]?.trim().toLowerCase() ?? "";
|
|
15
|
+
return enc === "identity" ? "" : enc;
|
|
16
|
+
}
|
|
17
|
+
/** 解码为明文;不支持/损坏 → null。 */
|
|
18
|
+
export function decodeBody(body, encoding) {
|
|
19
|
+
try {
|
|
20
|
+
if (encoding === "")
|
|
21
|
+
return body;
|
|
22
|
+
if (encoding === "gzip")
|
|
23
|
+
return gunzipSync(body);
|
|
24
|
+
if (encoding === "deflate")
|
|
25
|
+
return inflateSync(body);
|
|
26
|
+
if (encoding === "br")
|
|
27
|
+
return brotliDecompressSync(body);
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
/** 按原编码重新压缩;identity → 原样;不支持/失败 → null。 */
|
|
35
|
+
export function encodeBody(body, encoding) {
|
|
36
|
+
try {
|
|
37
|
+
if (encoding === "")
|
|
38
|
+
return body;
|
|
39
|
+
if (encoding === "gzip")
|
|
40
|
+
return gzipSync(body);
|
|
41
|
+
if (encoding === "deflate")
|
|
42
|
+
return deflateSync(body);
|
|
43
|
+
if (encoding === "br")
|
|
44
|
+
return brotliCompressSync(body);
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
//# sourceMappingURL=http-encoding.js.map
|
package/dist/join.d.ts
CHANGED
|
@@ -91,6 +91,18 @@ export declare function registerJoin(opts: JoinOptions): Promise<RegisterOutcome
|
|
|
91
91
|
* 启动 join 隧道(no-spawn):转发到外部 `opts.target`,不 spawn dsh、不 process.exit。
|
|
92
92
|
* 获取 pid 锁(opts.role);返回 `JoinHandle`,`stop()` 干净停止(关 WS/清 heartbeat/释放锁)。
|
|
93
93
|
*/
|
|
94
|
+
/**
|
|
95
|
+
* 上游失败分类。
|
|
96
|
+
*
|
|
97
|
+
* 旧实现把所有 `up.on("error")` 都报成 `UPSTREAM_UNREACHABLE: dsh not reachable`,
|
|
98
|
+
* 于是"dsh 已接受连接、但在读请求体阶段就断开(常见于 401/413)"被伪装成部署故障,
|
|
99
|
+
* 掩盖真实原因(2026-09-14 实测:上传大文件得到 502 "dsh not reachable",与事实不符)。
|
|
100
|
+
*/
|
|
101
|
+
export declare function classifyUpstreamFailure(code: string | undefined, responded: boolean, message?: string): {
|
|
102
|
+
kind: "error" | "close";
|
|
103
|
+
code: string;
|
|
104
|
+
message: string;
|
|
105
|
+
};
|
|
94
106
|
/** JS 响应判定(content-type 含 javascript)。 */
|
|
95
107
|
export declare function isJsContentType(headers: IncomingHttpHeaders): boolean;
|
|
96
108
|
/**
|
|
@@ -99,6 +111,13 @@ export declare function isJsContentType(headers: IncomingHttpHeaders): boolean;
|
|
|
99
111
|
* fail-open:未命中目标串 → 返回 null,调用方原样透传(DSH 升级不炸)。
|
|
100
112
|
*/
|
|
101
113
|
export declare function patchLoopbackJs(body: Buffer): Buffer | null;
|
|
114
|
+
/**
|
|
115
|
+
* E2EE 包装的固定开销:内层帧 15 B 头(MAGIC4+ver1+flags1+type1+streamId4+len4)+ AEAD
|
|
116
|
+
* nonce 12 B + GCM tag 16 B = **43 B**。WS 消息判界必须预留它,否则"明文 ≤16 MiB"的 WS 消息
|
|
117
|
+
* 经 E2EE 加密后刚好超出隧道上限,`encodeFrame` 在加密发送器里抛 `ProtocolError` 打死 host
|
|
118
|
+
*(2026-09-14 第三审发现的 28 B 窗口,加上内层帧头实为 43 B)。
|
|
119
|
+
*/
|
|
120
|
+
export declare const E2EE_FRAME_OVERHEAD: number;
|
|
102
121
|
export declare function startJoin(opts: StartJoinOptions): JoinHandle;
|
|
103
122
|
/** `rdsh host serve`(join 模式)的 CLI 封装:spawn dsh + 信号退出 + startJoin(role:cli)。 */
|
|
104
123
|
export declare function join(opts: JoinOptions): Promise<void>;
|
package/dist/join.js
CHANGED
|
@@ -13,10 +13,11 @@ import { request as httpRequest } from "node:http";
|
|
|
13
13
|
import { request as httpsRequest } from "node:https";
|
|
14
14
|
import { hostname as osHostname } from "node:os";
|
|
15
15
|
import { WebSocket } from "ws";
|
|
16
|
-
import { FrameParser, FRAME_TYPE, encodeFrame, jsonPayload, parseJsonPayload, FLAG_E2E } from "rdsh-tunnel";
|
|
16
|
+
import { FrameParser, FRAME_TYPE, encodeFrame, jsonPayload, parseJsonPayload, FLAG_E2E, MAX_PAYLOAD_LENGTH } from "rdsh-tunnel";
|
|
17
17
|
import { findDsh, spawnDsh, exchangeDshSessionCookie, detectDshVersion, dshVersionWarning } from "./spawn-dsh.js";
|
|
18
18
|
import { rewriteHeadersForDsh } from "./proxy.js";
|
|
19
19
|
import { clearPersistedToken, persistToken, readPersistedToken } from "./token-store.js";
|
|
20
|
+
import { decodeBody, encodeBody, firstEncoding } from "./http-encoding.js";
|
|
20
21
|
import { acquireJoinLock, releaseJoinLock } from "./lock.js";
|
|
21
22
|
import { responderHandshake, Aead } from "./e2ee.js";
|
|
22
23
|
import { loadOrCreateE2eeKeyPair } from "./e2ee-key-store.js";
|
|
@@ -131,6 +132,25 @@ async function register(hubUrl, joinToken, name, insecure, e2eePublicKey) {
|
|
|
131
132
|
* 启动 join 隧道(no-spawn):转发到外部 `opts.target`,不 spawn dsh、不 process.exit。
|
|
132
133
|
* 获取 pid 锁(opts.role);返回 `JoinHandle`,`stop()` 干净停止(关 WS/清 heartbeat/释放锁)。
|
|
133
134
|
*/
|
|
135
|
+
/**
|
|
136
|
+
* 上游失败分类。
|
|
137
|
+
*
|
|
138
|
+
* 旧实现把所有 `up.on("error")` 都报成 `UPSTREAM_UNREACHABLE: dsh not reachable`,
|
|
139
|
+
* 于是"dsh 已接受连接、但在读请求体阶段就断开(常见于 401/413)"被伪装成部署故障,
|
|
140
|
+
* 掩盖真实原因(2026-09-14 实测:上传大文件得到 502 "dsh not reachable",与事实不符)。
|
|
141
|
+
*/
|
|
142
|
+
export function classifyUpstreamFailure(code, responded, message) {
|
|
143
|
+
const reason = code !== undefined && code !== "" ? code : (message ?? "unknown");
|
|
144
|
+
if (responded) {
|
|
145
|
+
// 客户端已拿到状态头/部分 body:此时只能发 CLOSE 表示"响应体被截断"
|
|
146
|
+
return { kind: "close", code: "UPSTREAM_ABORTED", message: `upstream aborted mid-response (${reason})` };
|
|
147
|
+
}
|
|
148
|
+
const unreachable = code === "ECONNREFUSED" || code === "ENOTFOUND" || code === "EAI_AGAIN" || code === "EHOSTUNREACH" || code === "ENETUNREACH";
|
|
149
|
+
if (unreachable) {
|
|
150
|
+
return { kind: "error", code: "UPSTREAM_UNREACHABLE", message: `dsh not reachable (${reason})` };
|
|
151
|
+
}
|
|
152
|
+
return { kind: "error", code: "UPSTREAM_ABORTED", message: `upstream closed before responding (${reason})` };
|
|
153
|
+
}
|
|
134
154
|
/** JS 响应判定(content-type 含 javascript)。 */
|
|
135
155
|
export function isJsContentType(headers) {
|
|
136
156
|
const ct = headers["content-type"];
|
|
@@ -183,10 +203,42 @@ function gateChallengeHtml(hostName, actionPath, error, acceptLanguage) {
|
|
|
183
203
|
`<button type="submit" style="width:100%;margin-top:12px;height:38px;border:none;border-radius:8px;background:#2563eb;color:#fff;font-size:14px;cursor:pointer">${t.submit}</button>` +
|
|
184
204
|
`</form>${errHtml}</div></body></html>`);
|
|
185
205
|
}
|
|
206
|
+
/** http 响应体的分片发送上限:单帧超过隧道 16 MiB 会让 `encodeFrame` 抛 `ProtocolError`,
|
|
207
|
+
* 抛点在响应回调里未捕获 ⇒ 打死 host(gateway)进程(P1,2026-09-14 复审发现)。1 MiB 远低于上限,
|
|
208
|
+
* 且给 E2EE 方向(密文 = 内层帧 + 28 B nonce/tag)也留足余量。 */
|
|
209
|
+
const DATA_FRAME_CHUNK = 1 << 20;
|
|
210
|
+
/**
|
|
211
|
+
* E2EE 包装的固定开销:内层帧 15 B 头(MAGIC4+ver1+flags1+type1+streamId4+len4)+ AEAD
|
|
212
|
+
* nonce 12 B + GCM tag 16 B = **43 B**。WS 消息判界必须预留它,否则"明文 ≤16 MiB"的 WS 消息
|
|
213
|
+
* 经 E2EE 加密后刚好超出隧道上限,`encodeFrame` 在加密发送器里抛 `ProtocolError` 打死 host
|
|
214
|
+
*(2026-09-14 第三审发现的 28 B 窗口,加上内层帧头实为 43 B)。
|
|
215
|
+
*/
|
|
216
|
+
export const E2EE_FRAME_OVERHEAD = 15 + 12 + 16;
|
|
217
|
+
/** 按 DATA_FRAME_CHUNK 分片发送响应体(多 DATA 帧在 hub/浏览器侧天然拼回同一个 body)。 */
|
|
218
|
+
function sendChunkedBody(send, streamId, body) {
|
|
219
|
+
if (body.length === 0)
|
|
220
|
+
return;
|
|
221
|
+
for (let off = 0; off < body.length; off += DATA_FRAME_CHUNK) {
|
|
222
|
+
send(encodeFrame(FRAME_TYPE.DATA, streamId, body.subarray(off, Math.min(off + DATA_FRAME_CHUNK, body.length))));
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* WS 消息转发:**一条消息 = 一帧,不能分片**(分片会破坏 host 侧的消息边界)。
|
|
227
|
+
* 超限(>16 MiB − E2EE_FRAME_OVERHEAD)时发 CLOSE(1009) 给客户端并返回 false,由调用方关掉上游
|
|
228
|
+
* —— 绝不让 `encodeFrame` 的 `ProtocolError` 逃逸到 ws 回调打死 host 进程。
|
|
229
|
+
*/
|
|
230
|
+
function sendWsData(send, streamId, buf) {
|
|
231
|
+
if (buf.length > MAX_PAYLOAD_LENGTH - E2EE_FRAME_OVERHEAD) {
|
|
232
|
+
send(encodeFrame(FRAME_TYPE.CLOSE, streamId, jsonPayload({ code: 1009, message: "upstream ws message too large" })));
|
|
233
|
+
return false;
|
|
234
|
+
}
|
|
235
|
+
send(encodeFrame(FRAME_TYPE.DATA, streamId, buf));
|
|
236
|
+
return true;
|
|
237
|
+
}
|
|
186
238
|
/** 发送合成的 HTTP 响应帧(gateway 不触达 dsh)。 */
|
|
187
239
|
function sendSyntheticHttp(send, streamId, status, headers, body) {
|
|
188
240
|
send(encodeFrame(FRAME_TYPE.OPEN, streamId, jsonPayload({ kind: "http", status, reason: undefined, headers })));
|
|
189
|
-
send
|
|
241
|
+
sendChunkedBody(send, streamId, body);
|
|
190
242
|
send(encodeFrame(FRAME_TYPE.CLOSE, streamId, jsonPayload({ code: 0 })));
|
|
191
243
|
}
|
|
192
244
|
export function startJoin(opts) {
|
|
@@ -208,8 +260,22 @@ export function startJoin(opts) {
|
|
|
208
260
|
};
|
|
209
261
|
const lock = acquireJoinLock(opts.role, opts.lockPath);
|
|
210
262
|
if (!lock.ok) {
|
|
211
|
-
|
|
263
|
+
// 「同机单隧道」铁律:任何活锁都拒绝(含本进程自己的 pid —— 那是热重载时的同进程重复获取)
|
|
264
|
+
throw new Error("contended" in lock
|
|
265
|
+
? "join lock is contended by another instance; retry"
|
|
266
|
+
: lock.heldBy.pid === process.pid
|
|
267
|
+
? "another tunnel is already running in this process"
|
|
268
|
+
: `join lock held by ${lock.heldBy.role} (pid ${lock.heldBy.pid}); stop it first`);
|
|
212
269
|
}
|
|
270
|
+
/**
|
|
271
|
+
* 构造期同步抛错必须把锁还回去(否则本进程再也起不来隧道,还谎报「本进程已有隧道」)。
|
|
272
|
+
* 现实抛点:E2EE 密钥生成/序列化(`loadOrCreateE2eeKeyPair`)与 `connect()` 里的
|
|
273
|
+
* `new WebSocket(非法 URL)`、以及 embedder 的 onState 钩子。
|
|
274
|
+
*/
|
|
275
|
+
const releaseLockAndRethrow = (err) => {
|
|
276
|
+
releaseJoinLock(opts.lockPath);
|
|
277
|
+
throw err;
|
|
278
|
+
};
|
|
213
279
|
const parser = new FrameParser();
|
|
214
280
|
let shuttingDown = false;
|
|
215
281
|
let reconnectDelay = RECONNECT_BASE_MS;
|
|
@@ -233,6 +299,8 @@ export function startJoin(opts) {
|
|
|
233
299
|
const wsStreams = new Map();
|
|
234
300
|
// gate 未过、等待 code 提交的 http 流(OPEN 后缓冲 DATA,CLOSE 时校验)
|
|
235
301
|
const gatedHttp = new Map();
|
|
302
|
+
/** 已报告过"loopback 补丁未命中"的路径(每个路径只报一次,避免日志刷屏)。 */
|
|
303
|
+
const patchMissReported = new Set();
|
|
236
304
|
/** 从转发头里取 rdsh_gate cookie(hub D12 白名单透传)。 */
|
|
237
305
|
function gateCookie(headers) {
|
|
238
306
|
const ck = headers["cookie"];
|
|
@@ -326,7 +394,16 @@ export function startJoin(opts) {
|
|
|
326
394
|
: Buffer.isBuffer(data)
|
|
327
395
|
? data
|
|
328
396
|
: Buffer.from(data);
|
|
329
|
-
|
|
397
|
+
if (!sendWsData(send, streamId, buf)) {
|
|
398
|
+
// 超限:已发 CLOSE(1009) 给客户端,这里关掉上游 dsh 连接,绝不让异常逃逸
|
|
399
|
+
console.error(`[join] upstream ws message too large (${buf.length}B), closing stream ${streamId}`);
|
|
400
|
+
try {
|
|
401
|
+
upstream.close(1009, "message too big");
|
|
402
|
+
}
|
|
403
|
+
catch {
|
|
404
|
+
/* 已关闭 */
|
|
405
|
+
}
|
|
406
|
+
}
|
|
330
407
|
});
|
|
331
408
|
const cleanup = () => {
|
|
332
409
|
wsStreams.delete(streamId);
|
|
@@ -385,6 +462,8 @@ export function startJoin(opts) {
|
|
|
385
462
|
return;
|
|
386
463
|
}
|
|
387
464
|
const streamId = frame.streamId;
|
|
465
|
+
/** 响应头是否已发给客户端(决定失败时能回 ERROR 还是只能用 CLOSE 表示截断)。 */
|
|
466
|
+
let responded = false;
|
|
388
467
|
const up = httpRequest({
|
|
389
468
|
host: opts.target.host,
|
|
390
469
|
port: opts.target.port,
|
|
@@ -392,22 +471,17 @@ export function startJoin(opts) {
|
|
|
392
471
|
method,
|
|
393
472
|
headers: rewriteHeadersForDsh(headers, opts.target, dshAuthCookie),
|
|
394
473
|
}, (upRes) => {
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
upRes.on("data", (chunk) => chunks.push(chunk));
|
|
474
|
+
responded = true;
|
|
475
|
+
const status = upRes.statusCode ?? 502;
|
|
476
|
+
const baseHeaders = normalizeRespHeaders(upRes.headers);
|
|
477
|
+
const wantsPatch = dio?.jsPatch?.() === true && isJsContentType(upRes.headers);
|
|
478
|
+
// 非 JS / 未开补丁:保持原流式路径(OPEN 立即发,body 边到边发)
|
|
479
|
+
if (!wantsPatch) {
|
|
480
|
+
send(encodeFrame(FRAME_TYPE.OPEN, streamId, jsonPayload({ kind: "http", status, reason: upRes.statusMessage, headers: baseHeaders })));
|
|
481
|
+
upRes.on("data", (chunk) => {
|
|
482
|
+
sendChunkedBody(send, streamId, chunk);
|
|
483
|
+
});
|
|
406
484
|
upRes.on("end", () => {
|
|
407
|
-
const body = Buffer.concat(chunks);
|
|
408
|
-
const patched = patchLoopbackJs(body);
|
|
409
|
-
// fail-open:未命中也必须原样发 body(否则空响应白屏)
|
|
410
|
-
send(encodeFrame(FRAME_TYPE.DATA, streamId, patched !== null ? patched : body));
|
|
411
485
|
send(encodeFrame(FRAME_TYPE.CLOSE, streamId, jsonPayload({ code: 0 })));
|
|
412
486
|
httpStreams.delete(streamId);
|
|
413
487
|
});
|
|
@@ -417,10 +491,45 @@ export function startJoin(opts) {
|
|
|
417
491
|
});
|
|
418
492
|
return;
|
|
419
493
|
}
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
494
|
+
// JS + 补丁:**必须先缓冲/处理再发 OPEN** —— patch 与"按原编码重压"都会改变 body 长度,
|
|
495
|
+
// 而 OPEN 帧里的 content-length 必须与实际字节一致(否则浏览器截断或一直等)。
|
|
496
|
+
// dsh 会按 accept-encoding 压缩 JS(gzip 等),所以这里必须先解码再补丁、再按原编码重压。
|
|
497
|
+
const chunks = [];
|
|
498
|
+
upRes.on("data", (chunk) => chunks.push(chunk));
|
|
423
499
|
upRes.on("end", () => {
|
|
500
|
+
const raw = Buffer.concat(chunks);
|
|
501
|
+
const encoding = firstEncoding(upRes.headers["content-encoding"]);
|
|
502
|
+
const decoded = decodeBody(raw, encoding);
|
|
503
|
+
let body = raw;
|
|
504
|
+
let outHeaders = baseHeaders;
|
|
505
|
+
let hit = false;
|
|
506
|
+
if (decoded !== null) {
|
|
507
|
+
const patched = patchLoopbackJs(decoded);
|
|
508
|
+
const recoded = patched === null ? null : encodeBody(patched, encoding);
|
|
509
|
+
if (recoded !== null) {
|
|
510
|
+
body = recoded;
|
|
511
|
+
hit = true;
|
|
512
|
+
outHeaders = { ...baseHeaders, "content-length": String(body.length) };
|
|
513
|
+
delete outHeaders["transfer-encoding"];
|
|
514
|
+
// 不能沿用上游的 `immutable` / 长 max-age:补丁改了 body 但 URL(rev) 由上游内容决定,
|
|
515
|
+
// 浏览器会把"旧的未补丁 bundle"缓存很久 ⇒ 修复无法生效(2026-09-14 实测踩到)。
|
|
516
|
+
outHeaders["cache-control"] = "public, max-age=300";
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
// 未命中必须留痕(每个路径一次):fail-open 是刻意设计,但"补丁从未生效"不能静默
|
|
520
|
+
//(2026-09-14 gzip 事故正是因为没有这条日志而排查了很久)
|
|
521
|
+
if (!hit) {
|
|
522
|
+
const key = (path.split("?")[0] ?? path).slice(0, 120);
|
|
523
|
+
if (!patchMissReported.has(key)) {
|
|
524
|
+
patchMissReported.add(key);
|
|
525
|
+
console.log(`[patch] miss: ${key} (${raw.length}B, content-encoding=${encoding === "" ? "identity" : encoding})`);
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
else if (process.env.RDSH_DEBUG_PATCH === "1") {
|
|
529
|
+
console.log(`[patch] hit: ${path.slice(0, 120)} (${raw.length}B → ${body.length}B, ${encoding === "" ? "identity" : encoding})`);
|
|
530
|
+
}
|
|
531
|
+
send(encodeFrame(FRAME_TYPE.OPEN, streamId, jsonPayload({ kind: "http", status, reason: upRes.statusMessage, headers: outHeaders })));
|
|
532
|
+
sendChunkedBody(send, streamId, body);
|
|
424
533
|
send(encodeFrame(FRAME_TYPE.CLOSE, streamId, jsonPayload({ code: 0 })));
|
|
425
534
|
httpStreams.delete(streamId);
|
|
426
535
|
});
|
|
@@ -429,8 +538,10 @@ export function startJoin(opts) {
|
|
|
429
538
|
httpStreams.delete(streamId);
|
|
430
539
|
});
|
|
431
540
|
});
|
|
432
|
-
up.on("error", () => {
|
|
433
|
-
|
|
541
|
+
up.on("error", (err) => {
|
|
542
|
+
const outcome = classifyUpstreamFailure(err.code, responded, err.message);
|
|
543
|
+
const type = outcome.kind === "close" ? FRAME_TYPE.CLOSE : FRAME_TYPE.ERROR;
|
|
544
|
+
send(encodeFrame(type, streamId, jsonPayload({ code: outcome.code, message: outcome.message })));
|
|
434
545
|
httpStreams.delete(streamId);
|
|
435
546
|
});
|
|
436
547
|
httpStreams.set(streamId, { up });
|
|
@@ -506,15 +617,36 @@ export function startJoin(opts) {
|
|
|
506
617
|
}
|
|
507
618
|
const plainDispatcher = makeInnerDispatcher(sendTunnelFrame, { jsPatch: () => uiCompat.trustE2EEAsLoopback, gate: true });
|
|
508
619
|
// host 端 E2EE 静态密钥对(持久化 ~/.rdsh/e2ee-key.json;join 注册时上送指纹)
|
|
509
|
-
|
|
620
|
+
let hostE2eeKeypair;
|
|
621
|
+
try {
|
|
622
|
+
hostE2eeKeypair = loadOrCreateE2eeKeyPair();
|
|
623
|
+
}
|
|
624
|
+
catch (err) {
|
|
625
|
+
releaseLockAndRethrow(err);
|
|
626
|
+
}
|
|
510
627
|
const rawStreams = new Map();
|
|
511
628
|
function startRawStream(streamId) {
|
|
512
629
|
const inner = makeInnerDispatcher((frame) => {
|
|
513
630
|
const raw = rawStreams.get(streamId);
|
|
514
|
-
if (raw?.encryptor
|
|
631
|
+
if (raw?.encryptor === null || raw?.encryptor === undefined)
|
|
632
|
+
return;
|
|
633
|
+
try {
|
|
515
634
|
const ct = raw.encryptor.encrypt(frame, Buffer.alloc(0));
|
|
635
|
+
if (ct.length > MAX_PAYLOAD_LENGTH) {
|
|
636
|
+
// 结构兜底:内层帧经 E2EE 包装(+43 B)后超限。绝不能让 encodeFrame 的 ProtocolError
|
|
637
|
+
// 逃逸;给该内层流回一个 ERROR(小帧,加密后必然放得下),消费方据此干净报错。
|
|
638
|
+
const innerId = frame.length >= 11 ? frame.readUInt32BE(7) : 0;
|
|
639
|
+
const errCt = raw.encryptor.encrypt(encodeFrame(FRAME_TYPE.ERROR, innerId, jsonPayload({ code: "FRAME_TOO_LARGE", message: "e2ee frame too large" })), Buffer.alloc(0));
|
|
640
|
+
console.error(`[join] e2ee frame too large (${ct.length}B) for inner stream ${innerId}`);
|
|
641
|
+
sendTunnelFrame(encodeFrame(FRAME_TYPE.DATA, streamId, errCt, FLAG_E2E));
|
|
642
|
+
return;
|
|
643
|
+
}
|
|
516
644
|
sendTunnelFrame(encodeFrame(FRAME_TYPE.DATA, streamId, ct, FLAG_E2E));
|
|
517
645
|
}
|
|
646
|
+
catch (err) {
|
|
647
|
+
// 最后一道:任何加密/封帧异常只记录,绝不重抛打死 host
|
|
648
|
+
console.error(`[join] e2ee send failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
649
|
+
}
|
|
518
650
|
}, { jsPatch: () => uiCompat.trustE2EEAsLoopback });
|
|
519
651
|
rawStreams.set(streamId, {
|
|
520
652
|
handshakeBuf: Buffer.alloc(0),
|
|
@@ -621,7 +753,6 @@ export function startJoin(opts) {
|
|
|
621
753
|
const url = `${hubWsBase}/tunnel`;
|
|
622
754
|
const client = new WebSocket(url, { headers: { authorization: `Bearer ${opts.token}` }, rejectUnauthorized: !opts.insecure });
|
|
623
755
|
currentClient = client;
|
|
624
|
-
setState("connecting", { message: `connecting to ${opts.hubUrl}` });
|
|
625
756
|
// 401/403 = token 被拒(吊销/不存在)。监听此事件后 ws 不再自动 abort,
|
|
626
757
|
// 需手动 terminate → 触发 close → 决定「重配对」还是「普通重连」。
|
|
627
758
|
let tokenRejected = false;
|
|
@@ -714,8 +845,16 @@ export function startJoin(opts) {
|
|
|
714
845
|
/* 已关闭 */
|
|
715
846
|
}
|
|
716
847
|
});
|
|
848
|
+
// 状态回调放在所有监听器挂好**之后**:embedder 的 onState 若抛错,异常会向上抛并被
|
|
849
|
+
// releaseLockAndRethrow 处理;此时 socket 已有 error 监听,不会因后续连接失败变成未捕获 error 崩进程。
|
|
850
|
+
setState("connecting", { message: `connecting to ${opts.hubUrl}` });
|
|
851
|
+
}
|
|
852
|
+
try {
|
|
853
|
+
connect();
|
|
854
|
+
}
|
|
855
|
+
catch (err) {
|
|
856
|
+
releaseLockAndRethrow(err);
|
|
717
857
|
}
|
|
718
|
-
connect();
|
|
719
858
|
return {
|
|
720
859
|
setUiCompat(trustE2EEAsLoopback) {
|
|
721
860
|
uiCompat.trustE2EEAsLoopback = trustE2EEAsLoopback;
|
package/dist/lock.d.ts
CHANGED
|
@@ -5,8 +5,11 @@ export interface JoinLock {
|
|
|
5
5
|
role: JoinLockRole;
|
|
6
6
|
}
|
|
7
7
|
/**
|
|
8
|
-
*
|
|
8
|
+
* 读锁(**纯读,无副作用**):不存在 / 损坏 / pid 已死(stale)→ null。
|
|
9
9
|
* 供面板「外部托管」态判定(role=cli)与 acquire 前检测复用。
|
|
10
|
+
*
|
|
11
|
+
* 注意:不再顺手删除 stale 文件(旧实现有此行为且无二次校验,会误删他人新锁);
|
|
12
|
+
* 清理统一由 acquire 路径的 `reapDeadLock()` 以 compare-and-delete 完成。
|
|
10
13
|
*/
|
|
11
14
|
export declare function readJoinLock(path?: string): JoinLock | null;
|
|
12
15
|
export type AcquireResult = {
|
|
@@ -14,11 +17,21 @@ export type AcquireResult = {
|
|
|
14
17
|
} | {
|
|
15
18
|
ok: false;
|
|
16
19
|
heldBy: JoinLock;
|
|
20
|
+
}
|
|
21
|
+
/** 连续被抢占(拿不到也读不到持有者,极罕见):不伪造持有者,由调用方给出「被占用」提示。 */
|
|
22
|
+
| {
|
|
23
|
+
ok: false;
|
|
24
|
+
contended: true;
|
|
17
25
|
};
|
|
18
26
|
/**
|
|
19
|
-
*
|
|
20
|
-
*
|
|
27
|
+
* 获取锁:**原子发布**锁文件(临时文件 + `link`;目录 0700、文件 0600),内容 `{pid, role}`。
|
|
28
|
+
*
|
|
29
|
+
* - 已有活锁(**无论 pid 是否为自己**)→ 拒绝,防同机 / 同进程双隧道;
|
|
30
|
+
* - stale(pid 已死)或损坏残锁 → compare-and-delete 清除后重试;
|
|
31
|
+
* - 文件系统不支持硬链接 → 退回 `wx` 创建(旧实现在这些文件系统上就是这个行为,
|
|
32
|
+
* 短暂的"空文件窗口"由 `reapDeadLock` 的新鲜度保护兜住);
|
|
33
|
+
* - 连续 3 次被抢占 → `{ok:false, contended:true}`。
|
|
21
34
|
*/
|
|
22
35
|
export declare function acquireJoinLock(role: JoinLockRole, path?: string): AcquireResult;
|
|
23
|
-
/** 释放锁:仅当锁是自己 pid
|
|
36
|
+
/** 释放锁:仅当锁是自己 pid 持有才删(不误删他人锁);同时清本进程记账。 */
|
|
24
37
|
export declare function releaseJoinLock(path?: string): void;
|
package/dist/lock.js
CHANGED
|
@@ -1,10 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* lock.ts — join pid 锁:CLI 与插件共享「单身份铁律」(同机单隧道,D5 档2)。
|
|
3
3
|
*
|
|
4
|
-
* 锁文件 `~/.rdsh/join.lock`(0600)记 `{pid, role}
|
|
4
|
+
* 锁文件 `~/.rdsh/join.lock`(0600)记 `{pid, role}`。
|
|
5
5
|
* role = "cli"(rdsh CLI / systemd 服务持有)| "plugin"(dsh 插件持有)——面板据此显示「外部托管」。
|
|
6
|
+
*
|
|
7
|
+
* 三条不变量(2026-09-15 真机复盘:多个 `dsh web` 实例互相顶替导致面板反复掉线/连线):
|
|
8
|
+
* 1. **原子发布**「文件 + 内容」:先写临时文件再 `link` —— 锁路径要么不存在、要么内容完整,
|
|
9
|
+
* 不存在「已存在但内容为空」的窗口(旧实现 `writeFileSync(..., {flag:"wx"})` 有这个窗口,
|
|
10
|
+
* 会被并发清理者当成坏锁删掉,两边都以为自己持锁);
|
|
11
|
+
* 2. **拒绝任何活锁**(包括本进程自己的 pid):同进程重复获取由 `heldPaths` 记账拦住;
|
|
12
|
+
* 3. **清理只走 compare-and-delete**:读 → 判断 → 删除之间不得误删他人刚发布的锁
|
|
13
|
+
* (旧实现 `readJoinLock` 读到 stale 就无条件 `rmSync`,会删掉别人刚建立的新锁)。
|
|
6
14
|
*/
|
|
7
|
-
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
15
|
+
import { existsSync, linkSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
8
16
|
import { homedir } from "node:os";
|
|
9
17
|
import { dirname, join } from "node:path";
|
|
10
18
|
const RDSH_DIR = join(homedir(), ".rdsh");
|
|
@@ -19,47 +27,144 @@ function isAlive(pid) {
|
|
|
19
27
|
return err.code === "EPERM";
|
|
20
28
|
}
|
|
21
29
|
}
|
|
30
|
+
/** 解析锁内容;损坏/字段非法 → null。纯函数,不碰文件系统。 */
|
|
31
|
+
function parseLock(raw) {
|
|
32
|
+
try {
|
|
33
|
+
const j = JSON.parse(raw);
|
|
34
|
+
if (typeof j.pid !== "number" || (j.role !== "cli" && j.role !== "plugin"))
|
|
35
|
+
return null;
|
|
36
|
+
return { pid: j.pid, role: j.role };
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
22
42
|
/**
|
|
23
|
-
*
|
|
43
|
+
* 读锁(**纯读,无副作用**):不存在 / 损坏 / pid 已死(stale)→ null。
|
|
24
44
|
* 供面板「外部托管」态判定(role=cli)与 acquire 前检测复用。
|
|
45
|
+
*
|
|
46
|
+
* 注意:不再顺手删除 stale 文件(旧实现有此行为且无二次校验,会误删他人新锁);
|
|
47
|
+
* 清理统一由 acquire 路径的 `reapDeadLock()` 以 compare-and-delete 完成。
|
|
25
48
|
*/
|
|
26
49
|
export function readJoinLock(path = JOIN_LOCK_PATH) {
|
|
27
50
|
try {
|
|
28
51
|
if (!existsSync(path))
|
|
29
52
|
return null;
|
|
30
|
-
const
|
|
31
|
-
if (
|
|
53
|
+
const held = parseLock(readFileSync(path, "utf8"));
|
|
54
|
+
if (held === null)
|
|
32
55
|
return null;
|
|
33
|
-
|
|
34
|
-
try {
|
|
35
|
-
rmSync(path, { force: true });
|
|
36
|
-
}
|
|
37
|
-
catch {
|
|
38
|
-
/* 忽略 */
|
|
39
|
-
}
|
|
40
|
-
return null;
|
|
41
|
-
}
|
|
42
|
-
return { pid: raw.pid, role: raw.role };
|
|
56
|
+
return isAlive(held.pid) ? held : null;
|
|
43
57
|
}
|
|
44
58
|
catch {
|
|
45
59
|
return null;
|
|
46
60
|
}
|
|
47
61
|
}
|
|
62
|
+
function snapshotLock(path) {
|
|
63
|
+
try {
|
|
64
|
+
const st = statSync(path);
|
|
65
|
+
return { raw: readFileSync(path, "utf8"), ino: st.ino, mtimeMs: st.mtimeMs };
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
/** 内容解析不出来的锁文件,多新以内视为"并发创建者写到一半"而不回收。 */
|
|
72
|
+
const FRESH_LOCK_MS = 1000;
|
|
73
|
+
/**
|
|
74
|
+
* 清除「确定已经死了」的残锁(pid 已死,或内容损坏且已不新),成功则锁路径变空、可重试获取。
|
|
75
|
+
*
|
|
76
|
+
* 两道保护,避免把别人的**活锁**删掉:
|
|
77
|
+
* 1. compare-and-delete 比对**内容 + inode**(同内容但换了 inode = 别人刚发布的新锁,不删);
|
|
78
|
+
* 2. 内容解析不出来(空/半截)且文件很新 → 视为并发创建者写到一半,不删
|
|
79
|
+
* (本实现的发布是原子的,这种文件只可能来自旧版本或外部写入者)。
|
|
80
|
+
*/
|
|
81
|
+
function reapDeadLock(path) {
|
|
82
|
+
const first = snapshotLock(path);
|
|
83
|
+
if (first === null)
|
|
84
|
+
return;
|
|
85
|
+
const held = parseLock(first.raw);
|
|
86
|
+
if (held !== null && isAlive(held.pid))
|
|
87
|
+
return; // 有效活锁
|
|
88
|
+
if (held === null && Date.now() - first.mtimeMs < FRESH_LOCK_MS)
|
|
89
|
+
return; // 可能是写到一半
|
|
90
|
+
const second = snapshotLock(path);
|
|
91
|
+
if (second === null || second.ino !== first.ino || second.raw !== first.raw)
|
|
92
|
+
return; // 期间被替换
|
|
93
|
+
try {
|
|
94
|
+
rmSync(path, { force: true });
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
/* 忽略 */
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* 本进程已持有的锁路径。pid 无法区分同进程的两次获取,必须单独记账。
|
|
102
|
+
*
|
|
103
|
+
* 必要性:插件热重载(`patchReload: "live"`)时同一进程先后起两条隧道 —— 若只按 pid 判断,
|
|
104
|
+
* 「锁是自己」会被放行(旧实现正是如此)→ 同机双隧道 → 与 hub 的顶替逻辑叠加成无限互踢。
|
|
105
|
+
*/
|
|
106
|
+
const heldPaths = new Set();
|
|
107
|
+
/** 文件系统不支持硬链接时 `linkSync` 的 errno(SMB / FUSE / exFAT 之类)。 */
|
|
108
|
+
const LINK_UNSUPPORTED = new Set(["EPERM", "ENOSYS", "ENOTSUP", "EOPNOTSUPP"]);
|
|
48
109
|
/**
|
|
49
|
-
*
|
|
50
|
-
*
|
|
110
|
+
* 获取锁:**原子发布**锁文件(临时文件 + `link`;目录 0700、文件 0600),内容 `{pid, role}`。
|
|
111
|
+
*
|
|
112
|
+
* - 已有活锁(**无论 pid 是否为自己**)→ 拒绝,防同机 / 同进程双隧道;
|
|
113
|
+
* - stale(pid 已死)或损坏残锁 → compare-and-delete 清除后重试;
|
|
114
|
+
* - 文件系统不支持硬链接 → 退回 `wx` 创建(旧实现在这些文件系统上就是这个行为,
|
|
115
|
+
* 短暂的"空文件窗口"由 `reapDeadLock` 的新鲜度保护兜住);
|
|
116
|
+
* - 连续 3 次被抢占 → `{ok:false, contended:true}`。
|
|
51
117
|
*/
|
|
52
118
|
export function acquireJoinLock(role, path = JOIN_LOCK_PATH) {
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
119
|
+
if (heldPaths.has(path)) {
|
|
120
|
+
return { ok: false, heldBy: { pid: process.pid, role } };
|
|
121
|
+
}
|
|
122
|
+
const data = JSON.stringify({ pid: process.pid, role });
|
|
123
|
+
const tmp = `${path}.${process.pid}.tmp`;
|
|
124
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
125
|
+
try {
|
|
126
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
127
|
+
// 内容先落到临时文件(默认 flag 覆盖写,不会 EEXIST),再 link 到锁路径;
|
|
128
|
+
// link 是原子的:目标已存在则 EEXIST,且目标**一出现就带完整内容**。
|
|
129
|
+
writeFileSync(tmp, data, { mode: 0o600 });
|
|
130
|
+
linkSync(tmp, path);
|
|
131
|
+
heldPaths.add(path);
|
|
132
|
+
return { ok: true };
|
|
133
|
+
}
|
|
134
|
+
catch (err) {
|
|
135
|
+
const code = err.code;
|
|
136
|
+
if (code !== "EEXIST") {
|
|
137
|
+
if (!LINK_UNSUPPORTED.has(code ?? ""))
|
|
138
|
+
throw err;
|
|
139
|
+
try {
|
|
140
|
+
writeFileSync(path, data, { flag: "wx", mode: 0o600 });
|
|
141
|
+
heldPaths.add(path);
|
|
142
|
+
return { ok: true };
|
|
143
|
+
}
|
|
144
|
+
catch (err2) {
|
|
145
|
+
if (err2.code !== "EEXIST")
|
|
146
|
+
throw err2;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
finally {
|
|
151
|
+
try {
|
|
152
|
+
rmSync(tmp, { force: true }); // 删掉临时名,锁路径(硬链接)不受影响
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
/* 忽略 */
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
const held = readJoinLock(path);
|
|
159
|
+
if (held !== null)
|
|
160
|
+
return { ok: false, heldBy: held };
|
|
161
|
+
reapDeadLock(path);
|
|
56
162
|
}
|
|
57
|
-
|
|
58
|
-
writeFileSync(path, JSON.stringify({ pid: process.pid, role }), { mode: 0o600 });
|
|
59
|
-
return { ok: true };
|
|
163
|
+
return { ok: false, contended: true };
|
|
60
164
|
}
|
|
61
|
-
/** 释放锁:仅当锁是自己 pid
|
|
165
|
+
/** 释放锁:仅当锁是自己 pid 持有才删(不误删他人锁);同时清本进程记账。 */
|
|
62
166
|
export function releaseJoinLock(path = JOIN_LOCK_PATH) {
|
|
167
|
+
heldPaths.delete(path);
|
|
63
168
|
try {
|
|
64
169
|
const held = readJoinLock(path);
|
|
65
170
|
if (held !== null && held.pid === process.pid) {
|
package/dist/proxy.d.ts
CHANGED
|
@@ -16,6 +16,12 @@ export interface ForwardOptions {
|
|
|
16
16
|
* 提供时合并进每个转发/升级请求的 cookie 头,穿透 dsh 0.1.2 认证层。
|
|
17
17
|
*/
|
|
18
18
|
authCookie?: string | null;
|
|
19
|
+
/**
|
|
20
|
+
* JS 响应补丁(LAN 路径;隧道路径见 `join.ts` 的 `patchLoopbackJs`)。
|
|
21
|
+
* 命中 content-type 含 `javascript` 时缓冲后调用;返回 `null` = 原样透传(fail-open)。
|
|
22
|
+
* 用途:把 DSH 前端的 `isLoopback` 判定替换为 `true` —— DSH 的设置/凭据界面只对 loopback 开放。
|
|
23
|
+
*/
|
|
24
|
+
jsPatch?: (body: Buffer) => Buffer | null;
|
|
19
25
|
}
|
|
20
26
|
/**
|
|
21
27
|
* 重写转发头以通过 DSH 围栏(isTrustedApiRequest 要求 Host/Origin 一致且为 loopback)。
|
package/dist/proxy.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import { request } from "node:http";
|
|
8
8
|
import { WebSocket, WebSocketServer } from "ws";
|
|
9
|
+
import { decodeBody, encodeBody, firstEncoding } from "./http-encoding.js";
|
|
9
10
|
/**
|
|
10
11
|
* 重写转发头以通过 DSH 围栏(isTrustedApiRequest 要求 Host/Origin 一致且为 loopback)。
|
|
11
12
|
* - Host → 127.0.0.1:<port>(M1 事实:DSH 只信任 loopback/trusted Host)
|
|
@@ -80,6 +81,37 @@ export function forwardHttp(req, res, target, opts) {
|
|
|
80
81
|
});
|
|
81
82
|
return;
|
|
82
83
|
}
|
|
84
|
+
const canPatchJs = opts?.jsPatch !== undefined && typeof contentType === "string" && /javascript/i.test(contentType);
|
|
85
|
+
if (canPatchJs) {
|
|
86
|
+
// JS bundle 需缓冲后才能替换目标串;先按 content-encoding 解码(dsh 会 gzip JS),
|
|
87
|
+
// 补丁后按原编码重压(fail-open:未命中/不支持编码 → 原字节原头透传)
|
|
88
|
+
void (async () => {
|
|
89
|
+
const chunks = [];
|
|
90
|
+
for await (const chunk of upstreamRes)
|
|
91
|
+
chunks.push(chunk);
|
|
92
|
+
const raw = Buffer.concat(chunks);
|
|
93
|
+
const encoding = firstEncoding(upstreamRes.headers["content-encoding"]);
|
|
94
|
+
const decoded = decodeBody(raw, encoding);
|
|
95
|
+
const patched = decoded === null ? null : opts.jsPatch(decoded);
|
|
96
|
+
const recoded = patched === null ? null : encodeBody(patched, encoding);
|
|
97
|
+
const outBody = recoded ?? raw;
|
|
98
|
+
const outHeaders = { ...upstreamRes.headers };
|
|
99
|
+
if (recoded !== null) {
|
|
100
|
+
outHeaders["content-length"] = String(outBody.length);
|
|
101
|
+
delete outHeaders["transfer-encoding"];
|
|
102
|
+
// 同 join.ts:补丁后不可沿用 immutable/长 max-age(URL 不变,旧未补丁体可能被长期缓存)
|
|
103
|
+
outHeaders["cache-control"] = "public, max-age=300";
|
|
104
|
+
}
|
|
105
|
+
delete outHeaders.connection;
|
|
106
|
+
res.writeHead(upstreamRes.statusCode ?? 200, outHeaders);
|
|
107
|
+
res.end(outBody);
|
|
108
|
+
})().catch(() => {
|
|
109
|
+
if (!res.headersSent)
|
|
110
|
+
res.writeHead(502, { "content-type": "text/plain" });
|
|
111
|
+
res.end("bad gateway");
|
|
112
|
+
});
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
83
115
|
res.writeHead(upstreamRes.statusCode ?? 502, upstreamRes.headers);
|
|
84
116
|
upstreamRes.pipe(res);
|
|
85
117
|
});
|
package/dist/serve.js
CHANGED
package/dist/server.d.ts
CHANGED
|
@@ -26,6 +26,13 @@ export interface GatewayOptions {
|
|
|
26
26
|
dshPort: number;
|
|
27
27
|
/** 宿主代持的 dsh 浏览器会话 cookie(`dsh-auth-*`,0.1.2+);有值时注入转发 */
|
|
28
28
|
dshAuthCookieHeader?: string | null;
|
|
29
|
+
/**
|
|
30
|
+
* DSH UI 兼容(LAN 路径):`trustPairedAsLoopback` 默认 true —— 把已通过本机配对/登录的
|
|
31
|
+
* LAN 会话视同 loopback,使 DSH 的设置与"持久化凭据"界面可用(否则 LAN 用户无法在界面里输入 API key)。
|
|
32
|
+
*/
|
|
33
|
+
dshUiCompat?: {
|
|
34
|
+
trustPairedAsLoopback?: boolean;
|
|
35
|
+
};
|
|
29
36
|
/** true = 启动时重置会话密钥(全部会话失效) */
|
|
30
37
|
reset?: boolean;
|
|
31
38
|
/** 会话密钥目录(默认 ~/.rdsh;测试可注入临时目录) */
|
package/dist/server.js
CHANGED
|
@@ -18,6 +18,7 @@ import { basename, dirname } from "node:path";
|
|
|
18
18
|
import { SessionManager, sessionTokenFromCookie } from "./session.js";
|
|
19
19
|
import { PairManager } from "./pair.js";
|
|
20
20
|
import { createUpgradeProxy, forwardHttp } from "./proxy.js";
|
|
21
|
+
import { patchLoopbackJs } from "./join.js";
|
|
21
22
|
import { pairPageHtml } from "./pair-page.js";
|
|
22
23
|
import { loginPageHtml } from "./login-page.js";
|
|
23
24
|
import { SECURE_CONTEXT_POLYFILL } from "./secure-context-polyfill.js";
|
|
@@ -70,6 +71,7 @@ export async function startGateway(opts) {
|
|
|
70
71
|
authMode,
|
|
71
72
|
behindProxy,
|
|
72
73
|
dshAuthCookieHeader: opts.dshAuthCookieHeader ?? null,
|
|
74
|
+
trustPairedAsLoopback: opts.dshUiCompat?.trustPairedAsLoopback !== false,
|
|
73
75
|
getVersion: () => currentVersion,
|
|
74
76
|
isAllowed: (ip) => currentAllowFrom.length === 0 || ipInCidrs(ip, currentAllowFrom),
|
|
75
77
|
userManager: opts.userManager,
|
|
@@ -156,7 +158,11 @@ async function handleHttp(req, res, ctx, loginLimiter) {
|
|
|
156
158
|
return;
|
|
157
159
|
}
|
|
158
160
|
if (ctx.authMode === "none") {
|
|
159
|
-
forwardHttp(req, res, ctx.target, {
|
|
161
|
+
forwardHttp(req, res, ctx.target, {
|
|
162
|
+
htmlInject: SECURE_CONTEXT_POLYFILL,
|
|
163
|
+
authCookie: ctx.dshAuthCookieHeader,
|
|
164
|
+
jsPatch: ctx.trustPairedAsLoopback ? patchLoopbackJs : undefined,
|
|
165
|
+
});
|
|
160
166
|
return;
|
|
161
167
|
}
|
|
162
168
|
if (!hasValidSession(req, ctx)) {
|
|
@@ -164,7 +170,13 @@ async function handleHttp(req, res, ctx, loginLimiter) {
|
|
|
164
170
|
res.end();
|
|
165
171
|
return;
|
|
166
172
|
}
|
|
167
|
-
forwardHttp(req, res, ctx.target, {
|
|
173
|
+
forwardHttp(req, res, ctx.target, {
|
|
174
|
+
htmlInject: SECURE_CONTEXT_POLYFILL,
|
|
175
|
+
authCookie: ctx.dshAuthCookieHeader,
|
|
176
|
+
// 会话分支同样要打 loopback 补丁:LAN 没有 E2EE shim,设置/API key 唯一依赖这个补丁;
|
|
177
|
+
// 旧实现只在 authMode==="none" 分支传了 jsPatch ⇒ 配对/登录模式下设置页打不开(2026-09-14 复审发现)
|
|
178
|
+
jsPatch: ctx.trustPairedAsLoopback ? patchLoopbackJs : undefined,
|
|
179
|
+
});
|
|
168
180
|
}
|
|
169
181
|
async function handlePairPost(req, res, ctx) {
|
|
170
182
|
const ip = req.socket.remoteAddress ?? "unknown";
|