@dshn/agent 0.2.0 → 0.3.1

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/README.md CHANGED
@@ -1,7 +1,10 @@
1
1
  # dshn — DeepSeek Harness Network
2
2
 
3
+ **English** · [中文](./README.zh.md)
4
+
5
+ [![awesome · DSH plugin](https://awesome-dsh-plugin.com/badge.svg)](https://awesome-dsh-plugin.com/)
6
+ [![npm](https://img.shields.io/npm/v/@dshn/agent?label=%40dshn%2Fagent&color=cb3837)](https://www.npmjs.com/package/@dshn/agent)
3
7
  [![license](https://img.shields.io/badge/license-MIT-blue.svg)](./LICENSE)
4
- [![dsh-plugin](https://img.shields.io/badge/dsh-plugin-6aa84f.svg)](https://awesome-dsh-plugin.com/)
5
8
 
6
9
  Expose a locally-running **DeepSeek Harness** (`dsh`) web UI to the public
7
10
  internet under a `*.ds.hn` subdomain, gated by a login. Install the plugin, open
@@ -70,7 +73,7 @@ dsh (local web server) fence sees a loopback req
70
73
  | package | what it is | runs where |
71
74
  |---|---|---|
72
75
  | `@dshn/protocol` | the WSS frame contract both ends compile against | shared |
73
- | `dshn` | the dsh plugin: setup form + outbound tunnel + status widget + e2e | user's machine, inside dsh |
76
+ | `@dshn/agent` | the dsh plugin: setup form + outbound tunnel + status widget + e2e | user's machine, inside dsh |
74
77
  | `@dshn/relay` | login gate + claim store + subdomain router + HTTP/WS bridge | your server, behind Cloudflare |
75
78
 
76
79
  The claim store (`packages/relay/src/claims.ts`) is trust-on-first-use for now;
@@ -119,13 +122,15 @@ Agent environment (all optional; sensible defaults):
119
122
  ## Self-host your own network
120
123
 
121
124
  You don't have to use `ds.hn` — run the whole thing on your own domain. The relay
122
- ships as **`@dshn/relay`** (npm) and a Docker image; your agents point at it with
125
+ ships as **`@dshn/relay`** (npm) and a Docker image; point your agents at it in
126
+ the setup form (pick **自托管 / Self-hosted** and paste the relay URL) or with
123
127
  `DSHN_RELAY_HOST`. Full guide, including DNS + TLS options: **[SELF-HOSTING.md](./SELF-HOSTING.md)**.
124
128
 
125
129
  ```sh
126
- # your server
127
- DSHN_APEX=tunnel.example.com DSHN_COOKIE_SECRET=$(openssl rand -hex 32) npx @dshn/relay
128
- # your dsh
130
+ # your server — the only thing you set is your apex; the cookie secret is
131
+ # auto-generated and persisted, claims + secret live in --data-dir
132
+ npx @dshn/relay --apex tunnel.example.com --data-dir /var/lib/dshn
133
+ # your dsh — or just set it in Settings → 公网转发 → 自托管
129
134
  DSHN_RELAY_HOST=wss://tunnel.example.com dsh --profile web
130
135
  ```
131
136
 
@@ -133,22 +138,21 @@ Or from source:
133
138
 
134
139
  ```sh
135
140
  pnpm install && pnpm build
136
- DSHN_COOKIE_SECRET=$(openssl rand -hex 32) \
137
- DSHN_APEX=ds.hn \
138
- DSHN_RELAY_PORT=8787 \
139
- DSHN_CLAIMS=./claims.json \
140
- DSHN_TLS_CERT=./cert.pem DSHN_TLS_KEY=./key.pem \
141
- node packages/relay/lib/index.js
141
+ node packages/relay/lib/index.js --apex ds.hn --data-dir ./dshn-data
142
142
  ```
143
143
 
144
- | var | required | purpose |
145
- |---|---|---|
146
- | `DSHN_COOKIE_SECRET` | | HMAC secret for session cookies (rotating it logs everyone out) |
147
- | `DSHN_APEX` | — (`ds.hn`) | apex domain the wildcard hangs off |
148
- | `DSHN_RELAY_PORT` | (`8787`) | listen port |
149
- | `DSHN_CLAIMS` | — | JSON file the relay creates/maintains (subdomain → scrypt hash) |
150
- | `DSHN_TLS_CERT` / `DSHN_TLS_KEY` | | PEM paths to serve HTTPS directly (else plain HTTP behind CF) |
151
- | `DSHN_SITE` | | apex landing-page HTML |
144
+ The only setting you need is `--apex`. The cookie secret is auto-generated and
145
+ persisted under `--data-dir` (no `openssl rand`), reused across restarts; every
146
+ flag also has an env var (`DSHN_APEX`, …). `--help` lists them all:
147
+
148
+ | flag | env | default | purpose |
149
+ |---|---|---|---|
150
+ | `--apex` | `DSHN_APEX` | `ds.hn` | apex the wildcard hangs off |
151
+ | `--data-dir` | `DSHN_DATA_DIR` | `./dshn-data` | holds `claims.json` + the auto-generated `cookie-secret` |
152
+ | `--port` | `DSHN_RELAY_PORT` | `8787` | listen port |
153
+ | `--secret` | `DSHN_COOKIE_SECRET` | *(auto)* | cookie HMAC secret; set only to pin it |
154
+ | `--tls-cert` / `--tls-key` | `DSHN_TLS_CERT` / `DSHN_TLS_KEY` | — | PEM paths to serve HTTPS directly (else plain HTTP behind CF) |
155
+ | `--site` | `DSHN_SITE` | — | apex landing-page HTML |
152
156
 
153
157
  Cloudflare: proxy `*.ds.hn` (orange cloud) to the relay's origin. Harden the
154
158
  origin to accept only Cloudflare — firewall to the
package/README.zh.md ADDED
@@ -0,0 +1,110 @@
1
+ # dshn — DeepSeek Harness Network
2
+
3
+ [English](./README.md) · **中文**
4
+
5
+ [![awesome · DSH plugin](https://awesome-dsh-plugin.com/badge.svg)](https://awesome-dsh-plugin.com/)
6
+ [![npm](https://img.shields.io/npm/v/@dshn/agent?label=%40dshn%2Fagent&color=cb3837)](https://www.npmjs.com/package/@dshn/agent)
7
+ [![license](https://img.shields.io/badge/license-MIT-blue.svg)](./LICENSE)
8
+
9
+ 把本机运行的 **DeepSeek Harness**(`dsh`)网页界面,通过 `*.ds.hn` 子域名安全地开放到公网,并由登录门禁把守。安装插件、在本地打开 dsh,设置里的表单会让你填一个**子域前缀**和一个**密码**——这两项就是凭据。无需 token、无需环境变量、无需任何预置。还可选设置一个**端到端密码**加密流量,连中继运营者也只能看到密文。
10
+
11
+ > ⚠️ **dsh 内置 bash 与文件系统工具,公网可达的 dsh 界面就是一个远程 Shell。** 中继的登录门禁不是可选项,不要关掉它。请使用高强度密码,敏感场景优先启用端到端加密。
12
+
13
+ ## 特性
14
+
15
+ - **零配置凭据。** 在 dsh 设置里填一次 `(子域, 密码)` → 插件即认领子域并连接。凭据持久化到 dsh 自己的 `~/.dsh/settings.yaml`,重启自动重连。
16
+ - **信任首次使用(TOFU)。** 首个认领空闲子域的 agent 设定其密码(在中继上以 scrypt 哈希存储);此后的连接与每一次浏览器登录都必须匹配它——防抢占。
17
+ - **多设备。** 多台机器可用同一凭据绑定**同一个**子域,各自显示为一台具名设备。有 ≥2 台在线时,打开链接会出现设备选择页,页面侧栏底部也有切换器;选择按浏览器记住(路由 cookie),切换即对另一台机器做一次干净的重载。仅一台在线时行为与从前完全一致。
18
+ - **可选端到端加密**(默认关闭)。一个**独立**的 e2e 密码(绝不发往中继)加密 `/api` 请求体与事件流:PBKDF2-SHA256(21 万次)→ AES-256-GCM。访客在浏览器里输入一次即可,可按设备记在 `localStorage`(永不传输)。
19
+ - **原生 UI。** 配置就在 dsh 自己的设置里(「公网转发」),页脚一行实时显示延迟并可点入。
20
+ - **自持数据面。** 流量经 Cloudflare 边缘回到**你自己的**服务器——无需每用户的 Cloudflare 账号,无需 NS 委派。
21
+
22
+ ## 架构
23
+
24
+ ```
25
+ 浏览器 alice.ds.hn
26
+ │ HTTPS
27
+
28
+ Cloudflare 边缘 (*.ds.hn 代理 / 橙色云) 免费 DDoS、WAF、TLS、
29
+ │ 回源 Anycast、隐藏源站
30
+
31
+ 中继 relay (你的服务器, @dshn/relay) 登录门禁 + 子域认领表;
32
+ │ 每设备一条多路复用 WSS 只搬运字节
33
+
34
+ dshn (dsh 插件, 在用户机器上) 把 HTTP + WS 重放给 dsh,
35
+ │ http://127.0.0.1:<dsh 端口> Host/Origin 改写为环回
36
+
37
+ dsh (本地网页服务) 信任门禁看到的是一个环回请求
38
+ ```
39
+
40
+ - **不改 trustedHosts。** agent 把每个转发请求的 Host/Origin 改写为环回,于是 dsh 的 `/api` 浏览器信任门禁把它当作**任意**运行时选定子域的本地同源请求接受——这正是「子域来自表单而非组合」得以成立的原因。访问由中继登录把守,而非该门禁。
41
+ - **端到端模式** 在 agent 处密封请求/响应体、在浏览器里解开;中继始终是一个盲搬运者。应用外壳与插件包保持明文,以便浏览器自举并弹出解锁弹窗。它能防住被动/好奇的中继与静态数据泄露,但防不住一个主动作恶、篡改所投送 JS 的中继。
42
+
43
+ ## 包结构
44
+
45
+ | 包 | 是什么 | 运行在哪 |
46
+ |---|---|---|
47
+ | `@dshn/protocol` | 两端共同编译的 WSS 帧协议 | 共享 |
48
+ | `@dshn/agent` | dsh 插件:设置表单 + 出站隧道 + 状态挂件 + e2e | 用户机器,dsh 之内 |
49
+ | `@dshn/relay` | 登录门禁 + 认领表 + 子域路由 + HTTP/WS 桥接 | 你的服务器,Cloudflare 之后 |
50
+
51
+ 认领表(`packages/relay/src/claims.ts`)目前是信任首次使用;账号化的控制面日后替换它。
52
+
53
+ ## 安装 agent(用户机器)
54
+
55
+ 从 npm 安装(推荐——一条命令,完全自包含):
56
+
57
+ ```sh
58
+ dsh plugin --profile web add @dshn/agent
59
+ dsh --profile web
60
+ ```
61
+
62
+ 或从最新 GitHub Release 下载预构建 tarball:
63
+
64
+ ```sh
65
+ curl -L -o dshn.tgz \
66
+ https://github.com/jsdvjx/dshn/releases/latest/download/dshn.tgz
67
+ dsh plugin --profile web add ./dshn.tgz
68
+ ```
69
+
70
+ 或从源码构建:
71
+
72
+ ```sh
73
+ pnpm install && node scripts/build-dist.mjs
74
+ dsh plugin --profile web add ./dist/dshn
75
+ dsh --profile web
76
+ ```
77
+
78
+ 随后在本地打开 dsh,进入 **设置 → 公网转发**,填写子域前缀与密码(可选端到端密码),点**连接**。用同一个访问密码即可从手机登录。每个子域最多跑**一个** agent——相同凭据的两个 agent 会互相争抢。
79
+
80
+ agent 环境变量(全部可选,均有合理默认值):
81
+
82
+ | 变量 | 默认值 | 用途 |
83
+ |---|---|---|
84
+ | `DSHN_ENABLED` | `1` | 设为 `0` 则加载插件但不启用 |
85
+ | `DSHN_RELAY_HOST` | `relay.ds.hn` | 中继地址;直连(绕开 Cloudflare)用 `wss://origin.ds.hn:8787` |
86
+ | `DSHN_ORIGIN_CA` | — | 钉扎自签名直连源站证书的 PEM |
87
+ | `DSHN_STATE` | `~/.dsh/dshn-agent.json` | 旧版状态文件(凭据现在存于 `settings.yaml`) |
88
+ | `DSH_HOME` | `~/.dsh` | dsh 主目录 |
89
+
90
+ ## 自托管你自己的网络
91
+
92
+ 你不必用 `ds.hn`——整套都能跑在你自己的域名上。中继以 **`@dshn/relay`**(npm)及 Docker 镜像发布;在设置表单里选 **自托管**、填入中继地址即可指过去(也可用 `DSHN_RELAY_HOST`)。完整指南(含 DNS 与 TLS 各选项):**[SELF-HOSTING.md](./SELF-HOSTING.md)**。
93
+
94
+ ```sh
95
+ # 你的服务器 —— 唯一必填的只有 apex;登录密钥自动生成并持久化,
96
+ # claims 与密钥都放在 --data-dir 里
97
+ npx @dshn/relay --apex tunnel.example.com --data-dir /var/lib/dshn
98
+ # 你的 dsh —— 或直接在 设置 → 公网转发 → 自托管 里填
99
+ DSHN_RELAY_HOST=wss://tunnel.example.com dsh --profile web
100
+ ```
101
+
102
+ Cloudflare:把 `*.ds.hn`(橙色云代理)指向中继源站。请把源站加固为仅接受 Cloudflare——按 [Cloudflare IP 段](https://www.cloudflare.com/ips/)做防火墙,并启用 Authenticated Origin Pulls(mTLS)。因为 Cloudflare 约 100 秒关闭空闲 WebSocket,两端每 25 秒心跳——已内置。若要承载持续大流量的直连隧道,加一条灰云(仅 DNS)`origin.ds.hn` A 记录,并让 agent 用 `DSHN_RELAY_HOST` + `DSHN_ORIGIN_CA` 指过去。
103
+
104
+ ## 现状
105
+
106
+ 端到端可用。已知不足:偶发的隧道套接字断开会让该连接上的在途请求失败(尚无请求重放);持续大流量下 Cloudflare 可能重置隧道(改用直连源站方案);CF 免费版 100 MB 请求上限会截断较大的 dsh 图片上传;认领表仍是信任首次使用、无账号层;生产环境应把中继源站锁定到 Cloudflare IP 段并启用 Authenticated Origin Pulls。
107
+
108
+ ## 许可
109
+
110
+ [MIT](./LICENSE)
package/client.js CHANGED
@@ -22,244 +22,14 @@ window.__ModuleLoader__.load({
22
22
  const ID = 'dshn'
23
23
  const POLL_MS = 2500
24
24
  const MIN_PW = 8
25
- const E2E_HEADER = 'x-dshn-e2e'
26
25
  const E2E_PUB_PATH = '/dshn-e2e'
27
- const E2E_ITERS = 210000
28
-
29
- // ── end-to-end decryption shim ────────────────────────────────────────────
30
- // Runs only when the page is opened THROUGH the tunnel (a public host, not
31
- // loopback) and the agent reports E2E on. It patches fetch + WebSocket so
32
- // /api request bodies are sealed and responses / event messages are decrypted
33
- // with a key derived from an e2e password the visitor types — a password that
34
- // never reaches the relay. dsh's own traffic is gated until that key is ready.
35
- ;(function installE2E() {
36
- window.__dshnE2E = { stage: 'entered', host: (typeof location !== 'undefined' ? location.hostname : '?') }
37
- if (typeof window === 'undefined' || !window.crypto || !window.crypto.subtle) { window.__dshnE2E.stage = 'no-subtle'; return }
38
- const host = location.hostname
39
- const loopback = host === 'localhost' || host === '::1' || /^127\./.test(host)
40
- window.__dshnE2E.remote = !loopback
41
- if (loopback) { window.__dshnE2E.stage = 'loopback-skip'; return } // local access talks straight to dsh; nothing is encrypted
42
-
43
- const realFetch = window.fetch.bind(window)
44
- const RealWS = window.WebSocket
45
- const enc = new TextEncoder()
46
- let key = null // CryptoKey once the visitor unlocks; null = pass-through
47
- let active = false // agent reports E2E on
48
- let resolveReady
49
- const ready = new Promise((r) => { resolveReady = r })
50
- const hexToBytes = (hx) => { const a = new Uint8Array(hx.length / 2); for (let i = 0; i < a.length; i++) a[i] = parseInt(hx.substr(i * 2, 2), 16); return a }
51
- const isApi = (url) => { try { const u = new URL(url, location.href); return u.origin === location.origin && u.pathname.startsWith('/api') } catch { return false } }
52
-
53
- async function deriveKey(password, saltHex) {
54
- const base = await crypto.subtle.importKey('raw', enc.encode(password), 'PBKDF2', false, ['deriveKey'])
55
- return crypto.subtle.deriveKey({ name: 'PBKDF2', salt: hexToBytes(saltHex), iterations: E2E_ITERS, hash: 'SHA-256' },
56
- base, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt'])
57
- }
58
- async function sealBytes(bytes) {
59
- const iv = crypto.getRandomValues(new Uint8Array(12))
60
- const ct = new Uint8Array(await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, bytes))
61
- const out = new Uint8Array(iv.length + ct.length); out.set(iv); out.set(ct, iv.length); return out
62
- }
63
- async function openBytes(k, blob) {
64
- const iv = blob.subarray(0, 12)
65
- const pt = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, k, blob.subarray(12))
66
- return new Uint8Array(pt)
67
- }
68
-
69
- // fetch: seal /api request bodies, decrypt marked responses. Non-/api and
70
- // (once ready) the E2E-off case pass straight through.
71
- window.fetch = async function (input, init) {
72
- const url = typeof input === 'string' ? input : (input && input.url) || String(input)
73
- if (!isApi(url)) return realFetch(input, init)
74
- await ready
75
- if (!key) return realFetch(input, init)
76
- const req = new Request(input, init)
77
- const headers = new Headers(req.headers)
78
- let body = null
79
- const buf = await req.clone().arrayBuffer()
80
- if (buf.byteLength > 0) { body = await sealBytes(new Uint8Array(buf)); headers.set(E2E_HEADER, '1') }
81
- else headers.set(E2E_HEADER, '1')
82
- const res = await realFetch(url, { method: req.method, headers, body, credentials: 'include', mode: req.mode, cache: req.cache })
83
- if (res.headers.get(E2E_HEADER) !== '1') return res
84
- const sealed = new Uint8Array(await res.arrayBuffer())
85
- let plain
86
- try { plain = await openBytes(key, sealed) } catch { return new Response(null, { status: 502, statusText: 'e2e decrypt failed' }) }
87
- const outH = new Headers(res.headers); outH.delete(E2E_HEADER); outH.delete('content-length')
88
- return new Response(plain, { status: res.status, statusText: res.statusText, headers: outH })
89
- }
90
-
91
- // WebSocket: connect normally, but decrypt the sealed downlink messages in
92
- // arrival order once the key is ready. Everything else proxies through.
93
- const E2EWebSocket = class extends EventTarget {
94
- constructor(url, protocols) {
95
- super()
96
- this._ws = new RealWS(url, protocols)
97
- this._q = Promise.resolve()
98
- this._seal = active && isApi(String(url))
99
- for (const t of ['open', 'error']) this._ws.addEventListener(t, (e) => this._emit(t, e))
100
- this._ws.addEventListener('close', (e) => this._emit('close', e))
101
- this._ws.addEventListener('message', (e) => { this._q = this._q.then(() => this._msg(e)) })
102
- }
103
- get url() { return this._ws.url }
104
- get readyState() { return this._ws.readyState }
105
- get bufferedAmount() { return this._ws.bufferedAmount }
106
- get protocol() { return this._ws.protocol }
107
- get extensions() { return this._ws.extensions }
108
- get binaryType() { return this._ws.binaryType }
109
- set binaryType(v) { this._ws.binaryType = v }
110
- set onopen(f) { this._onopen = f } get onopen() { return this._onopen }
111
- set onclose(f) { this._onclose = f } get onclose() { return this._onclose }
112
- set onerror(f) { this._onerror = f } get onerror() { return this._onerror }
113
- set onmessage(f) { this._onmessage = f } get onmessage() { return this._onmessage }
114
- send(d) { this._ws.send(d) }
115
- close(c, r) { this._ws.close(c, r) }
116
- _emit(type, orig) {
117
- const ev = type === 'close' ? new CloseEvent('close', { code: orig.code, reason: orig.reason, wasClean: orig.wasClean }) : new Event(type)
118
- const on = this['_on' + type]; if (on) try { on.call(this, ev) } catch {}
119
- this.dispatchEvent(ev)
120
- }
121
- async _msg(e) {
122
- let data = e.data
123
- if (this._seal) {
124
- await ready
125
- if (key) {
126
- try {
127
- const raw = data instanceof ArrayBuffer ? new Uint8Array(data)
128
- : data instanceof Blob ? new Uint8Array(await data.arrayBuffer())
129
- : new Uint8Array(await new Blob([data]).arrayBuffer())
130
- const opened = await openBytes(key, raw)
131
- data = opened[0] === 0 ? new TextDecoder().decode(opened.subarray(1)) : opened.subarray(1).buffer
132
- } catch { return } // drop messages we can't decrypt
133
- }
134
- }
135
- const ev = new MessageEvent('message', { data })
136
- if (this._onmessage) try { this._onmessage.call(this, ev) } catch {}
137
- this.dispatchEvent(ev)
138
- }
139
- }
140
- for (const [k, v] of [['CONNECTING', 0], ['OPEN', 1], ['CLOSING', 2], ['CLOSED', 3]]) {
141
- E2EWebSocket[k] = v; E2EWebSocket.prototype[k] = v
142
- }
143
- window.WebSocket = E2EWebSocket
144
-
145
- // Discover E2E state, then (if on) show the unlock gate and derive the key.
146
- // If E2E is OFF (the default), restore the native fetch/WebSocket entirely
147
- // so nothing here sits in the normal path — the feature is truly opt-in.
148
- ;(async () => {
149
- try {
150
- window.__dshnE2E.stage = 'checking'
151
- const info = await realFetch(E2E_PUB_PATH, { cache: 'no-store' }).then((r) => r.json())
152
- window.__dshnE2E.stage = 'checked'; window.__dshnE2E.enabled = info && info.enabled
153
- if (!info || !info.enabled || !info.salt) { window.fetch = realFetch; window.WebSocket = RealWS; window.__dshnE2E.stage = 'off-restored'; resolveReady(); return }
154
- active = true
155
- window.__dshnE2E.stage = 'gating'
156
- await unlockGate(info.salt, info.device)
157
- window.__dshnE2E.stage = 'unlocked'
158
- } catch (e) { window.fetch = realFetch; window.WebSocket = RealWS; window.__dshnE2E.stage = 'error'; window.__dshnE2E.error = String(e && e.message || e) }
159
- resolveReady()
160
- })()
161
26
 
162
- // A blocking DOM overlay (not React — must appear before the app mounts)
163
- // asking for the e2e password; verified by a sealed probe to /api.
164
- function unlockGate(salt, deviceKey) {
165
- return new Promise((resolve) => {
166
- const zh = String(document.documentElement.lang || navigator.language || 'en').toLowerCase().indexOf('zh') === 0
167
- const L = zh
168
- ? { t: '端到端加密', s: '本页内容已端到端加密。输入端到端密码解锁——密码不会发送到云端。', p: '端到端密码', u: '解锁', bad: '密码错误,无法解密。',
169
- save: '在此设备记住密码', stale: '已保存的密码无法解锁(可能已被更改),请重新输入。' }
170
- : { t: 'End-to-end encrypted', s: 'This session is end-to-end encrypted. Enter the e2e password to unlock — it is never sent to the cloud.', p: 'E2E password', u: 'Unlock', bad: 'Wrong password — cannot decrypt.',
171
- save: 'Remember on this device', stale: 'The saved password no longer works (it may have been changed). Enter it again.' }
172
- // Remembered password lives in localStorage, per public host AND per
173
- // device, on THIS browser only — never transmitted (E2E is intact).
174
- // The device part matters on a multi-device subdomain: each machine
175
- // has its own e2e password, and one saved copy must not clobber (or be
176
- // probed against) another device's. Keyed by host+device (not salt) so
177
- // a changed e2e password is detected and re-prompted. The old
178
- // host-only key is read once as a fallback and migrated on success.
179
- const LEGACY_KEY = 'dshn:e2e:' + location.hostname
180
- const STORE_KEY = LEGACY_KEY + (deviceKey ? ':' + deviceKey : '')
181
- const readSaved = () => {
182
- try { return localStorage.getItem(STORE_KEY) || (STORE_KEY !== LEGACY_KEY ? localStorage.getItem(LEGACY_KEY) : null) } catch { return null }
183
- }
184
- const writeSaved = (v) => {
185
- try {
186
- if (v == null) localStorage.removeItem(STORE_KEY); else localStorage.setItem(STORE_KEY, v)
187
- if (STORE_KEY !== LEGACY_KEY) localStorage.removeItem(LEGACY_KEY)
188
- } catch { /* storage may be blocked */ }
189
- }
190
-
191
- // Derive from a password string and probe /api with a sealed body; on a
192
- // correct key set the live key and return true. A wrong key → agent 400
193
- // (or the response fails to open), so return false.
194
- const attempt = async (pwStr) => {
195
- try {
196
- const cand = await deriveKey(pwStr, salt)
197
- const iv = crypto.getRandomValues(new Uint8Array(12))
198
- const ct = new Uint8Array(await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, cand, enc.encode('{}')))
199
- const probeBody = new Uint8Array(iv.length + ct.length); probeBody.set(iv); probeBody.set(ct, iv.length)
200
- const r = await realFetch('/api/host.describe', { method: 'POST', headers: { [E2E_HEADER]: '1', 'content-type': 'application/json' }, body: probeBody, credentials: 'include' })
201
- if (r.status === 400) return false
202
- if (r.headers.get(E2E_HEADER) === '1') { await openBytes(cand, new Uint8Array(await r.arrayBuffer())) }
203
- key = cand
204
- return true
205
- } catch { return false }
206
- }
207
-
208
- ;(async () => {
209
- // 1. A remembered password unlocks silently — the gate never appears.
210
- let stale = false
211
- const saved = readSaved()
212
- if (saved) {
213
- if (await attempt(saved)) {
214
- writeSaved(saved) // re-write so a legacy host-only entry migrates to the per-device key
215
- window.__dshnE2E.autounlock = true; resolve(); return
216
- }
217
- writeSaved(null); stale = true // the saved one no longer works → drop it and tell the user
218
- }
219
-
220
- // 2. Otherwise show the unlock gate, themed with dsh's own tokens so it
221
- // matches the app (light/dark aware; dark fallbacks if vars are absent).
222
- const V = {
223
- mask: 'var(--dsw-alias-bg-mask-1, rgba(8,10,14,.55))', blur: 'var(--dsw-mask-blur, blur(4px))',
224
- card: 'var(--dsw-alias-bg-layer-2, #171a1f)', fg: 'var(--dsw-alias-label-primary, #e8eaed)',
225
- sub: 'var(--dsw-alias-label-tertiary, #9aa0aa)', bd: 'var(--dsw-alias-border-l1, rgba(128,134,142,.35))',
226
- shadow: 'var(--dsw-shadow-lv3, 0 24px 64px rgba(0,0,0,.5))',
227
- accent: 'var(--dsw-alias-button-primary-fill, #4176e6)', accentFg: 'var(--dsw-alias-label-primary-foreground, #fff)',
228
- err: 'var(--dsw-alias-state-error-primary, #e5484d)', warn: 'var(--dsw-alias-state-warn-primary, #d98324)',
229
- focus: 'var(--dsw-alias-label-primary-bluish, #4176e6)',
230
- }
231
- const ov = document.createElement('div')
232
- ov.setAttribute('style', 'position:fixed;inset:0;z-index:2147483000;display:grid;place-items:center;background:' + V.mask + ';backdrop-filter:' + V.blur + ';font-family:var(--dsw-font-family, system-ui, -apple-system, sans-serif)')
233
- ov.innerHTML =
234
- '<form style="width:min(360px,92vw);box-sizing:border-box;padding:22px;border-radius:16px;background:' + V.card + ';color:' + V.fg + ';border:1px solid ' + V.bd + ';box-shadow:' + V.shadow + '">'
235
- + '<div style="font-size:15px;font-weight:600;margin-bottom:6px">🔒 ' + L.t + '</div>'
236
- + '<div style="font-size:12.5px;color:' + V.sub + ';margin-bottom:16px;line-height:1.5">' + L.s + '</div>'
237
- + '<div id="dshn-e2e-err" style="display:none;font-size:12px;margin-bottom:10px;line-height:1.5"></div>'
238
- + '<input id="dshn-e2e-pw" type="password" placeholder="' + L.p + '" autocomplete="off" style="width:100%;box-sizing:border-box;padding:10px 12px;border-radius:10px;border:1px solid ' + V.bd + ';background:transparent;color:inherit;font-size:14.5px;outline:none">'
239
- + '<label style="display:flex;align-items:center;gap:8px;margin-top:12px;font-size:12.5px;color:' + V.sub + ';cursor:pointer;user-select:none">'
240
- + '<input id="dshn-e2e-remember" type="checkbox" checked style="width:15px;height:15px;margin:0;accent-color:' + V.accent + ';cursor:pointer">' + L.save + '</label>'
241
- + '<button type="submit" style="width:100%;margin-top:16px;padding:10px;border:0;border-radius:10px;background:' + V.accent + ';color:' + V.accentFg + ';font-size:14.5px;font-weight:500;cursor:pointer">' + L.u + '</button></form>'
242
- const mount = () => document.body.appendChild(ov)
243
- if (document.body) mount(); else document.addEventListener('DOMContentLoaded', mount)
244
- const form = ov.querySelector('form'), pw = ov.querySelector('#dshn-e2e-pw'), err = ov.querySelector('#dshn-e2e-err'), remember = ov.querySelector('#dshn-e2e-remember')
245
- pw.addEventListener('focus', () => { pw.style.borderColor = V.focus })
246
- pw.addEventListener('blur', () => { pw.style.borderColor = V.bd })
247
- const showErr = (msg, color) => { err.textContent = msg; err.style.color = color; err.style.display = 'block' }
248
- if (stale) showErr(L.stale, V.warn) // the "saved password no longer works" notice
249
- form.addEventListener('submit', async (e) => {
250
- e.preventDefault()
251
- const btn = form.querySelector('button'); btn.disabled = true
252
- if (await attempt(pw.value)) {
253
- writeSaved(remember.checked ? pw.value : null)
254
- ov.remove()
255
- resolve()
256
- } else { showErr(L.bad, V.err); btn.disabled = false; pw.select() }
257
- })
258
- setTimeout(() => pw.focus(), 50)
259
- })()
260
- })
261
- }
262
- })()
27
+ // ── end-to-end decryption ─────────────────────────────────────────────────
28
+ // The browser half of E2E (fetch/WebSocket patching + unlock gate) is NOT
29
+ // here any more: the host injects it into the app shell's <head> (see
30
+ // src/e2e-shim.ts) so it runs before any dsh code. As a module it arrived
31
+ // after dsh had already opened its event socket and issued its first /api
32
+ // calls, which then saw ciphertext. Nothing to install from this side.
263
33
 
264
34
  const CSS = `
265
35
  .dshn-root.dshn-root { position: fixed; left: 12px; bottom: 12px; z-index: 40;
@@ -417,6 +187,7 @@ window.__ModuleLoader__.load({
417
187
  savedHint: '手机访问用这个密码登录。忘记时点“复制/显示”取回。',
418
188
  weak: '弱', fair: '一般', good: '较强', strong: '强',
419
189
  infoRelay: '线路', infoMode: { direct: '直连源站', cloudflare: '经 Cloudflare' },
190
+ routePremium: '高级线路(加速)', routeStandard: '标准线路',
420
191
  infoUptime: '在线时长', infoServed: '已转发请求', infoPort: '本地端口', infoLatency: '延迟', infoDevice: '设备名',
421
192
  e2eLabel: '端到端密码(可选)', e2eHint: '设置后,会话内容用它加密,云端也看不到;密码不出本机。访问时需在网页再输一次。',
422
193
  e2eApply: '设置端到端密码', e2eUpdate: '更新端到端密码', e2eDisable: '关闭加密', e2eApplied: '✓ 端到端加密已开启', e2eOff2: '✓ 端到端加密已关闭', e2eIndep: '独立设置,不影响上面的连接。',
@@ -442,6 +213,7 @@ window.__ModuleLoader__.load({
442
213
  savedHint: 'Log in from a phone with this password. Copy/show it here if you forget.',
443
214
  weak: 'weak', fair: 'fair', good: 'good', strong: 'strong',
444
215
  infoRelay: 'Link', infoMode: { direct: 'direct to origin', cloudflare: 'via Cloudflare' },
216
+ routePremium: 'premium route (accelerated)', routeStandard: 'standard route',
445
217
  infoUptime: 'Uptime', infoServed: 'Requests served', infoPort: 'Local port', infoLatency: 'Latency', infoDevice: 'Device name',
446
218
  e2eLabel: 'End-to-end password (optional)', e2eHint: 'If set, session content is encrypted with it — even the cloud cannot read it, and it never leaves this machine. Visitors enter it again in the browser.',
447
219
  e2eApply: 'Set e2e password', e2eUpdate: 'Update e2e password', e2eDisable: 'Turn off', e2eApplied: '✓ End-to-end encryption on', e2eOff2: '✓ End-to-end encryption off', e2eIndep: 'Applied on its own — does not affect the connection above.',
@@ -478,6 +250,7 @@ window.__ModuleLoader__.load({
478
250
  P('M7 1.7c2.3 2.3 2.3 8.3 0 10.6'), P('M7 1.7c-2.3 2.3-2.3 8.3 0 10.6')],
479
251
  cloud: () => [P('M4.4 10.6a2.6 2.6 0 01.2-5.2 3.4 3.4 0 016.5.9 2.2 2.2 0 01-.4 4.3z')],
480
252
  plug: () => [P('M5 2.3v2.2M9 2.3v2.2'), P('M4 4.6h6v1.9a3 3 0 01-6 0z'), P('M7 9.4v2.3')],
253
+ bolt: () => [P('M7.6 1.8L3.3 7.8h3.1l-.8 4.4 4.3-6h-3.1z')],
481
254
  gauge: () => [P('M2.2 10.4a5 5 0 019.6 0'), P('M7 10.4l2.4-2.7'), h('circle', { key: 'd', cx: 7, cy: 10.4, r: .5, fill: 'currentColor' })],
482
255
  clock: () => [h('circle', { key: 'c', cx: 7, cy: 7, r: 5.3 }), P('M7 4.1v3.1l2 1.2')],
483
256
  swap: () => [P('M3.4 5h7.2l-2-2'), P('M10.6 9H3.4l2 2')],
@@ -641,9 +414,13 @@ window.__ModuleLoader__.load({
641
414
  })() : null,
642
415
 
643
416
  configured && s.connected ? h('div', { className: 'dshn-info' },
417
+ // The route is the operator's assignment (premium = accelerated path via
418
+ // the tunnel's own hostname); the mode is how the default relay is reached.
644
419
  h('div', { className: 'dshn-info-row' },
645
- h('span', { className: 'dshn-info-k' }, Icon(s.mode === 'direct' ? 'plug' : 'cloud'), T.infoRelay),
646
- h('span', { className: 'dshn-info-v' }, (T.infoMode[s.mode] || s.mode || '') + (s.relayHost ? ' · ' + s.relayHost : ''))),
420
+ h('span', { className: 'dshn-info-k' }, Icon(s.route === 'premium' ? 'bolt' : s.mode === 'direct' ? 'plug' : 'cloud'), T.infoRelay),
421
+ h('span', { className: 'dshn-info-v', style: s.route === 'premium' ? { color: '#c9930f' } : undefined },
422
+ (s.route === 'premium' ? T.routePremium : s.route === 'standard' ? T.routeStandard + ' · ' + (T.infoMode[s.mode] || s.mode || '') : (T.infoMode[s.mode] || s.mode || ''))
423
+ + (s.relayHost ? ' · ' + s.relayHost : ''))),
647
424
  h('div', { className: 'dshn-info-row' },
648
425
  h('span', { className: 'dshn-info-k' }, Icon('gauge'), T.infoLatency),
649
426
  h('span', { className: 'dshn-info-v', style: { color: latColor(s.latencyMs) } }, s.latencyMs == null ? '—' : s.latencyMs + ' ms')),
package/lib/index.js CHANGED
@@ -4604,6 +4604,251 @@ function open(key, blob) {
4604
4604
  return Buffer.concat([decipher.update(ct), decipher.final()]);
4605
4605
  }
4606
4606
 
4607
+ // packages/agent/lib/e2e-shim.js
4608
+ var SHIM_BODY = String.raw`
4609
+ if (window.__dshnE2E) return // already installed (double injection / legacy module)
4610
+ const E2E_HEADER = 'x-dshn-e2e'
4611
+ const E2E_ITERS = 210000
4612
+ window.__dshnE2E = { stage: 'entered', host: (typeof location !== 'undefined' ? location.hostname : '?') }
4613
+ if (typeof window === 'undefined' || !window.crypto || !window.crypto.subtle) { window.__dshnE2E.stage = 'no-subtle'; return }
4614
+ const host = location.hostname
4615
+ const loopback = host === 'localhost' || host === '::1' || /^127\./.test(host)
4616
+ window.__dshnE2E.remote = !loopback
4617
+ if (loopback) { window.__dshnE2E.stage = 'loopback-skip'; return } // local access talks straight to dsh; nothing is encrypted
4618
+
4619
+ const realFetch = window.fetch.bind(window)
4620
+ const RealWS = window.WebSocket
4621
+ const enc = new TextEncoder()
4622
+ let key = null // CryptoKey once the visitor unlocks; null = pass-through
4623
+ let active = false // agent reports E2E on
4624
+ let resolveReady
4625
+ const ready = new Promise((r) => { resolveReady = r })
4626
+ const hexToBytes = (hx) => { const a = new Uint8Array(hx.length / 2); for (let i = 0; i < a.length; i++) a[i] = parseInt(hx.substr(i * 2, 2), 16); return a }
4627
+ const isApi = (url) => { try { const u = new URL(url, location.href); return u.origin === location.origin && u.pathname.startsWith('/api') } catch { return false } }
4628
+
4629
+ async function deriveKey(password, saltHex) {
4630
+ const base = await crypto.subtle.importKey('raw', enc.encode(password), 'PBKDF2', false, ['deriveKey'])
4631
+ return crypto.subtle.deriveKey({ name: 'PBKDF2', salt: hexToBytes(saltHex), iterations: E2E_ITERS, hash: 'SHA-256' },
4632
+ base, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt'])
4633
+ }
4634
+ async function sealBytes(bytes) {
4635
+ const iv = crypto.getRandomValues(new Uint8Array(12))
4636
+ const ct = new Uint8Array(await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, bytes))
4637
+ const out = new Uint8Array(iv.length + ct.length); out.set(iv); out.set(ct, iv.length); return out
4638
+ }
4639
+ async function openBytes(k, blob) {
4640
+ const iv = blob.subarray(0, 12)
4641
+ const pt = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, k, blob.subarray(12))
4642
+ return new Uint8Array(pt)
4643
+ }
4644
+
4645
+ // fetch: seal /api request bodies, decrypt marked responses. Non-/api and
4646
+ // (once ready) the E2E-off case pass straight through.
4647
+ window.fetch = async function (input, init) {
4648
+ const url = typeof input === 'string' ? input : (input && input.url) || String(input)
4649
+ if (!isApi(url)) return realFetch(input, init)
4650
+ await ready
4651
+ if (!key) return realFetch(input, init)
4652
+ const req = new Request(input, init)
4653
+ const headers = new Headers(req.headers)
4654
+ let body = null
4655
+ const buf = await req.clone().arrayBuffer()
4656
+ if (buf.byteLength > 0) { body = await sealBytes(new Uint8Array(buf)); headers.set(E2E_HEADER, '1') }
4657
+ else headers.set(E2E_HEADER, '1')
4658
+ const res = await realFetch(url, { method: req.method, headers, body, credentials: 'include', mode: req.mode, cache: req.cache })
4659
+ if (res.headers.get(E2E_HEADER) !== '1') return res
4660
+ const sealed = new Uint8Array(await res.arrayBuffer())
4661
+ let plain
4662
+ try { plain = await openBytes(key, sealed) } catch { return new Response(null, { status: 502, statusText: 'e2e decrypt failed' }) }
4663
+ const outH = new Headers(res.headers); outH.delete(E2E_HEADER); outH.delete('content-length')
4664
+ return new Response(plain, { status: res.status, statusText: res.statusText, headers: outH })
4665
+ }
4666
+
4667
+ // WebSocket: connect normally, but decrypt the sealed downlink messages in
4668
+ // arrival order once the key is ready. Everything else proxies through.
4669
+ const E2EWebSocket = class extends EventTarget {
4670
+ constructor(url, protocols) {
4671
+ super()
4672
+ this._ws = new RealWS(url, protocols)
4673
+ this._q = Promise.resolve()
4674
+ this._api = isApi(String(url))
4675
+ for (const t of ['open', 'error']) this._ws.addEventListener(t, (e) => this._emit(t, e))
4676
+ this._ws.addEventListener('close', (e) => this._emit('close', e))
4677
+ this._ws.addEventListener('message', (e) => { this._q = this._q.then(() => this._msg(e)) })
4678
+ }
4679
+ get url() { return this._ws.url }
4680
+ get readyState() { return this._ws.readyState }
4681
+ get bufferedAmount() { return this._ws.bufferedAmount }
4682
+ get protocol() { return this._ws.protocol }
4683
+ get extensions() { return this._ws.extensions }
4684
+ get binaryType() { return this._ws.binaryType }
4685
+ set binaryType(v) { this._ws.binaryType = v }
4686
+ set onopen(f) { this._onopen = f } get onopen() { return this._onopen }
4687
+ set onclose(f) { this._onclose = f } get onclose() { return this._onclose }
4688
+ set onerror(f) { this._onerror = f } get onerror() { return this._onerror }
4689
+ set onmessage(f) { this._onmessage = f } get onmessage() { return this._onmessage }
4690
+ send(d) { this._ws.send(d) }
4691
+ close(c, r) { this._ws.close(c, r) }
4692
+ _emit(type, orig) {
4693
+ const ev = type === 'close' ? new CloseEvent('close', { code: orig.code, reason: orig.reason, wasClean: orig.wasClean }) : new Event(type)
4694
+ const on = this['_on' + type]; if (on) try { on.call(this, ev) } catch {}
4695
+ this.dispatchEvent(ev)
4696
+ }
4697
+ async _msg(e) {
4698
+ let data = e.data
4699
+ if (this._api) {
4700
+ await ready
4701
+ if (active && key) {
4702
+ try {
4703
+ const raw = data instanceof ArrayBuffer ? new Uint8Array(data)
4704
+ : data instanceof Blob ? new Uint8Array(await data.arrayBuffer())
4705
+ : new Uint8Array(await new Blob([data]).arrayBuffer())
4706
+ const opened = await openBytes(key, raw)
4707
+ data = opened[0] === 0 ? new TextDecoder().decode(opened.subarray(1)) : opened.subarray(1).buffer
4708
+ } catch { return } // drop messages we can't decrypt
4709
+ }
4710
+ }
4711
+ const ev = new MessageEvent('message', { data })
4712
+ if (this._onmessage) try { this._onmessage.call(this, ev) } catch {}
4713
+ this.dispatchEvent(ev)
4714
+ }
4715
+ }
4716
+ for (const [k, v] of [['CONNECTING', 0], ['OPEN', 1], ['CLOSING', 2], ['CLOSED', 3]]) {
4717
+ E2EWebSocket[k] = v; E2EWebSocket.prototype[k] = v
4718
+ }
4719
+ window.WebSocket = E2EWebSocket
4720
+
4721
+ // The host injected this script only because E2E is ON, with the public
4722
+ // salt and device id inline — so there is nothing to discover and no
4723
+ // window in which dsh's own traffic could slip past the gate: fetch and
4724
+ // WebSocket wait on the ready promise from the first byte of the page.
4725
+ ;(async () => {
4726
+ try {
4727
+ active = true
4728
+ window.__dshnE2E.enabled = true
4729
+ window.__dshnE2E.stage = 'gating'
4730
+ await unlockGate(__dshnInfo.salt, __dshnInfo.device)
4731
+ window.__dshnE2E.stage = 'unlocked'
4732
+ } catch (e) { window.__dshnE2E.stage = 'error'; window.__dshnE2E.error = String(e && e.message || e) }
4733
+ resolveReady()
4734
+ })()
4735
+
4736
+ // A blocking DOM overlay (not React — must appear before the app mounts)
4737
+ // asking for the e2e password; verified by a sealed probe to /api.
4738
+ function unlockGate(salt, deviceKey) {
4739
+ return new Promise((resolve) => {
4740
+ const zh = String(document.documentElement.lang || navigator.language || 'en').toLowerCase().indexOf('zh') === 0
4741
+ const L = zh
4742
+ ? { t: '端到端加密', s: '本页内容已端到端加密。输入端到端密码解锁——密码不会发送到云端。', p: '端到端密码', u: '解锁', bad: '密码错误,无法解密。',
4743
+ save: '在此设备记住密码', stale: '已保存的密码无法解锁(可能已被更改),请重新输入。' }
4744
+ : { t: 'End-to-end encrypted', s: 'This session is end-to-end encrypted. Enter the e2e password to unlock — it is never sent to the cloud.', p: 'E2E password', u: 'Unlock', bad: 'Wrong password — cannot decrypt.',
4745
+ save: 'Remember on this device', stale: 'The saved password no longer works (it may have been changed). Enter it again.' }
4746
+ // Remembered password lives in localStorage, per public host AND per
4747
+ // device, on THIS browser only — never transmitted (E2E is intact).
4748
+ // The device part matters on a multi-device subdomain: each machine
4749
+ // has its own e2e password, and one saved copy must not clobber (or be
4750
+ // probed against) another device's. Keyed by host+device (not salt) so
4751
+ // a changed e2e password is detected and re-prompted. The old
4752
+ // host-only key is read once as a fallback and migrated on success.
4753
+ const LEGACY_KEY = 'dshn:e2e:' + location.hostname
4754
+ const STORE_KEY = LEGACY_KEY + (deviceKey ? ':' + deviceKey : '')
4755
+ const readSaved = () => {
4756
+ try { return localStorage.getItem(STORE_KEY) || (STORE_KEY !== LEGACY_KEY ? localStorage.getItem(LEGACY_KEY) : null) } catch { return null }
4757
+ }
4758
+ const writeSaved = (v) => {
4759
+ try {
4760
+ if (v == null) localStorage.removeItem(STORE_KEY); else localStorage.setItem(STORE_KEY, v)
4761
+ if (STORE_KEY !== LEGACY_KEY) localStorage.removeItem(LEGACY_KEY)
4762
+ } catch { /* storage may be blocked */ }
4763
+ }
4764
+
4765
+ // Derive from a password string and probe /api with a sealed body; on a
4766
+ // correct key set the live key and return true. A wrong key → agent 400
4767
+ // (or the response fails to open), so return false.
4768
+ const attempt = async (pwStr) => {
4769
+ try {
4770
+ const cand = await deriveKey(pwStr, salt)
4771
+ const iv = crypto.getRandomValues(new Uint8Array(12))
4772
+ const ct = new Uint8Array(await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, cand, enc.encode('{}')))
4773
+ const probeBody = new Uint8Array(iv.length + ct.length); probeBody.set(iv); probeBody.set(ct, iv.length)
4774
+ const r = await realFetch('/api/host.describe', { method: 'POST', headers: { [E2E_HEADER]: '1', 'content-type': 'application/json' }, body: probeBody, credentials: 'include' })
4775
+ if (r.status === 400) return false
4776
+ if (r.headers.get(E2E_HEADER) === '1') { await openBytes(cand, new Uint8Array(await r.arrayBuffer())) }
4777
+ key = cand
4778
+ return true
4779
+ } catch { return false }
4780
+ }
4781
+
4782
+ ;(async () => {
4783
+ // 1. A remembered password unlocks silently — the gate never appears.
4784
+ let stale = false
4785
+ const saved = readSaved()
4786
+ if (saved) {
4787
+ if (await attempt(saved)) {
4788
+ writeSaved(saved) // re-write so a legacy host-only entry migrates to the per-device key
4789
+ window.__dshnE2E.autounlock = true; resolve(); return
4790
+ }
4791
+ writeSaved(null); stale = true // the saved one no longer works → drop it and tell the user
4792
+ }
4793
+
4794
+ // 2. Otherwise show the unlock gate, themed with dsh's own tokens so it
4795
+ // matches the app (light/dark aware; dark fallbacks if vars are absent).
4796
+ const V = {
4797
+ mask: 'var(--dsw-alias-bg-mask-1, rgba(8,10,14,.55))', blur: 'var(--dsw-mask-blur, blur(4px))',
4798
+ card: 'var(--dsw-alias-bg-layer-2, #171a1f)', fg: 'var(--dsw-alias-label-primary, #e8eaed)',
4799
+ sub: 'var(--dsw-alias-label-tertiary, #9aa0aa)', bd: 'var(--dsw-alias-border-l1, rgba(128,134,142,.35))',
4800
+ shadow: 'var(--dsw-shadow-lv3, 0 24px 64px rgba(0,0,0,.5))',
4801
+ accent: 'var(--dsw-alias-button-primary-fill, #4176e6)', accentFg: 'var(--dsw-alias-label-primary-foreground, #fff)',
4802
+ err: 'var(--dsw-alias-state-error-primary, #e5484d)', warn: 'var(--dsw-alias-state-warn-primary, #d98324)',
4803
+ focus: 'var(--dsw-alias-label-primary-bluish, #4176e6)',
4804
+ }
4805
+ const ov = document.createElement('div')
4806
+ ov.setAttribute('style', 'position:fixed;inset:0;z-index:2147483000;display:grid;place-items:center;background:' + V.mask + ';backdrop-filter:' + V.blur + ';font-family:var(--dsw-font-family, system-ui, -apple-system, sans-serif)')
4807
+ ov.innerHTML =
4808
+ '<form style="width:min(360px,92vw);box-sizing:border-box;padding:22px;border-radius:16px;background:' + V.card + ';color:' + V.fg + ';border:1px solid ' + V.bd + ';box-shadow:' + V.shadow + '">'
4809
+ + '<div style="font-size:15px;font-weight:600;margin-bottom:6px">🔒 ' + L.t + '</div>'
4810
+ + '<div style="font-size:12.5px;color:' + V.sub + ';margin-bottom:16px;line-height:1.5">' + L.s + '</div>'
4811
+ + '<div id="dshn-e2e-err" style="display:none;font-size:12px;margin-bottom:10px;line-height:1.5"></div>'
4812
+ + '<input id="dshn-e2e-pw" type="password" placeholder="' + L.p + '" autocomplete="off" style="width:100%;box-sizing:border-box;padding:10px 12px;border-radius:10px;border:1px solid ' + V.bd + ';background:transparent;color:inherit;font-size:14.5px;outline:none">'
4813
+ + '<label style="display:flex;align-items:center;gap:8px;margin-top:12px;font-size:12.5px;color:' + V.sub + ';cursor:pointer;user-select:none">'
4814
+ + '<input id="dshn-e2e-remember" type="checkbox" checked style="width:15px;height:15px;margin:0;accent-color:' + V.accent + ';cursor:pointer">' + L.save + '</label>'
4815
+ + '<button type="submit" style="width:100%;margin-top:16px;padding:10px;border:0;border-radius:10px;background:' + V.accent + ';color:' + V.accentFg + ';font-size:14.5px;font-weight:500;cursor:pointer">' + L.u + '</button></form>'
4816
+ const mount = () => document.body.appendChild(ov)
4817
+ if (document.body) mount(); else document.addEventListener('DOMContentLoaded', mount)
4818
+ const form = ov.querySelector('form'), pw = ov.querySelector('#dshn-e2e-pw'), err = ov.querySelector('#dshn-e2e-err'), remember = ov.querySelector('#dshn-e2e-remember')
4819
+ pw.addEventListener('focus', () => { pw.style.borderColor = V.focus })
4820
+ pw.addEventListener('blur', () => { pw.style.borderColor = V.bd })
4821
+ const showErr = (msg, color) => { err.textContent = msg; err.style.color = color; err.style.display = 'block' }
4822
+ if (stale) showErr(L.stale, V.warn) // the "saved password no longer works" notice
4823
+ form.addEventListener('submit', async (e) => {
4824
+ e.preventDefault()
4825
+ const btn = form.querySelector('button'); btn.disabled = true
4826
+ if (await attempt(pw.value)) {
4827
+ writeSaved(remember.checked ? pw.value : null)
4828
+ ov.remove()
4829
+ resolve()
4830
+ } else { showErr(L.bad, V.err); btn.disabled = false; pw.select() }
4831
+ })
4832
+ setTimeout(() => pw.focus(), 50)
4833
+ })()
4834
+ })
4835
+ }
4836
+ `;
4837
+ function e2eBootstrapTag(info) {
4838
+ const json = JSON.stringify({ salt: info.salt, device: info.device }).replace(/</g, "\\u003c");
4839
+ return `<script>(function (__dshnInfo) {${SHIM_BODY}})(${json})</script>`;
4840
+ }
4841
+ function injectE2EBootstrap(html, info) {
4842
+ const tag = e2eBootstrapTag(info);
4843
+ const head = /<head(\s[^>]*)?>/i.exec(html);
4844
+ if (head !== null)
4845
+ return html.slice(0, head.index + head[0].length) + tag + html.slice(head.index + head[0].length);
4846
+ const root = /<html(\s[^>]*)?>/i.exec(html);
4847
+ if (root !== null)
4848
+ return html.slice(0, root.index + root[0].length) + tag + html.slice(root.index + root[0].length);
4849
+ return tag + html;
4850
+ }
4851
+
4607
4852
  // packages/agent/lib/index.js
4608
4853
  var name = "@dshn/agent";
4609
4854
  var TUNNEL_MARKER = "x-dshn-forwarded";
@@ -4628,7 +4873,8 @@ var CREDS_SCHEMA = Schema.object({
4628
4873
  e2ePassword: Schema.string().role("secret").default(""),
4629
4874
  e2eSalt: Schema.string().default(""),
4630
4875
  relayHost: Schema.string().default(""),
4631
- originCa: Schema.string().default("")
4876
+ originCa: Schema.string().default(""),
4877
+ routeHost: Schema.string().default("")
4632
4878
  });
4633
4879
  function readCredsFile(path) {
4634
4880
  try {
@@ -4640,7 +4886,8 @@ function readCredsFile(path) {
4640
4886
  e2ePassword: typeof raw.e2ePassword === "string" && raw.e2ePassword !== "" ? raw.e2ePassword : void 0,
4641
4887
  e2eSalt: typeof raw.e2eSalt === "string" ? raw.e2eSalt : void 0,
4642
4888
  relayHost: typeof raw.relayHost === "string" && raw.relayHost !== "" ? raw.relayHost : void 0,
4643
- originCa: typeof raw.originCa === "string" && raw.originCa !== "" ? raw.originCa : void 0
4889
+ originCa: typeof raw.originCa === "string" && raw.originCa !== "" ? raw.originCa : void 0,
4890
+ routeHost: typeof raw.routeHost === "string" && raw.routeHost !== "" ? raw.routeHost : void 0
4644
4891
  };
4645
4892
  }
4646
4893
  } catch {
@@ -4660,7 +4907,7 @@ function settingsStore(scope, migrateFrom) {
4660
4907
  const store = {
4661
4908
  load: () => {
4662
4909
  const v = scope.get() ?? {};
4663
- return typeof v.subdomain === "string" && v.subdomain !== "" ? { subdomain: v.subdomain, password: v.password ?? "", e2ePassword: v.e2ePassword || void 0, e2eSalt: v.e2eSalt || void 0, relayHost: v.relayHost || void 0, originCa: v.originCa || void 0 } : null;
4910
+ return typeof v.subdomain === "string" && v.subdomain !== "" ? { subdomain: v.subdomain, password: v.password ?? "", e2ePassword: v.e2ePassword || void 0, e2eSalt: v.e2eSalt || void 0, relayHost: v.relayHost || void 0, originCa: v.originCa || void 0, routeHost: v.routeHost || void 0 } : null;
4664
4911
  },
4665
4912
  save: (creds) => {
4666
4913
  Promise.resolve(scope.update({
@@ -4669,7 +4916,8 @@ function settingsStore(scope, migrateFrom) {
4669
4916
  e2ePassword: creds?.e2ePassword ?? "",
4670
4917
  e2eSalt: creds?.e2eSalt ?? "",
4671
4918
  relayHost: creds?.relayHost ?? "",
4672
- originCa: creds?.originCa ?? ""
4919
+ originCa: creds?.originCa ?? "",
4920
+ routeHost: creds?.routeHost ?? ""
4673
4921
  })).catch(() => {
4674
4922
  });
4675
4923
  }
@@ -4691,6 +4939,16 @@ function settingsStore(scope, migrateFrom) {
4691
4939
  }
4692
4940
  return store;
4693
4941
  }
4942
+ var ROUTE_FAIL_MAX = 3;
4943
+ var ROUTE_FALLBACK_MS = 5 * 6e4;
4944
+ var ROUTE_FALLBACK_MAX_MS = 60 * 6e4;
4945
+ var ROUTE_PROBE_TIMEOUT_MS = 1e4;
4946
+ function isValidRouteHost(raw) {
4947
+ if (typeof raw !== "string" || raw.length === 0 || raw.length > 253)
4948
+ return false;
4949
+ const bare = raw.replace(/^wss?:\/\//, "");
4950
+ return /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*(:\d{1,5})?$/i.test(bare);
4951
+ }
4694
4952
  function toBuf(data) {
4695
4953
  if (Buffer.isBuffer(data))
4696
4954
  return data;
@@ -4769,6 +5027,16 @@ var AgentTunnel = class {
4769
5027
  latencyMs = null;
4770
5028
  /** ms epoch the outstanding latency ping was sent (0 = none in flight). */
4771
5029
  pingSentAt = 0;
5030
+ /** The authority the current control socket was dialled through. */
5031
+ dialledHost = null;
5032
+ /** Consecutive dials/probes of the premium host that failed. */
5033
+ routeFails = 0;
5034
+ /** Until when (ms epoch) the premium host is skipped in favour of the default relay. */
5035
+ routeFallbackUntil = 0;
5036
+ /** The next fallback period (grows while the premium host keeps failing). */
5037
+ routeFallbackMs = ROUTE_FALLBACK_MS;
5038
+ /** The in-flight probe of the premium host, if one is running. */
5039
+ routeProbe = null;
4772
5040
  constructor(config, localPort, store) {
4773
5041
  this.config = config;
4774
5042
  this.localPort = localPort;
@@ -4783,7 +5051,8 @@ var AgentTunnel = class {
4783
5051
  connected: false,
4784
5052
  publicUrl: null,
4785
5053
  subdomain: this.creds?.subdomain ?? null,
4786
- lastError: null
5054
+ lastError: null,
5055
+ route: null
4787
5056
  };
4788
5057
  }
4789
5058
  /**
@@ -4825,7 +5094,14 @@ var AgentTunnel = class {
4825
5094
  return "Password must be at least 8 characters.";
4826
5095
  const rh = typeof relayHost === "string" ? relayHost.trim() : this.creds?.relayHost ?? "";
4827
5096
  const ca = typeof originCa === "string" ? originCa.trim() : this.creds?.originCa ?? "";
4828
- this.creds = { subdomain: label, password: String(password), e2ePassword: this.creds?.e2ePassword, e2eSalt: this.creds?.e2eSalt, relayHost: rh || void 0, originCa: ca || void 0 };
5097
+ const sameTarget = this.creds?.subdomain === label && (this.creds?.relayHost ?? "") === (rh || "");
5098
+ this.creds = { subdomain: label, password: String(password), e2ePassword: this.creds?.e2ePassword, e2eSalt: this.creds?.e2eSalt, relayHost: rh || void 0, originCa: ca || void 0, routeHost: sameTarget ? this.creds?.routeHost : void 0 };
5099
+ this.status.route = null;
5100
+ if (!sameTarget) {
5101
+ this.routeFails = 0;
5102
+ this.routeFallbackUntil = 0;
5103
+ this.routeFallbackMs = ROUTE_FALLBACK_MS;
5104
+ }
4829
5105
  this.refreshE2E();
4830
5106
  this.saveCreds(this.creds);
4831
5107
  this.status.configured = true;
@@ -4899,9 +5175,12 @@ var AgentTunnel = class {
4899
5175
  info() {
4900
5176
  const host = this.effectiveRelayHost();
4901
5177
  const direct = /^wss?:\/\//.test(host) || this.effectiveOriginCa() !== null;
5178
+ const live = this.status.connected && this.dialledHost !== null ? this.dialledHost : host;
4902
5179
  return {
4903
- relayHost: host.replace(/^wss?:\/\//, "").replace(/\/.*$/, ""),
5180
+ relayHost: live.replace(/^wss?:\/\//, "").replace(/\/.*$/, ""),
4904
5181
  direct,
5182
+ route: this.status.route,
5183
+ routeHost: this.creds?.routeHost ?? null,
4905
5184
  connectedSince: this.connectedSince,
4906
5185
  served: this.served,
4907
5186
  localPort: this.localPort(),
@@ -4916,6 +5195,10 @@ var AgentTunnel = class {
4916
5195
  this.status.connected = false;
4917
5196
  this.status.publicUrl = null;
4918
5197
  this.status.subdomain = null;
5198
+ this.status.route = null;
5199
+ this.routeFails = 0;
5200
+ this.routeFallbackUntil = 0;
5201
+ this.routeFallbackMs = ROUTE_FALLBACK_MS;
4919
5202
  if (this.reconnectTimer !== null) {
4920
5203
  clearTimeout(this.reconnectTimer);
4921
5204
  this.reconnectTimer = null;
@@ -4934,15 +5217,168 @@ var AgentTunnel = class {
4934
5217
  clearTimeout(this.reconnectTimer);
4935
5218
  if (this.heartbeatTimer !== null)
4936
5219
  clearInterval(this.heartbeatTimer);
5220
+ this.dropStreams();
5221
+ this.control?.close();
5222
+ this.control = null;
5223
+ this.status.connected = false;
5224
+ }
5225
+ /**
5226
+ * Tear down every stream bridged over the current control socket. Must run
5227
+ * whenever that socket is abandoned — on close AND on a deliberate redial —
5228
+ * because the relay numbers streams per connection from 1: a request or
5229
+ * browser socket left over from the old connection would otherwise answer to
5230
+ * the ids of the new one and leak its bytes into a stranger's stream.
5231
+ */
5232
+ dropStreams() {
4937
5233
  for (const req of this.requests.values())
4938
5234
  req.destroy();
4939
5235
  for (const sock of this.sockets.values())
4940
5236
  sock.close();
4941
5237
  this.requests.clear();
4942
5238
  this.sockets.clear();
4943
- this.control?.close();
5239
+ this.reqE2E.clear();
5240
+ }
5241
+ /**
5242
+ * Which authority to dial: the relay-assigned premium host when one is
5243
+ * remembered and not in a fallback period, else the default relay host. The
5244
+ * premium host is only ever set by a route announcement from the relay.
5245
+ */
5246
+ dialHost() {
5247
+ const route = this.creds?.routeHost;
5248
+ if (route !== void 0 && route !== "" && Date.now() >= this.routeFallbackUntil)
5249
+ return route;
5250
+ return this.effectiveRelayHost();
5251
+ }
5252
+ /** Whether the live control socket was dialled through the remembered premium host. */
5253
+ onPremiumPath() {
5254
+ const route = this.creds?.routeHost;
5255
+ return route !== void 0 && route !== "" && this.dialledHost === route;
5256
+ }
5257
+ /** Whether the premium host is currently being skipped after repeated failures. */
5258
+ inRouteFallback() {
5259
+ return Date.now() < this.routeFallbackUntil;
5260
+ }
5261
+ /**
5262
+ * Apply a route announcement (READY or a mid-session ROUTE frame).
5263
+ *
5264
+ * `status.route` reflects the OPERATOR'S ASSIGNMENT, because that is what a
5265
+ * public visitor experiences: enabling premium points the subdomain's DNS at
5266
+ * the accelerator, so browser traffic is accelerated no matter which host the
5267
+ * agent's own control socket happens to use. Moving the control socket onto
5268
+ * the premium host too is a best-effort bonus for the uplink — it may briefly
5269
+ * fail while the fresh DNS record propagates, and if the host stays
5270
+ * unreachable the agent quietly keeps its control socket on the default relay.
5271
+ * Neither case changes the displayed route or breaks the tunnel.
5272
+ *
5273
+ * - `premium` with a usable host: show premium, remember the host, and (unless
5274
+ * in a fallback window) PROBE it; only a host that answers gets the control
5275
+ * socket moved onto it — a working tunnel is never dropped for a dead host.
5276
+ * - `standard`: the operator withdrew the fast path — show standard, forget
5277
+ * the host, and return the control socket to the default relay.
5278
+ */
5279
+ applyRoute(route, routeHost) {
5280
+ if (this.creds === null)
5281
+ return;
5282
+ if (route === "premium" && isValidRouteHost(routeHost)) {
5283
+ this.status.route = "premium";
5284
+ if (this.creds.routeHost !== routeHost) {
5285
+ this.creds = { ...this.creds, routeHost };
5286
+ this.saveCreds(this.creds);
5287
+ this.routeFails = 0;
5288
+ this.routeFallbackUntil = 0;
5289
+ this.routeFallbackMs = ROUTE_FALLBACK_MS;
5290
+ }
5291
+ this.tryPremium();
5292
+ return;
5293
+ }
5294
+ if (route === "standard" || route === "premium") {
5295
+ this.status.route = "standard";
5296
+ this.routeFallbackUntil = 0;
5297
+ this.routeFails = 0;
5298
+ this.routeFallbackMs = ROUTE_FALLBACK_MS;
5299
+ const hadRoute = this.creds.routeHost !== void 0 && this.creds.routeHost !== "";
5300
+ if (hadRoute) {
5301
+ this.creds = { ...this.creds, routeHost: void 0 };
5302
+ this.saveCreds(this.creds);
5303
+ if (this.dialledHost !== this.effectiveRelayHost())
5304
+ this.redial();
5305
+ }
5306
+ }
5307
+ }
5308
+ /**
5309
+ * Move the control socket onto the premium host when that is worth doing:
5310
+ * assigned premium, currently on the default relay, not in a fallback window,
5311
+ * and no probe already running. The host is probed first (a plain WebSocket
5312
+ * handshake, no HELLO — so the relay never sees a second agent) and the live
5313
+ * socket is only redialled once the host has answered. A failed probe counts
5314
+ * like a failed dial; enough of them open a fallback window.
5315
+ */
5316
+ tryPremium() {
5317
+ const host = this.creds?.routeHost;
5318
+ if (host === void 0 || host === "" || this.routeProbe !== null)
5319
+ return;
5320
+ if (!this.status.connected || this.onPremiumPath() || this.inRouteFallback())
5321
+ return;
5322
+ this.routeProbe = this.probeHost(host).then((ok) => {
5323
+ this.routeProbe = null;
5324
+ if (this.stopped || this.creds?.routeHost !== host || this.status.route !== "premium")
5325
+ return;
5326
+ if (!this.status.connected || this.onPremiumPath())
5327
+ return;
5328
+ if (ok)
5329
+ this.redial();
5330
+ else
5331
+ this.noteRouteFailure();
5332
+ });
5333
+ }
5334
+ /** Whether `host` accepts a WebSocket on the agent path right now (no HELLO is sent). */
5335
+ probeHost(host) {
5336
+ return new Promise((resolve2) => {
5337
+ const base = host.includes("://") ? host : `wss://${host}`;
5338
+ let ws;
5339
+ try {
5340
+ ws = new import_websocket.default(`${base}${AGENT_WS_PATH}`, { handshakeTimeout: ROUTE_PROBE_TIMEOUT_MS });
5341
+ } catch {
5342
+ resolve2(false);
5343
+ return;
5344
+ }
5345
+ let done = false;
5346
+ const finish = (ok) => {
5347
+ if (done)
5348
+ return;
5349
+ done = true;
5350
+ if (process.env.DSHN_DEBUG)
5351
+ console.error(`[dshn-agent] premium probe of ${host}: ${ok ? "reachable" : "unreachable"}`);
5352
+ resolve2(ok);
5353
+ if (ok)
5354
+ ws.close();
5355
+ else
5356
+ ws.terminate();
5357
+ };
5358
+ ws.on("open", () => finish(true));
5359
+ ws.on("error", () => finish(false));
5360
+ ws.on("close", () => finish(false));
5361
+ });
5362
+ }
5363
+ /** Count a failed dial/probe of the premium host; enough in a row open a (growing) fallback window. */
5364
+ noteRouteFailure() {
5365
+ this.routeFails++;
5366
+ if (this.routeFails < ROUTE_FAIL_MAX)
5367
+ return;
5368
+ this.routeFails = 0;
5369
+ this.routeFallbackUntil = Date.now() + this.routeFallbackMs;
5370
+ this.routeFallbackMs = Math.min(this.routeFallbackMs * 2, ROUTE_FALLBACK_MAX_MS);
5371
+ }
5372
+ /** Drop the live socket and dial again right away (route change). */
5373
+ redial() {
5374
+ if (process.env.DSHN_DEBUG)
5375
+ console.error(`[dshn-agent] route change \u2192 redialling via ${this.dialHost()}`);
5376
+ this.backoffMs = 1e3;
5377
+ const ws = this.control;
4944
5378
  this.control = null;
4945
- this.status.connected = false;
5379
+ this.dropStreams();
5380
+ ws?.close();
5381
+ this.connect();
4946
5382
  }
4947
5383
  connect() {
4948
5384
  if (this.stopped || this.creds === null)
@@ -4954,11 +5390,12 @@ var AgentTunnel = class {
4954
5390
  clearTimeout(this.reconnectTimer);
4955
5391
  this.reconnectTimer = null;
4956
5392
  }
4957
- const relayHost = this.effectiveRelayHost();
5393
+ const relayHost = this.dialHost();
4958
5394
  const base = relayHost.includes("://") ? relayHost : `wss://${relayHost}`;
5395
+ this.dialledHost = relayHost;
4959
5396
  const wsOpts = { maxPayload: 512 * 1024 * 1024 };
4960
5397
  const ca = this.effectiveOriginCa();
4961
- if (ca !== null)
5398
+ if (ca !== null && relayHost === this.effectiveRelayHost())
4962
5399
  wsOpts.ca = ca;
4963
5400
  const ws = new import_websocket.default(`${base}${AGENT_WS_PATH}`, wsOpts);
4964
5401
  this.control = ws;
@@ -5011,10 +5448,14 @@ var AgentTunnel = class {
5011
5448
  this.control.terminate();
5012
5449
  return;
5013
5450
  }
5451
+ if (this.status.route === "premium")
5452
+ this.tryPremium();
5014
5453
  this.sendPing();
5015
5454
  }, LATENCY_PING_MS);
5016
5455
  }
5017
5456
  onClose() {
5457
+ if (!this.status.connected && this.onPremiumPath())
5458
+ this.noteRouteFailure();
5018
5459
  this.status.connected = false;
5019
5460
  this.connectedSince = null;
5020
5461
  this.latencyMs = null;
@@ -5024,12 +5465,7 @@ var AgentTunnel = class {
5024
5465
  clearInterval(this.heartbeatTimer);
5025
5466
  this.heartbeatTimer = null;
5026
5467
  }
5027
- for (const req of this.requests.values())
5028
- req.destroy();
5029
- for (const sock of this.sockets.values())
5030
- sock.close();
5031
- this.requests.clear();
5032
- this.sockets.clear();
5468
+ this.dropStreams();
5033
5469
  if (this.stopped)
5034
5470
  return;
5035
5471
  if (this.reconnectTimer !== null)
@@ -5071,12 +5507,22 @@ var AgentTunnel = class {
5071
5507
  switch (frame.t) {
5072
5508
  case "ready":
5073
5509
  this.backoffMs = 1e3;
5510
+ this.routeFails = 0;
5511
+ if (this.onPremiumPath())
5512
+ this.routeFallbackMs = ROUTE_FALLBACK_MS;
5074
5513
  this.status.connected = true;
5075
5514
  this.status.publicUrl = frame.publicUrl;
5076
5515
  this.status.subdomain = frame.subdomain;
5077
5516
  this.status.lastError = null;
5078
5517
  this.connectedSince = Date.now();
5079
5518
  this.sendPing();
5519
+ if (frame.route !== void 0)
5520
+ this.applyRoute(frame.route, frame.routeHost);
5521
+ else if (this.creds?.routeHost)
5522
+ this.applyRoute("standard", void 0);
5523
+ break;
5524
+ case "route":
5525
+ this.applyRoute(frame.route, frame.routeHost);
5080
5526
  break;
5081
5527
  case "deny":
5082
5528
  this.status.lastError = frame.reason;
@@ -5168,7 +5614,27 @@ var AgentTunnel = class {
5168
5614
  this.reqE2E.set(id, { method, path, headers: outHeaders, marked, chunks: [] });
5169
5615
  return;
5170
5616
  }
5617
+ const wantsDocument = method === "GET" && headers.some(([k, v]) => k.toLowerCase() === "accept" && v.includes("text/html"));
5618
+ const injectBootstrap = this.e2eKey !== null && wantsDocument;
5619
+ if (injectBootstrap)
5620
+ outHeaders["accept-encoding"] = "identity";
5171
5621
  const req = http.request({ host: this.config.localHost, port: this.localPort(), method, path, headers: outHeaders }, (res) => {
5622
+ const contentType = String(res.headers["content-type"] ?? "");
5623
+ if (injectBootstrap && this.e2eKey !== null && res.statusCode === 200 && /^text\/html\b/i.test(contentType)) {
5624
+ const chunks = [];
5625
+ res.on("data", (c) => chunks.push(c));
5626
+ res.on("end", () => {
5627
+ const html = injectE2EBootstrap(Buffer.concat(chunks).toString("utf8"), { salt: this.e2eSalt, device: this.deviceId });
5628
+ const body = Buffer.from(html, "utf8");
5629
+ const resHeaders = filterHeaders(headerListFromRaw(res.rawHeaders), /* @__PURE__ */ new Set([...HOP_BY_HOP, "content-length", "content-encoding"]));
5630
+ resHeaders.push(["content-length", String(body.length)]);
5631
+ this.send({ t: "res_head", id, status: 200, headers: resHeaders });
5632
+ this.sendData(DATA_RES_BODY, id, body);
5633
+ this.send({ t: "res_end", id });
5634
+ });
5635
+ res.on("error", () => this.send({ t: "abort", id, reason: "response stream error" }));
5636
+ return;
5637
+ }
5172
5638
  this.send({
5173
5639
  t: "res_head",
5174
5640
  id,
@@ -5375,6 +5841,10 @@ function apply(ctx, rawConfig) {
5375
5841
  // Connection details for the panel.
5376
5842
  relayHost: info.relayHost,
5377
5843
  mode: info.direct ? "direct" : "cloudflare",
5844
+ // Which path the relay assigned: 'premium' (accelerated, via routeHost)
5845
+ // or 'standard'; null until a route-aware relay has said.
5846
+ route: info.route,
5847
+ routeHost: info.routeHost,
5378
5848
  connectedSince: info.connectedSince,
5379
5849
  served: info.served,
5380
5850
  localPort: info.localPort,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dshn/agent",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
4
4
  "description": "Forward a local dsh web service to the public internet over ds.hn (bundled).",
5
5
  "keywords": [
6
6
  "dsh",
@@ -31,6 +31,7 @@
31
31
  "client.js",
32
32
  "cordis.patch.yml",
33
33
  "README.md",
34
+ "README.zh.md",
34
35
  "LICENSE"
35
36
  ],
36
37
  "dsh": {