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.
- package/dist/e2ee-key-store.d.ts +5 -0
- package/dist/e2ee-key-store.js +42 -0
- package/dist/e2ee.d.ts +45 -0
- package/dist/e2ee.js +102 -0
- package/dist/join.d.ts +2 -1
- package/dist/join.js +253 -160
- package/package.json +2 -2
|
@@ -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
|
|
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
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
|
|
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
|
-
|
|
153
|
-
|
|
221
|
+
}
|
|
222
|
+
if (kind === "ws") {
|
|
223
|
+
openWsStream(frame.streamId, path, headers);
|
|
154
224
|
return;
|
|
155
225
|
}
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
282
|
+
case FRAME_TYPE.CLOSE:
|
|
283
|
+
case FRAME_TYPE.ERROR: {
|
|
284
|
+
closeStream(frame.streamId);
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
default:
|
|
288
|
+
return;
|
|
171
289
|
}
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
-
|
|
177
|
-
|
|
178
|
-
|
|
300
|
+
httpStreams.clear();
|
|
301
|
+
for (const s of wsStreams.values()) {
|
|
302
|
+
try {
|
|
303
|
+
s.upstream.terminate();
|
|
304
|
+
}
|
|
305
|
+
catch {
|
|
306
|
+
/* 已断 */
|
|
307
|
+
}
|
|
179
308
|
}
|
|
180
|
-
|
|
181
|
-
return;
|
|
309
|
+
wsStreams.clear();
|
|
182
310
|
}
|
|
311
|
+
return { handleFrame, cleanup };
|
|
183
312
|
}
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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
|
|
203
|
-
let kind;
|
|
204
|
-
let method = "GET";
|
|
205
|
-
let path = "/";
|
|
206
|
-
let headers = {};
|
|
334
|
+
function handleRawData(streamId, state, chunk) {
|
|
207
335
|
try {
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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
|
-
|
|
219
|
-
|
|
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
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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 + OPEN(http/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
|
-
|
|
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
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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
|
-
|
|
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
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
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
|
-
|
|
299
|
-
|
|
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
|
-
|
|
308
|
-
|
|
309
|
-
|
|
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
|
|
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.
|
|
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.
|
|
24
|
+
"rdsh-tunnel": "0.2.0"
|
|
25
25
|
},
|
|
26
26
|
"devDependencies": {
|
|
27
27
|
"@types/ws": "^8.5.0"
|