rdsh-gateway 0.1.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/index.d.ts +17 -0
- package/dist/index.js +13 -0
- package/dist/pair-page.d.ts +5 -0
- package/dist/pair-page.js +84 -0
- package/dist/pair.d.ts +20 -0
- package/dist/pair.js +60 -0
- package/dist/proxy.d.ts +26 -0
- package/dist/proxy.js +150 -0
- package/dist/secure-context-polyfill.d.ts +10 -0
- package/dist/secure-context-polyfill.js +30 -0
- package/dist/serve.d.ts +15 -0
- package/dist/serve.js +69 -0
- package/dist/server.d.ts +32 -0
- package/dist/server.js +124 -0
- package/dist/session.d.ts +26 -0
- package/dist/session.js +121 -0
- package/dist/spawn-dsh.d.ts +19 -0
- package/dist/spawn-dsh.js +91 -0
- package/package.json +32 -0
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* rdsh-gateway — host-side component. One process, two modes:
|
|
3
|
+
* - `serve` (MVP): LAN auth gateway in front of a spawned `dsh web`
|
|
4
|
+
* - `join` (M2): outbound tunnel endpoint connected to rdsh-hub
|
|
5
|
+
*/
|
|
6
|
+
export { serve } from "./serve.ts";
|
|
7
|
+
export type { ServeOptions } from "./serve.ts";
|
|
8
|
+
export { SessionManager, SESSION_COOKIE, sessionTokenFromCookie } from "./session.ts";
|
|
9
|
+
export type { SessionPayload } from "./session.ts";
|
|
10
|
+
export { PairManager } from "./pair.ts";
|
|
11
|
+
export { startGateway } from "./server.ts";
|
|
12
|
+
export type { GatewayOptions, RunningGateway } from "./server.ts";
|
|
13
|
+
export { findDsh, spawnDsh } from "./spawn-dsh.ts";
|
|
14
|
+
export type { SpawnedDsh } from "./spawn-dsh.ts";
|
|
15
|
+
export { forwardHttp, createUpgradeProxy } from "./proxy.ts";
|
|
16
|
+
export type { ProxyTarget } from "./proxy.ts";
|
|
17
|
+
export declare const NAME = "rdsh-gateway";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* rdsh-gateway — host-side component. One process, two modes:
|
|
3
|
+
* - `serve` (MVP): LAN auth gateway in front of a spawned `dsh web`
|
|
4
|
+
* - `join` (M2): outbound tunnel endpoint connected to rdsh-hub
|
|
5
|
+
*/
|
|
6
|
+
export { serve } from "./serve.js";
|
|
7
|
+
export { SessionManager, SESSION_COOKIE, sessionTokenFromCookie } from "./session.js";
|
|
8
|
+
export { PairManager } from "./pair.js";
|
|
9
|
+
export { startGateway } from "./server.js";
|
|
10
|
+
export { findDsh, spawnDsh } from "./spawn-dsh.js";
|
|
11
|
+
export { forwardHttp, createUpgradeProxy } from "./proxy.js";
|
|
12
|
+
export const NAME = "rdsh-gateway";
|
|
13
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pair-page.ts — 配对页(内联纯 HTML/CSS/JS,零外部资源)。
|
|
3
|
+
* 文案引导用户在开发机终端查看配对码(物理信任锚点)。
|
|
4
|
+
*/
|
|
5
|
+
export function pairPageHtml() {
|
|
6
|
+
return `<!doctype html>
|
|
7
|
+
<html lang="zh-CN">
|
|
8
|
+
<head>
|
|
9
|
+
<meta charset="utf-8">
|
|
10
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
11
|
+
<title>rdsh 配对</title>
|
|
12
|
+
<style>
|
|
13
|
+
:root { color-scheme: dark; }
|
|
14
|
+
* { box-sizing: border-box; margin: 0; }
|
|
15
|
+
body {
|
|
16
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
17
|
+
background: #0f172a; color: #e2e8f0; min-height: 100vh;
|
|
18
|
+
display: flex; align-items: center; justify-content: center; padding: 24px;
|
|
19
|
+
}
|
|
20
|
+
main { width: 100%; max-width: 360px; text-align: center; }
|
|
21
|
+
.logo { font-size: 40px; font-weight: 800; color: #38bdf8; letter-spacing: 1px; }
|
|
22
|
+
.hint { margin: 16px 0 24px; color: #94a3b8; font-size: 14px; line-height: 1.6; }
|
|
23
|
+
code { background: #1e293b; padding: 2px 6px; border-radius: 4px; color: #7dd3fc; }
|
|
24
|
+
input {
|
|
25
|
+
width: 100%; padding: 14px; font-size: 24px; text-align: center; letter-spacing: 8px;
|
|
26
|
+
background: #1e293b; color: #f1f5f9; border: 1px solid #334155; border-radius: 10px;
|
|
27
|
+
outline: none;
|
|
28
|
+
}
|
|
29
|
+
input:focus { border-color: #38bdf8; }
|
|
30
|
+
button {
|
|
31
|
+
width: 100%; margin-top: 12px; padding: 14px; font-size: 16px; font-weight: 600;
|
|
32
|
+
background: #0284c7; color: #fff; border: 0; border-radius: 10px; cursor: pointer;
|
|
33
|
+
}
|
|
34
|
+
button:disabled { opacity: .5; cursor: wait; }
|
|
35
|
+
.error { margin-top: 14px; color: #f87171; font-size: 14px; }
|
|
36
|
+
</style>
|
|
37
|
+
</head>
|
|
38
|
+
<body>
|
|
39
|
+
<main>
|
|
40
|
+
<div class="logo">rdsh</div>
|
|
41
|
+
<p class="hint">在<strong>开发机终端</strong>查看配对码(运行 <code>rdsh serve</code> 的窗口),输入后即可访问 DeepSeek Harness。</p>
|
|
42
|
+
<form id="pair-form">
|
|
43
|
+
<input id="code" inputmode="numeric" maxlength="6" autocomplete="one-time-code"
|
|
44
|
+
placeholder="6 位配对码" autofocus aria-label="配对码">
|
|
45
|
+
<button type="submit" id="submit">配对</button>
|
|
46
|
+
</form>
|
|
47
|
+
<p class="error" id="error" hidden></p>
|
|
48
|
+
</main>
|
|
49
|
+
<script>
|
|
50
|
+
const form = document.getElementById("pair-form");
|
|
51
|
+
const codeInput = document.getElementById("code");
|
|
52
|
+
const submit = document.getElementById("submit");
|
|
53
|
+
const error = document.getElementById("error");
|
|
54
|
+
form.addEventListener("submit", async (e) => {
|
|
55
|
+
e.preventDefault();
|
|
56
|
+
error.hidden = true;
|
|
57
|
+
submit.disabled = true;
|
|
58
|
+
try {
|
|
59
|
+
const res = await fetch("/pair", {
|
|
60
|
+
method: "POST",
|
|
61
|
+
headers: { "Content-Type": "application/json" },
|
|
62
|
+
body: JSON.stringify({ code: codeInput.value.trim() }),
|
|
63
|
+
});
|
|
64
|
+
if (res.ok) { location.href = "/"; return; }
|
|
65
|
+
if (res.status === 429) {
|
|
66
|
+
const secs = Number(res.headers.get("Retry-After") || 600);
|
|
67
|
+
error.textContent = "尝试次数过多,请 " + Math.ceil(secs / 60) + " 分钟后再试";
|
|
68
|
+
} else {
|
|
69
|
+
error.textContent = "配对码错误,请重试";
|
|
70
|
+
}
|
|
71
|
+
error.hidden = false;
|
|
72
|
+
} catch {
|
|
73
|
+
error.textContent = "网络错误,请重试";
|
|
74
|
+
error.hidden = false;
|
|
75
|
+
} finally {
|
|
76
|
+
submit.disabled = false;
|
|
77
|
+
codeInput.select();
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
</script>
|
|
81
|
+
</body>
|
|
82
|
+
</html>`;
|
|
83
|
+
}
|
|
84
|
+
//# sourceMappingURL=pair-page.js.map
|
package/dist/pair.d.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export interface PairCheckResult {
|
|
2
|
+
ok: boolean;
|
|
3
|
+
/** 该 IP 当前是否处于锁定状态 */
|
|
4
|
+
locked: boolean;
|
|
5
|
+
/** 剩余锁定毫秒数(用于 429 Retry-After) */
|
|
6
|
+
retryAfterMs: number;
|
|
7
|
+
}
|
|
8
|
+
export declare class PairManager {
|
|
9
|
+
private readonly code;
|
|
10
|
+
private readonly locks;
|
|
11
|
+
constructor(code?: string);
|
|
12
|
+
/** 当前配对码(终端展示用)。 */
|
|
13
|
+
codeValue(): string;
|
|
14
|
+
/**
|
|
15
|
+
* 校验输入;成功清零该 IP 失败计数;失败累加并可能触发锁定。
|
|
16
|
+
*/
|
|
17
|
+
check(input: string, ip: string): PairCheckResult;
|
|
18
|
+
/** 该 IP 剩余锁定毫秒(0 = 未锁定),供 429 Retry-After。 */
|
|
19
|
+
lockRemainingMs(ip: string): number;
|
|
20
|
+
}
|
package/dist/pair.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pair.ts — 配对码生成/校验 + IP 维度限流。
|
|
3
|
+
*
|
|
4
|
+
* 安全要点:
|
|
5
|
+
* - 恒定时间比较:先 SHA-256 再 timingSafeEqual(规避长度侧信道);
|
|
6
|
+
* - 限流:每 IP 5 次失败锁定 10 分钟(内存实现,进程生命周期有效)。
|
|
7
|
+
*/
|
|
8
|
+
import { createHash, randomInt, timingSafeEqual } from "node:crypto";
|
|
9
|
+
const PAIR_DIGITS = 6;
|
|
10
|
+
const MAX_FAILS = 5;
|
|
11
|
+
const LOCK_MS = 10 * 60 * 1000;
|
|
12
|
+
export class PairManager {
|
|
13
|
+
code;
|
|
14
|
+
locks = new Map();
|
|
15
|
+
constructor(code) {
|
|
16
|
+
this.code = code ?? generateCode();
|
|
17
|
+
}
|
|
18
|
+
/** 当前配对码(终端展示用)。 */
|
|
19
|
+
codeValue() {
|
|
20
|
+
return this.code;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* 校验输入;成功清零该 IP 失败计数;失败累加并可能触发锁定。
|
|
24
|
+
*/
|
|
25
|
+
check(input, ip) {
|
|
26
|
+
const now = Date.now();
|
|
27
|
+
const state = this.locks.get(ip) ?? { fails: 0, lockedUntil: 0 };
|
|
28
|
+
if (state.lockedUntil > now) {
|
|
29
|
+
return { ok: false, locked: true, retryAfterMs: state.lockedUntil - now };
|
|
30
|
+
}
|
|
31
|
+
if (constantEqual(input.trim(), this.code)) {
|
|
32
|
+
this.locks.delete(ip);
|
|
33
|
+
return { ok: true, locked: false, retryAfterMs: 0 };
|
|
34
|
+
}
|
|
35
|
+
state.fails += 1;
|
|
36
|
+
if (state.fails >= MAX_FAILS) {
|
|
37
|
+
state.fails = 0;
|
|
38
|
+
state.lockedUntil = now + LOCK_MS;
|
|
39
|
+
}
|
|
40
|
+
this.locks.set(ip, state);
|
|
41
|
+
return { ok: false, locked: state.lockedUntil > now, retryAfterMs: Math.max(0, state.lockedUntil - now) };
|
|
42
|
+
}
|
|
43
|
+
/** 该 IP 剩余锁定毫秒(0 = 未锁定),供 429 Retry-After。 */
|
|
44
|
+
lockRemainingMs(ip) {
|
|
45
|
+
const state = this.locks.get(ip);
|
|
46
|
+
if (!state)
|
|
47
|
+
return 0;
|
|
48
|
+
return Math.max(0, state.lockedUntil - Date.now());
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
/** 恒定时间字符串比较(长度差异也走相同路径:hash 后定长比较)。 */
|
|
52
|
+
function constantEqual(a, b) {
|
|
53
|
+
const ha = createHash("sha256").update(a).digest();
|
|
54
|
+
const hb = createHash("sha256").update(b).digest();
|
|
55
|
+
return timingSafeEqual(ha, hb);
|
|
56
|
+
}
|
|
57
|
+
function generateCode() {
|
|
58
|
+
return String(randomInt(10 ** (PAIR_DIGITS - 1), 10 ** PAIR_DIGITS));
|
|
59
|
+
}
|
|
60
|
+
//# sourceMappingURL=pair.js.map
|
package/dist/proxy.d.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
|
+
import type { Duplex } from "node:stream";
|
|
3
|
+
export interface ProxyTarget {
|
|
4
|
+
host: string;
|
|
5
|
+
port: number;
|
|
6
|
+
}
|
|
7
|
+
export interface ForwardOptions {
|
|
8
|
+
/**
|
|
9
|
+
* 注入到 text/html 响应的 <head> 的脚本。
|
|
10
|
+
* 用途:http://局域网IP 非 secure context 下,浏览器不提供 crypto.randomUUID
|
|
11
|
+
* (DSH 浏览器侧 RPC 依赖它),用非 secure context 也可用的 getRandomValues polyfill。
|
|
12
|
+
*/
|
|
13
|
+
htmlInject?: string;
|
|
14
|
+
}
|
|
15
|
+
/** 转发一个 HTTP 请求(含 SSE:响应流式写回,零缓冲)。 */
|
|
16
|
+
export declare function forwardHttp(req: IncomingMessage, res: ServerResponse, target: ProxyTarget, opts?: ForwardOptions): void;
|
|
17
|
+
/**
|
|
18
|
+
* WebSocket 转发:服务端完成客户端握手后,桥接客户端连接与上游 ws 客户端。
|
|
19
|
+
* - 服务端握手用 ws 的 noServer WebSocketServer(帧编码正确);
|
|
20
|
+
* - handleUpgrade 成功后 ws 库会自动 emit 'connection',这里不再手动 emit;
|
|
21
|
+
* - 客户端消息立即入队,upstream open 后按序发送(避免握手竞态丢消息)。
|
|
22
|
+
*/
|
|
23
|
+
export declare function createUpgradeProxy(target: ProxyTarget): {
|
|
24
|
+
/** 完成客户端 upgrade 握手并桥接(转发前必须已通过认证)。 */
|
|
25
|
+
handleUpgrade(req: IncomingMessage, socket: Duplex, head: Buffer): void;
|
|
26
|
+
};
|
package/dist/proxy.js
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* proxy.ts — 转发内核:HTTP/SSE 流式转发 + WebSocket 双向桥接。
|
|
3
|
+
*
|
|
4
|
+
* 关键事实(discussion.md §2):DSH 的 Host 围栏只信任 loopback/trusted Host,
|
|
5
|
+
* 因此**所有转发请求必须把 Host 重写为 127.0.0.1:<port>**,否则 /api 一律 403。
|
|
6
|
+
*/
|
|
7
|
+
import { request } from "node:http";
|
|
8
|
+
import { WebSocket, WebSocketServer } from "ws";
|
|
9
|
+
/** 转发一个 HTTP 请求(含 SSE:响应流式写回,零缓冲)。 */
|
|
10
|
+
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
|
+
}
|
|
20
|
+
const upstream = request({
|
|
21
|
+
host: target.host,
|
|
22
|
+
port: target.port,
|
|
23
|
+
path: req.url ?? "/",
|
|
24
|
+
method: req.method ?? "GET",
|
|
25
|
+
headers,
|
|
26
|
+
}, (upstreamRes) => {
|
|
27
|
+
const contentType = upstreamRes.headers["content-type"];
|
|
28
|
+
const canInject = opts?.htmlInject !== undefined &&
|
|
29
|
+
typeof contentType === "string" &&
|
|
30
|
+
contentType.includes("text/html") &&
|
|
31
|
+
upstreamRes.headers["content-encoding"] === undefined;
|
|
32
|
+
if (canInject) {
|
|
33
|
+
// 页面 HTML 很小(KB 级),缓冲注入后转发;其余流量仍走流式
|
|
34
|
+
void (async () => {
|
|
35
|
+
const chunks = [];
|
|
36
|
+
for await (const chunk of upstreamRes)
|
|
37
|
+
chunks.push(chunk);
|
|
38
|
+
let html = Buffer.concat(chunks).toString("utf8");
|
|
39
|
+
const script = `<script>${opts.htmlInject}</script>`;
|
|
40
|
+
if (html.includes("</head>"))
|
|
41
|
+
html = html.replace("</head>", `${script}</head>`);
|
|
42
|
+
else
|
|
43
|
+
html = `${script}${html}`;
|
|
44
|
+
const outHeaders = { ...upstreamRes.headers };
|
|
45
|
+
outHeaders["content-length"] = String(Buffer.byteLength(html));
|
|
46
|
+
// 拷贝上游头时剔除 Node 自行管理的编码/连接头,避免与 content-length 冲突
|
|
47
|
+
delete outHeaders["transfer-encoding"];
|
|
48
|
+
delete outHeaders["content-encoding"];
|
|
49
|
+
delete outHeaders.connection;
|
|
50
|
+
res.writeHead(upstreamRes.statusCode ?? 200, outHeaders);
|
|
51
|
+
res.end(html);
|
|
52
|
+
})().catch(() => {
|
|
53
|
+
if (!res.headersSent)
|
|
54
|
+
res.writeHead(502, { "content-type": "text/plain" });
|
|
55
|
+
res.end("bad gateway");
|
|
56
|
+
});
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
res.writeHead(upstreamRes.statusCode ?? 502, upstreamRes.headers);
|
|
60
|
+
upstreamRes.pipe(res);
|
|
61
|
+
});
|
|
62
|
+
upstream.on("error", () => {
|
|
63
|
+
if (!res.headersSent)
|
|
64
|
+
res.writeHead(502, { "content-type": "text/plain" });
|
|
65
|
+
res.end("bad gateway");
|
|
66
|
+
});
|
|
67
|
+
// 客户端断开 → 取消上游请求
|
|
68
|
+
res.on("close", () => upstream.destroy());
|
|
69
|
+
req.pipe(upstream);
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* WebSocket 转发:服务端完成客户端握手后,桥接客户端连接与上游 ws 客户端。
|
|
73
|
+
* - 服务端握手用 ws 的 noServer WebSocketServer(帧编码正确);
|
|
74
|
+
* - handleUpgrade 成功后 ws 库会自动 emit 'connection',这里不再手动 emit;
|
|
75
|
+
* - 客户端消息立即入队,upstream open 后按序发送(避免握手竞态丢消息)。
|
|
76
|
+
*/
|
|
77
|
+
export function createUpgradeProxy(target) {
|
|
78
|
+
const wss = new WebSocketServer({ noServer: true });
|
|
79
|
+
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 upstreamUrl = `ws://${target.host}:${target.port}${req.url ?? "/"}`;
|
|
87
|
+
const upstream = new WebSocket(upstreamUrl, { headers });
|
|
88
|
+
const queue = [];
|
|
89
|
+
clientWs.on("message", (data, isBinary) => {
|
|
90
|
+
const buf = Array.isArray(data)
|
|
91
|
+
? Buffer.concat(data)
|
|
92
|
+
: Buffer.isBuffer(data)
|
|
93
|
+
? data
|
|
94
|
+
: Buffer.from(data);
|
|
95
|
+
if (upstream.readyState === WebSocket.OPEN) {
|
|
96
|
+
upstream.send(buf, { binary: isBinary });
|
|
97
|
+
}
|
|
98
|
+
else {
|
|
99
|
+
queue.push({ data: buf, binary: isBinary });
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
upstream.on("open", () => {
|
|
103
|
+
for (const m of queue)
|
|
104
|
+
upstream.send(m.data, { binary: m.binary });
|
|
105
|
+
queue.length = 0;
|
|
106
|
+
// 上游 → 客户端
|
|
107
|
+
upstream.on("message", (data, isBinary) => {
|
|
108
|
+
if (clientWs.readyState === WebSocket.OPEN)
|
|
109
|
+
clientWs.send(data, { binary: isBinary });
|
|
110
|
+
});
|
|
111
|
+
// 任一端断开 → 另一端强制销毁(优雅 close 会等对端回帧,可能悬挂)
|
|
112
|
+
const close = () => {
|
|
113
|
+
try {
|
|
114
|
+
clientWs.terminate();
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
/* 已关闭 */
|
|
118
|
+
}
|
|
119
|
+
try {
|
|
120
|
+
upstream.terminate();
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
/* 已关闭 */
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
clientWs.on("close", close);
|
|
127
|
+
upstream.on("close", close);
|
|
128
|
+
clientWs.on("error", close);
|
|
129
|
+
upstream.on("error", close);
|
|
130
|
+
});
|
|
131
|
+
upstream.on("error", () => {
|
|
132
|
+
try {
|
|
133
|
+
clientWs.terminate();
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
/* 已关闭 */
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
return {
|
|
141
|
+
/** 完成客户端 upgrade 握手并桥接(转发前必须已通过认证)。 */
|
|
142
|
+
handleUpgrade(req, socket, head) {
|
|
143
|
+
wss.handleUpgrade(req, socket, head, (ws) => {
|
|
144
|
+
// handleUpgrade 不会自动 emit 'connection',需手动触发桥接逻辑
|
|
145
|
+
wss.emit("connection", ws, req);
|
|
146
|
+
});
|
|
147
|
+
},
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
//# sourceMappingURL=proxy.js.map
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* secure-context-polyfill.ts — 非 secure context 兼容脚本(注入 DSH 首页)。
|
|
3
|
+
*
|
|
4
|
+
* 背景(查档):DSH 浏览器侧 RPC(dsh-client-connection/lib/client.js)用
|
|
5
|
+
* crypto.randomUUID() 生成 MessageId/RpcId;但该 API 仅在 secure context
|
|
6
|
+
* (HTTPS / localhost)可用。局域网模式为 http://<LAN-IP>(非 secure
|
|
7
|
+
* context),故需 polyfill:crypto.getRandomValues 是唯一非 secure context
|
|
8
|
+
* 也可用的 WebCrypto API,用它实现 UUID v4。
|
|
9
|
+
*/
|
|
10
|
+
export declare const SECURE_CONTEXT_POLYFILL = "(function () {\n if (typeof crypto === \"undefined\") return;\n if (typeof crypto.randomUUID === \"function\") return;\n try {\n Object.defineProperty(crypto, \"randomUUID\", {\n value: function () {\n var b = crypto.getRandomValues(new Uint8Array(16));\n b[6] = (b[6] & 0x0f) | 0x40;\n b[8] = (b[8] & 0x3f) | 0x80;\n var h = \"\";\n for (var i = 0; i < 16; i++) {\n if (i === 4 || i === 6 || i === 8 || i === 10) h += \"-\";\n h += b[i].toString(16).padStart(2, \"0\");\n }\n return h;\n },\n writable: true\n });\n } catch (e) { /* ignore */ }\n})();";
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* secure-context-polyfill.ts — 非 secure context 兼容脚本(注入 DSH 首页)。
|
|
3
|
+
*
|
|
4
|
+
* 背景(查档):DSH 浏览器侧 RPC(dsh-client-connection/lib/client.js)用
|
|
5
|
+
* crypto.randomUUID() 生成 MessageId/RpcId;但该 API 仅在 secure context
|
|
6
|
+
* (HTTPS / localhost)可用。局域网模式为 http://<LAN-IP>(非 secure
|
|
7
|
+
* context),故需 polyfill:crypto.getRandomValues 是唯一非 secure context
|
|
8
|
+
* 也可用的 WebCrypto API,用它实现 UUID v4。
|
|
9
|
+
*/
|
|
10
|
+
export const SECURE_CONTEXT_POLYFILL = `(function () {
|
|
11
|
+
if (typeof crypto === "undefined") return;
|
|
12
|
+
if (typeof crypto.randomUUID === "function") return;
|
|
13
|
+
try {
|
|
14
|
+
Object.defineProperty(crypto, "randomUUID", {
|
|
15
|
+
value: function () {
|
|
16
|
+
var b = crypto.getRandomValues(new Uint8Array(16));
|
|
17
|
+
b[6] = (b[6] & 0x0f) | 0x40;
|
|
18
|
+
b[8] = (b[8] & 0x3f) | 0x80;
|
|
19
|
+
var h = "";
|
|
20
|
+
for (var i = 0; i < 16; i++) {
|
|
21
|
+
if (i === 4 || i === 6 || i === 8 || i === 10) h += "-";
|
|
22
|
+
h += b[i].toString(16).padStart(2, "0");
|
|
23
|
+
}
|
|
24
|
+
return h;
|
|
25
|
+
},
|
|
26
|
+
writable: true
|
|
27
|
+
});
|
|
28
|
+
} catch (e) { /* ignore */ }
|
|
29
|
+
})();`;
|
|
30
|
+
//# sourceMappingURL=secure-context-polyfill.js.map
|
package/dist/serve.d.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export interface ServeOptions {
|
|
2
|
+
host: string;
|
|
3
|
+
port: number;
|
|
4
|
+
pairCode?: string;
|
|
5
|
+
sessionTtlSeconds: number;
|
|
6
|
+
dshPath?: string;
|
|
7
|
+
reset?: boolean;
|
|
8
|
+
/** true = 跳过配对码认证(仅限完全可信网络!打印警告) */
|
|
9
|
+
noCode?: boolean;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* 启动 LAN 认证网关。进程常驻:SIGINT/SIGTERM 时先关网关再终止 dsh 子进程。
|
|
13
|
+
* 启动失败(dsh 缺失/启动失败/端口占用)→ 抛错(CLI 负责以非零码退出)。
|
|
14
|
+
*/
|
|
15
|
+
export declare function serve(opts: ServeOptions): Promise<void>;
|
package/dist/serve.js
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* serve.ts — `rdsh serve` 编排:spawn dsh → 启动网关 → 打印启动信息 → 生命周期。
|
|
3
|
+
*/
|
|
4
|
+
import { networkInterfaces } from "node:os";
|
|
5
|
+
import { startGateway } from "./server.js";
|
|
6
|
+
import { findDsh, spawnDsh } from "./spawn-dsh.js";
|
|
7
|
+
/**
|
|
8
|
+
* 启动 LAN 认证网关。进程常驻:SIGINT/SIGTERM 时先关网关再终止 dsh 子进程。
|
|
9
|
+
* 启动失败(dsh 缺失/启动失败/端口占用)→ 抛错(CLI 负责以非零码退出)。
|
|
10
|
+
*/
|
|
11
|
+
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
|
+
}
|
|
16
|
+
const dsh = await spawnDsh(dshPath);
|
|
17
|
+
if (opts.noCode) {
|
|
18
|
+
console.warn("\n⚠ rdsh: --no-code 已启用 —— 跳过配对码认证!\n" +
|
|
19
|
+
" 任何能访问该端口的设备都将直接操作 DSH(可执行任意命令)。\n" +
|
|
20
|
+
" 仅限完全可信的局域网(家庭/公司内网)使用!\n");
|
|
21
|
+
}
|
|
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
|
+
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(", ")}`);
|
|
36
|
+
}
|
|
37
|
+
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.");
|
|
40
|
+
let shuttingDown = false;
|
|
41
|
+
const shutdown = async (signal) => {
|
|
42
|
+
if (shuttingDown)
|
|
43
|
+
return; // 幂等:重复信号不再处理
|
|
44
|
+
shuttingDown = true;
|
|
45
|
+
console.log(`\nrdsh: received ${signal}, shutting down...`);
|
|
46
|
+
// 停止接受新连接;残留活跃连接由进程退出时由 OS 清理
|
|
47
|
+
gateway.server.close();
|
|
48
|
+
let code;
|
|
49
|
+
try {
|
|
50
|
+
code = await dsh.stop();
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
code = 1;
|
|
54
|
+
}
|
|
55
|
+
// 必须显式退出:仅设 exitCode 会因残留句柄(信号监听器/管道)而挂住
|
|
56
|
+
process.exit(code === 0 ? 0 : 1);
|
|
57
|
+
};
|
|
58
|
+
process.on("SIGINT", () => void shutdown("SIGINT"));
|
|
59
|
+
process.on("SIGTERM", () => void shutdown("SIGTERM"));
|
|
60
|
+
// 覆盖默认行为:SIGHUP(关终端/断 SSH)也优雅清理,避免 dsh 子进程成为孤儿
|
|
61
|
+
process.on("SIGHUP", () => void shutdown("SIGHUP"));
|
|
62
|
+
}
|
|
63
|
+
function lanAddresses() {
|
|
64
|
+
return Object.values(networkInterfaces())
|
|
65
|
+
.flat()
|
|
66
|
+
.filter((i) => i !== undefined && i.family === "IPv4" && !i.internal)
|
|
67
|
+
.map((i) => i.address);
|
|
68
|
+
}
|
|
69
|
+
//# sourceMappingURL=serve.js.map
|
package/dist/server.d.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* server.ts — HTTP 服务器 + 认证中间件 + 路由。
|
|
3
|
+
*
|
|
4
|
+
* 路由:
|
|
5
|
+
* GET /pair → 配对页
|
|
6
|
+
* POST /pair → 校验配对码(成功 Set-Cookie + 302 /;失败 401/429)
|
|
7
|
+
* 其余路径(含 upgrade)→ 有有效会话转发 dsh;无 → 307 /pair
|
|
8
|
+
*/
|
|
9
|
+
import { createServer } from "node:http";
|
|
10
|
+
import { SessionManager } from "./session.ts";
|
|
11
|
+
import { PairManager } from "./pair.ts";
|
|
12
|
+
export interface GatewayOptions {
|
|
13
|
+
host: string;
|
|
14
|
+
port: number;
|
|
15
|
+
pairCode?: string;
|
|
16
|
+
sessionTtlSeconds: number;
|
|
17
|
+
dshPort: number;
|
|
18
|
+
/** true = 启动时重置会话密钥(全部会话失效) */
|
|
19
|
+
reset?: boolean;
|
|
20
|
+
/** 会话密钥目录(默认 ~/.rdsh;测试可注入临时目录) */
|
|
21
|
+
keyDir?: string;
|
|
22
|
+
/** true = 跳过配对码认证(仅限完全可信网络!启动会打印警告) */
|
|
23
|
+
noCode?: boolean;
|
|
24
|
+
}
|
|
25
|
+
export interface RunningGateway {
|
|
26
|
+
server: ReturnType<typeof createServer>;
|
|
27
|
+
sessions: SessionManager;
|
|
28
|
+
pair: PairManager;
|
|
29
|
+
/** 实际监听端口(port 0 → OS 分配) */
|
|
30
|
+
actualPort: number;
|
|
31
|
+
}
|
|
32
|
+
export declare function startGateway(opts: GatewayOptions): Promise<RunningGateway>;
|
package/dist/server.js
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* server.ts — HTTP 服务器 + 认证中间件 + 路由。
|
|
3
|
+
*
|
|
4
|
+
* 路由:
|
|
5
|
+
* GET /pair → 配对页
|
|
6
|
+
* POST /pair → 校验配对码(成功 Set-Cookie + 302 /;失败 401/429)
|
|
7
|
+
* 其余路径(含 upgrade)→ 有有效会话转发 dsh;无 → 307 /pair
|
|
8
|
+
*/
|
|
9
|
+
import { createServer } from "node:http";
|
|
10
|
+
import { SessionManager, sessionTokenFromCookie } from "./session.js";
|
|
11
|
+
import { PairManager } from "./pair.js";
|
|
12
|
+
import { createUpgradeProxy, forwardHttp } from "./proxy.js";
|
|
13
|
+
import { pairPageHtml } from "./pair-page.js";
|
|
14
|
+
import { SECURE_CONTEXT_POLYFILL } from "./secure-context-polyfill.js";
|
|
15
|
+
export async function startGateway(opts) {
|
|
16
|
+
const sessions = new SessionManager(opts.keyDir);
|
|
17
|
+
if (opts.reset) {
|
|
18
|
+
await sessions.reset();
|
|
19
|
+
}
|
|
20
|
+
else {
|
|
21
|
+
await sessions.init();
|
|
22
|
+
}
|
|
23
|
+
const pair = new PairManager(opts.pairCode);
|
|
24
|
+
const target = { host: "127.0.0.1", port: opts.dshPort };
|
|
25
|
+
const upgradeProxy = createUpgradeProxy(target);
|
|
26
|
+
const ctx = {
|
|
27
|
+
sessions,
|
|
28
|
+
pair,
|
|
29
|
+
target,
|
|
30
|
+
sessionTtlSeconds: opts.sessionTtlSeconds,
|
|
31
|
+
noCode: opts.noCode === true,
|
|
32
|
+
};
|
|
33
|
+
const server = createServer((req, res) => {
|
|
34
|
+
void handleHttp(req, res, ctx).catch(() => {
|
|
35
|
+
if (!res.headersSent)
|
|
36
|
+
res.writeHead(500);
|
|
37
|
+
res.end();
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
server.on("upgrade", (req, socket, head) => {
|
|
41
|
+
if (!ctx.noCode && !hasValidSession(req, sessions)) {
|
|
42
|
+
socket.write("HTTP/1.1 307 Temporary Redirect\r\nLocation: /pair\r\n\r\n");
|
|
43
|
+
socket.destroy();
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
upgradeProxy.handleUpgrade(req, socket, head);
|
|
47
|
+
});
|
|
48
|
+
await new Promise((resolve, reject) => {
|
|
49
|
+
server.once("error", reject);
|
|
50
|
+
server.listen(opts.port, opts.host, () => resolve());
|
|
51
|
+
});
|
|
52
|
+
const address = server.address();
|
|
53
|
+
const actualPort = typeof address === "object" && address !== null ? address.port : opts.port;
|
|
54
|
+
return { server, sessions, pair, actualPort };
|
|
55
|
+
}
|
|
56
|
+
function hasValidSession(req, sessions) {
|
|
57
|
+
const token = sessionTokenFromCookie(req.headers.cookie);
|
|
58
|
+
return token !== null && sessions.verify(token) !== null;
|
|
59
|
+
}
|
|
60
|
+
async function handleHttp(req, res, ctx) {
|
|
61
|
+
const pathname = new URL(req.url ?? "/", "http://rdsh.local").pathname;
|
|
62
|
+
if (req.method === "GET" && pathname === "/pair") {
|
|
63
|
+
res.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
|
|
64
|
+
res.end(pairPageHtml());
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
if (req.method === "POST" && pathname === "/pair") {
|
|
68
|
+
await handlePairPost(req, res, ctx);
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
if (!ctx.noCode && !hasValidSession(req, ctx.sessions)) {
|
|
72
|
+
res.writeHead(307, { location: "/pair" });
|
|
73
|
+
res.end();
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
forwardHttp(req, res, ctx.target, { htmlInject: SECURE_CONTEXT_POLYFILL });
|
|
77
|
+
}
|
|
78
|
+
async function handlePairPost(req, res, ctx) {
|
|
79
|
+
const ip = req.socket.remoteAddress ?? "unknown";
|
|
80
|
+
let body = "";
|
|
81
|
+
for await (const chunk of req) {
|
|
82
|
+
body += chunk;
|
|
83
|
+
if (body.length > 1024) {
|
|
84
|
+
res.writeHead(413, { "content-type": "application/json" });
|
|
85
|
+
res.end(JSON.stringify({ error: { code: "TOO_LARGE", message: "body too large" } }));
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
let code;
|
|
90
|
+
try {
|
|
91
|
+
code = JSON.parse(body).code;
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
res.writeHead(400, { "content-type": "application/json" });
|
|
95
|
+
res.end(JSON.stringify({ error: { code: "BAD_REQUEST", message: "invalid body" } }));
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
if (typeof code !== "string" || code.length > 16) {
|
|
99
|
+
res.writeHead(400, { "content-type": "application/json" });
|
|
100
|
+
res.end(JSON.stringify({ error: { code: "BAD_REQUEST", message: "invalid code" } }));
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
const result = ctx.pair.check(code, ip);
|
|
104
|
+
if (result.ok) {
|
|
105
|
+
res.writeHead(302, {
|
|
106
|
+
location: "/",
|
|
107
|
+
"set-cookie": ctx.sessions.cookieHeader(ctx.sessionTtlSeconds),
|
|
108
|
+
});
|
|
109
|
+
res.end();
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
if (result.locked) {
|
|
113
|
+
const retryAfter = Math.max(1, Math.ceil(result.retryAfterMs / 1000));
|
|
114
|
+
res.writeHead(429, {
|
|
115
|
+
"content-type": "application/json",
|
|
116
|
+
"retry-after": String(retryAfter),
|
|
117
|
+
});
|
|
118
|
+
res.end(JSON.stringify({ error: { code: "RATE_LIMITED", message: "too many attempts" } }));
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
res.writeHead(401, { "content-type": "application/json" });
|
|
122
|
+
res.end(JSON.stringify({ error: { code: "BAD_CODE", message: "invalid pair code" } }));
|
|
123
|
+
}
|
|
124
|
+
//# sourceMappingURL=server.js.map
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export declare const SESSION_COOKIE = "rdsh_session";
|
|
2
|
+
export interface SessionPayload {
|
|
3
|
+
sid: string;
|
|
4
|
+
/** 过期时刻(epoch 秒) */
|
|
5
|
+
exp: number;
|
|
6
|
+
}
|
|
7
|
+
export declare class SessionManager {
|
|
8
|
+
private key;
|
|
9
|
+
private readonly dir;
|
|
10
|
+
/** @param dir 密钥目录(默认 ~/.rdsh;测试可注入临时目录) */
|
|
11
|
+
constructor(dir?: string);
|
|
12
|
+
private get keyFile();
|
|
13
|
+
/** 加载或创建密钥;必须在 sign/verify 前调用。 */
|
|
14
|
+
init(): Promise<void>;
|
|
15
|
+
/** 删除密钥并重建 —— 全部已签发会话立即失效。 */
|
|
16
|
+
reset(): Promise<void>;
|
|
17
|
+
/** 签发一个有效期 ttlSeconds 的会话 token。 */
|
|
18
|
+
sign(ttlSeconds: number): string;
|
|
19
|
+
/** 校验 token;无效/过期/被篡改 → null。 */
|
|
20
|
+
verify(token: string): SessionPayload | null;
|
|
21
|
+
/** 生成 Set-Cookie 头(HttpOnly + SameSite=Lax)。 */
|
|
22
|
+
cookieHeader(ttlSeconds: number): string;
|
|
23
|
+
private requireKey;
|
|
24
|
+
}
|
|
25
|
+
/** 从请求 Cookie 头解析出会话 token(无 → null)。 */
|
|
26
|
+
export declare function sessionTokenFromCookie(cookieHeader: string | undefined): string | null;
|
package/dist/session.js
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* session.ts — 会话密钥管理 + 签名 Cookie。
|
|
3
|
+
*
|
|
4
|
+
* 会话为无状态签名 Cookie:HMAC-SHA256(payload, key)。
|
|
5
|
+
* 密钥存 ~/.rdsh/secret.key(0600,首次生成);reset() 删除重建(全部会话失效)。
|
|
6
|
+
*/
|
|
7
|
+
import { createHmac, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
|
|
8
|
+
import { access, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
9
|
+
import { homedir } from "node:os";
|
|
10
|
+
import { dirname, join } from "node:path";
|
|
11
|
+
const RDSH_DIR = join(homedir(), ".rdsh");
|
|
12
|
+
const KEY_BYTES = 32;
|
|
13
|
+
export const SESSION_COOKIE = "rdsh_session";
|
|
14
|
+
/** 兜底上限:防伪造"未来过期时间"的 token 长存。 */
|
|
15
|
+
const MAX_EXP_FUTURE_SECONDS = 365 * 24 * 3600;
|
|
16
|
+
export class SessionManager {
|
|
17
|
+
key = null;
|
|
18
|
+
dir;
|
|
19
|
+
/** @param dir 密钥目录(默认 ~/.rdsh;测试可注入临时目录) */
|
|
20
|
+
constructor(dir) {
|
|
21
|
+
this.dir = dir ?? RDSH_DIR;
|
|
22
|
+
}
|
|
23
|
+
get keyFile() {
|
|
24
|
+
return join(this.dir, "secret.key");
|
|
25
|
+
}
|
|
26
|
+
/** 加载或创建密钥;必须在 sign/verify 前调用。 */
|
|
27
|
+
async init() {
|
|
28
|
+
this.key = await loadOrCreateKey(this.keyFile);
|
|
29
|
+
}
|
|
30
|
+
/** 删除密钥并重建 —— 全部已签发会话立即失效。 */
|
|
31
|
+
async reset() {
|
|
32
|
+
this.key = null;
|
|
33
|
+
await rm(this.keyFile, { force: true });
|
|
34
|
+
this.key = await loadOrCreateKey(this.keyFile);
|
|
35
|
+
}
|
|
36
|
+
/** 签发一个有效期 ttlSeconds 的会话 token。 */
|
|
37
|
+
sign(ttlSeconds) {
|
|
38
|
+
const key = this.requireKey();
|
|
39
|
+
const payload = {
|
|
40
|
+
sid: randomUUID(),
|
|
41
|
+
exp: Math.floor(Date.now() / 1000) + ttlSeconds,
|
|
42
|
+
};
|
|
43
|
+
const body = Buffer.from(JSON.stringify(payload));
|
|
44
|
+
const mac = createHmac("sha256", key).update(body).digest("base64url");
|
|
45
|
+
return `${mac}.${body.toString("base64url")}`;
|
|
46
|
+
}
|
|
47
|
+
/** 校验 token;无效/过期/被篡改 → null。 */
|
|
48
|
+
verify(token) {
|
|
49
|
+
const key = this.requireKey();
|
|
50
|
+
const dot = token.indexOf(".");
|
|
51
|
+
if (dot <= 0)
|
|
52
|
+
return null;
|
|
53
|
+
const mac = token.slice(0, dot);
|
|
54
|
+
const body = token.slice(dot + 1);
|
|
55
|
+
let bodyBytes;
|
|
56
|
+
try {
|
|
57
|
+
bodyBytes = Buffer.from(body, "base64url");
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
const expected = createHmac("sha256", key).update(bodyBytes).digest("base64url");
|
|
63
|
+
if (!safeEqual(mac, expected))
|
|
64
|
+
return null;
|
|
65
|
+
try {
|
|
66
|
+
const payload = JSON.parse(bodyBytes.toString("utf8"));
|
|
67
|
+
if (typeof payload.sid !== "string" || typeof payload.exp !== "number")
|
|
68
|
+
return null;
|
|
69
|
+
const now = Math.floor(Date.now() / 1000);
|
|
70
|
+
if (payload.exp <= now)
|
|
71
|
+
return null;
|
|
72
|
+
if (payload.exp > now + MAX_EXP_FUTURE_SECONDS)
|
|
73
|
+
return null;
|
|
74
|
+
return payload;
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
/** 生成 Set-Cookie 头(HttpOnly + SameSite=Lax)。 */
|
|
81
|
+
cookieHeader(ttlSeconds) {
|
|
82
|
+
const token = this.sign(ttlSeconds);
|
|
83
|
+
return `${SESSION_COOKIE}=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${ttlSeconds}`;
|
|
84
|
+
}
|
|
85
|
+
requireKey() {
|
|
86
|
+
if (this.key === null)
|
|
87
|
+
throw new Error("SessionManager.init() must be called before sign/verify");
|
|
88
|
+
return this.key;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
/** 从请求 Cookie 头解析出会话 token(无 → null)。 */
|
|
92
|
+
export function sessionTokenFromCookie(cookieHeader) {
|
|
93
|
+
if (!cookieHeader)
|
|
94
|
+
return null;
|
|
95
|
+
for (const part of cookieHeader.split(";")) {
|
|
96
|
+
const [name, ...rest] = part.trim().split("=");
|
|
97
|
+
if (name === SESSION_COOKIE)
|
|
98
|
+
return rest.join("=");
|
|
99
|
+
}
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
function safeEqual(a, b) {
|
|
103
|
+
const ba = Buffer.from(a);
|
|
104
|
+
const bb = Buffer.from(b);
|
|
105
|
+
if (ba.length !== bb.length)
|
|
106
|
+
return false;
|
|
107
|
+
return timingSafeEqual(ba, bb);
|
|
108
|
+
}
|
|
109
|
+
async function loadOrCreateKey(keyFile) {
|
|
110
|
+
try {
|
|
111
|
+
await access(keyFile);
|
|
112
|
+
return await readFile(keyFile);
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
await mkdir(dirname(keyFile), { recursive: true, mode: 0o700 });
|
|
116
|
+
const key = randomBytes(KEY_BYTES);
|
|
117
|
+
await writeFile(keyFile, key, { mode: 0o600 });
|
|
118
|
+
return key;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
//# sourceMappingURL=session.js.map
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { ChildProcess } from "node:child_process";
|
|
2
|
+
export interface SpawnedDsh {
|
|
3
|
+
/** dsh 实际监听端口(OS 分配) */
|
|
4
|
+
port: number;
|
|
5
|
+
child: ChildProcess;
|
|
6
|
+
/** 终止 dsh(SIGTERM,超时 SIGKILL),返回退出码 */
|
|
7
|
+
stop(): Promise<number>;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* 在 PATH 中查找 dsh 可执行文件;`override` 直接使用。
|
|
11
|
+
* 找不到返回 null。
|
|
12
|
+
*/
|
|
13
|
+
export declare function findDsh(override?: string): string | null;
|
|
14
|
+
/**
|
|
15
|
+
* spawn `dsh web --port 0 --no-open` 并等待其报告监听端口。
|
|
16
|
+
* 就绪后把 dsh 的 stdout/stderr 透传到本进程。
|
|
17
|
+
* dsh 启动失败/超时 → reject。
|
|
18
|
+
*/
|
|
19
|
+
export declare function spawnDsh(dshPath: string): Promise<SpawnedDsh>;
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* spawn-dsh.ts — 发现并启动 dsh web,解析其实际监听端口。
|
|
3
|
+
*
|
|
4
|
+
* 事实依据(discussion.md §2):`dsh web --port 0 --no-open` 由 OS 分配端口,
|
|
5
|
+
* 启动时打印 `dsh web: http://127.0.0.1:<port>`(dsh-web-app/lib/index.js)。
|
|
6
|
+
*/
|
|
7
|
+
import { spawn } from "node:child_process";
|
|
8
|
+
import { accessSync } from "node:fs";
|
|
9
|
+
import { createInterface } from "node:readline";
|
|
10
|
+
/** dsh 的 URL 行格式(`dsh web: http://127.0.0.1:<port> (LAN: ...)`)。 */
|
|
11
|
+
const URL_LINE_RE = /dsh web:\s*http:\/\/127\.0\.0\.1:(\d+)/;
|
|
12
|
+
const READY_TIMEOUT_MS = 30_000;
|
|
13
|
+
const STOP_TIMEOUT_MS = 5_000;
|
|
14
|
+
/**
|
|
15
|
+
* 在 PATH 中查找 dsh 可执行文件;`override` 直接使用。
|
|
16
|
+
* 找不到返回 null。
|
|
17
|
+
*/
|
|
18
|
+
export function findDsh(override) {
|
|
19
|
+
if (override)
|
|
20
|
+
return override;
|
|
21
|
+
const path = process.env.PATH ?? "";
|
|
22
|
+
for (const dir of path.split(":")) {
|
|
23
|
+
if (!dir)
|
|
24
|
+
continue;
|
|
25
|
+
for (const name of ["dsh", "dsh.cmd", "dsh.exe"]) {
|
|
26
|
+
const candidate = `${dir}/${name}`;
|
|
27
|
+
try {
|
|
28
|
+
accessSync(candidate);
|
|
29
|
+
return candidate;
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
/* 继续找下一个 */
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* spawn `dsh web --port 0 --no-open` 并等待其报告监听端口。
|
|
40
|
+
* 就绪后把 dsh 的 stdout/stderr 透传到本进程。
|
|
41
|
+
* dsh 启动失败/超时 → reject。
|
|
42
|
+
*/
|
|
43
|
+
export function spawnDsh(dshPath) {
|
|
44
|
+
return new Promise((resolve, reject) => {
|
|
45
|
+
const child = spawn(dshPath, ["web", "--port", "0", "--no-open"], {
|
|
46
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
47
|
+
});
|
|
48
|
+
let stderrBuf = "";
|
|
49
|
+
const timeout = setTimeout(() => {
|
|
50
|
+
child.kill("SIGKILL");
|
|
51
|
+
reject(new Error(`dsh did not report a listening port within ${READY_TIMEOUT_MS}ms: ${stderrBuf.slice(0, 200)}`));
|
|
52
|
+
}, READY_TIMEOUT_MS);
|
|
53
|
+
const rl = createInterface({ input: child.stdout ?? undefined });
|
|
54
|
+
rl.on("line", (line) => {
|
|
55
|
+
const m = URL_LINE_RE.exec(line);
|
|
56
|
+
if (m) {
|
|
57
|
+
clearTimeout(timeout);
|
|
58
|
+
// 就绪:后续 dsh 输出全部透传
|
|
59
|
+
child.stdout?.pipe(process.stdout);
|
|
60
|
+
child.stderr?.pipe(process.stderr);
|
|
61
|
+
resolve({ port: Number(m[1]), child, stop: () => stopDsh(child) });
|
|
62
|
+
}
|
|
63
|
+
else {
|
|
64
|
+
process.stdout.write(`${line}\n`);
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
child.stderr?.on("data", (chunk) => {
|
|
68
|
+
stderrBuf += chunk.toString("utf8");
|
|
69
|
+
});
|
|
70
|
+
child.on("error", (err) => {
|
|
71
|
+
clearTimeout(timeout);
|
|
72
|
+
reject(new Error(`failed to launch dsh: ${err.message}`));
|
|
73
|
+
});
|
|
74
|
+
child.on("exit", (code) => {
|
|
75
|
+
clearTimeout(timeout);
|
|
76
|
+
reject(new Error(`dsh exited before reporting a port (code ${code ?? "?"})`));
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
function stopDsh(child) {
|
|
81
|
+
return new Promise((resolve) => {
|
|
82
|
+
if (child.exitCode !== null) {
|
|
83
|
+
resolve(child.exitCode);
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
child.once("exit", (code) => resolve(code ?? 0));
|
|
87
|
+
child.kill("SIGTERM");
|
|
88
|
+
setTimeout(() => child.kill("SIGKILL"), STOP_TIMEOUT_MS).unref();
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
//# sourceMappingURL=spawn-dsh.js.map
|
package/package.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "rdsh-gateway",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "rdsh host-side component: LAN auth gateway + outbound tunnel endpoint (spawns dsh web)",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "dist/index.js",
|
|
8
|
+
"types": "dist/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
|
+
"default": "./dist/index.js"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"dist",
|
|
17
|
+
"!dist/**/*.map"
|
|
18
|
+
],
|
|
19
|
+
"engines": {
|
|
20
|
+
"node": ">=22"
|
|
21
|
+
},
|
|
22
|
+
"dependencies": {
|
|
23
|
+
"ws": "^8.18.0"
|
|
24
|
+
},
|
|
25
|
+
"devDependencies": {
|
|
26
|
+
"@types/ws": "^8.5.0"
|
|
27
|
+
},
|
|
28
|
+
"scripts": {
|
|
29
|
+
"build": "tsc -p tsconfig.json",
|
|
30
|
+
"test": "node --test test/"
|
|
31
|
+
}
|
|
32
|
+
}
|