rdsh-gateway 0.9.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/config.d.ts +3 -2
- package/dist/config.js +31 -10
- package/dist/direct-secret.d.ts +3 -0
- package/dist/direct-secret.js +35 -0
- package/dist/direct-ticket.d.ts +5 -0
- package/dist/direct-ticket.js +42 -0
- package/dist/direct.d.ts +47 -0
- package/dist/direct.js +79 -0
- package/dist/gate.d.ts +29 -0
- package/dist/gate.js +117 -0
- package/dist/index.d.ts +6 -2
- package/dist/index.js +4 -2
- package/dist/join.d.ts +26 -0
- package/dist/join.js +131 -5
- package/dist/rdsh-webview-api.d.ts +17 -0
- package/dist/rdsh-webview-api.js +167 -0
- package/dist/secure-context-polyfill.d.ts +9 -5
- package/dist/secure-context-polyfill.js +34 -5
- package/dist/serve.d.ts +0 -1
- package/dist/serve.js +8 -8
- package/dist/server.d.ts +18 -18
- package/dist/server.js +105 -52
- package/dist/spawn-dsh.d.ts +4 -1
- package/dist/spawn-dsh.js +4 -1
- package/package.json +1 -1
package/dist/join.js
CHANGED
|
@@ -22,6 +22,23 @@ import { acquireJoinLock, releaseJoinLock } from "./lock.js";
|
|
|
22
22
|
import { responderHandshake, Aead } from "./e2ee.js";
|
|
23
23
|
import { loadOrCreateE2eeKeyPair } from "./e2ee-key-store.js";
|
|
24
24
|
import { GATE_COOKIE, signGateCookie, verifyGateCookie, verifyGateCode } from "./access-gate.js";
|
|
25
|
+
import { startDirect, directBootstrapScript } from "./direct.js";
|
|
26
|
+
import { createDirectTicketManager } from "./direct-ticket.js";
|
|
27
|
+
import { loadOrCreateDirectSecret } from "./direct-secret.js";
|
|
28
|
+
import { RDSH_WEBVIEW_API, injectHtmlScript } from "./rdsh-webview-api.js";
|
|
29
|
+
/**
|
|
30
|
+
* 页面授权脚本(plain 与 raw 两条 inner dispatcher 共用,禁止各写一份):
|
|
31
|
+
* 直连 bootstrap + (设了口令时)raw 门禁解锁 token。
|
|
32
|
+
*
|
|
33
|
+
* 为什么 raw 通道也**必须**注入:raw(E2EE)流按设计不向 host 转发任何 cookie,
|
|
34
|
+
* 页面只能靠这段脚本带 token 请求 `/__rdsh/authorize` 才能把 rawAuthorized 置真;
|
|
35
|
+
* 漏注入 ⇒ raw 门禁(accessCode 非空时为 fail-closed)永远无法满足 ⇒ 经 E2EE 的
|
|
36
|
+
* 客户端每个请求都被 CLOSE 403、表现为"一直 connecting"。
|
|
37
|
+
* (24-direct-first 首版只在 plain 注入,E2EE 客户端因此被锁死;本函数即该回归的单一来源。)
|
|
38
|
+
*/
|
|
39
|
+
export function pageAuthorizeScript(accessCode) {
|
|
40
|
+
return directBootstrapScript(accessCode === null ? null : signGateCookie(accessCode).value);
|
|
41
|
+
}
|
|
25
42
|
/** 判断错误是否为 TLS 证书类错误(自签/过期/域名不匹配)。 */
|
|
26
43
|
function isCertError(err) {
|
|
27
44
|
const code = err?.code ?? "";
|
|
@@ -157,6 +174,12 @@ export function isJsContentType(headers) {
|
|
|
157
174
|
const s = Array.isArray(ct) ? ct.join(";") : (ct ?? "");
|
|
158
175
|
return /javascript/i.test(s);
|
|
159
176
|
}
|
|
177
|
+
/** HTML 响应判定(content-type 含 text/html)。 */
|
|
178
|
+
export function isHtmlContentType(headers) {
|
|
179
|
+
const ct = headers["content-type"];
|
|
180
|
+
const s = Array.isArray(ct) ? ct.join(";") : (ct ?? "");
|
|
181
|
+
return /text\/html/i.test(s);
|
|
182
|
+
}
|
|
160
183
|
/**
|
|
161
184
|
* 最小 patch:把 DSH 客户端 bundle 里的前端 isLoopback 判定替换为 true
|
|
162
185
|
* (持久设置/API key 输入只对 loopback 开放;E2EE 流上信任基础等同 loopback)。
|
|
@@ -250,6 +273,10 @@ export function startJoin(opts) {
|
|
|
250
273
|
const uiCompat = { trustE2EEAsLoopback: opts.dshUiCompat?.trustE2EEAsLoopback !== false };
|
|
251
274
|
// 访问口令(feature 15):可变引用 → setAccessCode 运行中切换;null = gate off
|
|
252
275
|
const gate = { accessCode: opts.gateway?.accessCode ?? null };
|
|
276
|
+
// raw 门禁 + 候选(24-direct-first):提供 direct 回调即启用候选端点;raw 门禁(authorize)仅设口令时生效
|
|
277
|
+
const rawGate = opts.direct !== undefined
|
|
278
|
+
? { candidates: opts.direct.candidates, mintTicket: opts.direct.mintTicket }
|
|
279
|
+
: undefined;
|
|
253
280
|
const hostName = opts.name ?? "本主机";
|
|
254
281
|
const gateFailures = { count: 0, lockedUntil: 0 };
|
|
255
282
|
const log = (level, message) => {
|
|
@@ -309,6 +336,10 @@ export function startJoin(opts) {
|
|
|
309
336
|
* 键含 query 且不截断:combo 路由用 query 选内容,资源 URL 由内容决定(rev/hash),内容一变 URL 就变。
|
|
310
337
|
*/
|
|
311
338
|
const patchSkipped = new Set();
|
|
339
|
+
/** rdsh WebView API 是否已注入过(每个进程只打一次日志,避免页面加载刷屏)。 */
|
|
340
|
+
let adapterInjectionLogged = false;
|
|
341
|
+
/** raw 门禁:当前 raw 流是否已被页面授权(动态判定:见 handleOpen 的条件,含运行中 gate 开关) */
|
|
342
|
+
let rawAuthorized = false;
|
|
312
343
|
/** 从转发头里取 rdsh_gate cookie(hub D12 白名单透传)。 */
|
|
313
344
|
function gateCookie(headers) {
|
|
314
345
|
const ck = headers["cookie"];
|
|
@@ -335,11 +366,11 @@ export function startJoin(opts) {
|
|
|
335
366
|
const html = gateChallengeHtml(hostName, path, error, acceptLanguage);
|
|
336
367
|
sendSyntheticHttp(send, streamId, 200, { "content-type": "text/html; charset=utf-8" }, Buffer.from(html));
|
|
337
368
|
}
|
|
338
|
-
/** 校验 code 提交(POST gate_code)→
|
|
369
|
+
/** 校验 code 提交(POST gate_code)→ 303 回跳 + 发 cookie,或回 challenge 错误。 */
|
|
339
370
|
function handleGateSubmit(streamId, state) {
|
|
340
371
|
const code = gate.accessCode;
|
|
341
372
|
if (code === null) {
|
|
342
|
-
sendSyntheticHttp(send, streamId,
|
|
373
|
+
sendSyntheticHttp(send, streamId, 303, { location: state.path }, Buffer.alloc(0));
|
|
343
374
|
return;
|
|
344
375
|
}
|
|
345
376
|
if (gateBlocked()) {
|
|
@@ -358,7 +389,7 @@ export function startJoin(opts) {
|
|
|
358
389
|
if (input !== null && verifyGateCode(input, code)) {
|
|
359
390
|
gateFailures.count = 0;
|
|
360
391
|
const { value } = signGateCookie(code);
|
|
361
|
-
sendSyntheticHttp(send, streamId,
|
|
392
|
+
sendSyntheticHttp(send, streamId, 303, { location: state.path, "set-cookie": `${GATE_COOKIE}=${value}; HttpOnly; SameSite=Lax; Path=/; Max-Age=${7 * 24 * 3600}` }, Buffer.alloc(0));
|
|
362
393
|
}
|
|
363
394
|
else {
|
|
364
395
|
gateFailures.count += 1;
|
|
@@ -439,6 +470,28 @@ export function startJoin(opts) {
|
|
|
439
470
|
send(encodeFrame(FRAME_TYPE.ERROR, frame.streamId, jsonPayload({ code: "BAD_OPEN", message: "malformed open" })));
|
|
440
471
|
return;
|
|
441
472
|
}
|
|
473
|
+
// ---- raw 门禁 + 候选端点(仅 raw inner dispatcher:dio.rawGate 定义时)----
|
|
474
|
+
if (dio?.rawGate !== undefined) {
|
|
475
|
+
// raw 门禁(仅设口令时):授权前只接受 authorize 请求,否则 fail-closed 关流(修 raw 绕过,R7)
|
|
476
|
+
if (gate.accessCode !== null && !rawAuthorized) {
|
|
477
|
+
if (path.startsWith("/__rdsh/authorize")) {
|
|
478
|
+
const token = new URL(path, "http://rdsh.local").searchParams.get("token") ?? "";
|
|
479
|
+
if (token !== "" && verifyGateCookie(gate.accessCode, token)) {
|
|
480
|
+
rawAuthorized = true;
|
|
481
|
+
sendSyntheticHttp(send, frame.streamId, 204, {}, Buffer.alloc(0));
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
send(encodeFrame(FRAME_TYPE.CLOSE, frame.streamId, jsonPayload({ code: 403, message: "raw stream not authorized" })));
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
// 候选 + 一次性直连票(无论是否设口令都提供;经 E2EE,hub 不可见,R3/R4)
|
|
489
|
+
if (path === "/__rdsh/direct-candidates" || path.startsWith("/__rdsh/direct-candidates?")) {
|
|
490
|
+
const body = Buffer.from(JSON.stringify({ candidates: dio.rawGate.candidates(), ticket: dio.rawGate.mintTicket() }));
|
|
491
|
+
sendSyntheticHttp(send, frame.streamId, 200, { "content-type": "application/json" }, body);
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
494
|
+
}
|
|
442
495
|
// ---- 访问口令 gate(仅 plain dispatcher:dio.gate=true 且已设 accessCode)----
|
|
443
496
|
if (dio?.gate === true && gate.accessCode !== null) {
|
|
444
497
|
const code = gate.accessCode;
|
|
@@ -484,6 +537,34 @@ export function startJoin(opts) {
|
|
|
484
537
|
const baseHeaders = normalizeRespHeaders(upRes.headers);
|
|
485
538
|
// 已确认该 URL 不含目标串(DSH 前端壳资源就属此类)→ 直接流式,省掉白解压与全量扫描
|
|
486
539
|
const wantsPatch = dio?.jsPatch?.() === true && isJsContentType(upRes.headers) && !patchSkipped.has(path);
|
|
540
|
+
const wantsInject = dio?.htmlInject !== undefined && isHtmlContentType(upRes.headers) && upRes.headers["content-encoding"] === undefined;
|
|
541
|
+
// HTML + 注入:缓冲后注入(htmlInject 会改 body 长度,须先处理再发 OPEN 并重算 content-length)
|
|
542
|
+
if (wantsInject) {
|
|
543
|
+
const htmlChunks = [];
|
|
544
|
+
upRes.on("data", (chunk) => htmlChunks.push(chunk));
|
|
545
|
+
upRes.on("end", () => {
|
|
546
|
+
const inject = typeof dio.htmlInject === "function" ? dio.htmlInject() : dio.htmlInject;
|
|
547
|
+
const html = injectHtmlScript(Buffer.concat(htmlChunks).toString("utf8"), inject);
|
|
548
|
+
const outHeaders = {
|
|
549
|
+
...baseHeaders,
|
|
550
|
+
"content-length": String(Buffer.byteLength(html)),
|
|
551
|
+
};
|
|
552
|
+
delete outHeaders["transfer-encoding"];
|
|
553
|
+
if (!adapterInjectionLogged) {
|
|
554
|
+
adapterInjectionLogged = true;
|
|
555
|
+
console.log("[rdsh] rdsh webview api injected (window.__rdshWebViewApi v1)");
|
|
556
|
+
}
|
|
557
|
+
send(encodeFrame(FRAME_TYPE.OPEN, streamId, jsonPayload({ kind: "http", status, reason: upRes.statusMessage, headers: outHeaders })));
|
|
558
|
+
sendChunkedBody(send, streamId, Buffer.from(html));
|
|
559
|
+
send(encodeFrame(FRAME_TYPE.CLOSE, streamId, jsonPayload({ code: 0 })));
|
|
560
|
+
httpStreams.delete(streamId);
|
|
561
|
+
});
|
|
562
|
+
upRes.on("error", () => {
|
|
563
|
+
send(encodeFrame(FRAME_TYPE.CLOSE, streamId, jsonPayload({ code: 502, message: "upstream error" })));
|
|
564
|
+
httpStreams.delete(streamId);
|
|
565
|
+
});
|
|
566
|
+
return;
|
|
567
|
+
}
|
|
487
568
|
// 非 JS / 未开补丁:保持原流式路径(OPEN 立即发,body 边到边发)
|
|
488
569
|
if (!wantsPatch) {
|
|
489
570
|
send(encodeFrame(FRAME_TYPE.OPEN, streamId, jsonPayload({ kind: "http", status, reason: upRes.statusMessage, headers: baseHeaders })));
|
|
@@ -636,7 +717,14 @@ export function startJoin(opts) {
|
|
|
636
717
|
}
|
|
637
718
|
return { handleFrame, cleanup };
|
|
638
719
|
}
|
|
639
|
-
const plainDispatcher = makeInnerDispatcher(sendTunnelFrame, {
|
|
720
|
+
const plainDispatcher = makeInnerDispatcher(sendTunnelFrame, {
|
|
721
|
+
jsPatch: () => uiCompat.trustE2EEAsLoopback,
|
|
722
|
+
gate: true,
|
|
723
|
+
htmlInject: () => {
|
|
724
|
+
// 直连 bootstrap 始终注入(join 模式);页面授权 token 仅设口令时生成(无口令则跳过 raw 授权、只取候选)
|
|
725
|
+
return RDSH_WEBVIEW_API + pageAuthorizeScript(gate.accessCode);
|
|
726
|
+
},
|
|
727
|
+
});
|
|
640
728
|
// host 端 E2EE 静态密钥对(持久化 ~/.rdsh/e2ee-key.json;join 注册时上送指纹)
|
|
641
729
|
let hostE2eeKeypair;
|
|
642
730
|
try {
|
|
@@ -668,7 +756,13 @@ export function startJoin(opts) {
|
|
|
668
756
|
// 最后一道:任何加密/封帧异常只记录,绝不重抛打死 host
|
|
669
757
|
console.error(`[join] e2ee send failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
670
758
|
}
|
|
671
|
-
}, {
|
|
759
|
+
}, {
|
|
760
|
+
jsPatch: () => uiCompat.trustE2EEAsLoopback,
|
|
761
|
+
rawGate,
|
|
762
|
+
// raw 门禁的解锁脚本:与 plain 同一来源(pageAuthorizeScript)。
|
|
763
|
+
// raw 流不转发 cookie,这里是页面唯一的授权入口;缺了它 raw 门禁必然 fail-closed。
|
|
764
|
+
htmlInject: () => pageAuthorizeScript(gate.accessCode),
|
|
765
|
+
});
|
|
672
766
|
rawStreams.set(streamId, {
|
|
673
767
|
handshakeBuf: Buffer.alloc(0),
|
|
674
768
|
keys: null,
|
|
@@ -934,6 +1028,12 @@ export async function join(opts) {
|
|
|
934
1028
|
const { token, insecure, name } = await registerJoin(opts);
|
|
935
1029
|
console.log(`rdsh join: dsh web on 127.0.0.1:${dsh.port}`);
|
|
936
1030
|
console.log(`rdsh join: connecting to ${opts.hubUrl}...`);
|
|
1031
|
+
const accessCode = opts.gateway?.accessCode ?? null;
|
|
1032
|
+
// 直连密钥(per-host 随机,自动生成)+ 一次性直连票管理器(R4):密钥独立于口令(方案 B)
|
|
1033
|
+
const directSecret = loadOrCreateDirectSecret();
|
|
1034
|
+
const ticketManager = createDirectTicketManager(directSecret);
|
|
1035
|
+
// 直连口句柄引用(candidates 回调在 startJoin 内被调用;直连口稍后才启动,故用可变引用)
|
|
1036
|
+
let directHandle;
|
|
937
1037
|
const handle = startJoin({
|
|
938
1038
|
hubUrl: opts.hubUrl,
|
|
939
1039
|
token,
|
|
@@ -942,6 +1042,9 @@ export async function join(opts) {
|
|
|
942
1042
|
role: "cli",
|
|
943
1043
|
dshUiCompat: opts.dshUiCompat,
|
|
944
1044
|
gateway: opts.gateway,
|
|
1045
|
+
direct: opts.direct !== undefined
|
|
1046
|
+
? { candidates: () => directHandle?.candidates() ?? [], mintTicket: () => ticketManager.mint() }
|
|
1047
|
+
: undefined,
|
|
945
1048
|
dshAuthCookieHeader,
|
|
946
1049
|
name,
|
|
947
1050
|
hooks: {
|
|
@@ -955,6 +1058,27 @@ export async function join(opts) {
|
|
|
955
1058
|
},
|
|
956
1059
|
},
|
|
957
1060
|
});
|
|
1061
|
+
// 直连口(24-direct-first,方案 B):join 模式**始终监听**,门禁 = ticket(可选口令为增强层)
|
|
1062
|
+
if (opts.direct !== undefined) {
|
|
1063
|
+
try {
|
|
1064
|
+
directHandle = await startDirect({
|
|
1065
|
+
dshPort: dsh.port,
|
|
1066
|
+
dshAuthCookieHeader,
|
|
1067
|
+
secret: directSecret,
|
|
1068
|
+
accessCode,
|
|
1069
|
+
consumeTicket: (t) => ticketManager.consume(t),
|
|
1070
|
+
host: opts.direct.host,
|
|
1071
|
+
port: opts.direct.port,
|
|
1072
|
+
dshUiCompat: { trustPairedAsLoopback: opts.dshUiCompat?.trustE2EEAsLoopback !== false },
|
|
1073
|
+
});
|
|
1074
|
+
const cands = directHandle.candidates();
|
|
1075
|
+
console.log(`rdsh join: direct on :${directHandle.actualPort}(${accessCode === null ? "ticket 门禁" : "ticket + 口令门禁"})—— ${cands.map((c) => `http://${c.host}:${c.port}`).join(", ") || "(无内网网卡)"}`);
|
|
1076
|
+
}
|
|
1077
|
+
catch (err) {
|
|
1078
|
+
// 直连口监听失败(端口占用等)不阻断隧道:隧道照常,仅直连不可用
|
|
1079
|
+
console.error(`rdsh join: direct listener failed to start: ${err instanceof Error ? err.message : String(err)}`);
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
958
1082
|
let shuttingDown = false;
|
|
959
1083
|
const shutdown = async (signal, code = 0) => {
|
|
960
1084
|
if (shuttingDown)
|
|
@@ -962,6 +1086,8 @@ export async function join(opts) {
|
|
|
962
1086
|
shuttingDown = true;
|
|
963
1087
|
if (signal !== "")
|
|
964
1088
|
console.log(`\nrdsh: received ${signal}, shutting down...`);
|
|
1089
|
+
if (directHandle !== undefined)
|
|
1090
|
+
await directHandle.stop().catch(() => undefined);
|
|
965
1091
|
await handle.stop();
|
|
966
1092
|
await dsh.stop();
|
|
967
1093
|
process.exit(code);
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* rdsh-webview-api.ts — 智能体 API 契约适配脚本(注入 DSH 首页,feature 22)。
|
|
3
|
+
*
|
|
4
|
+
* 挂出稳定契约 `window.__rdshWebViewApi`(version 1),供 WebView 客户端(如 garsync App)优先消费,
|
|
5
|
+
* 以把「DSH 版本适配」从 App(发版慢)挪到 gateway(随 npm 包快速发布)。
|
|
6
|
+
*
|
|
7
|
+
* 契约正文与语义以 garsync `doc/feature/108_agent_api_contract/req.md` R7 为准;
|
|
8
|
+
* 本脚本是 garsync `lib/rdsh/rdsh_web_bridge.dart` 的 `_bridgeJs` 的**精简等价物**:
|
|
9
|
+
* - 保留:isDshPage / readReply / fillAndSend 三方法及其 DOM 细节(Lexical 写入管线、
|
|
10
|
+
* 键盘守卫、最后一轮答案提取、UTF-8 安全 base64);
|
|
11
|
+
* - 去掉:`report()` 通道与 105 诊断探针(那是 App 侧独有的 debug 机制)。
|
|
12
|
+
*
|
|
13
|
+
* 安全约定:幂等(已注入即返回)、防御式(任何 DOM 缺失/异常返回安全默认值、绝不抛错)。
|
|
14
|
+
*/
|
|
15
|
+
export declare const RDSH_WEBVIEW_API = "(function () {\n if (window.__rdshWebViewApi) return;\n function findComposer() {\n var el = document.querySelector('[data-lexical-editor=\"true\"]');\n if (el) return { el: el, kind: 'lexical' };\n el = document.querySelector('[contenteditable=\"true\"]');\n if (el) return { el: el, kind: 'contenteditable' };\n el = document.querySelector('textarea');\n if (el) return { el: el, kind: 'textarea' };\n return null;\n }\n function selectAll(el) {\n var tag = el.tagName;\n if (tag === 'TEXTAREA' || tag === 'INPUT') { el.select(); return; }\n var range = document.createRange();\n range.selectNodeContents(el);\n var sel = window.getSelection();\n sel.removeAllRanges();\n sel.addRange(range);\n }\n function insertText(el, text) {\n el.focus();\n try { selectAll(el); } catch (e) {}\n // 1) execCommand \u8D70\u6D4F\u89C8\u5668\u7F16\u8F91\u7BA1\u7EBF\uFF0C\u8BA9 Lexical \u6536\u5230 input \u4E8B\u4EF6\u3002\n try { if (document.execCommand('insertText', false, text)) return true; } catch (e) {}\n // 2) \u663E\u5F0F beforeinput\u3002\n try {\n el.dispatchEvent(new InputEvent('beforeinput', { inputType: 'insertText', data: text, bubbles: true, cancelable: true }));\n return true;\n } catch (e) {}\n // 3) \u515C\u5E95\uFF1A\u76F4\u63A5\u5199 DOM + \u89E6\u53D1 input\u3002\n try {\n var tag = el.tagName;\n if (tag === 'TEXTAREA' || tag === 'INPUT') {\n var proto = tag === 'TEXTAREA' ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;\n Object.getOwnPropertyDescriptor(proto, 'value').set.call(el, text);\n } else {\n el.textContent = text;\n }\n el.dispatchEvent(new Event('input', { bubbles: true }));\n return true;\n } catch (e) {}\n return false;\n }\n function pressEnter(el) {\n var opts = { key: 'Enter', code: 'Enter', keyCode: 13, which: 13, bubbles: true, cancelable: true, composed: true, isComposing: false };\n try {\n el.dispatchEvent(new KeyboardEvent('keydown', opts));\n el.dispatchEvent(new KeyboardEvent('keypress', opts));\n el.dispatchEvent(new KeyboardEvent('keyup', opts));\n } catch (e) {}\n }\n // \u952E\u76D8\u5B88\u536B\uFF1A\u8BED\u97F3\u5199\u5165\u540E\u5173\u8F6F\u952E\u76D8\uFF1B\u7528\u6237\u771F\u5B9E\u89E6\u6478\u9875\u9762\u5373\u89E3\u9664\u3002\n var keyboardGuard = false;\n function dismissKeyboard() {\n try {\n var active = document.activeElement;\n if (active && active !== document.body && typeof active.blur === 'function') active.blur();\n var selection = window.getSelection();\n if (selection && selection.removeAllRanges) selection.removeAllRanges();\n } catch (e) {}\n }\n function onUserTouch() { if (keyboardGuard) keyboardGuard = false; }\n document.addEventListener('focusin', function () { if (!keyboardGuard) return; dismissKeyboard(); }, true);\n document.addEventListener('pointerdown', onUserTouch, true);\n document.addEventListener('touchstart', onUserTouch, true);\n\n // \u5355\u4E2A flow \u8282\u70B9\u7684\u53EF\u89C1\u6587\u672C\uFF1BproseOnly \u989D\u5916\u4E22\u5F03\u6574\u5757\u4EE3\u7801\u4E0E\u8868\u683C\uFF08105 \u53EA\u8BFB\u7B54\u6848\u6B63\u6587\uFF09\u3002\n function answerTextOf(el, proseOnly) {\n var clone = el.cloneNode(true);\n var selector = '[data-variant=\"think\"],[data-variant=\"others\"]';\n if (proseOnly) selector += ',pre,table';\n var drop = clone.querySelectorAll(selector);\n for (var i = 0; i < drop.length; i++) { if (drop[i].parentNode) drop[i].parentNode.removeChild(drop[i]); }\n return (clone.innerText || clone.textContent || '').trim();\n }\n // \u67D0\u4E2A flow kind \u7684\u6700\u5927 turn\uFF08\u53D6\u4E0D\u5230 = -1\uFF09\u3002\n // 111\uFF1AApp \u7528 turn / userTurn \u5224\u65AD\"\u7528\u6237\u662F\u5426\u53D1\u4E86\u65B0\u6D88\u606F\"\uFF0C\u4ECE\u800C\u7ACB\u5373\u505C\u6B62\u6717\u8BFB\u3002\n function maxTurnOf(kind) {\n var nodes = document.querySelectorAll('[data-chat-flow-kind=\"' + kind + '\"]');\n var max = -1;\n for (var i = 0; i < nodes.length; i++) {\n var t = parseInt(nodes[i].getAttribute('data-chat-turn'), 10);\n if (!isNaN(t) && t > max) max = t;\n }\n return max;\n }\n // \u6700\u540E\u4E00\u8F6E\u7684\u7B54\u6848\u90E8\u5206\uFF08\u6392\u9664 reasoning\uFF0C\u53D6\u6700\u5927 turn\uFF0C\u9010 part \u62FC\u63A5\uFF09\u3002\n // \u8FD4\u56DE { text, turn }\uFF1Aturn \u4E0E\u8BE5\u8F6E\u7B54\u6848\u5BF9\u5E94\uFF0C\u4F9B readReply \u56DE\u5E26\u7ED9 App\u3002\n function latestAnswer() {\n var steps = Array.prototype.slice.call(document.querySelectorAll('[data-chat-flow-kind=\"assistant-step\"]'));\n var answers = steps.filter(function (el) { return el.getAttribute('data-chat-group-part') !== 'reasoning'; });\n var maxTurn = -1;\n for (var i = 0; i < answers.length; i++) {\n var turn = parseInt(answers[i].getAttribute('data-chat-turn'), 10);\n if (!isNaN(turn) && turn > maxTurn) maxTurn = turn;\n }\n var parts = answers.filter(function (el) { return parseInt(el.getAttribute('data-chat-turn'), 10) === maxTurn; });\n var texts = [];\n for (var j = 0; j < parts.length; j++) {\n var text = answerTextOf(parts[j], true);\n if (text.length) texts.push(text);\n }\n return { text: texts.join('\\n\\n'), turn: maxTurn };\n }\n\n window.__rdshWebViewApi = {\n version: 1,\n // 104\uFF1A\u5199\u5165 composer \u5E76\u63D0\u4EA4\u3002\u8FD4\u56DE\u88F8\u72B6\u6001 token\u3002\n fillAndSend: function (text) {\n var found = findComposer();\n if (!found) return 'no-composer';\n keyboardGuard = false;\n if (!insertText(found.el, text)) return 'fill-failed';\n keyboardGuard = true;\n dismissKeyboard();\n setTimeout(function () { pressEnter(found.el); dismissKeyboard(); }, 80);\n return 'ok';\n },\n // 105/111\uFF1A\u5F85\u6717\u8BFB\u5185\u5BB9 + \u72B6\u6001\u3002\u8FD4\u56DE UTF-8 \u5B89\u5168\u7684 base64 JSON\n // {dsh, streaming, text, turn, userTurn}\uFF08turn / userTurn \u53D6\u4E0D\u5230\u65F6\u4E3A -1\uFF09\u3002\n readReply: function () {\n var out = { dsh: false, streaming: 0, text: '', turn: -1, userTurn: -1 };\n try {\n out.streaming = document.querySelectorAll('[data-streaming]').length;\n if (document.querySelectorAll('[data-chat-flow-kind]').length > 0) {\n out.dsh = true;\n var answer = latestAnswer();\n out.text = answer.text;\n out.turn = answer.turn;\n out.userTurn = maxTurnOf('user');\n } else {\n out.text = document.body ? document.body.innerText : '';\n }\n } catch (e) {}\n return window.btoa(unescape(encodeURIComponent(JSON.stringify(out))));\n },\n // 107\uFF1A\u662F\u5426 DSH \u4F1A\u8BDD\u9875\uFF08\u8F7B\u91CF\u5224\u5B9A\uFF0C\u53EF\u8F6E\u8BE2\uFF09\u3002\u8FD4\u56DE\u88F8 '1' / '0'\u3002\n isDshPage: function () {\n try {\n return document.querySelectorAll('[data-chat-flow-kind]').length > 0 ? '1' : '0';\n } catch (e) { return '0'; }\n }\n };\n})();";
|
|
16
|
+
/** 把 `<script>${script}</script>` 插到 `</head>` 前(无则前置);纯函数,可单测。 */
|
|
17
|
+
export declare function injectHtmlScript(html: string, script: string): string;
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* rdsh-webview-api.ts — 智能体 API 契约适配脚本(注入 DSH 首页,feature 22)。
|
|
3
|
+
*
|
|
4
|
+
* 挂出稳定契约 `window.__rdshWebViewApi`(version 1),供 WebView 客户端(如 garsync App)优先消费,
|
|
5
|
+
* 以把「DSH 版本适配」从 App(发版慢)挪到 gateway(随 npm 包快速发布)。
|
|
6
|
+
*
|
|
7
|
+
* 契约正文与语义以 garsync `doc/feature/108_agent_api_contract/req.md` R7 为准;
|
|
8
|
+
* 本脚本是 garsync `lib/rdsh/rdsh_web_bridge.dart` 的 `_bridgeJs` 的**精简等价物**:
|
|
9
|
+
* - 保留:isDshPage / readReply / fillAndSend 三方法及其 DOM 细节(Lexical 写入管线、
|
|
10
|
+
* 键盘守卫、最后一轮答案提取、UTF-8 安全 base64);
|
|
11
|
+
* - 去掉:`report()` 通道与 105 诊断探针(那是 App 侧独有的 debug 机制)。
|
|
12
|
+
*
|
|
13
|
+
* 安全约定:幂等(已注入即返回)、防御式(任何 DOM 缺失/异常返回安全默认值、绝不抛错)。
|
|
14
|
+
*/
|
|
15
|
+
export const RDSH_WEBVIEW_API = `(function () {
|
|
16
|
+
if (window.__rdshWebViewApi) return;
|
|
17
|
+
function findComposer() {
|
|
18
|
+
var el = document.querySelector('[data-lexical-editor="true"]');
|
|
19
|
+
if (el) return { el: el, kind: 'lexical' };
|
|
20
|
+
el = document.querySelector('[contenteditable="true"]');
|
|
21
|
+
if (el) return { el: el, kind: 'contenteditable' };
|
|
22
|
+
el = document.querySelector('textarea');
|
|
23
|
+
if (el) return { el: el, kind: 'textarea' };
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
function selectAll(el) {
|
|
27
|
+
var tag = el.tagName;
|
|
28
|
+
if (tag === 'TEXTAREA' || tag === 'INPUT') { el.select(); return; }
|
|
29
|
+
var range = document.createRange();
|
|
30
|
+
range.selectNodeContents(el);
|
|
31
|
+
var sel = window.getSelection();
|
|
32
|
+
sel.removeAllRanges();
|
|
33
|
+
sel.addRange(range);
|
|
34
|
+
}
|
|
35
|
+
function insertText(el, text) {
|
|
36
|
+
el.focus();
|
|
37
|
+
try { selectAll(el); } catch (e) {}
|
|
38
|
+
// 1) execCommand 走浏览器编辑管线,让 Lexical 收到 input 事件。
|
|
39
|
+
try { if (document.execCommand('insertText', false, text)) return true; } catch (e) {}
|
|
40
|
+
// 2) 显式 beforeinput。
|
|
41
|
+
try {
|
|
42
|
+
el.dispatchEvent(new InputEvent('beforeinput', { inputType: 'insertText', data: text, bubbles: true, cancelable: true }));
|
|
43
|
+
return true;
|
|
44
|
+
} catch (e) {}
|
|
45
|
+
// 3) 兜底:直接写 DOM + 触发 input。
|
|
46
|
+
try {
|
|
47
|
+
var tag = el.tagName;
|
|
48
|
+
if (tag === 'TEXTAREA' || tag === 'INPUT') {
|
|
49
|
+
var proto = tag === 'TEXTAREA' ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;
|
|
50
|
+
Object.getOwnPropertyDescriptor(proto, 'value').set.call(el, text);
|
|
51
|
+
} else {
|
|
52
|
+
el.textContent = text;
|
|
53
|
+
}
|
|
54
|
+
el.dispatchEvent(new Event('input', { bubbles: true }));
|
|
55
|
+
return true;
|
|
56
|
+
} catch (e) {}
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
function pressEnter(el) {
|
|
60
|
+
var opts = { key: 'Enter', code: 'Enter', keyCode: 13, which: 13, bubbles: true, cancelable: true, composed: true, isComposing: false };
|
|
61
|
+
try {
|
|
62
|
+
el.dispatchEvent(new KeyboardEvent('keydown', opts));
|
|
63
|
+
el.dispatchEvent(new KeyboardEvent('keypress', opts));
|
|
64
|
+
el.dispatchEvent(new KeyboardEvent('keyup', opts));
|
|
65
|
+
} catch (e) {}
|
|
66
|
+
}
|
|
67
|
+
// 键盘守卫:语音写入后关软键盘;用户真实触摸页面即解除。
|
|
68
|
+
var keyboardGuard = false;
|
|
69
|
+
function dismissKeyboard() {
|
|
70
|
+
try {
|
|
71
|
+
var active = document.activeElement;
|
|
72
|
+
if (active && active !== document.body && typeof active.blur === 'function') active.blur();
|
|
73
|
+
var selection = window.getSelection();
|
|
74
|
+
if (selection && selection.removeAllRanges) selection.removeAllRanges();
|
|
75
|
+
} catch (e) {}
|
|
76
|
+
}
|
|
77
|
+
function onUserTouch() { if (keyboardGuard) keyboardGuard = false; }
|
|
78
|
+
document.addEventListener('focusin', function () { if (!keyboardGuard) return; dismissKeyboard(); }, true);
|
|
79
|
+
document.addEventListener('pointerdown', onUserTouch, true);
|
|
80
|
+
document.addEventListener('touchstart', onUserTouch, true);
|
|
81
|
+
|
|
82
|
+
// 单个 flow 节点的可见文本;proseOnly 额外丢弃整块代码与表格(105 只读答案正文)。
|
|
83
|
+
function answerTextOf(el, proseOnly) {
|
|
84
|
+
var clone = el.cloneNode(true);
|
|
85
|
+
var selector = '[data-variant="think"],[data-variant="others"]';
|
|
86
|
+
if (proseOnly) selector += ',pre,table';
|
|
87
|
+
var drop = clone.querySelectorAll(selector);
|
|
88
|
+
for (var i = 0; i < drop.length; i++) { if (drop[i].parentNode) drop[i].parentNode.removeChild(drop[i]); }
|
|
89
|
+
return (clone.innerText || clone.textContent || '').trim();
|
|
90
|
+
}
|
|
91
|
+
// 某个 flow kind 的最大 turn(取不到 = -1)。
|
|
92
|
+
// 111:App 用 turn / userTurn 判断"用户是否发了新消息",从而立即停止朗读。
|
|
93
|
+
function maxTurnOf(kind) {
|
|
94
|
+
var nodes = document.querySelectorAll('[data-chat-flow-kind="' + kind + '"]');
|
|
95
|
+
var max = -1;
|
|
96
|
+
for (var i = 0; i < nodes.length; i++) {
|
|
97
|
+
var t = parseInt(nodes[i].getAttribute('data-chat-turn'), 10);
|
|
98
|
+
if (!isNaN(t) && t > max) max = t;
|
|
99
|
+
}
|
|
100
|
+
return max;
|
|
101
|
+
}
|
|
102
|
+
// 最后一轮的答案部分(排除 reasoning,取最大 turn,逐 part 拼接)。
|
|
103
|
+
// 返回 { text, turn }:turn 与该轮答案对应,供 readReply 回带给 App。
|
|
104
|
+
function latestAnswer() {
|
|
105
|
+
var steps = Array.prototype.slice.call(document.querySelectorAll('[data-chat-flow-kind="assistant-step"]'));
|
|
106
|
+
var answers = steps.filter(function (el) { return el.getAttribute('data-chat-group-part') !== 'reasoning'; });
|
|
107
|
+
var maxTurn = -1;
|
|
108
|
+
for (var i = 0; i < answers.length; i++) {
|
|
109
|
+
var turn = parseInt(answers[i].getAttribute('data-chat-turn'), 10);
|
|
110
|
+
if (!isNaN(turn) && turn > maxTurn) maxTurn = turn;
|
|
111
|
+
}
|
|
112
|
+
var parts = answers.filter(function (el) { return parseInt(el.getAttribute('data-chat-turn'), 10) === maxTurn; });
|
|
113
|
+
var texts = [];
|
|
114
|
+
for (var j = 0; j < parts.length; j++) {
|
|
115
|
+
var text = answerTextOf(parts[j], true);
|
|
116
|
+
if (text.length) texts.push(text);
|
|
117
|
+
}
|
|
118
|
+
return { text: texts.join('\\n\\n'), turn: maxTurn };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
window.__rdshWebViewApi = {
|
|
122
|
+
version: 1,
|
|
123
|
+
// 104:写入 composer 并提交。返回裸状态 token。
|
|
124
|
+
fillAndSend: function (text) {
|
|
125
|
+
var found = findComposer();
|
|
126
|
+
if (!found) return 'no-composer';
|
|
127
|
+
keyboardGuard = false;
|
|
128
|
+
if (!insertText(found.el, text)) return 'fill-failed';
|
|
129
|
+
keyboardGuard = true;
|
|
130
|
+
dismissKeyboard();
|
|
131
|
+
setTimeout(function () { pressEnter(found.el); dismissKeyboard(); }, 80);
|
|
132
|
+
return 'ok';
|
|
133
|
+
},
|
|
134
|
+
// 105/111:待朗读内容 + 状态。返回 UTF-8 安全的 base64 JSON
|
|
135
|
+
// {dsh, streaming, text, turn, userTurn}(turn / userTurn 取不到时为 -1)。
|
|
136
|
+
readReply: function () {
|
|
137
|
+
var out = { dsh: false, streaming: 0, text: '', turn: -1, userTurn: -1 };
|
|
138
|
+
try {
|
|
139
|
+
out.streaming = document.querySelectorAll('[data-streaming]').length;
|
|
140
|
+
if (document.querySelectorAll('[data-chat-flow-kind]').length > 0) {
|
|
141
|
+
out.dsh = true;
|
|
142
|
+
var answer = latestAnswer();
|
|
143
|
+
out.text = answer.text;
|
|
144
|
+
out.turn = answer.turn;
|
|
145
|
+
out.userTurn = maxTurnOf('user');
|
|
146
|
+
} else {
|
|
147
|
+
out.text = document.body ? document.body.innerText : '';
|
|
148
|
+
}
|
|
149
|
+
} catch (e) {}
|
|
150
|
+
return window.btoa(unescape(encodeURIComponent(JSON.stringify(out))));
|
|
151
|
+
},
|
|
152
|
+
// 107:是否 DSH 会话页(轻量判定,可轮询)。返回裸 '1' / '0'。
|
|
153
|
+
isDshPage: function () {
|
|
154
|
+
try {
|
|
155
|
+
return document.querySelectorAll('[data-chat-flow-kind]').length > 0 ? '1' : '0';
|
|
156
|
+
} catch (e) { return '0'; }
|
|
157
|
+
}
|
|
158
|
+
};
|
|
159
|
+
})();`;
|
|
160
|
+
/** 把 `<script>${script}</script>` 插到 `</head>` 前(无则前置);纯函数,可单测。 */
|
|
161
|
+
export function injectHtmlScript(html, script) {
|
|
162
|
+
const tag = `<script>${script}</script>`;
|
|
163
|
+
if (html.includes("</head>"))
|
|
164
|
+
return html.replace("</head>", `${tag}</head>`);
|
|
165
|
+
return `${tag}${html}`;
|
|
166
|
+
}
|
|
167
|
+
//# sourceMappingURL=rdsh-webview-api.js.map
|
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* secure-context-polyfill.ts — 非 secure context 兼容脚本(注入 DSH 首页)。
|
|
3
3
|
*
|
|
4
|
-
* 背景(查档):DSH 浏览器侧 RPC
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
4
|
+
* 背景(查档):DSH 浏览器侧 RPC 用 crypto.randomUUID();但该 API 仅在 secure context
|
|
5
|
+
* (HTTPS / localhost)可用。局域网模式为 http://<LAN-IP>(非 secure context),故需
|
|
6
|
+
* polyfill:crypto.getRandomValues 是唯一非 secure context 也可用的 WebCrypto API。
|
|
7
|
+
*
|
|
8
|
+
* 另补 `navigator.clipboard`(同样仅 secure context 可用):`writeText` 用
|
|
9
|
+
* `document.execCommand("copy")` + 隐藏 textarea 实现(execCommand 不受 secure context
|
|
10
|
+
* 限制,只需用户手势)。`readText` 无法安全 polyfill,保留 undefined。
|
|
9
11
|
*/
|
|
10
12
|
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})();";
|
|
13
|
+
/** 剪贴板 polyfill:非 secure context 下补 `navigator.clipboard.writeText`(execCommand('copy') 兜底)。 */
|
|
14
|
+
export declare const CLIPBOARD_POLYFILL = "(function () {\n if (typeof navigator === \"undefined\") return;\n if (navigator.clipboard && typeof navigator.clipboard.writeText === \"function\") return;\n function writeText(text) {\n return new Promise(function (resolve, reject) {\n var ta = document.createElement(\"textarea\");\n ta.value = String(text);\n ta.setAttribute(\"readonly\", \"\");\n ta.style.position = \"fixed\";\n ta.style.left = \"-9999px\";\n document.body.appendChild(ta);\n ta.select();\n var ok = false;\n try { ok = document.execCommand(\"copy\"); } catch (e) { /* ignore */ }\n document.body.removeChild(ta);\n if (ok) resolve(); else reject(new Error(\"copy failed\"));\n });\n }\n try {\n Object.defineProperty(navigator, \"clipboard\", {\n value: { writeText: writeText },\n writable: true,\n configurable: true\n });\n } catch (e) { /* ignore */ }\n})();";
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* secure-context-polyfill.ts — 非 secure context 兼容脚本(注入 DSH 首页)。
|
|
3
3
|
*
|
|
4
|
-
* 背景(查档):DSH 浏览器侧 RPC
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
4
|
+
* 背景(查档):DSH 浏览器侧 RPC 用 crypto.randomUUID();但该 API 仅在 secure context
|
|
5
|
+
* (HTTPS / localhost)可用。局域网模式为 http://<LAN-IP>(非 secure context),故需
|
|
6
|
+
* polyfill:crypto.getRandomValues 是唯一非 secure context 也可用的 WebCrypto API。
|
|
7
|
+
*
|
|
8
|
+
* 另补 `navigator.clipboard`(同样仅 secure context 可用):`writeText` 用
|
|
9
|
+
* `document.execCommand("copy")` + 隐藏 textarea 实现(execCommand 不受 secure context
|
|
10
|
+
* 限制,只需用户手势)。`readText` 无法安全 polyfill,保留 undefined。
|
|
9
11
|
*/
|
|
10
12
|
export const SECURE_CONTEXT_POLYFILL = `(function () {
|
|
11
13
|
if (typeof crypto === "undefined") return;
|
|
@@ -27,4 +29,31 @@ export const SECURE_CONTEXT_POLYFILL = `(function () {
|
|
|
27
29
|
});
|
|
28
30
|
} catch (e) { /* ignore */ }
|
|
29
31
|
})();`;
|
|
32
|
+
/** 剪贴板 polyfill:非 secure context 下补 `navigator.clipboard.writeText`(execCommand('copy') 兜底)。 */
|
|
33
|
+
export const CLIPBOARD_POLYFILL = `(function () {
|
|
34
|
+
if (typeof navigator === "undefined") return;
|
|
35
|
+
if (navigator.clipboard && typeof navigator.clipboard.writeText === "function") return;
|
|
36
|
+
function writeText(text) {
|
|
37
|
+
return new Promise(function (resolve, reject) {
|
|
38
|
+
var ta = document.createElement("textarea");
|
|
39
|
+
ta.value = String(text);
|
|
40
|
+
ta.setAttribute("readonly", "");
|
|
41
|
+
ta.style.position = "fixed";
|
|
42
|
+
ta.style.left = "-9999px";
|
|
43
|
+
document.body.appendChild(ta);
|
|
44
|
+
ta.select();
|
|
45
|
+
var ok = false;
|
|
46
|
+
try { ok = document.execCommand("copy"); } catch (e) { /* ignore */ }
|
|
47
|
+
document.body.removeChild(ta);
|
|
48
|
+
if (ok) resolve(); else reject(new Error("copy failed"));
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
try {
|
|
52
|
+
Object.defineProperty(navigator, "clipboard", {
|
|
53
|
+
value: { writeText: writeText },
|
|
54
|
+
writable: true,
|
|
55
|
+
configurable: true
|
|
56
|
+
});
|
|
57
|
+
} catch (e) { /* ignore */ }
|
|
58
|
+
})();`;
|
|
30
59
|
//# sourceMappingURL=secure-context-polyfill.js.map
|
package/dist/serve.d.ts
CHANGED
package/dist/serve.js
CHANGED
|
@@ -20,7 +20,7 @@ export async function serve(opts) {
|
|
|
20
20
|
const sessionTtlSeconds = opts.sessionTtlSeconds ?? config.sessionTtlSeconds;
|
|
21
21
|
const dshPath = opts.dshPath ?? config.dshPath;
|
|
22
22
|
const authMode = opts.noCode ? "none" : config.auth.mode;
|
|
23
|
-
const
|
|
23
|
+
const accessCode = config.gateway?.accessCode ?? null;
|
|
24
24
|
const foundDsh = findDsh(dshPath);
|
|
25
25
|
if (foundDsh === null) {
|
|
26
26
|
throw new Error("cannot find 'dsh' in PATH. Install DeepSeek Harness first, or set dshPath in config / pass --dsh <path>.");
|
|
@@ -47,22 +47,22 @@ export async function serve(opts) {
|
|
|
47
47
|
// password + http + 非反代 → server.ts 安全约束拒绝启动(需自行提供证书)。
|
|
48
48
|
const tlsMaterial = await loadTls(config.tls, config.behindProxy);
|
|
49
49
|
const userManager = authMode === "password" ? new UserManager(configPath) : undefined;
|
|
50
|
-
if (
|
|
51
|
-
console.warn("\n⚠ rdsh:
|
|
52
|
-
"
|
|
50
|
+
if (accessCode === null && authMode !== "password") {
|
|
51
|
+
console.warn("\n⚠ rdsh: 认证已禁用(未设访问口令且 auth.mode=none)—— 任何能访问该端口的设备将直接操作 DSH!\n" +
|
|
52
|
+
" 仅限完全可信网络!用 `rdsh host gate set <口令>` 开启访问口令。\n");
|
|
53
53
|
}
|
|
54
54
|
let gateway;
|
|
55
55
|
try {
|
|
56
56
|
gateway = await startGateway({
|
|
57
57
|
host,
|
|
58
58
|
port,
|
|
59
|
-
pairCode,
|
|
60
59
|
sessionTtlSeconds,
|
|
61
60
|
dshPort: dsh.port,
|
|
62
61
|
dshAuthCookieHeader,
|
|
63
62
|
reset: opts.reset,
|
|
64
63
|
noCode: opts.noCode,
|
|
65
64
|
authMode,
|
|
65
|
+
accessCode,
|
|
66
66
|
authVersion: config.auth.version,
|
|
67
67
|
allowFrom: config.allowFrom,
|
|
68
68
|
behindProxy: config.behindProxy,
|
|
@@ -85,7 +85,7 @@ export async function serve(opts) {
|
|
|
85
85
|
console.log(`rdsh serve: LAN: ${lan.map((ip) => `${scheme}://${ip}:${gateway.actualPort}`).join(", ")}`);
|
|
86
86
|
}
|
|
87
87
|
console.log(`rdsh serve: dsh web on 127.0.0.1:${dsh.port}`);
|
|
88
|
-
console.log(`rdsh serve: auth mode: ${
|
|
88
|
+
console.log(`rdsh serve: auth mode: ${accessCode !== null ? "accessCode" : authMode}${authMode === "password" ? ` (config: ${configPath})` : ""}`);
|
|
89
89
|
if (config.allowFrom.length > 0)
|
|
90
90
|
console.log(`rdsh serve: allow_from: ${config.allowFrom.join(", ")}`);
|
|
91
91
|
if (tlsMaterial !== null)
|
|
@@ -94,8 +94,8 @@ export async function serve(opts) {
|
|
|
94
94
|
console.log("rdsh serve: behind_proxy: true (TLS terminated by reverse proxy)");
|
|
95
95
|
if (authMode === "password")
|
|
96
96
|
console.log("rdsh serve: sign in with `rdsh user` credentials in the browser login page.");
|
|
97
|
-
else if (
|
|
98
|
-
console.log("rdsh serve:
|
|
97
|
+
else if (accessCode !== null)
|
|
98
|
+
console.log("rdsh serve: 访问口令已启用(经隧道或直连访问都需先输入口令)。");
|
|
99
99
|
let shuttingDown = false;
|
|
100
100
|
const shutdown = async (signal) => {
|
|
101
101
|
if (shuttingDown)
|