rdsh-gateway 0.5.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 +13 -0
- package/dist/config.js +29 -0
- package/dist/join.d.ts +31 -0
- package/dist/join.js +204 -6
- 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
|
@@ -30,6 +30,19 @@ export interface RdshConfig {
|
|
|
30
30
|
hub?: string;
|
|
31
31
|
name?: string;
|
|
32
32
|
insecure?: boolean;
|
|
33
|
+
/** DSH UI 兼容开关(跟随 E2EE;trustE2EEAsLoopback 默认 true) */
|
|
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;
|
|
41
|
+
}
|
|
42
|
+
/** DSH UI 兼容:把经隧道访问的前端 isLoopback 判定视为 loopback,使 Models/设置持久化可用。 */
|
|
43
|
+
export interface DshUiCompat {
|
|
44
|
+
/** E2EE 激活(或宿主启用)时 patch JS;false = 保持 DSH 原样(共享 host/敏感场景) */
|
|
45
|
+
trustE2EEAsLoopback?: boolean;
|
|
33
46
|
}
|
|
34
47
|
export declare const DEFAULT_HOST_CONFIG_PATH: string;
|
|
35
48
|
/** 解析配置文件路径(--config > $RDSH_CONFIG > 默认 host.json)。 */
|
package/dist/config.js
CHANGED
|
@@ -21,6 +21,8 @@ const DEFAULTS = {
|
|
|
21
21
|
behindProxy: false,
|
|
22
22
|
allowFrom: [],
|
|
23
23
|
auth: DEFAULT_AUTH,
|
|
24
|
+
dshUiCompat: { trustE2EEAsLoopback: true },
|
|
25
|
+
gateway: { accessCode: null },
|
|
24
26
|
};
|
|
25
27
|
/** 解析配置文件路径(--config > $RDSH_CONFIG > 默认 host.json)。 */
|
|
26
28
|
export function resolveConfigPath(cliPath, env = process.env) {
|
|
@@ -174,6 +176,33 @@ export function normalizeConfig(raw, source = "config") {
|
|
|
174
176
|
assertString(cfg.dshPath, "dshPath", source);
|
|
175
177
|
out.dshPath = cfg.dshPath;
|
|
176
178
|
}
|
|
179
|
+
// ---- dshUiCompat(缺省 trustE2EEAsLoopback: true)----
|
|
180
|
+
if (cfg.dshUiCompat !== undefined) {
|
|
181
|
+
if (typeof cfg.dshUiCompat !== "object" || cfg.dshUiCompat === null) {
|
|
182
|
+
throw new Error(`${source}: "dshUiCompat" must be an object`);
|
|
183
|
+
}
|
|
184
|
+
const compat = cfg.dshUiCompat;
|
|
185
|
+
if (compat.trustE2EEAsLoopback !== undefined) {
|
|
186
|
+
if (typeof compat.trustE2EEAsLoopback !== "boolean") {
|
|
187
|
+
throw new Error(`${source}: "dshUiCompat.trustE2EEAsLoopback" must be boolean`);
|
|
188
|
+
}
|
|
189
|
+
out.dshUiCompat = { trustE2EEAsLoopback: compat.trustE2EEAsLoopback };
|
|
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
|
+
}
|
|
177
206
|
return out;
|
|
178
207
|
}
|
|
179
208
|
function assertString(v, field, source) {
|
package/dist/join.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { IncomingHttpHeaders } from "node:http";
|
|
1
2
|
import type { ProxyTarget } from "./proxy.ts";
|
|
2
3
|
import type { JoinLockRole } from "./lock.ts";
|
|
3
4
|
export interface JoinOptions {
|
|
@@ -11,6 +12,14 @@ export interface JoinOptions {
|
|
|
11
12
|
insecure?: boolean;
|
|
12
13
|
/** 主机名(注册命名 / host.json) */
|
|
13
14
|
name?: string;
|
|
15
|
+
/** DSH UI 兼容(透传 host.json dshUiCompat;缺省 true) */
|
|
16
|
+
dshUiCompat?: {
|
|
17
|
+
trustE2EEAsLoopback?: boolean;
|
|
18
|
+
};
|
|
19
|
+
/** 网关访问口令(feature 15;accessCode null = 关闭) */
|
|
20
|
+
gateway?: {
|
|
21
|
+
accessCode?: string | null;
|
|
22
|
+
};
|
|
14
23
|
}
|
|
15
24
|
/** 注册/接入结果:解析出的 host token + 是否需 insecure + 生效的主机名(缺省=机器 hostname)。 */
|
|
16
25
|
export interface RegisterOutcome {
|
|
@@ -41,10 +50,24 @@ export interface StartJoinOptions {
|
|
|
41
50
|
/** 锁文件路径(缺省 ~/.rdsh/join.lock;测试可注入临时路径) */
|
|
42
51
|
lockPath?: string;
|
|
43
52
|
hooks?: JoinHooks;
|
|
53
|
+
/** DSH UI 兼容(缺省 trustE2EEAsLoopback=true;false 关闭 JS patch) */
|
|
54
|
+
dshUiCompat?: {
|
|
55
|
+
trustE2EEAsLoopback?: boolean;
|
|
56
|
+
};
|
|
57
|
+
/** 网关访问口令(feature 15;accessCode null = 关闭) */
|
|
58
|
+
gateway?: {
|
|
59
|
+
accessCode?: string | null;
|
|
60
|
+
};
|
|
61
|
+
/** 主机名(challenge 页展示;缺省「本主机」) */
|
|
62
|
+
name?: string;
|
|
44
63
|
}
|
|
45
64
|
/** 可停止的 join 隧道句柄。 */
|
|
46
65
|
export interface JoinHandle {
|
|
47
66
|
stop(): Promise<void>;
|
|
67
|
+
/** 运行中切换 DSH UI 兼容(trustE2EEAsLoopback);下一个请求即生效。 */
|
|
68
|
+
setUiCompat(trustE2EEAsLoopback: boolean): void;
|
|
69
|
+
/** 运行中设置/清除访问口令(null = 关闭 gate);下一个请求即生效。 */
|
|
70
|
+
setAccessCode(code: string | null): void;
|
|
48
71
|
}
|
|
49
72
|
/** 探测 hub 是否需 insecure:以严格校验握手一次;证书错误 → true(需 insecure)。 */
|
|
50
73
|
export declare function detectInsecure(hubUrl: string): Promise<boolean>;
|
|
@@ -56,6 +79,14 @@ export declare function registerJoin(opts: JoinOptions): Promise<RegisterOutcome
|
|
|
56
79
|
* 启动 join 隧道(no-spawn):转发到外部 `opts.target`,不 spawn dsh、不 process.exit。
|
|
57
80
|
* 获取 pid 锁(opts.role);返回 `JoinHandle`,`stop()` 干净停止(关 WS/清 heartbeat/释放锁)。
|
|
58
81
|
*/
|
|
82
|
+
/** JS 响应判定(content-type 含 javascript)。 */
|
|
83
|
+
export declare function isJsContentType(headers: IncomingHttpHeaders): boolean;
|
|
84
|
+
/**
|
|
85
|
+
* 最小 patch:把 DSH 客户端 bundle 里的前端 isLoopback 判定替换为 true
|
|
86
|
+
* (持久设置/API key 输入只对 loopback 开放;E2EE 流上信任基础等同 loopback)。
|
|
87
|
+
* fail-open:未命中目标串 → 返回 null,调用方原样透传(DSH 升级不炸)。
|
|
88
|
+
*/
|
|
89
|
+
export declare function patchLoopbackJs(body: Buffer): Buffer | null;
|
|
59
90
|
export declare function startJoin(opts: StartJoinOptions): JoinHandle;
|
|
60
91
|
/** `rdsh host serve`(join 模式)的 CLI 封装:spawn dsh + 信号退出 + startJoin(role:cli)。 */
|
|
61
92
|
export declare function join(opts: JoinOptions): Promise<void>;
|
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 ?? "";
|
|
@@ -128,9 +129,73 @@ async function register(hubUrl, joinToken, name, insecure, e2eePublicKey) {
|
|
|
128
129
|
* 启动 join 隧道(no-spawn):转发到外部 `opts.target`,不 spawn dsh、不 process.exit。
|
|
129
130
|
* 获取 pid 锁(opts.role);返回 `JoinHandle`,`stop()` 干净停止(关 WS/清 heartbeat/释放锁)。
|
|
130
131
|
*/
|
|
132
|
+
/** JS 响应判定(content-type 含 javascript)。 */
|
|
133
|
+
export function isJsContentType(headers) {
|
|
134
|
+
const ct = headers["content-type"];
|
|
135
|
+
const s = Array.isArray(ct) ? ct.join(";") : (ct ?? "");
|
|
136
|
+
return /javascript/i.test(s);
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* 最小 patch:把 DSH 客户端 bundle 里的前端 isLoopback 判定替换为 true
|
|
140
|
+
* (持久设置/API key 输入只对 loopback 开放;E2EE 流上信任基础等同 loopback)。
|
|
141
|
+
* fail-open:未命中目标串 → 返回 null,调用方原样透传(DSH 升级不炸)。
|
|
142
|
+
*/
|
|
143
|
+
export function patchLoopbackJs(body) {
|
|
144
|
+
const src = body.toString("utf8");
|
|
145
|
+
const target = "isLoopbackHostname(pageLocation.hostname)";
|
|
146
|
+
if (!src.includes(target))
|
|
147
|
+
return null;
|
|
148
|
+
return Buffer.from(src.split(target).join("true"), "utf8");
|
|
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
|
+
}
|
|
131
190
|
export function startJoin(opts) {
|
|
132
191
|
const hubWsBase = opts.hubUrl.replace(/^https/, "wss").replace(/^http/, "ws");
|
|
133
192
|
const hooks = opts.hooks ?? {};
|
|
193
|
+
// DSH UI 兼容开关:缺省 true(跟随 E2EE);可变引用 → 运行中可切换(插件面板即时生效)
|
|
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 };
|
|
134
199
|
const log = (level, message) => {
|
|
135
200
|
hooks.onLog?.(level, message);
|
|
136
201
|
};
|
|
@@ -153,9 +218,69 @@ export function startJoin(opts) {
|
|
|
153
218
|
}
|
|
154
219
|
}
|
|
155
220
|
/** 内层帧分发器(plain 与 raw 共用):OPEN http/ws + DATA → DSH 转发,响应帧经 `send` 回传。 */
|
|
156
|
-
function makeInnerDispatcher(send) {
|
|
221
|
+
function makeInnerDispatcher(send, dio) {
|
|
157
222
|
const httpStreams = new Map();
|
|
158
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
|
+
}
|
|
159
284
|
function closeStream(streamId) {
|
|
160
285
|
const ws = wsStreams.get(streamId);
|
|
161
286
|
if (ws !== undefined) {
|
|
@@ -219,6 +344,28 @@ export function startJoin(opts) {
|
|
|
219
344
|
send(encodeFrame(FRAME_TYPE.ERROR, frame.streamId, jsonPayload({ code: "BAD_OPEN", message: "malformed open" })));
|
|
220
345
|
return;
|
|
221
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
|
+
}
|
|
222
369
|
if (kind === "ws") {
|
|
223
370
|
openWsStream(frame.streamId, path, headers);
|
|
224
371
|
return;
|
|
@@ -241,6 +388,25 @@ export function startJoin(opts) {
|
|
|
241
388
|
reason: upRes.statusMessage,
|
|
242
389
|
headers: normalizeRespHeaders(upRes.headers),
|
|
243
390
|
})));
|
|
391
|
+
if (dio?.jsPatch?.() === true && isJsContentType(upRes.headers)) {
|
|
392
|
+
// 最小 patch:E2EE 流上的 JS 响应,把前端 isLoopback 判定替换为 true
|
|
393
|
+
// (fail-open:未命中 → 原样透传;DSH 升级不炸)
|
|
394
|
+
const chunks = [];
|
|
395
|
+
upRes.on("data", (chunk) => chunks.push(chunk));
|
|
396
|
+
upRes.on("end", () => {
|
|
397
|
+
const body = Buffer.concat(chunks);
|
|
398
|
+
const patched = patchLoopbackJs(body);
|
|
399
|
+
// fail-open:未命中也必须原样发 body(否则空响应白屏)
|
|
400
|
+
send(encodeFrame(FRAME_TYPE.DATA, streamId, patched !== null ? patched : body));
|
|
401
|
+
send(encodeFrame(FRAME_TYPE.CLOSE, streamId, jsonPayload({ code: 0 })));
|
|
402
|
+
httpStreams.delete(streamId);
|
|
403
|
+
});
|
|
404
|
+
upRes.on("error", () => {
|
|
405
|
+
send(encodeFrame(FRAME_TYPE.CLOSE, streamId, jsonPayload({ code: 502, message: "upstream error" })));
|
|
406
|
+
httpStreams.delete(streamId);
|
|
407
|
+
});
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
244
410
|
upRes.on("data", (chunk) => {
|
|
245
411
|
send(encodeFrame(FRAME_TYPE.DATA, streamId, chunk));
|
|
246
412
|
});
|
|
@@ -266,6 +432,16 @@ export function startJoin(opts) {
|
|
|
266
432
|
return;
|
|
267
433
|
}
|
|
268
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
|
+
}
|
|
269
445
|
const ws = wsStreams.get(frame.streamId);
|
|
270
446
|
if (ws !== undefined) {
|
|
271
447
|
if (ws.upstream.readyState === ws.upstream.OPEN)
|
|
@@ -281,6 +457,13 @@ export function startJoin(opts) {
|
|
|
281
457
|
}
|
|
282
458
|
case FRAME_TYPE.CLOSE:
|
|
283
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
|
+
}
|
|
284
467
|
closeStream(frame.streamId);
|
|
285
468
|
return;
|
|
286
469
|
}
|
|
@@ -307,10 +490,11 @@ export function startJoin(opts) {
|
|
|
307
490
|
}
|
|
308
491
|
}
|
|
309
492
|
wsStreams.clear();
|
|
493
|
+
gatedHttp.clear();
|
|
310
494
|
}
|
|
311
495
|
return { handleFrame, cleanup };
|
|
312
496
|
}
|
|
313
|
-
const plainDispatcher = makeInnerDispatcher(sendTunnelFrame);
|
|
497
|
+
const plainDispatcher = makeInnerDispatcher(sendTunnelFrame, { jsPatch: () => uiCompat.trustE2EEAsLoopback, gate: true });
|
|
314
498
|
// host 端 E2EE 静态密钥对(持久化 ~/.rdsh/e2ee-key.json;join 注册时上送指纹)
|
|
315
499
|
const hostE2eeKeypair = loadOrCreateE2eeKeyPair();
|
|
316
500
|
const rawStreams = new Map();
|
|
@@ -321,7 +505,7 @@ export function startJoin(opts) {
|
|
|
321
505
|
const ct = raw.encryptor.encrypt(frame, Buffer.alloc(0));
|
|
322
506
|
sendTunnelFrame(encodeFrame(FRAME_TYPE.DATA, streamId, ct, FLAG_E2E));
|
|
323
507
|
}
|
|
324
|
-
});
|
|
508
|
+
}, { jsPatch: () => uiCompat.trustE2EEAsLoopback });
|
|
325
509
|
rawStreams.set(streamId, {
|
|
326
510
|
handshakeBuf: Buffer.alloc(0),
|
|
327
511
|
keys: null,
|
|
@@ -419,8 +603,9 @@ export function startJoin(opts) {
|
|
|
419
603
|
function connect() {
|
|
420
604
|
if (shuttingDown)
|
|
421
605
|
return;
|
|
422
|
-
|
|
423
|
-
const
|
|
606
|
+
// 认证走 Authorization 头(不入 URL,避免 token 进日志)
|
|
607
|
+
const url = `${hubWsBase}/tunnel`;
|
|
608
|
+
const client = new WebSocket(url, { headers: { authorization: `Bearer ${opts.token}` }, rejectUnauthorized: !opts.insecure });
|
|
424
609
|
currentClient = client;
|
|
425
610
|
setState("connecting", { message: `connecting to ${opts.hubUrl}` });
|
|
426
611
|
// 401/403 = token 被拒(吊销/不存在)。监听此事件后 ws 不再自动 abort,
|
|
@@ -498,6 +683,16 @@ export function startJoin(opts) {
|
|
|
498
683
|
}
|
|
499
684
|
connect();
|
|
500
685
|
return {
|
|
686
|
+
setUiCompat(trustE2EEAsLoopback) {
|
|
687
|
+
uiCompat.trustE2EEAsLoopback = trustE2EEAsLoopback;
|
|
688
|
+
console.log(`rdsh join: dshUiCompat.trustE2EEAsLoopback = ${trustE2EEAsLoopback}(运行中生效)`);
|
|
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
|
+
},
|
|
501
696
|
async stop() {
|
|
502
697
|
if (shuttingDown)
|
|
503
698
|
return;
|
|
@@ -525,7 +720,7 @@ export async function join(opts) {
|
|
|
525
720
|
const dsh = await spawnDsh(foundDsh);
|
|
526
721
|
const target = { host: "127.0.0.1", port: dsh.port };
|
|
527
722
|
// 解析 host token(含证书自动检测 + 持久化);进程重启后复用,避免重复配对。
|
|
528
|
-
const { token, insecure } = await registerJoin(opts);
|
|
723
|
+
const { token, insecure, name } = await registerJoin(opts);
|
|
529
724
|
console.log(`rdsh join: dsh web on 127.0.0.1:${dsh.port}`);
|
|
530
725
|
console.log(`rdsh join: connecting to ${opts.hubUrl}...`);
|
|
531
726
|
const handle = startJoin({
|
|
@@ -534,6 +729,9 @@ export async function join(opts) {
|
|
|
534
729
|
insecure,
|
|
535
730
|
target,
|
|
536
731
|
role: "cli",
|
|
732
|
+
dshUiCompat: opts.dshUiCompat,
|
|
733
|
+
gateway: opts.gateway,
|
|
734
|
+
name,
|
|
537
735
|
hooks: {
|
|
538
736
|
onLog: (level, message) => {
|
|
539
737
|
(level === "error" ? console.error : console.log)(`rdsh join: ${message}`);
|