dsh-mobile 0.6.12 → 0.6.13

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/CHANGELOG.md CHANGED
@@ -2,6 +2,15 @@
2
2
 
3
3
  Notable changes are recorded here. GitHub Releases provide the downloadable packages and generated commit notes.
4
4
 
5
+ ## 0.6.13 - 2026-10-19
6
+
7
+ - Distinguish inbound HTTP receive timeouts (408 `request_timeout`) and oversized request headers (431 `headers_too_large`) from other malformed HTTP requests (400 `bad_request`). Preserve the existing receive limits and single-attempt POST forwarding; do not automatically retry messages.
8
+ - Add sampled HTTP rejection diagnostics that distinguish parser errors, invalid targets and authenticated upstream prompt rejections without recording request contents, raw packets, Cookies or arbitrary URLs. Connect diagnostic sinks for all Gateway factories, including own-origin and self-signed FRP entries.
9
+ - Add real-page prompt-failure checks for restored text/images and visible errors. Separately reproduce a DSH 0.2.0-rc.2 and 0.2.1-rc.2 lifecycle defect: leaving a Session during a pending send cancels that request and discards its recovery state, including in pure stock profiles without Mobile installed. DSH source remains unchanged; this upstream defect and the exact cause of the reported #234 rejection are not claimed fixed. Thanks @Void-LifeStream for [#234](https://github.com/saya-ch/dsh-mobile/issues/234).
10
+ - Distinguish HTTP receive timeouts and oversized request headers in the Gateway response and add bounded, privacy-preserving rejection diagnostics across all Gateway factories. Existing request limits, authentication checks and single-attempt POST forwarding remain unchanged; this release does not add automatic message retries.
11
+ - Add a packed prompt-failure smoke covering text/image recovery and visible DSH errors on DSH 0.2.0-rc.2 and 0.2.1-rc.2. The optional navigation diagnostic records an upstream Session-lifecycle failure but is not a passing regression lane.
12
+ - Keep DSH source and installed frontend artifacts read-only. The proposed iroh P2P channel in [#235](https://github.com/saya-ch/dsh-mobile/issues/235) is intentionally outside this repository and this release; it requires a separate host-side proof of concept.
13
+
5
14
  ## 0.6.12 - 2026-10-18
6
15
 
7
16
  - Send explicit non-navigation Fetch Metadata during the Gateway's internal DSH cookie exchange. This avoids the HTML navigation bounce produced by community authentication plugins without accepting arbitrary HTTP 200 pages, following redirects, or forwarding the upstream Cookie to the phone. Thanks @jamorzzz for [#230](https://github.com/saya-ch/dsh-mobile/issues/230).
package/CONTRIBUTORS.md CHANGED
@@ -136,7 +136,7 @@ Thanks [@xhwxt](https://github.com/xhwxt) for the document-picker proposal in [#
136
136
  | [@sortjiajun](https://github.com/sortjiajun) | [#90](https://github.com/saya-ch/dsh-mobile/issues/90) |
137
137
  | [@StrawberryAO](https://github.com/StrawberryAO) | [#39](https://github.com/saya-ch/dsh-mobile/issues/39) |
138
138
  | [@Surpass567](https://github.com/Surpass567) | [#80](https://github.com/saya-ch/dsh-mobile/issues/80), [#81](https://github.com/saya-ch/dsh-mobile/issues/81) |
139
- | [@Void-LifeStream](https://github.com/Void-LifeStream) | [#205](https://github.com/saya-ch/dsh-mobile/issues/205), [#219](https://github.com/saya-ch/dsh-mobile/issues/219), [#225](https://github.com/saya-ch/dsh-mobile/issues/225) |
139
+ | [@Void-LifeStream](https://github.com/Void-LifeStream) | [#205](https://github.com/saya-ch/dsh-mobile/issues/205), [#219](https://github.com/saya-ch/dsh-mobile/issues/219), [#225](https://github.com/saya-ch/dsh-mobile/issues/225), [#234](https://github.com/saya-ch/dsh-mobile/issues/234) |
140
140
  | [@WalkerJay666](https://github.com/WalkerJay666) | [#201](https://github.com/saya-ch/dsh-mobile/issues/201) |
141
141
  | [@up-and-down-0618](https://github.com/up-and-down-0618) | [#115](https://github.com/saya-ch/dsh-mobile/issues/115) |
142
142
  | [@taki-ohh](https://github.com/taki-ohh) | [#19](https://github.com/saya-ch/dsh-mobile/issues/19) |
package/README.en.md CHANGED
@@ -26,13 +26,13 @@
26
26
  <a href="#compatibility">Compatibility</a> ·
27
27
  <a href="docs/README.md">Documentation</a> ·
28
28
  <a href="#contributors">Contributors</a> ·
29
- <a href="https://github.com/saya-ch/dsh-mobile/releases/tag/v0.6.12">Release notes</a> ·
29
+ <a href="https://github.com/saya-ch/dsh-mobile/releases/tag/v0.6.13">Release notes</a> ·
30
30
  <a href="README.md">简体中文</a>
31
31
  </p>
32
32
 
33
33
  > DSH Mobile is a DeepSeek Harness community plugin; the native app supports Android only.
34
34
  >
35
- > **Current stable release: 0.6.12**. Fix page 502 errors with community authentication and pairing/renewal limits behind shared proxies; add missing old-engine APIs and actionable startup feedback. Android App **0.6.5** is unchanged and needs no matching update. [Release notes and downloads](https://github.com/saya-ch/dsh-mobile/releases/tag/v0.6.12) · [Full changelog](CHANGELOG.md).
35
+ > **Current stable release: 0.6.13**. Distinguish Gateway receive timeouts and oversized headers, add privacy-bounded rejection diagnostics, and cover packed text/image prompt failures. Android App **0.6.5** is unchanged and needs no matching update. [Release notes and downloads](https://github.com/saya-ch/dsh-mobile/releases/tag/v0.6.13) · [Full changelog](CHANGELOG.md).
36
36
  >
37
37
  > **Version policy**: since plugin **0.6.2**, the Android app and plugin use independent version numbers and releases. Their numbers do not need to match, and a plugin update does not imply an app update. [Compatibility notes](#compatibility).
38
38
 
@@ -340,7 +340,7 @@ The dedicated mobile page synchronously loads the authenticated, same-origin `/m
340
340
 
341
341
  **Old-engine limits**: API shims cannot repair JavaScript syntax an engine cannot parse. The published **DSH 0.2.1-rc.2** frontend contains class static blocks, which Chromium **92** cannot parse. This syntax became enabled by default in Chrome 94, but that does not establish full DSH compatibility on 94. [V8 announcement](https://v8.dev/blog/v8-release-94). The App's below-100 advisory is not a compatibility guarantee; choosing Continue does not make the current DSH runnable. If system WebView cannot be updated, use a phone browser already confirmed to work with the computer's mobile address, without clearing existing App pairings.
342
342
 
343
- **Plugin 0.6.12** adds identified missing array, string, object and structured-clone APIs, plus startup-failure feedback with Reload page and Keep waiting actions when DSH has not rendered. It does not transpile DSH or reload automatically; acceptance on the physical Huawei Chromium 92 App remains unverified. Reports should include DSH/plugin/App and WebView versions, entry mode and the first redacted console error, never tokens or complete pairing URLs.
343
+ **Plugin 0.6.13** retains the 0.6.12 compatibility layer and startup feedback, distinguishes Gateway receive timeouts from oversized headers, and adds packed text/image prompt-failure checks. It does not transpile or modify DSH, and it does not automatically replay messages; acceptance on the physical Huawei Chromium 92 App remains unverified. Reports should include DSH/plugin/App and WebView versions, entry mode and the first redacted console error, never tokens or complete pairing URLs.
344
344
 
345
345
  > **Community client (unofficial)**: [WeChat Mini-Program client](https://github.com/StrawberryAO/dsh-mobile-minapp)
346
346
  > A native WeChat Mini-Program that reuses the Mobile Access pairing and Remote stream protocol (requires dsh-mobile ≥ 0.3.8).
@@ -408,6 +408,7 @@ On macOS, local network, cloudflared, self-hosted FRP and the own reverse proxy
408
408
  | --- | --- |
409
409
  | `0.6.11` | Isolated packed-browser CI on `0.1.7-alpha.2`, `0.1.7-rc.1`, `0.1.7-rc.2`, `0.2.0-rc.1`, `0.2.0-rc.2`, `0.2.1-alpha.2` and `0.2.1-alpha.3`; additional real history-pagination, viewport-preservation and manual-loading checks on `0.2.0-rc.2` and `0.2.1-alpha.3`. Browser checks do not establish acceptance of every phone engine, third-party plugin or public route |
410
410
  | `0.6.12` | Packed installation, mobile boot, missing-API simulation, Workspace streaming, retained-page transport recovery and mobile settings on `0.2.1-rc.2`; authentication/resource regression with the actual community `dsh-web-startup-auth@0.1.12` on `0.2.0-rc.2`. This does not establish physical Chromium 92, arbitrary auth-plugin or public-route acceptance |
411
+ | `0.6.13` | Packed text/image HTTP 400 recovery, Host refusal guard and error-feedback checks on `0.2.0-rc.2` and `0.2.1-rc.2`; Gateway 408/431 classification, bounded diagnostics and four Gateway factory wiring passed local tests. DSH source, iroh P2P and Android native code are outside this release |
411
412
 
412
413
  <details>
413
414
  <summary>Historical version checks</summary>
package/README.md CHANGED
@@ -26,13 +26,13 @@
26
26
  <a href="#兼容性">兼容性</a> ·
27
27
  <a href="docs/README.md">文档</a> ·
28
28
  <a href="#贡献者">贡献者</a> ·
29
- <a href="https://github.com/saya-ch/dsh-mobile/releases/tag/v0.6.12">更新记录</a> ·
29
+ <a href="https://github.com/saya-ch/dsh-mobile/releases/tag/v0.6.13">更新记录</a> ·
30
30
  <a href="README.en.md">English</a>
31
31
  </p>
32
32
 
33
33
  > DSH Mobile 是 DeepSeek Harness 社区插件,原生 App 仅支持 Android。
34
34
  >
35
- > **当前正式版本:0.6.12**。修复社区认证插件下的页面 502、共享代理下的配对与续期限流问题,并补充旧内核 API 和启动失败提示。Android App **0.6.5** 保持不变,无需同步升级。[更新与下载](https://github.com/saya-ch/dsh-mobile/releases/tag/v0.6.12) · [完整更新记录](CHANGELOG.md)。
35
+ > **当前正式版本:0.6.13**。区分网关 HTTP 接收超时与请求头过大错误,增加隐私受限的拒绝诊断,并补充图文发送失败回归。Android App **0.6.5** 保持不变,无需同步升级。[更新与下载](https://github.com/saya-ch/dsh-mobile/releases/tag/v0.6.13) · [完整更新记录](CHANGELOG.md)。
36
36
  >
37
37
  > **版本说明**:自插件 **0.6.2** 起,Android App 与插件独立编号、独立发布,版本号无需一致;插件升级不代表 App 需要同步升级。[兼容说明](#兼容性)。
38
38
 
@@ -340,7 +340,7 @@ Android App 只是 Kotlin WebView 薄壳,不内置另一份网页;手机浏
340
340
 
341
341
  **旧内核限制**:API 兼容实现不能修复内核无法解析的 JavaScript 语法。DSH **0.2.1-rc.2** 的正式前端包含 class static blocks,Chromium **92** 无法解析;该语法从 Chrome 94 默认可用,但这不代表 94 已通过整个 DSH 的兼容验收。[V8 说明](https://v8.dev/blog/v8-release-94)。App 的“内核低于 100”提醒只是建议,点击继续不代表当前 DSH 可以运行。若系统 WebView 无法更新,可使用已确认正常的手机浏览器访问电脑端提供的移动地址,无需清除原有 App 配对。
342
342
 
343
- 插件 **0.6.12** 补齐已识别的数组、字符串、对象和结构化克隆 API,并在 DSH 尚未显示时提供启动失败说明、“重新载入页面”和“继续等待”。它不转译 DSH,也不自动刷新页面;华为 Chromium 92 的实际 App 尚未完成验收。反馈问题时请提供 DSH/插件/App 与 WebView 版本、进入方式和脱敏后的首个控制台错误,不要发送令牌或完整配对地址。
343
+ 插件 **0.6.13** 在保留 0.6.12 兼容层和启动反馈的基础上,区分网关接收超时与请求头过大,并增加图文发送失败的打包回归。它不转译 DSH、不修改 DSH 源码,也不自动重发消息;华为 Chromium 92 的实际 App 尚未完成验收。反馈问题时请提供 DSH/插件/App 与 WebView 版本、进入方式和脱敏后的首个控制台错误,不要发送令牌或完整配对地址。
344
344
 
345
345
  > **社区客户端(非官方)**:[微信小程序客户端](https://github.com/StrawberryAO/dsh-mobile-minapp)
346
346
  > 原生微信小程序实现,复用「移动访问」的配对与 Remote 流协议(需 dsh-mobile ≥ 0.3.8)。
@@ -409,6 +409,7 @@ macOS 上局域网、cloudflared、自建 FRP 与自有反向代理可用;Funn
409
409
  | --- | --- |
410
410
  | `0.6.11` | `0.1.7-alpha.2`、`0.1.7-rc.1`、`0.1.7-rc.2`、`0.2.0-rc.1`、`0.2.0-rc.2`、`0.2.1-alpha.2`、`0.2.1-alpha.3` 的隔离打包浏览器 CI;`0.2.0-rc.2` 与 `0.2.1-alpha.3` 另通过真实历史分页、滚动位置保持和手动加载检查。浏览器检查不代表所有手机内核、第三方插件或公网线路已验收 |
411
411
  | `0.6.12` | `0.2.1-rc.2` 的打包安装、移动启动、缺失 API 模拟、工作区实时读取、保留页面的断线恢复及移动设置;`0.2.0-rc.2` 搭配实际社区认证插件 `dsh-web-startup-auth@0.1.12` 的认证与资源回归。未将 Chromium 92 真机、任意认证插件或公网线路写成已验收 |
412
+ | `0.6.13` | `0.2.0-rc.2`、`0.2.1-rc.2` 的打包图文 HTTP 400 恢复、Host 拒绝护栏与错误提示回归;网关 408/431 分类、三类受限诊断及四种 Gateway 工厂接线通过本地测试。DSH 源码、iroh P2P 和 Android 原生代码不在本版范围 |
412
413
 
413
414
  <details>
414
415
  <summary>历史版本验证记录</summary>
package/SECURITY.md CHANGED
@@ -50,6 +50,7 @@ The maintainer will acknowledge a complete report within seven days. Publication
50
50
  - The Funnel node stores its Tailscale login state under `$DSH_HOME/mobile-access/remote/tailscale/`. The plugin does not request or store a Tailscale password, Auth Key, or OAuth secret.
51
51
  - The proxy-assisted remote diagnostic probes only the public `/mobile-access/health` path. It tries direct HTTPS first and, only after direct failure, may use an HTTP CONNECT proxy named by the DSH process environment unless `NO_PROXY` excludes the target. The probe sends no device token or session Cookie; configured proxy credentials are sent to that proxy for CONNECT, not placed in the diagnostic report. A proxy-only success is a warning to test from the phone, not proof that the phone or the provider route is available. This diagnostic does not change the remote provider's traffic path.
52
52
  - Plugin 0.6.11 and later record limited HTTP authorization and CSRF rejection metadata. It omits credential values and query strings, categorizes arbitrary paths, normalizes Origin and admits only recognized fetch-site values. Each Gateway keeps two fixed diagnostic categories with 30-second repeat aggregation, not an unbounded per-client/path history. Diagnostic-callback failures do not change the rejection response. These records are not complete WebSocket or native-pairing traces, and absence of a record does not establish successful authentication. Review even bounded metadata before sharing logs.
53
+ - The unreleased HTTP-diagnostic change adds three fixed per-Gateway categories: parser rejection, invalid target, and an authenticated exact `POST /api/session/prompt` rejected by the upstream. Each has 30-second sampling with suppressed counts. Parser codes are allowlisted; raw packets, headers, request bodies, error messages, arbitrary URLs and query strings are excluded. A diagnostic failure cannot change the response or retry a write. Request timeout and header overflow remain rejected, using 408 and 431 rather than a generic 400; receive limits are not relaxed.
53
54
  - cpolar is downloaded only after confirmation from a pinned official artifact whose size and SHA-256 are verified. Its Authtoken is stored in a private, self-update-disabled configuration under `$DSH_HOME/mobile-access/`, never returned by the admin API or written to logs. Component removal deletes its managed executable, Authtoken configuration, logs and staging files, but retains LAN and shared remote pairing records.
54
55
  - Self-hosted FRP accepts a VPS address, frps control port, operator-generated high-entropy shared token, and standard-port public HTTPS origin. The 0.4.6 self-signed entry additionally selects a non-reserved public TCP port on a globally routable IPv4 address. The plugin validates token length and format; the operator remains responsible for its entropy. Managed deployment creates one HTTP vhost and Caddy; existing-frps attachment changes no VPS files or services and allows either a restricted HTTP vhost with a public CA or one TCP passthrough to a computer-side HTTPS gateway. Arbitrary FRP configuration, general TCP/UDP forwarding, FRP plugins, PATH entries, and startup tasks are not supported.
55
56
  - For an HTTP-vhost entry, the generated managed-frps template binds the plaintext listener to `127.0.0.1` and uses Caddy for public HTTPS. An existing frps must have its actual `vhostHTTPPort` entered explicitly and its plaintext vhost kept off the public network; the plugin probes the configured port before starting `frpc`. frps `proxyBindAddr` governs proxy listeners globally: exposing a TCP passthrough on the same instance must not accidentally expose another plaintext vhost. A separate frps instance may be required. For self-signed ingress, the computer-side CA lasts five years and the leaf lasts 397 days; the leaf is renewed under the same CA, but an expired or replaced CA is not silently trusted and requires fresh pairing. A local readiness probe does not establish phone reachability; verify the public entry from an independent network.
@@ -20,6 +20,14 @@ Both [#126](https://github.com/saya-ch/dsh-mobile/issues/126) and [#146](https:/
20
20
 
21
21
  Plugin 0.6.11 and later write limited `auth failure` HTTP diagnostics to the plugin log (`$DSH_HOME/mobile-access/logs/dsh-mobile.log`): authorization versus CSRF rejection, status, method, a safe route or route category, related Cookie/header presence and counts, normalized Origin and recognized fetch-site values. It omits tokens, Cookie values, query strings and arbitrary original paths. Each Gateway rate-limits its two categories separately: the first event is immediate and same-category rejections aggregate within a 30-second window; `suppressed` counts previously suppressed records of that category, not all requests or only the current path. This helps investigate missing credentials and CSRF failures; it does not automatically repair a 401/403. The absence of a record does not establish successful authentication: earlier Host/Origin rejections, WebSocket upgrades and native pairing/renewal are outside these two recording points. Review logs before sharing them.
22
22
 
23
+ ## HTTP 400, 408 and 431 (in development)
24
+
25
+ The unpublished plugin correction distinguishes request receive timeouts as `408 request_timeout` and oversized headers as `431 headers_too_large`; other malformed HTTP requests remain `400 bad_request`. Older versions may return 400 with the same short JSON for all of them, so a status and a 24-byte body do not identify the cause.
26
+
27
+ New `http failure` diagnostics distinguish three per-Gateway categories: `parser` for HTTP parsing/receive failures, `target` for invalid request targets, and `upstream` for an authenticated `POST /api/session/prompt` rejected upstream. The first event of each kind is immediate; repeats aggregate for 30 seconds, with `suppressed` counting earlier suppressed events of that kind. Logs exclude raw errors, request bodies, raw packets, Cookies, Tokens, queries and arbitrary URLs. No record does not establish success.
28
+
29
+ `upstreamTimeoutMs` governs upstream transport waits and also configures the complete inbound-request receive deadline. A continuously active upload can still exceed that total deadline, and Node's periodic checks affect the observed firing time. Do not delete pairings, clear App data or automatically resend because of HTTP 400 alone. Identify the rejection layer using the actual response body, Content-Type, exact versions and redacted proxy upstream status. Check the Session result before retrying an operation whose execution outcome is unknown.
30
+
23
31
  ## Keep event streams distinct
24
32
 
25
33
  In DSH 0.2.0-rc.2, `/plugins/events` is the SSE stream for plugin-module graph and build updates, not conversation-message delivery. Its `200` response, small body or repeated reconnects alone cannot establish successful delivery, failure or a missing processing animation. Inspect the `/api/remote.mux` WebSocket and the actual HTTP request for that operation as well.
@@ -20,6 +20,14 @@
20
20
 
21
21
  插件 0.6.11 起会在插件日志(`$DSH_HOME/mobile-access/logs/dsh-mobile.log`)记录有限的 `auth failure` HTTP 诊断:区分授权与 CSRF 拒绝,包含状态、方法、安全路径或路径类别、相关 Cookie/请求头是否存在及计数、规范化 Origin 和受支持的 fetch-site 值,不记录令牌、Cookie 值、查询参数或任意原始路径。每个网关的两类诊断分别限频,首条立即记录,30 秒窗口内同类拒绝聚合;`suppressed` 表示此前被抑制的同类记录数量,不是全部请求数,也不限定于当前路径。它帮助排查凭据未携带或 CSRF 校验失败,不会自动修复 401/403。没有日志也不代表认证成功:更早的 Host/Origin 拒绝、WebSocket 升级和原生配对/续期不在这两处记录范围内。分享前仍应检查日志。
22
22
 
23
+ ## HTTP 400、408 与 431 的区别(开发中)
24
+
25
+ 尚未发布的插件修正会把请求接收超时区分为 `408 request_timeout`,请求头过大区分为 `431 headers_too_large`;其他非法 HTTP 请求仍为 `400 bad_request`。旧版可能把这几类情况都返回成 400 和相同的短 JSON,因此状态码与“24 字节”不足以定位原因。
26
+
27
+ 新的 `http failure` 日志按网关区分 `parser`、`target`、`upstream` 三类:分别表示 HTTP 解析/接收失败、非法请求目标、已认证的 `POST /api/session/prompt` 被上游拒绝。每类首条立即记录,后续 30 秒内聚合,下一条的 `suppressed` 是此前被抑制的同类数量。日志不保留原始报错、请求正文、原始包、Cookie、Token、查询参数或任意 URL;不记录某条也不能证明它成功。
28
+
29
+ `upstreamTimeoutMs` 既用于上游传输等待,也配置了入站完整请求的接收期限;持续上传不保证避开这个总期限,Node 的定期检查会影响实际触发时刻。不要仅凭 HTTP 400 就删除配对、清空 App 数据或自动重发。先区分拒绝来源,并查看实际响应正文、Content-Type、准确版本和脱敏的代理上游状态;消息或工具是否已执行不明时,先核对会话结果。
30
+
23
31
  ## 不同事件流不能混为一谈
24
32
 
25
33
  在 DSH 0.2.0-rc.2 中,`/plugins/events` 是插件模块图和构建更新的 SSE,不是对话消息投递通道。它返回 `200`、传输很少字节或反复重连,不能单独证明消息发送成功、失败或处理中动画丢失;同时检查 `/api/remote.mux` WebSocket 与那次操作实际发出的 HTTP 请求。
package/lib/index.d.mts CHANGED
@@ -540,6 +540,33 @@ interface AuthFailureEntry {
540
540
  readonly suppressed?: number;
541
541
  }
542
542
  //#endregion
543
+ //#region src/http-failure-log.d.ts
544
+ /** Fixed parser details safe to record without Node's raw packet, message or request headers. */
545
+ type HttpParserCode = 'ERR_HTTP_REQUEST_TIMEOUT' | 'HPE_HEADER_OVERFLOW' | 'HPE_INVALID_CHUNK_SIZE' | 'HPE_INVALID_CONTENT_LENGTH' | 'HPE_INVALID_EOF_STATE' | 'HPE_INVALID_HEADER_TOKEN' | 'HPE_INVALID_TRANSFER_ENCODING' | 'HPE_UNEXPECTED_CONTENT_LENGTH' | 'other';
546
+ /** A rejected inbound HTTP message's public response and bounded diagnostic category. */
547
+ interface HttpParserFailure {
548
+ readonly status: 400 | 408 | 431;
549
+ readonly code: 'bad_request' | 'request_timeout' | 'headers_too_large';
550
+ readonly parserCode: HttpParserCode;
551
+ }
552
+ /** One sampled rejection. Arbitrary paths, query strings, body data and credentials are excluded. */
553
+ type HttpFailureEntry = ({
554
+ readonly kind: 'parser';
555
+ readonly status: HttpParserFailure['status'];
556
+ readonly code: HttpParserCode;
557
+ } | {
558
+ readonly kind: 'target';
559
+ readonly status: 400;
560
+ readonly code: 'bad_request';
561
+ } | {
562
+ readonly kind: 'upstream';
563
+ readonly status: number;
564
+ readonly method: 'POST';
565
+ readonly path: '/api/session/prompt';
566
+ }) & {
567
+ readonly suppressed?: number;
568
+ };
569
+ //#endregion
543
570
  //#region src/http-security.d.ts
544
571
  export declare const DEVICE_COOKIE = "dsh_ma_device";
545
572
  export declare const SESSION_COOKIE = "dsh_ma_session";
@@ -1002,9 +1029,10 @@ export declare class MobileAccessGateway {
1002
1029
  private readonly clientModuleTasks;
1003
1030
  private readonly clientModuleRevocations;
1004
1031
  private readonly authFailures;
1032
+ private readonly httpFailures;
1005
1033
  constructor(config: ResolvedGatewayConfig, store: DeviceStore, extensions?: MobileAccessService | undefined, upstreamAuthenticatedUrl?: string | undefined, extraWebSocketPaths?: {
1006
1034
  has(pathname: string): boolean;
1007
- } | undefined, blockedUpgradeLog?: BlockedUpgradePathLog | undefined, onDiscoveryDegraded?: ((source: 'broadcast' | 'mdns', code: string) => void) | undefined, clientModulePreferences?: ClientModulePreferenceStore, authFailureLog?: (entry: AuthFailureEntry) => void);
1035
+ } | undefined, blockedUpgradeLog?: BlockedUpgradePathLog | undefined, onDiscoveryDegraded?: ((source: 'broadcast' | 'mdns', code: string) => void) | undefined, clientModulePreferences?: ClientModulePreferenceStore, authFailureLog?: (entry: AuthFailureEntry) => void, httpFailureLog?: (entry: HttpFailureEntry) => void);
1008
1036
  /** Initialize durable state, validate TLS, and bind the externally reachable listener. */
1009
1037
  start(): Promise<void>;
1010
1038
  /** Install a newly signed provided leaf for future TLS handshakes without dropping sessions. */
package/lib/index.mjs CHANGED
@@ -9,7 +9,7 @@ import { performance as performance$1 } from "node:perf_hooks";
9
9
  import { execFile, spawn } from "node:child_process";
10
10
  import { createSocket } from "node:dgram";
11
11
  import { homedir, hostname, networkInterfaces, tmpdir } from "node:os";
12
- import { createServer as createServer$1, request } from "node:http";
12
+ import { STATUS_CODES, createServer as createServer$1, request } from "node:http";
13
13
  import { createServer as createServer$2, request as request$1 } from "node:https";
14
14
  import { Readable, Transform, finished } from "node:stream";
15
15
  import { finished as finished$1, pipeline } from "node:stream/promises";
@@ -1968,7 +1968,7 @@ function authenticatedCookie(values, authenticate, accepts = () => true) {
1968
1968
  }
1969
1969
  //#endregion
1970
1970
  //#region src/auth-failure-log.ts
1971
- const LOG_INTERVAL_MS = 3e4;
1971
+ const LOG_INTERVAL_MS$1 = 3e4;
1972
1972
  const MAX_LOGGED_ORIGIN_LENGTH = 512;
1973
1973
  function requestPathCategory(path) {
1974
1974
  switch (path) {
@@ -2065,7 +2065,7 @@ var AuthFailureRecorder = class {
2065
2065
  return;
2066
2066
  }
2067
2067
  const suppressed = window.suppressed;
2068
- window.nextLogAt = now + LOG_INTERVAL_MS;
2068
+ window.nextLogAt = now + LOG_INTERVAL_MS$1;
2069
2069
  window.suppressed = 0;
2070
2070
  try {
2071
2071
  this.observe({
@@ -2082,6 +2082,129 @@ var AuthFailureRecorder = class {
2082
2082
  }
2083
2083
  };
2084
2084
  //#endregion
2085
+ //#region src/http-failure-log.ts
2086
+ function safeParserCode(code) {
2087
+ switch (code) {
2088
+ case "ERR_HTTP_REQUEST_TIMEOUT":
2089
+ case "HPE_HEADER_OVERFLOW":
2090
+ case "HPE_INVALID_CHUNK_SIZE":
2091
+ case "HPE_INVALID_CONTENT_LENGTH":
2092
+ case "HPE_INVALID_EOF_STATE":
2093
+ case "HPE_INVALID_HEADER_TOKEN":
2094
+ case "HPE_INVALID_TRANSFER_ENCODING":
2095
+ case "HPE_UNEXPECTED_CONTENT_LENGTH": return code;
2096
+ default: return "other";
2097
+ }
2098
+ }
2099
+ /**
2100
+ * Classify Node's HTTP rejection without reflecting an unrecognized error code.
2101
+ * @param nodeCode - The parser error's machine code, not its message or raw packet.
2102
+ * @returns The fixed public HTTP status/category and allowlisted diagnostic code.
2103
+ */
2104
+ function classifyHttpParserFailure(nodeCode) {
2105
+ const parserCode = safeParserCode(nodeCode);
2106
+ if (parserCode === "ERR_HTTP_REQUEST_TIMEOUT") return {
2107
+ status: 408,
2108
+ code: "request_timeout",
2109
+ parserCode
2110
+ };
2111
+ if (parserCode === "HPE_HEADER_OVERFLOW") return {
2112
+ status: 431,
2113
+ code: "headers_too_large",
2114
+ parserCode
2115
+ };
2116
+ return {
2117
+ status: 400,
2118
+ code: "bad_request",
2119
+ parserCode
2120
+ };
2121
+ }
2122
+ const LOG_INTERVAL_MS = 3e4;
2123
+ /**
2124
+ * Per-Gateway diagnostics with three fixed sampling slots and no timers or request-selected keys.
2125
+ * Each kind records once immediately, then at most once per 30 seconds with suppressed counts.
2126
+ * A sink failure cannot change a response, retry a write or expose the diagnostic sink's error.
2127
+ */
2128
+ var HttpFailureRecorder = class {
2129
+ observe;
2130
+ now;
2131
+ reportObserverFailure;
2132
+ windows = {
2133
+ parser: {
2134
+ nextLogAt: 0,
2135
+ suppressed: 0
2136
+ },
2137
+ target: {
2138
+ nextLogAt: 0,
2139
+ suppressed: 0
2140
+ },
2141
+ upstream: {
2142
+ nextLogAt: 0,
2143
+ suppressed: 0
2144
+ }
2145
+ };
2146
+ observerFailureReported = false;
2147
+ constructor(observe, now = () => performance.now(), reportObserverFailure = () => {
2148
+ process.emitWarning("Mobile access HTTP diagnostics could not be recorded", { code: "DSH_MOBILE_HTTP_LOG_FAILED" });
2149
+ }) {
2150
+ this.observe = observe;
2151
+ this.now = now;
2152
+ this.reportObserverFailure = reportObserverFailure;
2153
+ }
2154
+ /** Classify an inbound parser rejection and sample only its fixed status and safe code. */
2155
+ recordParser(nodeCode) {
2156
+ const failure = classifyHttpParserFailure(nodeCode);
2157
+ this.record({
2158
+ kind: "parser",
2159
+ status: failure.status,
2160
+ code: failure.parserCode
2161
+ });
2162
+ return failure;
2163
+ }
2164
+ /** Sample an invalid request target without retaining its raw or decoded URL. */
2165
+ recordTarget() {
2166
+ this.record({
2167
+ kind: "target",
2168
+ status: 400,
2169
+ code: "bad_request"
2170
+ });
2171
+ }
2172
+ /** Sample only authenticated prompt rejections; the caller must authorize before forwarding. */
2173
+ recordUpstream(method, pathname, status) {
2174
+ if (method !== "POST" || pathname !== "/api/session/prompt" || !Number.isInteger(status) || status < 400 || status > 599) return;
2175
+ this.record({
2176
+ kind: "upstream",
2177
+ status,
2178
+ method: "POST",
2179
+ path: "/api/session/prompt"
2180
+ });
2181
+ }
2182
+ record(entry) {
2183
+ if (this.observe === void 0) return;
2184
+ const now = this.now();
2185
+ const window = this.windows[entry.kind];
2186
+ if (now < window.nextLogAt) {
2187
+ window.suppressed = Math.min(Number.MAX_SAFE_INTEGER, window.suppressed + 1);
2188
+ return;
2189
+ }
2190
+ const suppressed = window.suppressed;
2191
+ window.nextLogAt = now + LOG_INTERVAL_MS;
2192
+ window.suppressed = 0;
2193
+ try {
2194
+ this.observe({
2195
+ ...entry,
2196
+ ...suppressed === 0 ? {} : { suppressed }
2197
+ });
2198
+ } catch (observerError) {
2199
+ if (this.observerFailureReported) return;
2200
+ this.observerFailureReported = true;
2201
+ try {
2202
+ this.reportObserverFailure();
2203
+ } catch (reporterError) {}
2204
+ }
2205
+ }
2206
+ };
2207
+ //#endregion
2085
2208
  //#region src/client-module-recovery.ts
2086
2209
  const CLIENT_MODULE_RECOVERY_COPY = {
2087
2210
  zh: {
@@ -5052,7 +5175,7 @@ function rejectUpgrade(socket, status, code) {
5052
5175
  if (socket.destroyed) return;
5053
5176
  const body = `${JSON.stringify({ error: code })}\n`;
5054
5177
  socket.end([
5055
- `HTTP/1.1 ${String(status)} ${status === 401 ? "Unauthorized" : status === 403 ? "Forbidden" : "Bad Request"}`,
5178
+ `HTTP/1.1 ${String(status)} ${STATUS_CODES[status] ?? "Bad Request"}`,
5056
5179
  "Connection: close",
5057
5180
  "Cache-Control: no-store",
5058
5181
  "Content-Type: application/json; charset=utf-8",
@@ -5423,7 +5546,8 @@ var MobileAccessGateway = class {
5423
5546
  clientModuleTasks = /* @__PURE__ */ new Set();
5424
5547
  clientModuleRevocations = /* @__PURE__ */ new Set();
5425
5548
  authFailures;
5426
- constructor(config, store, extensions, upstreamAuthenticatedUrl, extraWebSocketPaths, blockedUpgradeLog, onDiscoveryDegraded, clientModulePreferences, authFailureLog) {
5549
+ httpFailures;
5550
+ constructor(config, store, extensions, upstreamAuthenticatedUrl, extraWebSocketPaths, blockedUpgradeLog, onDiscoveryDegraded, clientModulePreferences, authFailureLog, httpFailureLog) {
5427
5551
  this.config = config;
5428
5552
  this.extensions = extensions;
5429
5553
  this.upstreamAuthenticatedUrl = upstreamAuthenticatedUrl;
@@ -5431,6 +5555,7 @@ var MobileAccessGateway = class {
5431
5555
  this.blockedUpgradeLog = blockedUpgradeLog;
5432
5556
  this.onDiscoveryDegraded = onDiscoveryDegraded;
5433
5557
  this.authFailures = new AuthFailureRecorder(authFailureLog);
5558
+ this.httpFailures = new HttpFailureRecorder(httpFailureLog);
5434
5559
  this.clientModulePreferences = clientModulePreferences ?? new ClientModulePreferenceStore(join(dirname(config.stateFile), `${basename(config.stateFile)}.client-modules.json`), config.excludedClientModules);
5435
5560
  this.listenerTlsEnabled = config.tls.mode === "provided";
5436
5561
  this.tlsEnabled = config.publicTls;
@@ -5527,8 +5652,10 @@ var MobileAccessGateway = class {
5527
5652
  rejectUpgrade(client, mapped.status, mapped.code);
5528
5653
  });
5529
5654
  });
5530
- server.on("clientError", (_error, socket) => {
5531
- rejectUpgrade(socket, 400, "bad_request");
5655
+ server.on("clientError", (error, socket) => {
5656
+ const nodeCode = "code" in error && typeof error.code === "string" ? error.code : void 0;
5657
+ const failure = this.httpFailures.recordParser(nodeCode);
5658
+ rejectUpgrade(socket, failure.status, failure.code);
5532
5659
  });
5533
5660
  await new Promise((resolve, reject) => {
5534
5661
  const failed = (error) => {
@@ -5932,7 +6059,13 @@ var MobileAccessGateway = class {
5932
6059
  sendJson(response, 200, { loggedOut: true }, this.tlsEnabled);
5933
6060
  }
5934
6061
  async handleExternalRequest(request, response) {
5935
- const target = parseRequestTarget(request.url);
6062
+ let target;
6063
+ try {
6064
+ target = parseRequestTarget(request.url);
6065
+ } catch (error) {
6066
+ if (error instanceof HttpError && error.status === 400) this.httpFailures.recordTarget();
6067
+ throw error;
6068
+ }
5936
6069
  const policy = this.requirePolicy();
5937
6070
  const isMutation = request.method !== "GET" && request.method !== "HEAD";
5938
6071
  assertExternalTrust(request, policy, isMutation);
@@ -7093,6 +7226,7 @@ var MobileAccessGateway = class {
7093
7226
  setSecurityHeaders(response, this.tlsEnabled, "proxied");
7094
7227
  const headers = sanitizeResponseHeaders(proxied.headers, this.config.upstreamOrigin);
7095
7228
  const statusCode = proxied.statusCode ?? 502;
7229
+ this.httpFailures.recordUpstream(request$2.method, request$2.url?.split("?", 1)[0] ?? "", statusCode);
7096
7230
  const cacheControl = revisionedStaticCacheControl(request$2, statusCode);
7097
7231
  if (cacheControl !== void 0) headers["cache-control"] = cacheControl;
7098
7232
  const compressible = canCompressResponse(request$2, proxied);
@@ -17601,6 +17735,8 @@ async function apply(ctx, config) {
17601
17735
  logger.warn("%s discovery degraded while DSH remains available: %s", source, code);
17602
17736
  }, clientModulePreferences, (entry) => {
17603
17737
  logger.warn("auth failure %s", JSON.stringify(entry));
17738
+ }, (entry) => {
17739
+ logger.warn("http failure %s", JSON.stringify(entry));
17604
17740
  });
17605
17741
  await candidate.start();
17606
17742
  lanGateway = candidate;
@@ -17659,6 +17795,8 @@ async function apply(ctx, config) {
17659
17795
  const resolved = remoteGatewayConfig(template, publicOrigin, remoteDeviceFile, remoteInstanceId, listenPort, instanceId);
17660
17796
  const candidate = new MobileAccessGateway(resolved, new JsonDeviceStore(resolved.stateFile, resolved.maxDevices), mobileAccess, upstreamLoginUrl, webSocketPaths, blockedUpgradePaths, void 0, clientModulePreferences, (entry) => {
17661
17797
  logger.warn("auth failure %s", JSON.stringify(entry));
17798
+ }, (entry) => {
17799
+ logger.warn("http failure %s", JSON.stringify(entry));
17662
17800
  });
17663
17801
  await candidate.start();
17664
17802
  return candidate;
@@ -17675,7 +17813,11 @@ async function apply(ctx, config) {
17675
17813
  if (!isFrpSelfSignedIngress(settings)) return createRemoteGateway(publicOrigin);
17676
17814
  const ingress = await ensureFrpIngressCertificate(settings, remoteDeviceFile, Date.now(), void 0, stateDirectory);
17677
17815
  const resolved = frpIngressGatewayConfig(template, settings, remoteDeviceFile, ingress);
17678
- const candidate = new MobileAccessGateway(resolved, new JsonDeviceStore(resolved.stateFile, resolved.maxDevices), mobileAccess, upstreamLoginUrl, webSocketPaths, blockedUpgradePaths, void 0, clientModulePreferences);
17816
+ const candidate = new MobileAccessGateway(resolved, new JsonDeviceStore(resolved.stateFile, resolved.maxDevices), mobileAccess, upstreamLoginUrl, webSocketPaths, blockedUpgradePaths, void 0, clientModulePreferences, (entry) => {
17817
+ logger.warn("auth failure %s", JSON.stringify(entry));
17818
+ }, (entry) => {
17819
+ logger.warn("http failure %s", JSON.stringify(entry));
17820
+ });
17679
17821
  try {
17680
17822
  await candidate.start();
17681
17823
  } catch (error) {
@@ -17686,7 +17828,11 @@ async function apply(ctx, config) {
17686
17828
  };
17687
17829
  const createOriginGateway = async (settings) => {
17688
17830
  const resolved = originGatewayConfig(template, settings, remoteDeviceFile, remoteInstanceId, settings.listenPort, instanceId);
17689
- const candidate = new MobileAccessGateway(resolved, new JsonDeviceStore(resolved.stateFile, resolved.maxDevices), mobileAccess, upstreamLoginUrl, webSocketPaths, blockedUpgradePaths, void 0, clientModulePreferences);
17831
+ const candidate = new MobileAccessGateway(resolved, new JsonDeviceStore(resolved.stateFile, resolved.maxDevices), mobileAccess, upstreamLoginUrl, webSocketPaths, blockedUpgradePaths, void 0, clientModulePreferences, (entry) => {
17832
+ logger.warn("auth failure %s", JSON.stringify(entry));
17833
+ }, (entry) => {
17834
+ logger.warn("http failure %s", JSON.stringify(entry));
17835
+ });
17690
17836
  try {
17691
17837
  await candidate.start();
17692
17838
  } catch (error) {