rdsh-gateway 0.6.0 → 0.7.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/dist/access-gate.d.ts +13 -0
- package/dist/access-gate.js +49 -0
- package/dist/config.d.ts +6 -0
- package/dist/config.js +15 -0
- package/dist/join.d.ts +12 -0
- package/dist/join.js +158 -4
- package/package.json +1 -1
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/** 网关访问 cookie 名(hub 侧 relay 白名单同名硬编码,注释互指)。 */
|
|
2
|
+
export declare const GATE_COOKIE = "rdsh_gate";
|
|
3
|
+
/** 访问 cookie 有效期(对齐 host cookie 7 天)。 */
|
|
4
|
+
export declare const GATE_COOKIE_TTL_MS: number;
|
|
5
|
+
/** 签发访问 cookie;返回 value + 过期时间(毫秒)。 */
|
|
6
|
+
export declare function signGateCookie(accessCode: string, now?: number): {
|
|
7
|
+
value: string;
|
|
8
|
+
expiresAt: number;
|
|
9
|
+
};
|
|
10
|
+
/** 验签访问 cookie:签名(恒定时间)+ 未过期。 */
|
|
11
|
+
export declare function verifyGateCookie(accessCode: string, value: string, now?: number): boolean;
|
|
12
|
+
/** code 恒定时间比对(对 sha256 摘要比较,避免长度侧信道)。 */
|
|
13
|
+
export declare function verifyGateCode(input: string, accessCode: string): boolean;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* access-gate.ts — host 侧访问口令(feature 15):访问 cookie 签发/验签 + code 恒定时间比对。
|
|
3
|
+
*
|
|
4
|
+
* - cookie 名 `rdsh_gate`(hub relay D12 白名单需同名透传,见 packages/hub/src/relay.ts);
|
|
5
|
+
* - 无状态验签:HMAC-SHA256(payload, key=sha256(accessCode)),payload = `${exp}.${nonce}`;
|
|
6
|
+
* - 改 code → key 变 → 旧 cookie 全失效(无需版本/黑名单)。
|
|
7
|
+
*/
|
|
8
|
+
import { createHmac, createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
|
9
|
+
/** 网关访问 cookie 名(hub 侧 relay 白名单同名硬编码,注释互指)。 */
|
|
10
|
+
export const GATE_COOKIE = "rdsh_gate";
|
|
11
|
+
/** 访问 cookie 有效期(对齐 host cookie 7 天)。 */
|
|
12
|
+
export const GATE_COOKIE_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
|
13
|
+
/** 派生 HMAC 密钥:sha256(accessCode)(改 code 即吊销旧 cookie)。 */
|
|
14
|
+
function keyFor(accessCode) {
|
|
15
|
+
return createHash("sha256").update(accessCode).digest();
|
|
16
|
+
}
|
|
17
|
+
function hmac(payload, accessCode) {
|
|
18
|
+
return createHmac("sha256", keyFor(accessCode)).update(payload).digest("base64url");
|
|
19
|
+
}
|
|
20
|
+
/** 签发访问 cookie;返回 value + 过期时间(毫秒)。 */
|
|
21
|
+
export function signGateCookie(accessCode, now = Date.now()) {
|
|
22
|
+
const exp = now + GATE_COOKIE_TTL_MS;
|
|
23
|
+
const nonce = randomBytes(16).toString("base64url");
|
|
24
|
+
const payload = `${exp}.${nonce}`;
|
|
25
|
+
return { value: `${payload}.${hmac(payload, accessCode)}`, expiresAt: exp };
|
|
26
|
+
}
|
|
27
|
+
/** 验签访问 cookie:签名(恒定时间)+ 未过期。 */
|
|
28
|
+
export function verifyGateCookie(accessCode, value, now = Date.now()) {
|
|
29
|
+
const parts = value.split(".");
|
|
30
|
+
if (parts.length !== 3)
|
|
31
|
+
return false;
|
|
32
|
+
const [expStr, nonce, sig] = parts;
|
|
33
|
+
if (expStr === "" || nonce === "" || sig === "")
|
|
34
|
+
return false;
|
|
35
|
+
const exp = Number(expStr);
|
|
36
|
+
if (!Number.isFinite(exp) || exp <= now)
|
|
37
|
+
return false;
|
|
38
|
+
const payload = `${expStr}.${nonce}`;
|
|
39
|
+
const expected = Buffer.from(hmac(payload, accessCode));
|
|
40
|
+
const actual = Buffer.from(sig);
|
|
41
|
+
return actual.length === expected.length && timingSafeEqual(actual, expected);
|
|
42
|
+
}
|
|
43
|
+
/** code 恒定时间比对(对 sha256 摘要比较,避免长度侧信道)。 */
|
|
44
|
+
export function verifyGateCode(input, accessCode) {
|
|
45
|
+
const a = createHash("sha256").update(input).digest();
|
|
46
|
+
const b = createHash("sha256").update(accessCode).digest();
|
|
47
|
+
return timingSafeEqual(a, b);
|
|
48
|
+
}
|
|
49
|
+
//# sourceMappingURL=access-gate.js.map
|
package/dist/config.d.ts
CHANGED
|
@@ -32,6 +32,12 @@ export interface RdshConfig {
|
|
|
32
32
|
insecure?: boolean;
|
|
33
33
|
/** DSH UI 兼容开关(跟随 E2EE;trustE2EEAsLoopback 默认 true) */
|
|
34
34
|
dshUiCompat?: DshUiCompat;
|
|
35
|
+
/** 网关访问口令(host 侧独立于 hub 的访问保护) */
|
|
36
|
+
gateway?: GatewayConfig;
|
|
37
|
+
}
|
|
38
|
+
/** 网关访问口令:accessCode 缺失/null/"" → null(gate off);非空 ≥4 位(gate on)。 */
|
|
39
|
+
export interface GatewayConfig {
|
|
40
|
+
accessCode: string | null;
|
|
35
41
|
}
|
|
36
42
|
/** DSH UI 兼容:把经隧道访问的前端 isLoopback 判定视为 loopback,使 Models/设置持久化可用。 */
|
|
37
43
|
export interface DshUiCompat {
|
package/dist/config.js
CHANGED
|
@@ -22,6 +22,7 @@ const DEFAULTS = {
|
|
|
22
22
|
allowFrom: [],
|
|
23
23
|
auth: DEFAULT_AUTH,
|
|
24
24
|
dshUiCompat: { trustE2EEAsLoopback: true },
|
|
25
|
+
gateway: { accessCode: null },
|
|
25
26
|
};
|
|
26
27
|
/** 解析配置文件路径(--config > $RDSH_CONFIG > 默认 host.json)。 */
|
|
27
28
|
export function resolveConfigPath(cliPath, env = process.env) {
|
|
@@ -188,6 +189,20 @@ export function normalizeConfig(raw, source = "config") {
|
|
|
188
189
|
out.dshUiCompat = { trustE2EEAsLoopback: compat.trustE2EEAsLoopback };
|
|
189
190
|
}
|
|
190
191
|
}
|
|
192
|
+
// ---- gateway(访问口令;缺省 accessCode null = gate off)----
|
|
193
|
+
if (cfg.gateway !== undefined) {
|
|
194
|
+
if (typeof cfg.gateway !== "object" || cfg.gateway === null)
|
|
195
|
+
throw new Error(`${source}: "gateway" must be an object`);
|
|
196
|
+
const g = cfg.gateway;
|
|
197
|
+
// 折叠语义:缺失 / null / "" → null(gate off);非空字符串 → ≥4 位(gate on)
|
|
198
|
+
if (g.accessCode !== undefined && g.accessCode !== null && g.accessCode !== "") {
|
|
199
|
+
if (typeof g.accessCode !== "string")
|
|
200
|
+
throw new Error(`${source}: "gateway.accessCode" must be a string`);
|
|
201
|
+
if (g.accessCode.length < 4)
|
|
202
|
+
throw new Error(`${source}: "gateway.accessCode" must be at least 4 chars`);
|
|
203
|
+
out.gateway = { accessCode: g.accessCode };
|
|
204
|
+
}
|
|
205
|
+
}
|
|
191
206
|
return out;
|
|
192
207
|
}
|
|
193
208
|
function assertString(v, field, source) {
|
package/dist/join.d.ts
CHANGED
|
@@ -16,6 +16,10 @@ export interface JoinOptions {
|
|
|
16
16
|
dshUiCompat?: {
|
|
17
17
|
trustE2EEAsLoopback?: boolean;
|
|
18
18
|
};
|
|
19
|
+
/** 网关访问口令(feature 15;accessCode null = 关闭) */
|
|
20
|
+
gateway?: {
|
|
21
|
+
accessCode?: string | null;
|
|
22
|
+
};
|
|
19
23
|
}
|
|
20
24
|
/** 注册/接入结果:解析出的 host token + 是否需 insecure + 生效的主机名(缺省=机器 hostname)。 */
|
|
21
25
|
export interface RegisterOutcome {
|
|
@@ -50,12 +54,20 @@ export interface StartJoinOptions {
|
|
|
50
54
|
dshUiCompat?: {
|
|
51
55
|
trustE2EEAsLoopback?: boolean;
|
|
52
56
|
};
|
|
57
|
+
/** 网关访问口令(feature 15;accessCode null = 关闭) */
|
|
58
|
+
gateway?: {
|
|
59
|
+
accessCode?: string | null;
|
|
60
|
+
};
|
|
61
|
+
/** 主机名(challenge 页展示;缺省「本主机」) */
|
|
62
|
+
name?: string;
|
|
53
63
|
}
|
|
54
64
|
/** 可停止的 join 隧道句柄。 */
|
|
55
65
|
export interface JoinHandle {
|
|
56
66
|
stop(): Promise<void>;
|
|
57
67
|
/** 运行中切换 DSH UI 兼容(trustE2EEAsLoopback);下一个请求即生效。 */
|
|
58
68
|
setUiCompat(trustE2EEAsLoopback: boolean): void;
|
|
69
|
+
/** 运行中设置/清除访问口令(null = 关闭 gate);下一个请求即生效。 */
|
|
70
|
+
setAccessCode(code: string | null): void;
|
|
59
71
|
}
|
|
60
72
|
/** 探测 hub 是否需 insecure:以严格校验握手一次;证书错误 → true(需 insecure)。 */
|
|
61
73
|
export declare function detectInsecure(hubUrl: string): Promise<boolean>;
|
package/dist/join.js
CHANGED
|
@@ -20,6 +20,7 @@ import { clearPersistedToken, persistToken, readPersistedToken } from "./token-s
|
|
|
20
20
|
import { acquireJoinLock, releaseJoinLock } from "./lock.js";
|
|
21
21
|
import { responderHandshake, Aead } from "./e2ee.js";
|
|
22
22
|
import { loadOrCreateE2eeKeyPair } from "./e2ee-key-store.js";
|
|
23
|
+
import { GATE_COOKIE, signGateCookie, verifyGateCookie, verifyGateCode } from "./access-gate.js";
|
|
23
24
|
/** 判断错误是否为 TLS 证书类错误(自签/过期/域名不匹配)。 */
|
|
24
25
|
function isCertError(err) {
|
|
25
26
|
const code = err?.code ?? "";
|
|
@@ -146,11 +147,55 @@ export function patchLoopbackJs(body) {
|
|
|
146
147
|
return null;
|
|
147
148
|
return Buffer.from(src.split(target).join("true"), "utf8");
|
|
148
149
|
}
|
|
150
|
+
/** 从转发头里取 Accept-Language(数组取首个,缺失 undefined)。 */
|
|
151
|
+
function headerAcceptLanguage(headers) {
|
|
152
|
+
const al = headers["accept-language"];
|
|
153
|
+
return Array.isArray(al) ? al.join(",") : typeof al === "string" ? al : undefined;
|
|
154
|
+
}
|
|
155
|
+
/** HTML 转义(challenge 页内插 hostName/actionPath 防注入;actionPath 经 hub URL 解析已 percent-encoded,此处为纵深防御)。 */
|
|
156
|
+
function escapeHtml(s) {
|
|
157
|
+
return s
|
|
158
|
+
.replaceAll("&", "&")
|
|
159
|
+
.replaceAll("<", "<")
|
|
160
|
+
.replaceAll(">", ">")
|
|
161
|
+
.replaceAll('"', """)
|
|
162
|
+
.replaceAll("'", "'");
|
|
163
|
+
}
|
|
164
|
+
/** 访问口令 challenge 页(内联,零外部依赖;经隧道在 hub 域名下展示;Accept-Language 含 zh → 中文,否则英文兜底)。 */
|
|
165
|
+
function gateChallengeHtml(hostName, actionPath, error, acceptLanguage) {
|
|
166
|
+
const safeHost = escapeHtml(hostName);
|
|
167
|
+
const safePath = escapeHtml(actionPath);
|
|
168
|
+
const zh = typeof acceptLanguage === "string" && /zh/i.test(acceptLanguage);
|
|
169
|
+
const t = zh
|
|
170
|
+
? { title: "访问密码", heading: `主机「${safeHost}」受访问密码保护`, note: "此密码由主机所有者设置,hub 无法绕过。", placeholder: "请输入访问密码", submit: "进入", wrong: "访问密码错误", locked: "尝试次数过多,请稍后再试" }
|
|
171
|
+
: { title: "Access code", heading: `Host "${safeHost}" is protected by an access code`, note: "This code is set by the host owner; the hub cannot bypass it.", placeholder: "Enter access code", submit: "Enter", wrong: "Incorrect access code", locked: "Too many attempts — please try again later" };
|
|
172
|
+
const errHtml = error === null ? "" : `<p style="color:#dc2626;font-size:13px;margin:10px 0 0">${error === "wrong" ? t.wrong : t.locked}</p>`;
|
|
173
|
+
return (`<!doctype html><html lang="${zh ? "zh-CN" : "en"}"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">` +
|
|
174
|
+
`<title>${t.title}</title>` +
|
|
175
|
+
`<body style="font-family:system-ui,sans-serif;background:#f6f7f9;margin:0">` +
|
|
176
|
+
`<div style="max-width:360px;margin:64px auto;background:#fff;border:1px solid #e5e7eb;border-radius:12px;padding:24px">` +
|
|
177
|
+
`<h1 style="font-size:18px;margin:0 0 8px">${t.heading}</h1>` +
|
|
178
|
+
`<p style="font-size:13px;color:#6b7280;margin:0 0 16px">${t.note}</p>` +
|
|
179
|
+
`<form method="POST" action="${safePath}">` +
|
|
180
|
+
`<input name="gate_code" type="password" autocomplete="off" autofocus placeholder="${t.placeholder}" style="width:100%;box-sizing:border-box;height:38px;border:1px solid #d1d5db;border-radius:8px;padding:0 12px;font-size:14px">` +
|
|
181
|
+
`<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>` +
|
|
182
|
+
`</form>${errHtml}</div></body></html>`);
|
|
183
|
+
}
|
|
184
|
+
/** 发送合成的 HTTP 响应帧(gateway 不触达 dsh)。 */
|
|
185
|
+
function sendSyntheticHttp(send, streamId, status, headers, body) {
|
|
186
|
+
send(encodeFrame(FRAME_TYPE.OPEN, streamId, jsonPayload({ kind: "http", status, reason: undefined, headers })));
|
|
187
|
+
send(encodeFrame(FRAME_TYPE.DATA, streamId, body));
|
|
188
|
+
send(encodeFrame(FRAME_TYPE.CLOSE, streamId, jsonPayload({ code: 0 })));
|
|
189
|
+
}
|
|
149
190
|
export function startJoin(opts) {
|
|
150
191
|
const hubWsBase = opts.hubUrl.replace(/^https/, "wss").replace(/^http/, "ws");
|
|
151
192
|
const hooks = opts.hooks ?? {};
|
|
152
193
|
// DSH UI 兼容开关:缺省 true(跟随 E2EE);可变引用 → 运行中可切换(插件面板即时生效)
|
|
153
194
|
const uiCompat = { trustE2EEAsLoopback: opts.dshUiCompat?.trustE2EEAsLoopback !== false };
|
|
195
|
+
// 访问口令(feature 15):可变引用 → setAccessCode 运行中切换;null = gate off
|
|
196
|
+
const gate = { accessCode: opts.gateway?.accessCode ?? null };
|
|
197
|
+
const hostName = opts.name ?? "本主机";
|
|
198
|
+
const gateFailures = { count: 0, lockedUntil: 0 };
|
|
154
199
|
const log = (level, message) => {
|
|
155
200
|
hooks.onLog?.(level, message);
|
|
156
201
|
};
|
|
@@ -176,6 +221,66 @@ export function startJoin(opts) {
|
|
|
176
221
|
function makeInnerDispatcher(send, dio) {
|
|
177
222
|
const httpStreams = new Map();
|
|
178
223
|
const wsStreams = new Map();
|
|
224
|
+
// gate 未过、等待 code 提交的 http 流(OPEN 后缓冲 DATA,CLOSE 时校验)
|
|
225
|
+
const gatedHttp = new Map();
|
|
226
|
+
/** 从转发头里取 rdsh_gate cookie(hub D12 白名单透传)。 */
|
|
227
|
+
function gateCookie(headers) {
|
|
228
|
+
const ck = headers["cookie"];
|
|
229
|
+
const s = Array.isArray(ck) ? ck.join(";") : typeof ck === "string" ? ck : "";
|
|
230
|
+
for (const part of s.split(";")) {
|
|
231
|
+
const idx = part.indexOf("=");
|
|
232
|
+
if (idx <= 0)
|
|
233
|
+
continue;
|
|
234
|
+
if (part.slice(0, idx).trim() === GATE_COOKIE)
|
|
235
|
+
return part.slice(idx + 1).trim();
|
|
236
|
+
}
|
|
237
|
+
return null;
|
|
238
|
+
}
|
|
239
|
+
/** gate 开启时的失败计数:全局封顶,达限短时锁定(隧道流量无真实客户端 IP)。 */
|
|
240
|
+
function gateBlocked() {
|
|
241
|
+
if (gateFailures.lockedUntil > Date.now())
|
|
242
|
+
return true;
|
|
243
|
+
if (gateFailures.lockedUntil !== 0)
|
|
244
|
+
gateFailures.lockedUntil = 0;
|
|
245
|
+
return false;
|
|
246
|
+
}
|
|
247
|
+
/** 发送 challenge 页(或带错误)响应。 */
|
|
248
|
+
function sendChallenge(streamId, path, error, acceptLanguage) {
|
|
249
|
+
const html = gateChallengeHtml(hostName, path, error, acceptLanguage);
|
|
250
|
+
sendSyntheticHttp(send, streamId, 200, { "content-type": "text/html; charset=utf-8" }, Buffer.from(html));
|
|
251
|
+
}
|
|
252
|
+
/** 校验 code 提交(POST gate_code)→ 302 回跳 + 发 cookie,或回 challenge 错误。 */
|
|
253
|
+
function handleGateSubmit(streamId, state) {
|
|
254
|
+
const code = gate.accessCode;
|
|
255
|
+
if (code === null) {
|
|
256
|
+
sendSyntheticHttp(send, streamId, 302, { location: state.path }, Buffer.alloc(0));
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
if (gateBlocked()) {
|
|
260
|
+
sendChallenge(streamId, state.path, "locked", state.acceptLanguage);
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
const raw = Buffer.concat(state.body).toString("utf8");
|
|
264
|
+
let input = null;
|
|
265
|
+
try {
|
|
266
|
+
const params = new URLSearchParams(raw);
|
|
267
|
+
input = params.get("gate_code");
|
|
268
|
+
}
|
|
269
|
+
catch {
|
|
270
|
+
input = null;
|
|
271
|
+
}
|
|
272
|
+
if (input !== null && verifyGateCode(input, code)) {
|
|
273
|
+
gateFailures.count = 0;
|
|
274
|
+
const { value } = signGateCookie(code);
|
|
275
|
+
sendSyntheticHttp(send, streamId, 302, { location: state.path, "set-cookie": `${GATE_COOKIE}=${value}; HttpOnly; SameSite=Lax; Path=/; Max-Age=${7 * 24 * 3600}` }, Buffer.alloc(0));
|
|
276
|
+
}
|
|
277
|
+
else {
|
|
278
|
+
gateFailures.count += 1;
|
|
279
|
+
if (gateFailures.count >= 10)
|
|
280
|
+
gateFailures.lockedUntil = Date.now() + 60_000;
|
|
281
|
+
sendChallenge(streamId, state.path, "wrong", state.acceptLanguage);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
179
284
|
function closeStream(streamId) {
|
|
180
285
|
const ws = wsStreams.get(streamId);
|
|
181
286
|
if (ws !== undefined) {
|
|
@@ -239,6 +344,28 @@ export function startJoin(opts) {
|
|
|
239
344
|
send(encodeFrame(FRAME_TYPE.ERROR, frame.streamId, jsonPayload({ code: "BAD_OPEN", message: "malformed open" })));
|
|
240
345
|
return;
|
|
241
346
|
}
|
|
347
|
+
// ---- 访问口令 gate(仅 plain dispatcher:dio.gate=true 且已设 accessCode)----
|
|
348
|
+
if (dio?.gate === true && gate.accessCode !== null) {
|
|
349
|
+
const code = gate.accessCode;
|
|
350
|
+
const authed = verifyGateCookie(code, gateCookie(headers) ?? "");
|
|
351
|
+
if (kind === "ws") {
|
|
352
|
+
if (!authed) {
|
|
353
|
+
send(encodeFrame(FRAME_TYPE.CLOSE, frame.streamId, jsonPayload({ code: 403, message: "access code required" })));
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
openWsStream(frame.streamId, path, headers);
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
if (!authed) {
|
|
360
|
+
if (method === "POST") {
|
|
361
|
+
// 可能是 code 提交:缓冲 body,CLOSE 时校验(见 handleFrame)
|
|
362
|
+
gatedHttp.set(frame.streamId, { method, path, body: [], size: 0, acceptLanguage: headerAcceptLanguage(headers) });
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
sendChallenge(frame.streamId, path, null, headerAcceptLanguage(headers));
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
242
369
|
if (kind === "ws") {
|
|
243
370
|
openWsStream(frame.streamId, path, headers);
|
|
244
371
|
return;
|
|
@@ -305,6 +432,16 @@ export function startJoin(opts) {
|
|
|
305
432
|
return;
|
|
306
433
|
}
|
|
307
434
|
case FRAME_TYPE.DATA: {
|
|
435
|
+
const gated = gatedHttp.get(frame.streamId);
|
|
436
|
+
if (gated !== undefined) {
|
|
437
|
+
gated.body.push(frame.payload);
|
|
438
|
+
gated.size += frame.payload.length;
|
|
439
|
+
if (gated.size > 64 * 1024) {
|
|
440
|
+
gatedHttp.delete(frame.streamId);
|
|
441
|
+
send(encodeFrame(FRAME_TYPE.CLOSE, frame.streamId, jsonPayload({ code: 413, message: "body too large" })));
|
|
442
|
+
}
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
308
445
|
const ws = wsStreams.get(frame.streamId);
|
|
309
446
|
if (ws !== undefined) {
|
|
310
447
|
if (ws.upstream.readyState === ws.upstream.OPEN)
|
|
@@ -320,6 +457,13 @@ export function startJoin(opts) {
|
|
|
320
457
|
}
|
|
321
458
|
case FRAME_TYPE.CLOSE:
|
|
322
459
|
case FRAME_TYPE.ERROR: {
|
|
460
|
+
const gated = gatedHttp.get(frame.streamId);
|
|
461
|
+
if (gated !== undefined) {
|
|
462
|
+
gatedHttp.delete(frame.streamId);
|
|
463
|
+
if (frame.type === FRAME_TYPE.CLOSE)
|
|
464
|
+
handleGateSubmit(frame.streamId, gated);
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
323
467
|
closeStream(frame.streamId);
|
|
324
468
|
return;
|
|
325
469
|
}
|
|
@@ -346,10 +490,11 @@ export function startJoin(opts) {
|
|
|
346
490
|
}
|
|
347
491
|
}
|
|
348
492
|
wsStreams.clear();
|
|
493
|
+
gatedHttp.clear();
|
|
349
494
|
}
|
|
350
495
|
return { handleFrame, cleanup };
|
|
351
496
|
}
|
|
352
|
-
const plainDispatcher = makeInnerDispatcher(sendTunnelFrame, { jsPatch: () => uiCompat.trustE2EEAsLoopback });
|
|
497
|
+
const plainDispatcher = makeInnerDispatcher(sendTunnelFrame, { jsPatch: () => uiCompat.trustE2EEAsLoopback, gate: true });
|
|
353
498
|
// host 端 E2EE 静态密钥对(持久化 ~/.rdsh/e2ee-key.json;join 注册时上送指纹)
|
|
354
499
|
const hostE2eeKeypair = loadOrCreateE2eeKeyPair();
|
|
355
500
|
const rawStreams = new Map();
|
|
@@ -458,8 +603,9 @@ export function startJoin(opts) {
|
|
|
458
603
|
function connect() {
|
|
459
604
|
if (shuttingDown)
|
|
460
605
|
return;
|
|
461
|
-
|
|
462
|
-
const
|
|
606
|
+
// 认证走 Authorization 头(不入 URL,避免 token 进日志)
|
|
607
|
+
const url = `${hubWsBase}/tunnel`;
|
|
608
|
+
const client = new WebSocket(url, { headers: { authorization: `Bearer ${opts.token}` }, rejectUnauthorized: !opts.insecure });
|
|
463
609
|
currentClient = client;
|
|
464
610
|
setState("connecting", { message: `connecting to ${opts.hubUrl}` });
|
|
465
611
|
// 401/403 = token 被拒(吊销/不存在)。监听此事件后 ws 不再自动 abort,
|
|
@@ -541,6 +687,12 @@ export function startJoin(opts) {
|
|
|
541
687
|
uiCompat.trustE2EEAsLoopback = trustE2EEAsLoopback;
|
|
542
688
|
console.log(`rdsh join: dshUiCompat.trustE2EEAsLoopback = ${trustE2EEAsLoopback}(运行中生效)`);
|
|
543
689
|
},
|
|
690
|
+
setAccessCode(code) {
|
|
691
|
+
gate.accessCode = code;
|
|
692
|
+
gateFailures.count = 0;
|
|
693
|
+
gateFailures.lockedUntil = 0;
|
|
694
|
+
console.log(`rdsh join: accessCode = ${code === null ? "(off)" : "***"}(运行中生效)`);
|
|
695
|
+
},
|
|
544
696
|
async stop() {
|
|
545
697
|
if (shuttingDown)
|
|
546
698
|
return;
|
|
@@ -568,7 +720,7 @@ export async function join(opts) {
|
|
|
568
720
|
const dsh = await spawnDsh(foundDsh);
|
|
569
721
|
const target = { host: "127.0.0.1", port: dsh.port };
|
|
570
722
|
// 解析 host token(含证书自动检测 + 持久化);进程重启后复用,避免重复配对。
|
|
571
|
-
const { token, insecure } = await registerJoin(opts);
|
|
723
|
+
const { token, insecure, name } = await registerJoin(opts);
|
|
572
724
|
console.log(`rdsh join: dsh web on 127.0.0.1:${dsh.port}`);
|
|
573
725
|
console.log(`rdsh join: connecting to ${opts.hubUrl}...`);
|
|
574
726
|
const handle = startJoin({
|
|
@@ -578,6 +730,8 @@ export async function join(opts) {
|
|
|
578
730
|
target,
|
|
579
731
|
role: "cli",
|
|
580
732
|
dshUiCompat: opts.dshUiCompat,
|
|
733
|
+
gateway: opts.gateway,
|
|
734
|
+
name,
|
|
581
735
|
hooks: {
|
|
582
736
|
onLog: (level, message) => {
|
|
583
737
|
(level === "error" ? console.error : console.log)(`rdsh join: ${message}`);
|