rdsh-gateway 0.4.0 → 0.6.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/config.d.ts CHANGED
@@ -30,6 +30,13 @@ 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
+ }
36
+ /** DSH UI 兼容:把经隧道访问的前端 isLoopback 判定视为 loopback,使 Models/设置持久化可用。 */
37
+ export interface DshUiCompat {
38
+ /** E2EE 激活(或宿主启用)时 patch JS;false = 保持 DSH 原样(共享 host/敏感场景) */
39
+ trustE2EEAsLoopback?: boolean;
33
40
  }
34
41
  export declare const DEFAULT_HOST_CONFIG_PATH: string;
35
42
  /** 解析配置文件路径(--config > $RDSH_CONFIG > 默认 host.json)。 */
package/dist/config.js CHANGED
@@ -21,6 +21,7 @@ const DEFAULTS = {
21
21
  behindProxy: false,
22
22
  allowFrom: [],
23
23
  auth: DEFAULT_AUTH,
24
+ dshUiCompat: { trustE2EEAsLoopback: true },
24
25
  };
25
26
  /** 解析配置文件路径(--config > $RDSH_CONFIG > 默认 host.json)。 */
26
27
  export function resolveConfigPath(cliPath, env = process.env) {
@@ -174,6 +175,19 @@ export function normalizeConfig(raw, source = "config") {
174
175
  assertString(cfg.dshPath, "dshPath", source);
175
176
  out.dshPath = cfg.dshPath;
176
177
  }
178
+ // ---- dshUiCompat(缺省 trustE2EEAsLoopback: true)----
179
+ if (cfg.dshUiCompat !== undefined) {
180
+ if (typeof cfg.dshUiCompat !== "object" || cfg.dshUiCompat === null) {
181
+ throw new Error(`${source}: "dshUiCompat" must be an object`);
182
+ }
183
+ const compat = cfg.dshUiCompat;
184
+ if (compat.trustE2EEAsLoopback !== undefined) {
185
+ if (typeof compat.trustE2EEAsLoopback !== "boolean") {
186
+ throw new Error(`${source}: "dshUiCompat.trustE2EEAsLoopback" must be boolean`);
187
+ }
188
+ out.dshUiCompat = { trustE2EEAsLoopback: compat.trustE2EEAsLoopback };
189
+ }
190
+ }
177
191
  return out;
178
192
  }
179
193
  function assertString(v, field, source) {
@@ -0,0 +1,5 @@
1
+ import type { KeyPair } from "./e2ee.ts";
2
+ /** 密钥文件路径(可注入 dir 便于测试)。 */
3
+ export declare function e2eeKeyFilePath(dir?: string): string;
4
+ /** 加载或生成 host E2EE 静态密钥对(一次生成,持久化复用;损坏则重新生成)。 */
5
+ export declare function loadOrCreateE2eeKeyPair(dir?: string): KeyPair;
@@ -0,0 +1,42 @@
1
+ /**
2
+ * e2ee-key-store.ts — host 端 E2EE 静态密钥对持久化(~/.rdsh/e2ee-key.json,0600)。
3
+ *
4
+ * 一次生成、跨 hub 复用(host 身份与 join 到哪个 hub 无关);指纹(公钥)在 join 注册时
5
+ * 上送 hub,供 portal「添加主机」pin 展示。
6
+ */
7
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
8
+ import { homedir } from "node:os";
9
+ import { dirname, join } from "node:path";
10
+ import { generateKeyPair, deserializeKeyPair, serializeKeyPair } from "./e2ee.js";
11
+ const RDSH_DIR = join(homedir(), ".rdsh");
12
+ const KEY_FILE_NAME = "e2ee-key.json";
13
+ /** 密钥文件路径(可注入 dir 便于测试)。 */
14
+ export function e2eeKeyFilePath(dir = RDSH_DIR) {
15
+ return join(dir, KEY_FILE_NAME);
16
+ }
17
+ /** 加载或生成 host E2EE 静态密钥对(一次生成,持久化复用;损坏则重新生成)。 */
18
+ export function loadOrCreateE2eeKeyPair(dir = RDSH_DIR) {
19
+ const p = e2eeKeyFilePath(dir);
20
+ try {
21
+ if (existsSync(p)) {
22
+ const j = JSON.parse(readFileSync(p, "utf8"));
23
+ if (typeof j.publicRaw === "string" && typeof j.privateRaw === "string") {
24
+ return deserializeKeyPair(j.publicRaw, j.privateRaw);
25
+ }
26
+ }
27
+ }
28
+ catch {
29
+ /* 损坏 → 重新生成 */
30
+ }
31
+ const kp = generateKeyPair();
32
+ const s = serializeKeyPair(kp);
33
+ try {
34
+ mkdirSync(dirname(p), { recursive: true, mode: 0o700 });
35
+ writeFileSync(p, JSON.stringify(s), { mode: 0o600 });
36
+ }
37
+ catch {
38
+ /* 写失败 → 用内存密钥(下次重启指纹会变,属异常场景) */
39
+ }
40
+ return kp;
41
+ }
42
+ //# sourceMappingURL=e2ee-key-store.js.map
package/dist/e2ee.d.ts ADDED
@@ -0,0 +1,45 @@
1
+ import type { KeyObject } from "node:crypto";
2
+ export declare const PROTOCOL_LABEL: Buffer<ArrayBuffer>;
3
+ export interface KeyPair {
4
+ privateKey: KeyObject;
5
+ publicRaw: Buffer;
6
+ }
7
+ export interface E2eeKeys {
8
+ /** initiator(浏览器)→ responder(host)方向密钥 */
9
+ initiatorToResponder: Buffer;
10
+ /** responder(host)→ initiator(浏览器)方向密钥 */
11
+ responderToInitiator: Buffer;
12
+ }
13
+ /** 生成 X25519 密钥对。 */
14
+ export declare function generateKeyPair(): KeyPair;
15
+ export declare function publicToRaw(pub: KeyObject): Buffer;
16
+ export declare function publicFromRaw(raw: Buffer): KeyObject;
17
+ export declare function privateFromRaw(raw: Buffer): KeyObject;
18
+ /** 持久化:raw 私钥 + 公钥(base64url 字符串)。 */
19
+ export declare function serializeKeyPair(kp: KeyPair): {
20
+ publicRaw: string;
21
+ privateRaw: string;
22
+ };
23
+ export declare function deserializeKeyPair(publicRaw: string, privateRaw: string): KeyPair;
24
+ /** X25519 共享密钥(32B)。 */
25
+ export declare function ecdh(privateKey: KeyObject, theirPublicRaw: Buffer): Buffer;
26
+ /** HKDF 派生双向密钥。 */
27
+ export declare function deriveKeys(sharedSecret: Buffer): E2eeKeys;
28
+ /** 指纹(pinning 展示):SHA-256 前 8 字节 hex 分组。 */
29
+ export declare function fingerprint(publicRaw: Buffer): string;
30
+ /** 发起方(浏览器):临时密钥 × host 静态公钥 → 会话密钥;返回要发送的临时公钥。 */
31
+ export declare function initiatorHandshake(responderStaticPublicRaw: Buffer): {
32
+ ephemeralPublicRaw: Buffer;
33
+ keys: E2eeKeys;
34
+ };
35
+ /** 响应方(host):静态私钥 × 收到的发起方临时公钥 → 会话密钥。 */
36
+ export declare function responderHandshake(staticKeyPair: KeyPair, initiatorEphemeralPublicRaw: Buffer): E2eeKeys;
37
+ /** AES-256-GCM 包:[12B nonce][ciphertext][16B tag]。显式 nonce(每方向独立计数,防错)。 */
38
+ export declare class Aead {
39
+ private readonly key;
40
+ private counter;
41
+ constructor(key: Buffer);
42
+ encrypt(plaintext: Buffer, aad: Buffer): Buffer;
43
+ decrypt(packet: Buffer, aad: Buffer): Buffer;
44
+ private nextNonce;
45
+ }
package/dist/e2ee.js ADDED
@@ -0,0 +1,102 @@
1
+ /**
2
+ * e2ee.ts — 内层 Noise NK(X25519 + HKDF-SHA256 + AES-256-GCM)Node 端(gateway 用)。
3
+ *
4
+ * 简化自 Noise NK 模式(host 静态密钥 + 浏览器临时密钥,浏览器靠 pin 认证 host):
5
+ * - 共享密钥 = X25519(本端私钥, 对端公钥)
6
+ * - 派生 = HKDF-SHA256(ikm=ss, salt=PROTOCOL_LABEL, info="session") → 64B → [initiator→responder(32), responder→initiator(32)]
7
+ * - AEAD = AES-256-GCM,包格式 [12B nonce][ciphertext][16B tag],显式 nonce + 每方向独立计数
8
+ *
9
+ * 注:为浏览器 WebCrypto 兼容选 AES-256-GCM(ChaCha20-Poly1305 在 WebCrypto 支持不普及)。
10
+ * 上线前建议与 Noise 规范 / 被审计库交叉复核(见 solution.md §6/§7)。
11
+ */
12
+ import { generateKeyPairSync, createPublicKey, createPrivateKey, diffieHellman, hkdfSync, createCipheriv, createDecipheriv, createHash, } from "node:crypto";
13
+ export const PROTOCOL_LABEL = Buffer.from("rdsh-e2ee-nk-v1");
14
+ const NONCE_LEN = 12;
15
+ const TAG_LEN = 16;
16
+ const KEY_LEN = 32;
17
+ /** X25519 PKCS8 DER 前缀(16B,后接 32B raw 私钥)。 */
18
+ const PKCS8_X25519_PREFIX = Buffer.from("302e020100300506032b656e04220420", "hex");
19
+ /** 生成 X25519 密钥对。 */
20
+ export function generateKeyPair() {
21
+ const { publicKey, privateKey } = generateKeyPairSync("x25519");
22
+ return { privateKey, publicRaw: publicToRaw(publicKey) };
23
+ }
24
+ export function publicToRaw(pub) {
25
+ const jwk = pub.export({ format: "jwk" });
26
+ return Buffer.from(jwk.x, "base64url");
27
+ }
28
+ export function publicFromRaw(raw) {
29
+ return createPublicKey({ key: { kty: "OKP", crv: "X25519", x: raw.toString("base64url") }, format: "jwk" });
30
+ }
31
+ export function privateFromRaw(raw) {
32
+ return createPrivateKey({ key: Buffer.concat([PKCS8_X25519_PREFIX, raw]), format: "der", type: "pkcs8" });
33
+ }
34
+ /** 持久化:raw 私钥 + 公钥(base64url 字符串)。 */
35
+ export function serializeKeyPair(kp) {
36
+ const jwk = kp.privateKey.export({ format: "jwk" });
37
+ return { publicRaw: kp.publicRaw.toString("base64url"), privateRaw: Buffer.from(jwk.d, "base64url").toString("base64url") };
38
+ }
39
+ export function deserializeKeyPair(publicRaw, privateRaw) {
40
+ return { privateKey: privateFromRaw(Buffer.from(privateRaw, "base64url")), publicRaw: Buffer.from(publicRaw, "base64url") };
41
+ }
42
+ /** X25519 共享密钥(32B)。 */
43
+ export function ecdh(privateKey, theirPublicRaw) {
44
+ return diffieHellman({ privateKey, publicKey: publicFromRaw(theirPublicRaw) });
45
+ }
46
+ /** HKDF 派生双向密钥。 */
47
+ export function deriveKeys(sharedSecret) {
48
+ const okm = Buffer.from(hkdfSync("sha256", sharedSecret, PROTOCOL_LABEL, Buffer.from("session"), 2 * KEY_LEN));
49
+ return {
50
+ initiatorToResponder: okm.subarray(0, KEY_LEN),
51
+ responderToInitiator: okm.subarray(KEY_LEN, 2 * KEY_LEN),
52
+ };
53
+ }
54
+ /** 指纹(pinning 展示):SHA-256 前 8 字节 hex 分组。 */
55
+ export function fingerprint(publicRaw) {
56
+ const h = createHash("sha256").update(publicRaw).digest("hex").slice(0, 16).toUpperCase();
57
+ return `${h.slice(0, 4)}-${h.slice(4, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}`;
58
+ }
59
+ /** 发起方(浏览器):临时密钥 × host 静态公钥 → 会话密钥;返回要发送的临时公钥。 */
60
+ export function initiatorHandshake(responderStaticPublicRaw) {
61
+ const eph = generateKeyPair();
62
+ const ss = ecdh(eph.privateKey, responderStaticPublicRaw);
63
+ return { ephemeralPublicRaw: eph.publicRaw, keys: deriveKeys(ss) };
64
+ }
65
+ /** 响应方(host):静态私钥 × 收到的发起方临时公钥 → 会话密钥。 */
66
+ export function responderHandshake(staticKeyPair, initiatorEphemeralPublicRaw) {
67
+ const ss = ecdh(staticKeyPair.privateKey, initiatorEphemeralPublicRaw);
68
+ return deriveKeys(ss);
69
+ }
70
+ /** AES-256-GCM 包:[12B nonce][ciphertext][16B tag]。显式 nonce(每方向独立计数,防错)。 */
71
+ export class Aead {
72
+ key;
73
+ counter = 0n;
74
+ constructor(key) {
75
+ this.key = key;
76
+ }
77
+ encrypt(plaintext, aad) {
78
+ const nonce = this.nextNonce();
79
+ const cipher = createCipheriv("aes-256-gcm", this.key, nonce);
80
+ cipher.setAAD(aad);
81
+ const ct = Buffer.concat([cipher.update(plaintext), cipher.final()]);
82
+ return Buffer.concat([nonce, ct, cipher.getAuthTag()]);
83
+ }
84
+ decrypt(packet, aad) {
85
+ if (packet.length < NONCE_LEN + TAG_LEN)
86
+ throw new Error("e2ee: packet too short");
87
+ const nonce = packet.subarray(0, NONCE_LEN);
88
+ const tag = packet.subarray(packet.length - TAG_LEN);
89
+ const ct = packet.subarray(NONCE_LEN, packet.length - TAG_LEN);
90
+ const decipher = createDecipheriv("aes-256-gcm", this.key, nonce);
91
+ decipher.setAAD(aad);
92
+ decipher.setAuthTag(tag);
93
+ return Buffer.concat([decipher.update(ct), decipher.final()]);
94
+ }
95
+ nextNonce() {
96
+ const n = Buffer.alloc(NONCE_LEN);
97
+ n.writeBigUInt64BE(this.counter, NONCE_LEN - 8);
98
+ this.counter += 1n;
99
+ return n;
100
+ }
101
+ }
102
+ //# sourceMappingURL=e2ee.js.map
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,11 +12,16 @@ 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
+ };
14
19
  }
15
- /** 注册/接入结果:解析出的 host token + 是否需 insecure */
20
+ /** 注册/接入结果:解析出的 host token + 是否需 insecure + 生效的主机名(缺省=机器 hostname)。 */
16
21
  export interface RegisterOutcome {
17
22
  token: string;
18
23
  insecure: boolean;
24
+ name: string;
19
25
  }
20
26
  /** 隧道状态机(onState 事件值)。 */
21
27
  export type JoinState = "connecting" | "connected" | "reconnecting" | "rejected" | "stopped";
@@ -40,10 +46,16 @@ export interface StartJoinOptions {
40
46
  /** 锁文件路径(缺省 ~/.rdsh/join.lock;测试可注入临时路径) */
41
47
  lockPath?: string;
42
48
  hooks?: JoinHooks;
49
+ /** DSH UI 兼容(缺省 trustE2EEAsLoopback=true;false 关闭 JS patch) */
50
+ dshUiCompat?: {
51
+ trustE2EEAsLoopback?: boolean;
52
+ };
43
53
  }
44
54
  /** 可停止的 join 隧道句柄。 */
45
55
  export interface JoinHandle {
46
56
  stop(): Promise<void>;
57
+ /** 运行中切换 DSH UI 兼容(trustE2EEAsLoopback);下一个请求即生效。 */
58
+ setUiCompat(trustE2EEAsLoopback: boolean): void;
47
59
  }
48
60
  /** 探测 hub 是否需 insecure:以严格校验握手一次;证书错误 → true(需 insecure)。 */
49
61
  export declare function detectInsecure(hubUrl: string): Promise<boolean>;
@@ -55,6 +67,14 @@ export declare function registerJoin(opts: JoinOptions): Promise<RegisterOutcome
55
67
  * 启动 join 隧道(no-spawn):转发到外部 `opts.target`,不 spawn dsh、不 process.exit。
56
68
  * 获取 pid 锁(opts.role);返回 `JoinHandle`,`stop()` 干净停止(关 WS/清 heartbeat/释放锁)。
57
69
  */
70
+ /** JS 响应判定(content-type 含 javascript)。 */
71
+ export declare function isJsContentType(headers: IncomingHttpHeaders): boolean;
72
+ /**
73
+ * 最小 patch:把 DSH 客户端 bundle 里的前端 isLoopback 判定替换为 true
74
+ * (持久设置/API key 输入只对 loopback 开放;E2EE 流上信任基础等同 loopback)。
75
+ * fail-open:未命中目标串 → 返回 null,调用方原样透传(DSH 升级不炸)。
76
+ */
77
+ export declare function patchLoopbackJs(body: Buffer): Buffer | null;
58
78
  export declare function startJoin(opts: StartJoinOptions): JoinHandle;
59
79
  /** `rdsh host serve`(join 模式)的 CLI 封装:spawn dsh + 信号退出 + startJoin(role:cli)。 */
60
80
  export declare function join(opts: JoinOptions): Promise<void>;
package/dist/join.js CHANGED
@@ -11,12 +11,15 @@
11
11
  */
12
12
  import { request as httpRequest } from "node:http";
13
13
  import { request as httpsRequest } from "node:https";
14
+ import { hostname as osHostname } from "node:os";
14
15
  import { WebSocket } from "ws";
15
- import { FrameParser, FRAME_TYPE, encodeFrame, jsonPayload, parseJsonPayload } from "rdsh-tunnel";
16
+ import { FrameParser, FRAME_TYPE, encodeFrame, jsonPayload, parseJsonPayload, FLAG_E2E } from "rdsh-tunnel";
16
17
  import { findDsh, spawnDsh } from "./spawn-dsh.js";
17
18
  import { rewriteHeadersForDsh } from "./proxy.js";
18
19
  import { clearPersistedToken, persistToken, readPersistedToken } from "./token-store.js";
19
20
  import { acquireJoinLock, releaseJoinLock } from "./lock.js";
21
+ import { responderHandshake, Aead } from "./e2ee.js";
22
+ import { loadOrCreateE2eeKeyPair } from "./e2ee-key-store.js";
20
23
  /** 判断错误是否为 TLS 证书类错误(自签/过期/域名不匹配)。 */
21
24
  function isCertError(err) {
22
25
  const code = err?.code ?? "";
@@ -81,10 +84,13 @@ export async function selfRevoke(hubUrl, token, insecure) {
81
84
  /** 解析 host token(--token 注册 > 持久化复用)+ 自动检测证书;供 CLI 配置命令与 join() 复用。 */
82
85
  export async function registerJoin(opts) {
83
86
  const insecure = opts.insecure === true || (await detectInsecure(opts.hubUrl));
87
+ // 主机名缺省 = 机器 hostname(CLI / service install / 插件三条路径统一;--name 可覆盖)
88
+ const name = opts.name !== undefined && opts.name.trim() !== "" ? opts.name.trim() : osHostname();
84
89
  let token;
85
90
  if (opts.token !== undefined) {
86
91
  // --token = join token(或旧 host token)→ register 端点换 host token
87
- const { hostToken } = await register(opts.hubUrl, opts.token, opts.name, insecure);
92
+ const e2eeKeyPair = loadOrCreateE2eeKeyPair();
93
+ const { hostToken } = await register(opts.hubUrl, opts.token, name, insecure, e2eeKeyPair.publicRaw.toString("base64url"));
88
94
  token = hostToken;
89
95
  persistToken(opts.hubUrl, token);
90
96
  }
@@ -100,11 +106,14 @@ export async function registerJoin(opts) {
100
106
  throw new Error("未接入:无持久化 session 且未提供 --token;先 `rdsh host join <hub>` 生成/粘贴 join token");
101
107
  }
102
108
  }
103
- return { token, insecure };
109
+ return { token, insecure, name };
104
110
  }
105
111
  /** 调 register 端点:join token → host token(对旧 host token 幂等返回同一 token)。 */
106
- async function register(hubUrl, joinToken, name, insecure) {
107
- const res = await hubRequest(hubUrl, "/api/hosts/register", { method: "POST", insecure, body: { token: joinToken, name } });
112
+ async function register(hubUrl, joinToken, name, insecure, e2eePublicKey) {
113
+ const body = { token: joinToken, name };
114
+ if (e2eePublicKey !== undefined)
115
+ body.e2eePublicKey = e2eePublicKey;
116
+ const res = await hubRequest(hubUrl, "/api/hosts/register", { method: "POST", insecure, body });
108
117
  if (!res.ok) {
109
118
  const msg = res.body.error?.message ?? `HTTP ${res.status}`;
110
119
  throw new Error(`hub rejected register: ${msg}`);
@@ -119,9 +128,29 @@ async function register(hubUrl, joinToken, name, insecure) {
119
128
  * 启动 join 隧道(no-spawn):转发到外部 `opts.target`,不 spawn dsh、不 process.exit。
120
129
  * 获取 pid 锁(opts.role);返回 `JoinHandle`,`stop()` 干净停止(关 WS/清 heartbeat/释放锁)。
121
130
  */
131
+ /** JS 响应判定(content-type 含 javascript)。 */
132
+ export function isJsContentType(headers) {
133
+ const ct = headers["content-type"];
134
+ const s = Array.isArray(ct) ? ct.join(";") : (ct ?? "");
135
+ return /javascript/i.test(s);
136
+ }
137
+ /**
138
+ * 最小 patch:把 DSH 客户端 bundle 里的前端 isLoopback 判定替换为 true
139
+ * (持久设置/API key 输入只对 loopback 开放;E2EE 流上信任基础等同 loopback)。
140
+ * fail-open:未命中目标串 → 返回 null,调用方原样透传(DSH 升级不炸)。
141
+ */
142
+ export function patchLoopbackJs(body) {
143
+ const src = body.toString("utf8");
144
+ const target = "isLoopbackHostname(pageLocation.hostname)";
145
+ if (!src.includes(target))
146
+ return null;
147
+ return Buffer.from(src.split(target).join("true"), "utf8");
148
+ }
122
149
  export function startJoin(opts) {
123
150
  const hubWsBase = opts.hubUrl.replace(/^https/, "wss").replace(/^http/, "ws");
124
151
  const hooks = opts.hooks ?? {};
152
+ // DSH UI 兼容开关:缺省 true(跟随 E2EE);可变引用 → 运行中可切换(插件面板即时生效)
153
+ const uiCompat = { trustE2EEAsLoopback: opts.dshUiCompat?.trustE2EEAsLoopback !== false };
125
154
  const log = (level, message) => {
126
155
  hooks.onLog?.(level, message);
127
156
  };
@@ -133,195 +162,298 @@ export function startJoin(opts) {
133
162
  throw new Error(`join lock held by ${lock.heldBy.role} (pid ${lock.heldBy.pid}); stop it first`);
134
163
  }
135
164
  const parser = new FrameParser();
136
- /** http 流:streamId → 本地请求(写请求体 / 结束)。 */
137
- const httpStreams = new Map();
138
- /** ws 流:streamId → 本地 ws 客户端(DATA 帧 → upstream)。 */
139
- const wsStreams = new Map();
140
165
  let shuttingDown = false;
141
166
  let reconnectDelay = RECONNECT_BASE_MS;
142
167
  let heartbeat;
143
168
  let currentClient;
144
- function handleFrame(frame, client) {
145
- switch (frame.type) {
146
- case FRAME_TYPE.PING: {
147
- client.send(encodeFrame(FRAME_TYPE.PONG, frame.streamId, frame.payload));
169
+ /** 发送一个隧道帧(走当前隧道 WS;flags 由调用方在 encodeFrame 时给定)。 */
170
+ function sendTunnelFrame(frame) {
171
+ if (currentClient !== undefined && currentClient.readyState === currentClient.OPEN) {
172
+ currentClient.send(frame);
173
+ }
174
+ }
175
+ /** 内层帧分发器(plain 与 raw 共用):OPEN http/ws + DATA → DSH 转发,响应帧经 `send` 回传。 */
176
+ function makeInnerDispatcher(send, dio) {
177
+ const httpStreams = new Map();
178
+ const wsStreams = new Map();
179
+ function closeStream(streamId) {
180
+ const ws = wsStreams.get(streamId);
181
+ if (ws !== undefined) {
182
+ wsStreams.delete(streamId);
183
+ try {
184
+ ws.upstream.terminate();
185
+ }
186
+ catch {
187
+ /* 已关闭 */
188
+ }
148
189
  return;
149
190
  }
150
- case FRAME_TYPE.PONG:
191
+ const http = httpStreams.get(streamId);
192
+ if (http !== undefined) {
193
+ httpStreams.delete(streamId);
194
+ http.up.end();
195
+ }
196
+ }
197
+ function openWsStream(streamId, path, headers) {
198
+ const upstream = new WebSocket(`ws://${opts.target.host}:${opts.target.port}${path}`, {
199
+ headers: rewriteHeadersForDsh(headers, opts.target),
200
+ });
201
+ const queue = [];
202
+ wsStreams.set(streamId, { upstream, queue });
203
+ upstream.on("open", () => {
204
+ for (const q of queue)
205
+ upstream.send(q, { binary: false });
206
+ queue.length = 0;
207
+ });
208
+ upstream.on("message", (data) => {
209
+ const buf = Array.isArray(data)
210
+ ? Buffer.concat(data)
211
+ : Buffer.isBuffer(data)
212
+ ? data
213
+ : Buffer.from(data);
214
+ send(encodeFrame(FRAME_TYPE.DATA, streamId, buf));
215
+ });
216
+ const cleanup = () => {
217
+ wsStreams.delete(streamId);
218
+ send(encodeFrame(FRAME_TYPE.CLOSE, streamId, jsonPayload({ code: 0 })));
219
+ };
220
+ upstream.on("close", cleanup);
221
+ upstream.on("error", cleanup);
222
+ }
223
+ function handleOpen(frame) {
224
+ let kind;
225
+ let method = "GET";
226
+ let path = "/";
227
+ let headers = {};
228
+ try {
229
+ const p = parseJsonPayload(frame);
230
+ kind = p.kind;
231
+ if (typeof p.method === "string")
232
+ method = p.method;
233
+ if (typeof p.path === "string")
234
+ path = p.path;
235
+ if (typeof p.headers === "object" && p.headers !== null)
236
+ headers = p.headers;
237
+ }
238
+ catch {
239
+ send(encodeFrame(FRAME_TYPE.ERROR, frame.streamId, jsonPayload({ code: "BAD_OPEN", message: "malformed open" })));
151
240
  return;
152
- case FRAME_TYPE.OPEN: {
153
- handleOpen(frame, client);
241
+ }
242
+ if (kind === "ws") {
243
+ openWsStream(frame.streamId, path, headers);
154
244
  return;
155
245
  }
156
- case FRAME_TYPE.DATA: {
157
- const ws = wsStreams.get(frame.streamId);
158
- if (ws !== undefined) {
159
- if (ws.upstream.readyState === ws.upstream.OPEN) {
160
- ws.upstream.send(frame.payload, { binary: false }); // DSH WS 为 text(JSON),保持文本帧
161
- }
162
- else {
163
- ws.queue.push(frame.payload);
246
+ if (kind !== "http") {
247
+ send(encodeFrame(FRAME_TYPE.ERROR, frame.streamId, jsonPayload({ code: "BAD_OPEN", message: "unknown kind" })));
248
+ return;
249
+ }
250
+ const streamId = frame.streamId;
251
+ const up = httpRequest({
252
+ host: opts.target.host,
253
+ port: opts.target.port,
254
+ path,
255
+ method,
256
+ headers: rewriteHeadersForDsh(headers, opts.target),
257
+ }, (upRes) => {
258
+ send(encodeFrame(FRAME_TYPE.OPEN, streamId, jsonPayload({
259
+ kind: "http",
260
+ status: upRes.statusCode ?? 502,
261
+ reason: upRes.statusMessage,
262
+ headers: normalizeRespHeaders(upRes.headers),
263
+ })));
264
+ if (dio?.jsPatch?.() === true && isJsContentType(upRes.headers)) {
265
+ // 最小 patch:E2EE 流上的 JS 响应,把前端 isLoopback 判定替换为 true
266
+ // (fail-open:未命中 → 原样透传;DSH 升级不炸)
267
+ const chunks = [];
268
+ upRes.on("data", (chunk) => chunks.push(chunk));
269
+ upRes.on("end", () => {
270
+ const body = Buffer.concat(chunks);
271
+ const patched = patchLoopbackJs(body);
272
+ // fail-open:未命中也必须原样发 body(否则空响应白屏)
273
+ send(encodeFrame(FRAME_TYPE.DATA, streamId, patched !== null ? patched : body));
274
+ send(encodeFrame(FRAME_TYPE.CLOSE, streamId, jsonPayload({ code: 0 })));
275
+ httpStreams.delete(streamId);
276
+ });
277
+ upRes.on("error", () => {
278
+ send(encodeFrame(FRAME_TYPE.CLOSE, streamId, jsonPayload({ code: 502, message: "upstream error" })));
279
+ httpStreams.delete(streamId);
280
+ });
281
+ return;
282
+ }
283
+ upRes.on("data", (chunk) => {
284
+ send(encodeFrame(FRAME_TYPE.DATA, streamId, chunk));
285
+ });
286
+ upRes.on("end", () => {
287
+ send(encodeFrame(FRAME_TYPE.CLOSE, streamId, jsonPayload({ code: 0 })));
288
+ httpStreams.delete(streamId);
289
+ });
290
+ upRes.on("error", () => {
291
+ send(encodeFrame(FRAME_TYPE.CLOSE, streamId, jsonPayload({ code: 502, message: "upstream error" })));
292
+ httpStreams.delete(streamId);
293
+ });
294
+ });
295
+ up.on("error", () => {
296
+ send(encodeFrame(FRAME_TYPE.ERROR, streamId, jsonPayload({ code: "UPSTREAM_UNREACHABLE", message: "dsh not reachable" })));
297
+ httpStreams.delete(streamId);
298
+ });
299
+ httpStreams.set(streamId, { up });
300
+ }
301
+ function handleFrame(frame) {
302
+ switch (frame.type) {
303
+ case FRAME_TYPE.OPEN: {
304
+ handleOpen(frame);
305
+ return;
306
+ }
307
+ case FRAME_TYPE.DATA: {
308
+ const ws = wsStreams.get(frame.streamId);
309
+ if (ws !== undefined) {
310
+ if (ws.upstream.readyState === ws.upstream.OPEN)
311
+ ws.upstream.send(frame.payload, { binary: false }); // DSH WS 为 text(JSON)
312
+ else
313
+ ws.queue.push(frame.payload);
314
+ return;
164
315
  }
316
+ const http = httpStreams.get(frame.streamId);
317
+ if (http !== undefined)
318
+ http.up.write(frame.payload);
165
319
  return;
166
320
  }
167
- const http = httpStreams.get(frame.streamId);
168
- if (http !== undefined)
169
- http.up.write(frame.payload);
170
- return;
321
+ case FRAME_TYPE.CLOSE:
322
+ case FRAME_TYPE.ERROR: {
323
+ closeStream(frame.streamId);
324
+ return;
325
+ }
326
+ default:
327
+ return;
171
328
  }
172
- case FRAME_TYPE.CLOSE: {
173
- closeStream(frame.streamId);
174
- return;
329
+ }
330
+ function cleanup() {
331
+ for (const s of httpStreams.values()) {
332
+ try {
333
+ s.up.destroy();
334
+ }
335
+ catch {
336
+ /* 已断 */
337
+ }
175
338
  }
176
- case FRAME_TYPE.ERROR: {
177
- closeStream(frame.streamId);
178
- return;
339
+ httpStreams.clear();
340
+ for (const s of wsStreams.values()) {
341
+ try {
342
+ s.upstream.terminate();
343
+ }
344
+ catch {
345
+ /* 已断 */
346
+ }
179
347
  }
180
- default:
181
- return;
348
+ wsStreams.clear();
182
349
  }
350
+ return { handleFrame, cleanup };
183
351
  }
184
- function closeStream(streamId) {
185
- const ws = wsStreams.get(streamId);
186
- if (ws !== undefined) {
187
- wsStreams.delete(streamId);
188
- try {
189
- ws.upstream.terminate();
352
+ const plainDispatcher = makeInnerDispatcher(sendTunnelFrame, { jsPatch: () => uiCompat.trustE2EEAsLoopback });
353
+ // host E2EE 静态密钥对(持久化 ~/.rdsh/e2ee-key.json;join 注册时上送指纹)
354
+ const hostE2eeKeypair = loadOrCreateE2eeKeyPair();
355
+ const rawStreams = new Map();
356
+ function startRawStream(streamId) {
357
+ const inner = makeInnerDispatcher((frame) => {
358
+ const raw = rawStreams.get(streamId);
359
+ if (raw?.encryptor !== null && raw?.encryptor !== undefined) {
360
+ const ct = raw.encryptor.encrypt(frame, Buffer.alloc(0));
361
+ sendTunnelFrame(encodeFrame(FRAME_TYPE.DATA, streamId, ct, FLAG_E2E));
190
362
  }
191
- catch {
192
- /* 已关闭 */
193
- }
194
- return;
195
- }
196
- const http = httpStreams.get(streamId);
197
- if (http !== undefined) {
198
- httpStreams.delete(streamId);
199
- http.up.end();
200
- }
363
+ }, { jsPatch: () => uiCompat.trustE2EEAsLoopback });
364
+ rawStreams.set(streamId, {
365
+ handshakeBuf: Buffer.alloc(0),
366
+ keys: null,
367
+ decryptor: null,
368
+ encryptor: null,
369
+ innerParser: new FrameParser(),
370
+ inner,
371
+ });
201
372
  }
202
- function handleOpen(frame, client) {
203
- let kind;
204
- let method = "GET";
205
- let path = "/";
206
- let headers = {};
373
+ function handleRawData(streamId, state, chunk) {
207
374
  try {
208
- const p = parseJsonPayload(frame);
209
- kind = p.kind;
210
- if (typeof p.method === "string")
211
- method = p.method;
212
- if (typeof p.path === "string")
213
- path = p.path;
214
- if (typeof p.headers === "object" && p.headers !== null)
215
- headers = p.headers;
375
+ if (state.keys === null) {
376
+ // Noise 握手:缓冲到 32B(发起方临时公钥)→ 派生密钥
377
+ state.handshakeBuf = Buffer.concat([state.handshakeBuf, chunk]);
378
+ if (state.handshakeBuf.length < 32)
379
+ return;
380
+ const ephPub = state.handshakeBuf.subarray(0, 32);
381
+ state.handshakeBuf = state.handshakeBuf.subarray(32);
382
+ state.keys = responderHandshake(hostE2eeKeypair, ephPub);
383
+ state.decryptor = new Aead(state.keys.initiatorToResponder);
384
+ state.encryptor = new Aead(state.keys.responderToInitiator);
385
+ if (state.handshakeBuf.length === 0)
386
+ return;
387
+ chunk = state.handshakeBuf; // 剩余 = 首个密文分片
388
+ state.handshakeBuf = Buffer.alloc(0);
389
+ }
390
+ const dec = state.decryptor.decrypt(chunk, Buffer.alloc(0));
391
+ for (const f of state.innerParser.push(dec))
392
+ state.inner.handleFrame(f);
216
393
  }
217
394
  catch {
218
- client.send(encodeFrame(FRAME_TYPE.ERROR, frame.streamId, jsonPayload({ code: "BAD_OPEN", message: "malformed open" })));
219
- return;
220
- }
221
- if (kind === "ws") {
222
- openWsStream(frame, client, path, headers);
223
- return;
395
+ // 解密失败(篡改/错序)→ 结束该 raw
396
+ rawStreams.delete(streamId);
397
+ sendTunnelFrame(encodeFrame(FRAME_TYPE.CLOSE, streamId, jsonPayload({ code: 1, message: "e2ee decrypt failed" })));
224
398
  }
225
- if (kind !== "http") {
226
- client.send(encodeFrame(FRAME_TYPE.ERROR, frame.streamId, jsonPayload({ code: "BAD_OPEN", message: "unknown kind" })));
227
- return;
228
- }
229
- const streamId = frame.streamId;
230
- // http 转发:本地 dsh(loopback http)
231
- const up = httpRequest({
232
- host: opts.target.host,
233
- port: opts.target.port,
234
- path,
235
- method,
236
- headers: rewriteHeadersForDsh(headers, opts.target),
237
- }, (upRes) => {
238
- client.send(encodeFrame(FRAME_TYPE.OPEN, streamId, jsonPayload({
239
- kind: "http",
240
- status: upRes.statusCode ?? 502,
241
- reason: upRes.statusMessage,
242
- headers: normalizeRespHeaders(upRes.headers),
243
- })));
244
- upRes.on("data", (chunk) => {
245
- if (client.readyState === client.OPEN) {
246
- client.send(encodeFrame(FRAME_TYPE.DATA, streamId, chunk));
399
+ }
400
+ /** 隧道级帧分发:PING/PONG + OPEN(http/ws/raw)+ DATA/CLOSE/ERROR(plain raw 路由)。 */
401
+ function handleFrame(frame) {
402
+ switch (frame.type) {
403
+ case FRAME_TYPE.PING: {
404
+ sendTunnelFrame(encodeFrame(FRAME_TYPE.PONG, frame.streamId, frame.payload));
405
+ return;
406
+ }
407
+ case FRAME_TYPE.PONG:
408
+ return;
409
+ case FRAME_TYPE.OPEN: {
410
+ let kind;
411
+ try {
412
+ const p = parseJsonPayload(frame);
413
+ kind = typeof p.kind === "string" ? p.kind : undefined;
247
414
  }
248
- });
249
- upRes.on("end", () => {
250
- if (client.readyState === client.OPEN) {
251
- client.send(encodeFrame(FRAME_TYPE.CLOSE, streamId, jsonPayload({ code: 0 })));
415
+ catch {
416
+ /* 交给 plain dispatcher 报 BAD_OPEN */
252
417
  }
253
- httpStreams.delete(streamId);
254
- });
255
- upRes.on("error", () => {
256
- if (client.readyState === client.OPEN) {
257
- client.send(encodeFrame(FRAME_TYPE.CLOSE, streamId, jsonPayload({ code: 502, message: "upstream error" })));
418
+ if (kind === "raw") {
419
+ startRawStream(frame.streamId);
420
+ return;
258
421
  }
259
- httpStreams.delete(streamId);
260
- });
261
- });
262
- up.on("error", () => {
263
- if (client.readyState === client.OPEN) {
264
- client.send(encodeFrame(FRAME_TYPE.ERROR, streamId, jsonPayload({ code: "UPSTREAM_UNREACHABLE", message: "dsh not reachable" })));
422
+ plainDispatcher.handleFrame(frame);
423
+ return;
265
424
  }
266
- httpStreams.delete(streamId);
267
- });
268
- httpStreams.set(streamId, { up });
269
- }
270
- function openWsStream(frame, client, path, headers) {
271
- const streamId = frame.streamId;
272
- const upstream = new WebSocket(`ws://${opts.target.host}:${opts.target.port}${path}`, {
273
- headers: rewriteHeadersForDsh(headers, opts.target),
274
- });
275
- const queue = [];
276
- wsStreams.set(streamId, { upstream, queue });
277
- upstream.on("open", () => {
278
- for (const q of queue)
279
- upstream.send(q, { binary: false });
280
- queue.length = 0;
281
- });
282
- upstream.on("message", (data, isBinary) => {
283
- const buf = Array.isArray(data)
284
- ? Buffer.concat(data)
285
- : Buffer.isBuffer(data)
286
- ? data
287
- : Buffer.from(data);
288
- if (client.readyState === client.OPEN) {
289
- client.send(encodeFrame(FRAME_TYPE.DATA, streamId, buf));
425
+ case FRAME_TYPE.DATA: {
426
+ const raw = rawStreams.get(frame.streamId);
427
+ if (raw !== undefined) {
428
+ handleRawData(frame.streamId, raw, frame.payload);
429
+ return;
430
+ }
431
+ plainDispatcher.handleFrame(frame);
432
+ return;
290
433
  }
291
- });
292
- const cleanup = () => {
293
- wsStreams.delete(streamId);
294
- if (client.readyState === client.OPEN) {
295
- client.send(encodeFrame(FRAME_TYPE.CLOSE, streamId, jsonPayload({ code: 0 })));
434
+ case FRAME_TYPE.CLOSE:
435
+ case FRAME_TYPE.ERROR: {
436
+ if (rawStreams.has(frame.streamId)) {
437
+ rawStreams.delete(frame.streamId);
438
+ return;
439
+ }
440
+ plainDispatcher.handleFrame(frame);
441
+ return;
296
442
  }
297
- };
298
- upstream.on("close", cleanup);
299
- upstream.on("error", cleanup);
443
+ default:
444
+ return;
445
+ }
300
446
  }
301
- /** 清空本地 http/ws 流 + heartbeat(断线/停止时)。 */
447
+ /** 清空本地 http/ws/raw 流 + heartbeat(断线/停止时)。 */
302
448
  function cleanupStreams() {
303
449
  if (heartbeat !== undefined) {
304
450
  clearInterval(heartbeat);
305
451
  heartbeat = undefined;
306
452
  }
307
- for (const s of httpStreams.values()) {
308
- try {
309
- s.up.destroy();
310
- }
311
- catch {
312
- /* 已断 */
313
- }
314
- }
315
- httpStreams.clear();
316
- for (const s of wsStreams.values()) {
317
- try {
318
- s.upstream.terminate();
319
- }
320
- catch {
321
- /* 已断 */
322
- }
323
- }
324
- wsStreams.clear();
453
+ plainDispatcher.cleanup();
454
+ for (const raw of rawStreams.values())
455
+ raw.inner.cleanup();
456
+ rawStreams.clear();
325
457
  }
326
458
  function connect() {
327
459
  if (shuttingDown)
@@ -372,7 +504,7 @@ export function startJoin(opts) {
372
504
  return;
373
505
  }
374
506
  for (const frame of frames)
375
- handleFrame(frame, client);
507
+ handleFrame(frame);
376
508
  });
377
509
  client.on("close", () => {
378
510
  cleanupStreams();
@@ -405,6 +537,10 @@ export function startJoin(opts) {
405
537
  }
406
538
  connect();
407
539
  return {
540
+ setUiCompat(trustE2EEAsLoopback) {
541
+ uiCompat.trustE2EEAsLoopback = trustE2EEAsLoopback;
542
+ console.log(`rdsh join: dshUiCompat.trustE2EEAsLoopback = ${trustE2EEAsLoopback}(运行中生效)`);
543
+ },
408
544
  async stop() {
409
545
  if (shuttingDown)
410
546
  return;
@@ -441,6 +577,7 @@ export async function join(opts) {
441
577
  insecure,
442
578
  target,
443
579
  role: "cli",
580
+ dshUiCompat: opts.dshUiCompat,
444
581
  hooks: {
445
582
  onLog: (level, message) => {
446
583
  (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.4.0",
3
+ "version": "0.6.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",
@@ -21,7 +21,7 @@
21
21
  },
22
22
  "dependencies": {
23
23
  "ws": "^8.18.0",
24
- "rdsh-tunnel": "0.1.0"
24
+ "rdsh-tunnel": "0.2.0"
25
25
  },
26
26
  "devDependencies": {
27
27
  "@types/ws": "^8.5.0"