rdsh-gateway 0.4.0 → 0.5.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,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
@@ -12,10 +12,11 @@ export interface JoinOptions {
12
12
  /** 主机名(注册命名 / host.json) */
13
13
  name?: string;
14
14
  }
15
- /** 注册/接入结果:解析出的 host token + 是否需 insecure */
15
+ /** 注册/接入结果:解析出的 host token + 是否需 insecure + 生效的主机名(缺省=机器 hostname)。 */
16
16
  export interface RegisterOutcome {
17
17
  token: string;
18
18
  insecure: boolean;
19
+ name: string;
19
20
  }
20
21
  /** 隧道状态机(onState 事件值)。 */
21
22
  export type JoinState = "connecting" | "connected" | "reconnecting" | "rejected" | "stopped";
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}`);
@@ -133,195 +142,279 @@ export function startJoin(opts) {
133
142
  throw new Error(`join lock held by ${lock.heldBy.role} (pid ${lock.heldBy.pid}); stop it first`);
134
143
  }
135
144
  const parser = new FrameParser();
136
- /** http 流:streamId → 本地请求(写请求体 / 结束)。 */
137
- const httpStreams = new Map();
138
- /** ws 流:streamId → 本地 ws 客户端(DATA 帧 → upstream)。 */
139
- const wsStreams = new Map();
140
145
  let shuttingDown = false;
141
146
  let reconnectDelay = RECONNECT_BASE_MS;
142
147
  let heartbeat;
143
148
  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));
149
+ /** 发送一个隧道帧(走当前隧道 WS;flags 由调用方在 encodeFrame 时给定)。 */
150
+ function sendTunnelFrame(frame) {
151
+ if (currentClient !== undefined && currentClient.readyState === currentClient.OPEN) {
152
+ currentClient.send(frame);
153
+ }
154
+ }
155
+ /** 内层帧分发器(plain 与 raw 共用):OPEN http/ws + DATA → DSH 转发,响应帧经 `send` 回传。 */
156
+ function makeInnerDispatcher(send) {
157
+ const httpStreams = new Map();
158
+ const wsStreams = new Map();
159
+ function closeStream(streamId) {
160
+ const ws = wsStreams.get(streamId);
161
+ if (ws !== undefined) {
162
+ wsStreams.delete(streamId);
163
+ try {
164
+ ws.upstream.terminate();
165
+ }
166
+ catch {
167
+ /* 已关闭 */
168
+ }
148
169
  return;
149
170
  }
150
- case FRAME_TYPE.PONG:
171
+ const http = httpStreams.get(streamId);
172
+ if (http !== undefined) {
173
+ httpStreams.delete(streamId);
174
+ http.up.end();
175
+ }
176
+ }
177
+ function openWsStream(streamId, path, headers) {
178
+ const upstream = new WebSocket(`ws://${opts.target.host}:${opts.target.port}${path}`, {
179
+ headers: rewriteHeadersForDsh(headers, opts.target),
180
+ });
181
+ const queue = [];
182
+ wsStreams.set(streamId, { upstream, queue });
183
+ upstream.on("open", () => {
184
+ for (const q of queue)
185
+ upstream.send(q, { binary: false });
186
+ queue.length = 0;
187
+ });
188
+ upstream.on("message", (data) => {
189
+ const buf = Array.isArray(data)
190
+ ? Buffer.concat(data)
191
+ : Buffer.isBuffer(data)
192
+ ? data
193
+ : Buffer.from(data);
194
+ send(encodeFrame(FRAME_TYPE.DATA, streamId, buf));
195
+ });
196
+ const cleanup = () => {
197
+ wsStreams.delete(streamId);
198
+ send(encodeFrame(FRAME_TYPE.CLOSE, streamId, jsonPayload({ code: 0 })));
199
+ };
200
+ upstream.on("close", cleanup);
201
+ upstream.on("error", cleanup);
202
+ }
203
+ function handleOpen(frame) {
204
+ let kind;
205
+ let method = "GET";
206
+ let path = "/";
207
+ let headers = {};
208
+ try {
209
+ const p = parseJsonPayload(frame);
210
+ kind = p.kind;
211
+ if (typeof p.method === "string")
212
+ method = p.method;
213
+ if (typeof p.path === "string")
214
+ path = p.path;
215
+ if (typeof p.headers === "object" && p.headers !== null)
216
+ headers = p.headers;
217
+ }
218
+ catch {
219
+ send(encodeFrame(FRAME_TYPE.ERROR, frame.streamId, jsonPayload({ code: "BAD_OPEN", message: "malformed open" })));
151
220
  return;
152
- case FRAME_TYPE.OPEN: {
153
- handleOpen(frame, client);
221
+ }
222
+ if (kind === "ws") {
223
+ openWsStream(frame.streamId, path, headers);
154
224
  return;
155
225
  }
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);
226
+ if (kind !== "http") {
227
+ send(encodeFrame(FRAME_TYPE.ERROR, frame.streamId, jsonPayload({ code: "BAD_OPEN", message: "unknown kind" })));
228
+ return;
229
+ }
230
+ const streamId = frame.streamId;
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
+ 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
+ send(encodeFrame(FRAME_TYPE.DATA, streamId, chunk));
246
+ });
247
+ upRes.on("end", () => {
248
+ send(encodeFrame(FRAME_TYPE.CLOSE, streamId, jsonPayload({ code: 0 })));
249
+ httpStreams.delete(streamId);
250
+ });
251
+ upRes.on("error", () => {
252
+ send(encodeFrame(FRAME_TYPE.CLOSE, streamId, jsonPayload({ code: 502, message: "upstream error" })));
253
+ httpStreams.delete(streamId);
254
+ });
255
+ });
256
+ up.on("error", () => {
257
+ send(encodeFrame(FRAME_TYPE.ERROR, streamId, jsonPayload({ code: "UPSTREAM_UNREACHABLE", message: "dsh not reachable" })));
258
+ httpStreams.delete(streamId);
259
+ });
260
+ httpStreams.set(streamId, { up });
261
+ }
262
+ function handleFrame(frame) {
263
+ switch (frame.type) {
264
+ case FRAME_TYPE.OPEN: {
265
+ handleOpen(frame);
266
+ return;
267
+ }
268
+ case FRAME_TYPE.DATA: {
269
+ const ws = wsStreams.get(frame.streamId);
270
+ if (ws !== undefined) {
271
+ if (ws.upstream.readyState === ws.upstream.OPEN)
272
+ ws.upstream.send(frame.payload, { binary: false }); // DSH WS 为 text(JSON)
273
+ else
274
+ ws.queue.push(frame.payload);
275
+ return;
164
276
  }
277
+ const http = httpStreams.get(frame.streamId);
278
+ if (http !== undefined)
279
+ http.up.write(frame.payload);
165
280
  return;
166
281
  }
167
- const http = httpStreams.get(frame.streamId);
168
- if (http !== undefined)
169
- http.up.write(frame.payload);
170
- return;
282
+ case FRAME_TYPE.CLOSE:
283
+ case FRAME_TYPE.ERROR: {
284
+ closeStream(frame.streamId);
285
+ return;
286
+ }
287
+ default:
288
+ return;
171
289
  }
172
- case FRAME_TYPE.CLOSE: {
173
- closeStream(frame.streamId);
174
- return;
290
+ }
291
+ function cleanup() {
292
+ for (const s of httpStreams.values()) {
293
+ try {
294
+ s.up.destroy();
295
+ }
296
+ catch {
297
+ /* 已断 */
298
+ }
175
299
  }
176
- case FRAME_TYPE.ERROR: {
177
- closeStream(frame.streamId);
178
- return;
300
+ httpStreams.clear();
301
+ for (const s of wsStreams.values()) {
302
+ try {
303
+ s.upstream.terminate();
304
+ }
305
+ catch {
306
+ /* 已断 */
307
+ }
179
308
  }
180
- default:
181
- return;
309
+ wsStreams.clear();
182
310
  }
311
+ return { handleFrame, cleanup };
183
312
  }
184
- function closeStream(streamId) {
185
- const ws = wsStreams.get(streamId);
186
- if (ws !== undefined) {
187
- wsStreams.delete(streamId);
188
- try {
189
- ws.upstream.terminate();
190
- }
191
- catch {
192
- /* 已关闭 */
313
+ const plainDispatcher = makeInnerDispatcher(sendTunnelFrame);
314
+ // host E2EE 静态密钥对(持久化 ~/.rdsh/e2ee-key.json;join 注册时上送指纹)
315
+ const hostE2eeKeypair = loadOrCreateE2eeKeyPair();
316
+ const rawStreams = new Map();
317
+ function startRawStream(streamId) {
318
+ const inner = makeInnerDispatcher((frame) => {
319
+ const raw = rawStreams.get(streamId);
320
+ if (raw?.encryptor !== null && raw?.encryptor !== undefined) {
321
+ const ct = raw.encryptor.encrypt(frame, Buffer.alloc(0));
322
+ sendTunnelFrame(encodeFrame(FRAME_TYPE.DATA, streamId, ct, FLAG_E2E));
193
323
  }
194
- return;
195
- }
196
- const http = httpStreams.get(streamId);
197
- if (http !== undefined) {
198
- httpStreams.delete(streamId);
199
- http.up.end();
200
- }
324
+ });
325
+ rawStreams.set(streamId, {
326
+ handshakeBuf: Buffer.alloc(0),
327
+ keys: null,
328
+ decryptor: null,
329
+ encryptor: null,
330
+ innerParser: new FrameParser(),
331
+ inner,
332
+ });
201
333
  }
202
- function handleOpen(frame, client) {
203
- let kind;
204
- let method = "GET";
205
- let path = "/";
206
- let headers = {};
334
+ function handleRawData(streamId, state, chunk) {
207
335
  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;
336
+ if (state.keys === null) {
337
+ // Noise 握手:缓冲到 32B(发起方临时公钥)→ 派生密钥
338
+ state.handshakeBuf = Buffer.concat([state.handshakeBuf, chunk]);
339
+ if (state.handshakeBuf.length < 32)
340
+ return;
341
+ const ephPub = state.handshakeBuf.subarray(0, 32);
342
+ state.handshakeBuf = state.handshakeBuf.subarray(32);
343
+ state.keys = responderHandshake(hostE2eeKeypair, ephPub);
344
+ state.decryptor = new Aead(state.keys.initiatorToResponder);
345
+ state.encryptor = new Aead(state.keys.responderToInitiator);
346
+ if (state.handshakeBuf.length === 0)
347
+ return;
348
+ chunk = state.handshakeBuf; // 剩余 = 首个密文分片
349
+ state.handshakeBuf = Buffer.alloc(0);
350
+ }
351
+ const dec = state.decryptor.decrypt(chunk, Buffer.alloc(0));
352
+ for (const f of state.innerParser.push(dec))
353
+ state.inner.handleFrame(f);
216
354
  }
217
355
  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;
224
- }
225
- if (kind !== "http") {
226
- client.send(encodeFrame(FRAME_TYPE.ERROR, frame.streamId, jsonPayload({ code: "BAD_OPEN", message: "unknown kind" })));
227
- return;
356
+ // 解密失败(篡改/错序)→ 结束该 raw
357
+ rawStreams.delete(streamId);
358
+ sendTunnelFrame(encodeFrame(FRAME_TYPE.CLOSE, streamId, jsonPayload({ code: 1, message: "e2ee decrypt failed" })));
228
359
  }
229
- const streamId = frame.streamId;
230
- // http 转发:本地 dshloopback 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));
360
+ }
361
+ /** 隧道级帧分发:PING/PONG + OPENhttp/ws/raw)+ DATA/CLOSE/ERROR(plain 或 raw 路由)。 */
362
+ function handleFrame(frame) {
363
+ switch (frame.type) {
364
+ case FRAME_TYPE.PING: {
365
+ sendTunnelFrame(encodeFrame(FRAME_TYPE.PONG, frame.streamId, frame.payload));
366
+ return;
367
+ }
368
+ case FRAME_TYPE.PONG:
369
+ return;
370
+ case FRAME_TYPE.OPEN: {
371
+ let kind;
372
+ try {
373
+ const p = parseJsonPayload(frame);
374
+ kind = typeof p.kind === "string" ? p.kind : undefined;
247
375
  }
248
- });
249
- upRes.on("end", () => {
250
- if (client.readyState === client.OPEN) {
251
- client.send(encodeFrame(FRAME_TYPE.CLOSE, streamId, jsonPayload({ code: 0 })));
376
+ catch {
377
+ /* 交给 plain dispatcher 报 BAD_OPEN */
252
378
  }
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" })));
379
+ if (kind === "raw") {
380
+ startRawStream(frame.streamId);
381
+ return;
258
382
  }
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" })));
383
+ plainDispatcher.handleFrame(frame);
384
+ return;
265
385
  }
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));
386
+ case FRAME_TYPE.DATA: {
387
+ const raw = rawStreams.get(frame.streamId);
388
+ if (raw !== undefined) {
389
+ handleRawData(frame.streamId, raw, frame.payload);
390
+ return;
391
+ }
392
+ plainDispatcher.handleFrame(frame);
393
+ return;
290
394
  }
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 })));
395
+ case FRAME_TYPE.CLOSE:
396
+ case FRAME_TYPE.ERROR: {
397
+ if (rawStreams.has(frame.streamId)) {
398
+ rawStreams.delete(frame.streamId);
399
+ return;
400
+ }
401
+ plainDispatcher.handleFrame(frame);
402
+ return;
296
403
  }
297
- };
298
- upstream.on("close", cleanup);
299
- upstream.on("error", cleanup);
404
+ default:
405
+ return;
406
+ }
300
407
  }
301
- /** 清空本地 http/ws 流 + heartbeat(断线/停止时)。 */
408
+ /** 清空本地 http/ws/raw 流 + heartbeat(断线/停止时)。 */
302
409
  function cleanupStreams() {
303
410
  if (heartbeat !== undefined) {
304
411
  clearInterval(heartbeat);
305
412
  heartbeat = undefined;
306
413
  }
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();
414
+ plainDispatcher.cleanup();
415
+ for (const raw of rawStreams.values())
416
+ raw.inner.cleanup();
417
+ rawStreams.clear();
325
418
  }
326
419
  function connect() {
327
420
  if (shuttingDown)
@@ -372,7 +465,7 @@ export function startJoin(opts) {
372
465
  return;
373
466
  }
374
467
  for (const frame of frames)
375
- handleFrame(frame, client);
468
+ handleFrame(frame);
376
469
  });
377
470
  client.on("close", () => {
378
471
  cleanupStreams();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rdsh-gateway",
3
- "version": "0.4.0",
3
+ "version": "0.5.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"