rdsh-gateway 0.8.6 → 0.9.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 CHANGED
@@ -21,7 +21,7 @@ export { ipInCidrs, parseCidr, ipToInt } from "./cidr.ts";
21
21
  export { loadTls } from "./tls.ts";
22
22
  export type { TlsMaterial } from "./tls.ts";
23
23
  export { loginPageHtml } from "./login-page.ts";
24
- export { installService, uninstallService, serviceStatus, systemdUnit, launchdPlist, SERVICE_NAME, JOIN_SERVICE_NAME, HOST_SERVICE_NAME, HUB_SERVICE_NAME } from "./service.ts";
24
+ export { installService, uninstallService, serviceStatus, startService, stopService, restartService, systemdUnit, launchdPlist, SERVICE_NAME, JOIN_SERVICE_NAME, HOST_SERVICE_NAME, HUB_SERVICE_NAME } from "./service.ts";
25
25
  export type { ServiceSpec } from "./service.ts";
26
26
  export declare const NAME = "rdsh-gateway";
27
27
  export { join, startJoin, registerJoin, detectInsecure, selfRevoke, patchLoopbackJs } from "./join.ts";
package/dist/index.js CHANGED
@@ -14,7 +14,7 @@ export { hashPassword, verifyPassword, UserManager } from "./auth.js";
14
14
  export { ipInCidrs, parseCidr, ipToInt } from "./cidr.js";
15
15
  export { loadTls } from "./tls.js";
16
16
  export { loginPageHtml } from "./login-page.js";
17
- export { installService, uninstallService, serviceStatus, systemdUnit, launchdPlist, SERVICE_NAME, JOIN_SERVICE_NAME, HOST_SERVICE_NAME, HUB_SERVICE_NAME } from "./service.js";
17
+ export { installService, uninstallService, serviceStatus, startService, stopService, restartService, systemdUnit, launchdPlist, SERVICE_NAME, JOIN_SERVICE_NAME, HOST_SERVICE_NAME, HUB_SERVICE_NAME } from "./service.js";
18
18
  export const NAME = "rdsh-gateway";
19
19
  export { join, startJoin, registerJoin, detectInsecure, selfRevoke, patchLoopbackJs } from "./join.js";
20
20
  export { readPersistedToken, clearPersistedToken } from "./token-store.js";
package/dist/join.js CHANGED
@@ -301,6 +301,14 @@ export function startJoin(opts) {
301
301
  const gatedHttp = new Map();
302
302
  /** 已报告过"loopback 补丁未命中"的路径(每个路径只报一次,避免日志刷屏)。 */
303
303
  const patchMissReported = new Set();
304
+ /** 已报告过"loopback 补丁命中"的路径(与 miss 对称:只看得到 miss 会让人误判"补丁从未生效")。 */
305
+ const patchHitReported = new Set();
306
+ /**
307
+ * 已确认**不含**补丁目标串的 URL(键为完整 path,含 query)——命中过一次 miss 后直接走流式,
308
+ * 不再每次页面加载都缓冲 + 解压 + 全量扫描(DSH 前端壳资源 2 个就 ~1.3 MB)。
309
+ * 键含 query 且不截断:combo 路由用 query 选内容,资源 URL 由内容决定(rev/hash),内容一变 URL 就变。
310
+ */
311
+ const patchSkipped = new Set();
304
312
  /** 从转发头里取 rdsh_gate cookie(hub D12 白名单透传)。 */
305
313
  function gateCookie(headers) {
306
314
  const ck = headers["cookie"];
@@ -474,7 +482,8 @@ export function startJoin(opts) {
474
482
  responded = true;
475
483
  const status = upRes.statusCode ?? 502;
476
484
  const baseHeaders = normalizeRespHeaders(upRes.headers);
477
- const wantsPatch = dio?.jsPatch?.() === true && isJsContentType(upRes.headers);
485
+ // 已确认该 URL 不含目标串(DSH 前端壳资源就属此类)→ 直接流式,省掉白解压与全量扫描
486
+ const wantsPatch = dio?.jsPatch?.() === true && isJsContentType(upRes.headers) && !patchSkipped.has(path);
478
487
  // 非 JS / 未开补丁:保持原流式路径(OPEN 立即发,body 边到边发)
479
488
  if (!wantsPatch) {
480
489
  send(encodeFrame(FRAME_TYPE.OPEN, streamId, jsonPayload({ kind: "http", status, reason: upRes.statusMessage, headers: baseHeaders })));
@@ -503,8 +512,11 @@ export function startJoin(opts) {
503
512
  let body = raw;
504
513
  let outHeaders = baseHeaders;
505
514
  let hit = false;
515
+ /** 解码成功、且明文里确实**没有**目标串 → 该 URL 可以永久短路(换编码回来也一样没有)。 */
516
+ let targetAbsent = false;
506
517
  if (decoded !== null) {
507
518
  const patched = patchLoopbackJs(decoded);
519
+ targetAbsent = patched === null;
508
520
  const recoded = patched === null ? null : encodeBody(patched, encoding);
509
521
  if (recoded !== null) {
510
522
  body = recoded;
@@ -516,17 +528,26 @@ export function startJoin(opts) {
516
528
  outHeaders["cache-control"] = "public, max-age=300";
517
529
  }
518
530
  }
519
- // 未命中必须留痕(每个路径一次):fail-open 是刻意设计,但"补丁从未生效"不能静默
520
- //(2026-09-14 gzip 事故正是因为没有这条日志而排查了很久)
521
- if (!hit) {
522
- const key = (path.split("?")[0] ?? path).slice(0, 120);
523
- if (!patchMissReported.has(key)) {
524
- patchMissReported.add(key);
525
- console.log(`[patch] miss: ${key} (${raw.length}B, content-encoding=${encoding === "" ? "identity" : encoding})`);
531
+ // 命中与未命中都必须留痕(每个路径一次):fail-open 是刻意设计,但"补丁是否生效"不能靠猜。
532
+ // 两次误判都源于只看得到一半日志:2026-09-14 gzip 事故(只见 miss)、2026-09-17
533
+ // 「补丁在 0.1.5-rc.2 上永不命中」(把前端壳资源的 miss 当成了判据所在的 bundle)。
534
+ const logKey = (path.split("?")[0] ?? path).slice(0, 120);
535
+ if (hit) {
536
+ if (!patchHitReported.has(logKey) || process.env.RDSH_DEBUG_PATCH === "1") {
537
+ patchHitReported.add(logKey);
538
+ console.log(`[patch] hit: ${logKey} (${raw.length}B → ${body.length}B, ${encoding === "" ? "identity" : encoding})`);
526
539
  }
527
540
  }
528
- else if (process.env.RDSH_DEBUG_PATCH === "1") {
529
- console.log(`[patch] hit: ${path.slice(0, 120)} (${raw.length}B → ${body.length}B, ${encoding === "" ? "identity" : encoding})`);
541
+ else {
542
+ const firstMiss = !patchMissReported.has(logKey);
543
+ patchMissReported.add(logKey);
544
+ // 只有"解码后确实没有目标串"才永久短路;编码不支持(decoded === null)时并没看过明文,
545
+ // 保持每次尝试(同一 URL 换个编码回来仍能被补丁);重压失败同理不算。
546
+ if (targetAbsent)
547
+ patchSkipped.add(path);
548
+ if (firstMiss) {
549
+ console.log(`[patch] miss: ${logKey} (${raw.length}B, content-encoding=${encoding === "" ? "identity" : encoding}) — fail-open${targetAbsent ? ";此 URL 后续不再尝试补丁" : ""}`);
550
+ }
530
551
  }
531
552
  send(encodeFrame(FRAME_TYPE.OPEN, streamId, jsonPayload({ kind: "http", status, reason: upRes.statusMessage, headers: outHeaders })));
532
553
  sendChunkedBody(send, streamId, body);
package/dist/proxy.js CHANGED
@@ -7,6 +7,20 @@
7
7
  import { request } from "node:http";
8
8
  import { WebSocket, WebSocketServer } from "ws";
9
9
  import { decodeBody, encodeBody, firstEncoding } from "./http-encoding.js";
10
+ /**
11
+ * 已知**不含**补丁目标串的 URL(按 patcher 函数身份隔离——目标串由 patcher 决定,只看 URL 会误判)。
12
+ * 目的与隧道路径(`join.ts` 的 `patchSkipped`)一致:DSH 前端壳资源每次页面加载都被缓冲 + 解压 + 全量扫描,
13
+ * 而这些字节永远不会命中;记住一次即可一直走流式。
14
+ */
15
+ const jsPatchMissedUrls = new WeakMap();
16
+ function missedUrlsFor(patcher) {
17
+ const existing = jsPatchMissedUrls.get(patcher);
18
+ if (existing !== undefined)
19
+ return existing;
20
+ const created = new Set();
21
+ jsPatchMissedUrls.set(patcher, created);
22
+ return created;
23
+ }
10
24
  /**
11
25
  * 重写转发头以通过 DSH 围栏(isTrustedApiRequest 要求 Host/Origin 一致且为 loopback)。
12
26
  * - Host → 127.0.0.1:<port>(M1 事实:DSH 只信任 loopback/trusted Host)
@@ -81,7 +95,12 @@ export function forwardHttp(req, res, target, opts) {
81
95
  });
82
96
  return;
83
97
  }
84
- const canPatchJs = opts?.jsPatch !== undefined && typeof contentType === "string" && /javascript/i.test(contentType);
98
+ const patchUrl = req.url ?? "";
99
+ const patchMissed = opts?.jsPatch === undefined ? undefined : missedUrlsFor(opts.jsPatch);
100
+ const canPatchJs = opts?.jsPatch !== undefined &&
101
+ typeof contentType === "string" &&
102
+ /javascript/i.test(contentType) &&
103
+ patchMissed?.has(patchUrl) !== true;
85
104
  if (canPatchJs) {
86
105
  // JS bundle 需缓冲后才能替换目标串;先按 content-encoding 解码(dsh 会 gzip JS),
87
106
  // 补丁后按原编码重压(fail-open:未命中/不支持编码 → 原字节原头透传)
@@ -95,6 +114,9 @@ export function forwardHttp(req, res, target, opts) {
95
114
  const patched = decoded === null ? null : opts.jsPatch(decoded);
96
115
  const recoded = patched === null ? null : encodeBody(patched, encoding);
97
116
  const outBody = recoded ?? raw;
117
+ // 只有"解码后确实没有目标串"才记住该 URL(编码不支持时没看过明文,保持每次尝试)
118
+ if (patched === null && decoded !== null)
119
+ patchMissed?.add(patchUrl);
98
120
  const outHeaders = { ...upstreamRes.headers };
99
121
  if (recoded !== null) {
100
122
  outHeaders["content-length"] = String(outBody.length);
package/dist/service.d.ts CHANGED
@@ -30,3 +30,9 @@ export declare function installService(spec: ServiceSpec): Promise<string>;
30
30
  export declare function serviceStatus(name?: string): Promise<string>;
31
31
  /** 停止并移除服务。 */
32
32
  export declare function uninstallService(name?: string): Promise<string>;
33
+ /** 启动服务(运行时控制,不改变开机自启配置)。 */
34
+ export declare function startService(name?: string): Promise<string>;
35
+ /** 停止服务(运行时控制,不改变开机自启配置)。 */
36
+ export declare function stopService(name?: string): Promise<string>;
37
+ /** 重启服务(运行时控制,不改变开机自启配置)。 */
38
+ export declare function restartService(name?: string): Promise<string>;
package/dist/service.js CHANGED
@@ -158,4 +158,33 @@ export async function uninstallService(name = SERVICE_NAME) {
158
158
  await rm(launchdPlistPath(name), { force: true });
159
159
  return `removed ${launchdPlistPath(name)}`;
160
160
  }
161
+ /** 启动服务(运行时控制,不改变开机自启配置)。 */
162
+ export async function startService(name = SERVICE_NAME) {
163
+ if (isLinux()) {
164
+ await run("systemctl", ["--user", "start", name]);
165
+ return `started ${name}`;
166
+ }
167
+ // macOS:kickstart -k 确保在跑(已运行则先停再启)。
168
+ await run("launchctl", ["kickstart", "-k", `com.${name}`]);
169
+ return `started com.${name}`;
170
+ }
171
+ /** 停止服务(运行时控制,不改变开机自启配置)。 */
172
+ export async function stopService(name = SERVICE_NAME) {
173
+ if (isLinux()) {
174
+ await run("systemctl", ["--user", "stop", name]);
175
+ return `stopped ${name}`;
176
+ }
177
+ // macOS:stop 仅停当前实例;KeepAlive(仅失败退出重启)可能立即拉起 —— 与 systemd stop 语义不同。
178
+ await run("launchctl", ["stop", `com.${name}`]).catch(() => undefined);
179
+ return `stopped com.${name}`;
180
+ }
181
+ /** 重启服务(运行时控制,不改变开机自启配置)。 */
182
+ export async function restartService(name = SERVICE_NAME) {
183
+ if (isLinux()) {
184
+ await run("systemctl", ["--user", "restart", name]);
185
+ return `restarted ${name}`;
186
+ }
187
+ await run("launchctl", ["kickstart", "-k", `com.${name}`]);
188
+ return `restarted com.${name}`;
189
+ }
161
190
  //# sourceMappingURL=service.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rdsh-gateway",
3
- "version": "0.8.6",
3
+ "version": "0.9.0",
4
4
  "description": "rdsh host-side component: LAN auth gateway + outbound tunnel endpoint (spawns dsh web)",
5
5
  "license": "MIT",
6
6
  "type": "module",