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.
@@ -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("&", "&amp;")
159
+ .replaceAll("<", "&lt;")
160
+ .replaceAll(">", "&gt;")
161
+ .replaceAll('"', "&quot;")
162
+ .replaceAll("'", "&#39;");
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
- const url = `${hubWsBase}/tunnel?token=${encodeURIComponent(opts.token)}`;
462
- const client = new WebSocket(url, { rejectUnauthorized: !opts.insecure });
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}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rdsh-gateway",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "rdsh host-side component: LAN auth gateway + outbound tunnel endpoint (spawns dsh web)",
5
5
  "license": "MIT",
6
6
  "type": "module",