rdsh-gateway 0.1.0 → 0.2.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/join.js ADDED
@@ -0,0 +1,352 @@
1
+ /**
2
+ * join.ts — `rdsh join <hub-url>`:出站隧道客户端(公网模式,M3)。
3
+ *
4
+ * 流程:spawn dsh(复用)→ 绑定(配对码轮询 或 --token 直填)→ WSS 隧道(?token=)
5
+ * → 帧循环(OPEN http/ws → 本地 dsh 转发 → 响应帧回传)→ 断线指数退避重连。
6
+ *
7
+ * 安全:只出站(不监听任何入站端口);hub 认证在层 1,gateway 侧只认隧道内来源。
8
+ */
9
+ import { request as httpRequest } from "node:http";
10
+ import { request as httpsRequest } from "node:https";
11
+ import { WebSocket } from "ws";
12
+ import { FrameParser, FRAME_TYPE, encodeFrame, jsonPayload, parseJsonPayload } from "rdsh-tunnel";
13
+ import { findDsh, spawnDsh } from "./spawn-dsh.js";
14
+ import { rewriteHeadersForDsh } from "./proxy.js";
15
+ const PENDING_POLL_MS = 5_000;
16
+ const BIND_TIMEOUT_MS = 10 * 60 * 1000; // 配对码 10 分钟
17
+ const HEARTBEAT_MS = 30_000;
18
+ const RECONNECT_BASE_MS = 1_000;
19
+ const RECONNECT_MAX_MS = 60_000;
20
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
21
+ /** 绑定流程:POST /api/hosts/pending → 打印配对码 → 轮询取 host token。 */
22
+ async function bind(hubUrl, insecure) {
23
+ const pendingRes = await hubRequest(hubUrl, "/api/hosts/pending", { method: "POST", insecure });
24
+ if (!pendingRes.ok) {
25
+ throw new Error(`hub rejected pending request: HTTP ${pendingRes.status}`);
26
+ }
27
+ const pending = pendingRes.body;
28
+ console.log(`rdsh join: pair code: ${pending.code}`);
29
+ console.log(`rdsh join: sign in to ${hubUrl} and enter this code (10 min) to bind this host.`);
30
+ console.log(`rdsh join: waiting for binding...`);
31
+ const deadline = Date.now() + BIND_TIMEOUT_MS;
32
+ while (Date.now() < deadline) {
33
+ await sleep(PENDING_POLL_MS);
34
+ const res = await hubRequest(hubUrl, `/api/hosts/pending/${pending.pendingId}`, { method: "GET", insecure });
35
+ if (!res.ok)
36
+ continue;
37
+ const body = res.body;
38
+ if (body.status === "bound" && typeof body.token === "string") {
39
+ console.log("rdsh join: bound — establishing tunnel...");
40
+ return body.token;
41
+ }
42
+ }
43
+ throw new Error("binding timed out (10 min): re-run rdsh join to get a new code");
44
+ }
45
+ /** hub HTTP 调用(node:https 支持自签跳过校验 —— undici fetch 不受 NODE_TLS_REJECT_UNAUTHORIZED 影响)。 */
46
+ function hubRequest(baseUrl, path, opts) {
47
+ const url = new URL(baseUrl + path);
48
+ const lib = url.protocol === "https:" ? httpsRequest : httpRequest;
49
+ return new Promise((resolve, reject) => {
50
+ const req = lib({
51
+ host: url.hostname,
52
+ port: url.port === "" ? undefined : Number(url.port),
53
+ path: url.pathname + url.search,
54
+ method: opts.method,
55
+ headers: { "content-type": "application/json" },
56
+ rejectUnauthorized: !opts.insecure,
57
+ }, (res) => {
58
+ const chunks = [];
59
+ res.on("data", (c) => chunks.push(c));
60
+ res.on("end", () => {
61
+ let body = {};
62
+ try {
63
+ body = JSON.parse(Buffer.concat(chunks).toString("utf8"));
64
+ }
65
+ catch {
66
+ /* 非 JSON */
67
+ }
68
+ resolve({ ok: res.statusCode !== undefined && res.statusCode >= 200 && res.statusCode < 300, status: res.statusCode ?? 0, body });
69
+ });
70
+ res.on("error", reject);
71
+ });
72
+ req.on("error", reject);
73
+ req.end(opts.method === "POST" ? "{}" : undefined);
74
+ });
75
+ }
76
+ export async function join(opts) {
77
+ const hubWsBase = opts.hubUrl.replace(/^https/, "wss").replace(/^http/, "ws");
78
+ const foundDsh = findDsh(opts.dshPath);
79
+ if (foundDsh === null) {
80
+ throw new Error("cannot find 'dsh' in PATH. Install DeepSeek Harness first, or pass --dsh <path>.");
81
+ }
82
+ const dsh = await spawnDsh(foundDsh);
83
+ const target = { host: "127.0.0.1", port: dsh.port };
84
+ const token = opts.token ?? (await bind(opts.hubUrl, opts.insecure === true));
85
+ console.log(`rdsh join: dsh web on 127.0.0.1:${dsh.port}`);
86
+ console.log(`rdsh join: connecting to ${opts.hubUrl}...`);
87
+ const parser = new FrameParser();
88
+ /** http 流:streamId → 本地请求(写请求体 / 结束)。 */
89
+ const httpStreams = new Map();
90
+ /** ws 流:streamId → 本地 ws 客户端(DATA 帧 → upstream)。 */
91
+ const wsStreams = new Map();
92
+ let shuttingDown = false;
93
+ let reconnectDelay = RECONNECT_BASE_MS;
94
+ let heartbeat;
95
+ const shutdown = async (signal) => {
96
+ if (shuttingDown)
97
+ return;
98
+ shuttingDown = true;
99
+ console.log(`\nrdsh: received ${signal}, shutting down...`);
100
+ if (heartbeat !== undefined)
101
+ clearInterval(heartbeat);
102
+ await dsh.stop();
103
+ process.exit(0);
104
+ };
105
+ process.on("SIGINT", () => void shutdown("SIGINT"));
106
+ process.on("SIGTERM", () => void shutdown("SIGTERM"));
107
+ process.on("SIGHUP", () => void shutdown("SIGHUP"));
108
+ function handleFrame(frame, client) {
109
+ switch (frame.type) {
110
+ case FRAME_TYPE.PING: {
111
+ client.send(encodeFrame(FRAME_TYPE.PONG, frame.streamId, frame.payload));
112
+ return;
113
+ }
114
+ case FRAME_TYPE.PONG:
115
+ return;
116
+ case FRAME_TYPE.OPEN: {
117
+ handleOpen(frame, client);
118
+ return;
119
+ }
120
+ case FRAME_TYPE.DATA: {
121
+ const ws = wsStreams.get(frame.streamId);
122
+ if (ws !== undefined) {
123
+ if (ws.upstream.readyState === ws.upstream.OPEN) {
124
+ ws.upstream.send(frame.payload, { binary: true });
125
+ }
126
+ else {
127
+ ws.queue.push(frame.payload);
128
+ }
129
+ return;
130
+ }
131
+ const http = httpStreams.get(frame.streamId);
132
+ if (http !== undefined)
133
+ http.up.write(frame.payload);
134
+ return;
135
+ }
136
+ case FRAME_TYPE.CLOSE: {
137
+ closeStream(frame.streamId);
138
+ return;
139
+ }
140
+ case FRAME_TYPE.ERROR: {
141
+ closeStream(frame.streamId);
142
+ return;
143
+ }
144
+ default:
145
+ return;
146
+ }
147
+ }
148
+ function closeStream(streamId) {
149
+ const ws = wsStreams.get(streamId);
150
+ if (ws !== undefined) {
151
+ wsStreams.delete(streamId);
152
+ try {
153
+ ws.upstream.terminate();
154
+ }
155
+ catch {
156
+ /* 已关闭 */
157
+ }
158
+ return;
159
+ }
160
+ const http = httpStreams.get(streamId);
161
+ if (http !== undefined) {
162
+ httpStreams.delete(streamId);
163
+ http.up.end();
164
+ }
165
+ }
166
+ function handleOpen(frame, client) {
167
+ let kind;
168
+ let method = "GET";
169
+ let path = "/";
170
+ let headers = {};
171
+ try {
172
+ const p = parseJsonPayload(frame);
173
+ kind = p.kind;
174
+ if (typeof p.method === "string")
175
+ method = p.method;
176
+ if (typeof p.path === "string")
177
+ path = p.path;
178
+ if (typeof p.headers === "object" && p.headers !== null)
179
+ headers = p.headers;
180
+ }
181
+ catch {
182
+ client.send(encodeFrame(FRAME_TYPE.ERROR, frame.streamId, jsonPayload({ code: "BAD_OPEN", message: "malformed open" })));
183
+ return;
184
+ }
185
+ if (kind === "ws") {
186
+ openWsStream(frame, client, path, headers);
187
+ return;
188
+ }
189
+ if (kind !== "http") {
190
+ client.send(encodeFrame(FRAME_TYPE.ERROR, frame.streamId, jsonPayload({ code: "BAD_OPEN", message: "unknown kind" })));
191
+ return;
192
+ }
193
+ const streamId = frame.streamId;
194
+ // http 转发:本地 dsh(loopback http)
195
+ const up = httpRequest({
196
+ host: target.host,
197
+ port: target.port,
198
+ path,
199
+ method,
200
+ headers: rewriteHeadersForDsh(headers, target),
201
+ }, (upRes) => {
202
+ client.send(encodeFrame(FRAME_TYPE.OPEN, streamId, jsonPayload({
203
+ kind: "http",
204
+ status: upRes.statusCode ?? 502,
205
+ reason: upRes.statusMessage,
206
+ headers: normalizeRespHeaders(upRes.headers),
207
+ })));
208
+ upRes.on("data", (chunk) => {
209
+ if (client.readyState === client.OPEN) {
210
+ client.send(encodeFrame(FRAME_TYPE.DATA, streamId, chunk));
211
+ }
212
+ });
213
+ upRes.on("end", () => {
214
+ if (client.readyState === client.OPEN) {
215
+ client.send(encodeFrame(FRAME_TYPE.CLOSE, streamId, jsonPayload({ code: 0 })));
216
+ }
217
+ httpStreams.delete(streamId);
218
+ });
219
+ upRes.on("error", () => {
220
+ if (client.readyState === client.OPEN) {
221
+ client.send(encodeFrame(FRAME_TYPE.CLOSE, streamId, jsonPayload({ code: 502, message: "upstream error" })));
222
+ }
223
+ httpStreams.delete(streamId);
224
+ });
225
+ });
226
+ up.on("error", () => {
227
+ if (client.readyState === client.OPEN) {
228
+ client.send(encodeFrame(FRAME_TYPE.ERROR, streamId, jsonPayload({ code: "UPSTREAM_UNREACHABLE", message: "dsh not reachable" })));
229
+ }
230
+ httpStreams.delete(streamId);
231
+ });
232
+ httpStreams.set(streamId, { up });
233
+ }
234
+ function openWsStream(frame, client, path, headers) {
235
+ const streamId = frame.streamId;
236
+ const upstream = new WebSocket(`ws://${target.host}:${target.port}${path}`, {
237
+ headers: rewriteHeadersForDsh(headers, target),
238
+ });
239
+ const queue = [];
240
+ wsStreams.set(streamId, { upstream, queue });
241
+ upstream.on("open", () => {
242
+ for (const q of queue)
243
+ upstream.send(q, { binary: true });
244
+ queue.length = 0;
245
+ });
246
+ upstream.on("message", (data, isBinary) => {
247
+ const buf = Array.isArray(data)
248
+ ? Buffer.concat(data)
249
+ : Buffer.isBuffer(data)
250
+ ? data
251
+ : Buffer.from(data);
252
+ if (client.readyState === client.OPEN) {
253
+ client.send(encodeFrame(FRAME_TYPE.DATA, streamId, buf));
254
+ }
255
+ });
256
+ const cleanup = () => {
257
+ wsStreams.delete(streamId);
258
+ if (client.readyState === client.OPEN) {
259
+ client.send(encodeFrame(FRAME_TYPE.CLOSE, streamId, jsonPayload({ code: 0 })));
260
+ }
261
+ };
262
+ upstream.on("close", cleanup);
263
+ upstream.on("error", cleanup);
264
+ }
265
+ function connect() {
266
+ if (shuttingDown)
267
+ return;
268
+ const url = `${hubWsBase}/tunnel?token=${encodeURIComponent(token)}`;
269
+ const client = new WebSocket(url, { rejectUnauthorized: opts.insecure !== true });
270
+ client.on("open", () => {
271
+ reconnectDelay = RECONNECT_BASE_MS;
272
+ console.log("rdsh join: tunnel established (heartbeat 30s)");
273
+ if (heartbeat !== undefined)
274
+ clearInterval(heartbeat);
275
+ heartbeat = setInterval(() => {
276
+ if (client.readyState === client.OPEN) {
277
+ client.send(encodeFrame(FRAME_TYPE.PING, 0, jsonPayload({ ts: Date.now() })));
278
+ }
279
+ }, HEARTBEAT_MS);
280
+ });
281
+ client.on("message", (data, isBinary) => {
282
+ if (!isBinary)
283
+ return;
284
+ const chunk = Array.isArray(data)
285
+ ? Buffer.concat(data)
286
+ : Buffer.isBuffer(data)
287
+ ? data
288
+ : Buffer.from(data);
289
+ let frames;
290
+ try {
291
+ frames = parser.push(chunk);
292
+ }
293
+ catch {
294
+ client.terminate();
295
+ return;
296
+ }
297
+ for (const frame of frames)
298
+ handleFrame(frame, client);
299
+ });
300
+ client.on("close", () => {
301
+ if (heartbeat !== undefined) {
302
+ clearInterval(heartbeat);
303
+ heartbeat = undefined;
304
+ }
305
+ for (const s of httpStreams.values()) {
306
+ try {
307
+ s.up.destroy();
308
+ }
309
+ catch {
310
+ /* 已断 */
311
+ }
312
+ }
313
+ httpStreams.clear();
314
+ for (const s of wsStreams.values()) {
315
+ try {
316
+ s.upstream.terminate();
317
+ }
318
+ catch {
319
+ /* 已断 */
320
+ }
321
+ }
322
+ wsStreams.clear();
323
+ if (shuttingDown)
324
+ return;
325
+ console.log(`rdsh join: tunnel lost — reconnecting in ${Math.round(reconnectDelay / 1000)}s...`);
326
+ setTimeout(connect, reconnectDelay + Math.random() * 500);
327
+ reconnectDelay = Math.min(reconnectDelay * 2, RECONNECT_MAX_MS);
328
+ });
329
+ client.on("error", () => {
330
+ try {
331
+ client.terminate();
332
+ }
333
+ catch {
334
+ /* 已关闭 */
335
+ }
336
+ });
337
+ }
338
+ connect();
339
+ }
340
+ function normalizeRespHeaders(headers) {
341
+ const out = {};
342
+ for (const [k, v] of Object.entries(headers)) {
343
+ if (v === undefined)
344
+ continue;
345
+ // hop-by-hop 头由各自连接管理,不透传
346
+ if (k === "transfer-encoding" || k === "connection" || k === "keep-alive" || k === "upgrade")
347
+ continue;
348
+ out[k] = Array.isArray(v) ? v : String(v);
349
+ }
350
+ return out;
351
+ }
352
+ //# sourceMappingURL=join.js.map
@@ -0,0 +1,4 @@
1
+ /**
2
+ * login-page.ts — 用户名/密码登录页(内联纯 HTML/CSS/JS,零外部资源)。
3
+ */
4
+ export declare function loginPageHtml(): string;
@@ -0,0 +1,86 @@
1
+ /**
2
+ * login-page.ts — 用户名/密码登录页(内联纯 HTML/CSS/JS,零外部资源)。
3
+ */
4
+ export function loginPageHtml() {
5
+ return `<!doctype html>
6
+ <html lang="zh-CN">
7
+ <head>
8
+ <meta charset="utf-8">
9
+ <meta name="viewport" content="width=device-width, initial-scale=1">
10
+ <title>rdsh 登录</title>
11
+ <style>
12
+ :root { color-scheme: dark; }
13
+ * { box-sizing: border-box; margin: 0; }
14
+ body {
15
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
16
+ background: #0f172a; color: #e2e8f0; min-height: 100vh;
17
+ display: flex; align-items: center; justify-content: center; padding: 24px;
18
+ }
19
+ main { width: 100%; max-width: 340px; text-align: center; }
20
+ .logo { font-size: 40px; font-weight: 800; color: #38bdf8; letter-spacing: 1px; }
21
+ .hint { margin: 16px 0 24px; color: #94a3b8; font-size: 14px; line-height: 1.6; }
22
+ label { display: block; text-align: left; color: #94a3b8; font-size: 13px; margin: 12px 0 6px; }
23
+ input {
24
+ width: 100%; padding: 12px; font-size: 16px;
25
+ background: #1e293b; color: #f1f5f9; border: 1px solid #334155; border-radius: 10px;
26
+ outline: none;
27
+ }
28
+ input:focus { border-color: #38bdf8; }
29
+ button {
30
+ width: 100%; margin-top: 18px; padding: 13px; font-size: 16px; font-weight: 600;
31
+ background: #0284c7; color: #fff; border: 0; border-radius: 10px; cursor: pointer;
32
+ }
33
+ button:disabled { opacity: .5; cursor: wait; }
34
+ .error { margin-top: 14px; color: #f87171; font-size: 14px; min-height: 20px; }
35
+ </style>
36
+ </head>
37
+ <body>
38
+ <main>
39
+ <div class="logo">rdsh</div>
40
+ <p class="hint">登录以访问 DeepSeek Harness 智能体。</p>
41
+ <form id="login-form">
42
+ <label for="name">用户名</label>
43
+ <input id="name" name="name" autocomplete="username" autofocus required>
44
+ <label for="password">密码</label>
45
+ <input id="password" name="password" type="password" autocomplete="current-password" required>
46
+ <button type="submit" id="submit">登录</button>
47
+ </form>
48
+ <p class="error" id="error" hidden></p>
49
+ </main>
50
+ <script>
51
+ const form = document.getElementById("login-form");
52
+ const nameInput = document.getElementById("name");
53
+ const passInput = document.getElementById("password");
54
+ const submit = document.getElementById("submit");
55
+ const error = document.getElementById("error");
56
+ form.addEventListener("submit", async (e) => {
57
+ e.preventDefault();
58
+ error.hidden = true;
59
+ submit.disabled = true;
60
+ try {
61
+ const res = await fetch("/login", {
62
+ method: "POST",
63
+ headers: { "Content-Type": "application/json" },
64
+ body: JSON.stringify({ name: nameInput.value.trim(), password: passInput.value }),
65
+ });
66
+ if (res.ok) { location.href = "/"; return; }
67
+ if (res.status === 429) {
68
+ const secs = Number(res.headers.get("Retry-After") || 600);
69
+ error.textContent = "尝试次数过多,请 " + Math.ceil(secs / 60) + " 分钟后再试";
70
+ } else {
71
+ error.textContent = "用户名或密码错误";
72
+ }
73
+ error.hidden = false;
74
+ } catch {
75
+ error.textContent = "网络错误,请重试";
76
+ error.hidden = false;
77
+ } finally {
78
+ submit.disabled = false;
79
+ passInput.select();
80
+ }
81
+ });
82
+ </script>
83
+ </body>
84
+ </html>`;
85
+ }
86
+ //# sourceMappingURL=login-page.js.map
package/dist/proxy.d.ts CHANGED
@@ -12,6 +12,13 @@ export interface ForwardOptions {
12
12
  */
13
13
  htmlInject?: string;
14
14
  }
15
+ /**
16
+ * 重写转发头以通过 DSH 围栏(isTrustedApiRequest 要求 Host/Origin 一致且为 loopback)。
17
+ * - Host → 127.0.0.1:<port>(M1 事实:DSH 只信任 loopback/trusted Host)
18
+ * - Origin 同步改写为 http://127.0.0.1:<port>(浏览器视角仍同源,不影响 CORS)
19
+ * 供 forwardHttp / createUpgradeProxy / join.ts(隧道→本地转发)复用。
20
+ */
21
+ export declare function rewriteHeadersForDsh(headers: Record<string, string | string[] | undefined>, target: ProxyTarget): Record<string, string | string[] | undefined>;
15
22
  /** 转发一个 HTTP 请求(含 SSE:响应流式写回,零缓冲)。 */
16
23
  export declare function forwardHttp(req: IncomingMessage, res: ServerResponse, target: ProxyTarget, opts?: ForwardOptions): void;
17
24
  /**
package/dist/proxy.js CHANGED
@@ -6,17 +6,23 @@
6
6
  */
7
7
  import { request } from "node:http";
8
8
  import { WebSocket, WebSocketServer } from "ws";
9
+ /**
10
+ * 重写转发头以通过 DSH 围栏(isTrustedApiRequest 要求 Host/Origin 一致且为 loopback)。
11
+ * - Host → 127.0.0.1:<port>(M1 事实:DSH 只信任 loopback/trusted Host)
12
+ * - Origin 同步改写为 http://127.0.0.1:<port>(浏览器视角仍同源,不影响 CORS)
13
+ * 供 forwardHttp / createUpgradeProxy / join.ts(隧道→本地转发)复用。
14
+ */
15
+ export function rewriteHeadersForDsh(headers, target) {
16
+ const out = { ...headers };
17
+ out.host = `${target.host}:${target.port}`;
18
+ if (out.origin !== undefined) {
19
+ out.origin = `http://${target.host}:${target.port}`;
20
+ }
21
+ return out;
22
+ }
9
23
  /** 转发一个 HTTP 请求(含 SSE:响应流式写回,零缓冲)。 */
10
24
  export function forwardHttp(req, res, target, opts) {
11
- const headers = { ...req.headers };
12
- headers.host = `${target.host}:${target.port}`;
13
- // DSH 围栏(isTrustedApiRequest)要求 Origin.host === Host.host。
14
- // 浏览器 Origin 是网关的 LAN 地址(如 http://192.168.x.x:8443),而 Host 已被
15
- // 重写为 loopback —— 不一致会 403。改写 Origin 使其与 Host 一致;浏览器视角
16
- // 请求仍是发往网关的同源请求,不影响 CORS。
17
- if (headers.origin !== undefined) {
18
- headers.origin = `http://${target.host}:${target.port}`;
19
- }
25
+ const headers = rewriteHeadersForDsh(req.headers, target);
20
26
  const upstream = request({
21
27
  host: target.host,
22
28
  port: target.port,
@@ -77,12 +83,7 @@ export function forwardHttp(req, res, target, opts) {
77
83
  export function createUpgradeProxy(target) {
78
84
  const wss = new WebSocketServer({ noServer: true });
79
85
  wss.on("connection", (clientWs, req) => {
80
- const headers = { ...req.headers };
81
- headers.host = `${target.host}:${target.port}`;
82
- // 同 forwardHttp:改写 Origin 使其与重写后的 Host 一致,通过 DSH 围栏校验
83
- if (headers.origin !== undefined) {
84
- headers.origin = `http://${target.host}:${target.port}`;
85
- }
86
+ const headers = rewriteHeadersForDsh(req.headers, target);
86
87
  const upstreamUrl = `ws://${target.host}:${target.port}${req.url ?? "/"}`;
87
88
  const upstream = new WebSocket(upstreamUrl, { headers });
88
89
  const queue = [];
package/dist/serve.d.ts CHANGED
@@ -1,15 +1,16 @@
1
1
  export interface ServeOptions {
2
- host: string;
3
- port: number;
2
+ host?: string;
3
+ port?: number;
4
4
  pairCode?: string;
5
- sessionTtlSeconds: number;
5
+ sessionTtlSeconds?: number;
6
6
  dshPath?: string;
7
7
  reset?: boolean;
8
- /** true = 跳过配对码认证(仅限完全可信网络!打印警告) */
9
8
  noCode?: boolean;
9
+ /** 配置文件路径(--config / $RDSH_CONFIG / 默认 ~/.rdsh/config.json) */
10
+ configPath?: string;
10
11
  }
11
12
  /**
12
- * 启动 LAN 认证网关。进程常驻:SIGINT/SIGTERM 时先关网关再终止 dsh 子进程。
13
- * 启动失败(dsh 缺失/启动失败/端口占用)→ 抛错(CLI 负责以非零码退出)。
13
+ * 启动网关。进程常驻:SIGINT/SIGTERM/SIGHUP 时先关网关再终止 dsh 子进程。
14
+ * 启动失败(dsh 缺失/TLS 缺失/端口占用/安全约束)→ 抛错(CLI 以非零码退出)。
14
15
  */
15
16
  export declare function serve(opts: ServeOptions): Promise<void>;
package/dist/serve.js CHANGED
@@ -1,49 +1,88 @@
1
1
  /**
2
- * serve.ts — `rdsh serve` 编排:spawn dsh → 启动网关 → 打印启动信息 → 生命周期。
2
+ * serve.ts — `rdsh serve` 编排:加载 config(CLI 覆盖)→ 解析 TLS → 启动网关 → 生命周期。
3
3
  */
4
4
  import { networkInterfaces } from "node:os";
5
5
  import { startGateway } from "./server.js";
6
6
  import { findDsh, spawnDsh } from "./spawn-dsh.js";
7
+ import { loadConfig, resolveConfigPath } from "./config.js";
8
+ import { UserManager } from "./auth.js";
9
+ import { loadTls } from "./tls.js";
7
10
  /**
8
- * 启动 LAN 认证网关。进程常驻:SIGINT/SIGTERM 时先关网关再终止 dsh 子进程。
9
- * 启动失败(dsh 缺失/启动失败/端口占用)→ 抛错(CLI 负责以非零码退出)。
11
+ * 启动网关。进程常驻:SIGINT/SIGTERM/SIGHUP 时先关网关再终止 dsh 子进程。
12
+ * 启动失败(dsh 缺失/TLS 缺失/端口占用/安全约束)→ 抛错(CLI 以非零码退出)。
10
13
  */
11
14
  export async function serve(opts) {
12
- const dshPath = findDsh(opts.dshPath);
13
- if (dshPath === null) {
14
- throw new Error("cannot find 'dsh' in PATH. Install DeepSeek Harness first, or pass --dsh <path>.");
15
+ const configPath = resolveConfigPath(opts.configPath);
16
+ const config = await loadConfig(configPath);
17
+ // CLI 参数优先于 config(持久配置在 config,CLI 做临时覆盖/调试)
18
+ const host = opts.host ?? config.host;
19
+ const port = opts.port ?? config.port;
20
+ const sessionTtlSeconds = opts.sessionTtlSeconds ?? config.sessionTtlSeconds;
21
+ const dshPath = opts.dshPath ?? config.dshPath;
22
+ const authMode = opts.noCode ? "none" : config.auth.mode;
23
+ const pairCode = opts.pairCode ?? config.auth.pairCode;
24
+ const foundDsh = findDsh(dshPath);
25
+ if (foundDsh === null) {
26
+ throw new Error("cannot find 'dsh' in PATH. Install DeepSeek Harness first, or set dshPath in config / pass --dsh <path>.");
15
27
  }
16
- const dsh = await spawnDsh(dshPath);
17
- if (opts.noCode) {
18
- console.warn("\n⚠ rdsh: --no-code 已启用 —— 跳过配对码认证!\n" +
19
- " 任何能访问该端口的设备都将直接操作 DSH(可执行任意命令)。\n" +
20
- " 仅限完全可信的局域网(家庭/公司内网)使用!\n");
28
+ const dsh = await spawnDsh(foundDsh);
29
+ // TLS 决策:有 tls.cert/key → https;无 → http(behindProxy 或 pair/none)。
30
+ // password + http + 非反代 → server.ts 安全约束拒绝启动(需自行提供证书)。
31
+ const tlsMaterial = await loadTls(config.tls, config.behindProxy);
32
+ const userManager = authMode === "password" ? new UserManager(configPath) : undefined;
33
+ if (opts.noCode || authMode === "none") {
34
+ console.warn("\n⚠ rdsh: 认证已禁用(auth.mode=none / --no-code)—— 任何能访问该端口的设备将直接操作 DSH!\n" +
35
+ " 仅限完全可信网络!\n");
36
+ }
37
+ let gateway;
38
+ try {
39
+ gateway = await startGateway({
40
+ host,
41
+ port,
42
+ pairCode,
43
+ sessionTtlSeconds,
44
+ dshPort: dsh.port,
45
+ reset: opts.reset,
46
+ noCode: opts.noCode,
47
+ authMode,
48
+ authVersion: config.auth.version,
49
+ allowFrom: config.allowFrom,
50
+ behindProxy: config.behindProxy,
51
+ tlsMaterial,
52
+ userManager,
53
+ configPath,
54
+ });
55
+ }
56
+ catch (err) {
57
+ // 启动失败(端口占用 / 安全约束拒绝)→ 先停掉已 spawn 的 dsh,避免残留
58
+ await dsh.stop().catch(() => undefined);
59
+ throw err;
21
60
  }
22
- const gateway = await startGateway({
23
- host: opts.host,
24
- port: opts.port,
25
- pairCode: opts.pairCode,
26
- sessionTtlSeconds: opts.sessionTtlSeconds,
27
- dshPort: dsh.port,
28
- reset: opts.reset,
29
- noCode: opts.noCode,
30
- });
31
61
  const lan = lanAddresses();
32
- const displayHost = opts.host === "0.0.0.0" ? (lan[0] ?? "127.0.0.1") : opts.host;
33
- console.log(`rdsh serve: gateway on http://${displayHost}:${gateway.actualPort}`);
34
- if (opts.host === "0.0.0.0" && lan.length > 0) {
35
- console.log(`rdsh serve: LAN: ${lan.map((ip) => `http://${ip}:${gateway.actualPort}`).join(", ")}`);
62
+ const scheme = tlsMaterial === null ? "http" : "https";
63
+ const displayHost = host === "0.0.0.0" ? (lan[0] ?? "127.0.0.1") : host;
64
+ console.log(`rdsh serve: gateway on ${scheme}://${displayHost}:${gateway.actualPort}`);
65
+ if (host === "0.0.0.0" && lan.length > 0) {
66
+ console.log(`rdsh serve: LAN: ${lan.map((ip) => `${scheme}://${ip}:${gateway.actualPort}`).join(", ")}`);
36
67
  }
37
68
  console.log(`rdsh serve: dsh web on 127.0.0.1:${dsh.port}`);
38
- console.log(`rdsh serve: pair code: ${gateway.pair.codeValue()}`);
39
- console.log("rdsh serve: enter the pair code in the browser on your other device.");
69
+ console.log(`rdsh serve: auth mode: ${authMode}${authMode === "password" ? ` (config: ${configPath})` : authMode === "pair" ? ` (pair code: ${gateway.pair.codeValue()})` : ""}`);
70
+ if (config.allowFrom.length > 0)
71
+ console.log(`rdsh serve: allow_from: ${config.allowFrom.join(", ")}`);
72
+ if (tlsMaterial !== null)
73
+ console.log(`rdsh serve: TLS: custom cert (${config.tls.cert})`);
74
+ if (config.behindProxy)
75
+ console.log("rdsh serve: behind_proxy: true (TLS terminated by reverse proxy)");
76
+ if (authMode === "password")
77
+ console.log("rdsh serve: sign in with `rdsh user` credentials in the browser login page.");
78
+ else if (authMode === "pair")
79
+ console.log("rdsh serve: enter the pair code in the browser on your other device.");
40
80
  let shuttingDown = false;
41
81
  const shutdown = async (signal) => {
42
82
  if (shuttingDown)
43
- return; // 幂等:重复信号不再处理
83
+ return;
44
84
  shuttingDown = true;
45
85
  console.log(`\nrdsh: received ${signal}, shutting down...`);
46
- // 停止接受新连接;残留活跃连接由进程退出时由 OS 清理
47
86
  gateway.server.close();
48
87
  let code;
49
88
  try {
@@ -52,12 +91,10 @@ export async function serve(opts) {
52
91
  catch {
53
92
  code = 1;
54
93
  }
55
- // 必须显式退出:仅设 exitCode 会因残留句柄(信号监听器/管道)而挂住
56
94
  process.exit(code === 0 ? 0 : 1);
57
95
  };
58
96
  process.on("SIGINT", () => void shutdown("SIGINT"));
59
97
  process.on("SIGTERM", () => void shutdown("SIGTERM"));
60
- // 覆盖默认行为:SIGHUP(关终端/断 SSH)也优雅清理,避免 dsh 子进程成为孤儿
61
98
  process.on("SIGHUP", () => void shutdown("SIGHUP"));
62
99
  }
63
100
  function lanAddresses() {