@dshn/agent 0.1.7 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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
@@ -22,6 +25,11 @@ the relay operator sees only ciphertext.
22
25
  - **Trust-on-first-use claim.** The first agent to present a free subdomain sets
23
26
  its password (scrypt-hashed on the relay). Later connects and every browser
24
27
  login must match it — squatting-protected.
28
+ - **Multi-device.** Several machines can bind ONE subdomain with the same
29
+ credential — each shows up as a named device. With ≥2 online, opening the URL
30
+ offers a device picker, and a switcher appears in the page's sidebar footer;
31
+ the choice sticks per browser (a routing cookie), and switching is a clean
32
+ reload against the other machine. One device online behaves exactly as before.
25
33
  - **Optional end-to-end encryption** (off by default). A *separate* e2e password,
26
34
  never sent to the relay, encrypts `/api` bodies and the event stream:
27
35
  PBKDF2-SHA256 (210k) → AES-256-GCM. Visitors enter it once in the browser; it
@@ -65,7 +73,7 @@ dsh (local web server) fence sees a loopback req
65
73
  | package | what it is | runs where |
66
74
  |---|---|---|
67
75
  | `@dshn/protocol` | the WSS frame contract both ends compile against | shared |
68
- | `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 |
69
77
  | `@dshn/relay` | login gate + claim store + subdomain router + HTTP/WS bridge | your server, behind Cloudflare |
70
78
 
71
79
  The claim store (`packages/relay/src/claims.ts`) is trust-on-first-use for now;
@@ -114,13 +122,15 @@ Agent environment (all optional; sensible defaults):
114
122
  ## Self-host your own network
115
123
 
116
124
  You don't have to use `ds.hn` — run the whole thing on your own domain. The relay
117
- 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
118
127
  `DSHN_RELAY_HOST`. Full guide, including DNS + TLS options: **[SELF-HOSTING.md](./SELF-HOSTING.md)**.
119
128
 
120
129
  ```sh
121
- # your server
122
- DSHN_APEX=tunnel.example.com DSHN_COOKIE_SECRET=$(openssl rand -hex 32) npx @dshn/relay
123
- # 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 → 公网转发 → 自托管
124
134
  DSHN_RELAY_HOST=wss://tunnel.example.com dsh --profile web
125
135
  ```
126
136
 
@@ -128,22 +138,21 @@ Or from source:
128
138
 
129
139
  ```sh
130
140
  pnpm install && pnpm build
131
- DSHN_COOKIE_SECRET=$(openssl rand -hex 32) \
132
- DSHN_APEX=ds.hn \
133
- DSHN_RELAY_PORT=8787 \
134
- DSHN_CLAIMS=./claims.json \
135
- DSHN_TLS_CERT=./cert.pem DSHN_TLS_KEY=./key.pem \
136
- node packages/relay/lib/index.js
141
+ node packages/relay/lib/index.js --apex ds.hn --data-dir ./dshn-data
137
142
  ```
138
143
 
139
- | var | required | purpose |
140
- |---|---|---|
141
- | `DSHN_COOKIE_SECRET` | | HMAC secret for session cookies (rotating it logs everyone out) |
142
- | `DSHN_APEX` | — (`ds.hn`) | apex domain the wildcard hangs off |
143
- | `DSHN_RELAY_PORT` | (`8787`) | listen port |
144
- | `DSHN_CLAIMS` | — | JSON file the relay creates/maintains (subdomain → scrypt hash) |
145
- | `DSHN_TLS_CERT` / `DSHN_TLS_KEY` | | PEM paths to serve HTTPS directly (else plain HTTP behind CF) |
146
- | `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 |
147
156
 
148
157
  Cloudflare: proxy `*.ds.hn` (orange cloud) to the relay's origin. Harden the
149
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
@@ -153,7 +153,7 @@ window.__ModuleLoader__.load({
153
153
  if (!info || !info.enabled || !info.salt) { window.fetch = realFetch; window.WebSocket = RealWS; window.__dshnE2E.stage = 'off-restored'; resolveReady(); return }
154
154
  active = true
155
155
  window.__dshnE2E.stage = 'gating'
156
- await unlockGate(info.salt)
156
+ await unlockGate(info.salt, info.device)
157
157
  window.__dshnE2E.stage = 'unlocked'
158
158
  } catch (e) { window.fetch = realFetch; window.WebSocket = RealWS; window.__dshnE2E.stage = 'error'; window.__dshnE2E.error = String(e && e.message || e) }
159
159
  resolveReady()
@@ -161,7 +161,7 @@ window.__ModuleLoader__.load({
161
161
 
162
162
  // A blocking DOM overlay (not React — must appear before the app mounts)
163
163
  // asking for the e2e password; verified by a sealed probe to /api.
164
- function unlockGate(salt) {
164
+ function unlockGate(salt, deviceKey) {
165
165
  return new Promise((resolve) => {
166
166
  const zh = String(document.documentElement.lang || navigator.language || 'en').toLowerCase().indexOf('zh') === 0
167
167
  const L = zh
@@ -169,12 +169,24 @@ window.__ModuleLoader__.load({
169
169
  save: '在此设备记住密码', stale: '已保存的密码无法解锁(可能已被更改),请重新输入。' }
170
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
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, on THIS
173
- // device only — never transmitted (E2E is intact). Keyed by host (not
174
- // salt) so a changed e2e password is detected and re-prompted.
175
- const STORE_KEY = 'dshn:e2e:' + location.hostname
176
- const readSaved = () => { try { return localStorage.getItem(STORE_KEY) } catch { return null } }
177
- const writeSaved = (v) => { try { if (v == null) localStorage.removeItem(STORE_KEY); else localStorage.setItem(STORE_KEY, v) } catch { /* storage may be blocked */ } }
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
+ }
178
190
 
179
191
  // Derive from a password string and probe /api with a sealed body; on a
180
192
  // correct key set the live key and return true. A wrong key → agent 400
@@ -198,7 +210,10 @@ window.__ModuleLoader__.load({
198
210
  let stale = false
199
211
  const saved = readSaved()
200
212
  if (saved) {
201
- if (await attempt(saved)) { window.__dshnE2E.autounlock = true; resolve(); return }
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
+ }
202
217
  writeSaved(null); stale = true // the saved one no longer works → drop it and tell the user
203
218
  }
204
219
 
@@ -364,6 +379,22 @@ window.__ModuleLoader__.load({
364
379
  .dshn-dcwarn-body { font-size: 11.5px; color: var(--dsw-alias-label-secondary, #4a4f57); }
365
380
  .dshn-danger { flex: 1; padding: 7px; border: 0; border-radius: 8px; cursor: pointer;
366
381
  background: var(--dsw-alias-state-error-primary, #e5484d); color: #fff; font-size: 13px; }
382
+
383
+ /* Multi-device switcher (remote pages only): a footer row like the local one,
384
+ opening a small fixed popover above it listing this subdomain's devices. */
385
+ .dshn-devpop { position: fixed; left: 12px; bottom: 56px; z-index: 70; width: 244px;
386
+ box-sizing: border-box; padding: 10px 10px 8px; border-radius: 12px;
387
+ background: var(--dsw-alias-bg-layer-3, #fff);
388
+ border: 1px solid var(--dsw-alias-border-l1, rgba(128,134,142,.25));
389
+ box-shadow: var(--dsw-shadow-lv3, 0 12px 32px rgba(0,0,0,.24)); }
390
+ .dshn-devpop-title { font-size: 11px; color: var(--dsw-alias-label-tertiary, #8b9099); margin: 0 4px 6px; }
391
+ .dshn-devrow { display: flex; align-items: center; gap: 8px; width: 100%; box-sizing: border-box;
392
+ padding: 8px 9px; border: 0; border-radius: 8px; background: transparent; cursor: pointer; text-align: left;
393
+ color: var(--dsw-alias-label-primary, #1c1e21); font-size: 13px; font-family: inherit; }
394
+ .dshn-devrow:hover:not(:disabled) { background: var(--dsw-alias-interactive-bg-hover, rgba(128,134,142,.12)); }
395
+ .dshn-devrow:disabled { cursor: default; opacity: .6; }
396
+ .dshn-devrow-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
397
+ .dshn-devrow-tag { font-size: 10.5px; color: var(--dsw-alias-label-tertiary, #8b9099); }
367
398
  `
368
399
  const cssId = ID + '/widget.css'
369
400
  if (typeof document !== 'undefined'
@@ -386,7 +417,8 @@ window.__ModuleLoader__.load({
386
417
  savedHint: '手机访问用这个密码登录。忘记时点“复制/显示”取回。',
387
418
  weak: '弱', fair: '一般', good: '较强', strong: '强',
388
419
  infoRelay: '线路', infoMode: { direct: '直连源站', cloudflare: '经 Cloudflare' },
389
- infoUptime: '在线时长', infoServed: '已转发请求', infoPort: '本地端口', infoLatency: '延迟',
420
+ routePremium: '高级线路(加速)', routeStandard: '标准线路',
421
+ infoUptime: '在线时长', infoServed: '已转发请求', infoPort: '本地端口', infoLatency: '延迟', infoDevice: '设备名',
390
422
  e2eLabel: '端到端密码(可选)', e2eHint: '设置后,会话内容用它加密,云端也看不到;密码不出本机。访问时需在网页再输一次。',
391
423
  e2eApply: '设置端到端密码', e2eUpdate: '更新端到端密码', e2eDisable: '关闭加密', e2eApplied: '✓ 端到端加密已开启', e2eOff2: '✓ 端到端加密已关闭', e2eIndep: '独立设置,不影响上面的连接。',
392
424
  infoE2E: '端到端加密', e2eOn: '已开启', e2eOff: '未开启',
@@ -398,7 +430,8 @@ window.__ModuleLoader__.load({
398
430
  mode: '模式', modeOfficial: '官方 ds.hn', modeSelf: '自托管', yourDomain: '你的域名', relayHost: '中继地址',
399
431
  relayHostHint: '填你自己的 @dshn/relay,如 wss://tunnel.example.com。子域会挂在它的域名下。',
400
432
  relayCa: '中继证书(自签名,可选)',
401
- relayCaHint: '仅当你的中继用自签名证书时:粘贴其 PEM 证书以固定信任(公有证书/套 Cloudflare 时留空)。' }
433
+ relayCaHint: '仅当你的中继用自签名证书时:粘贴其 PEM 证书以固定信任(公有证书/套 Cloudflare 时留空)。',
434
+ devLabel: '设备', devSwitch: '切换设备', devOffline: '离线', devCurrent: '当前' }
402
435
  : { brand: 'Public forwarding · ds.hn', connecting: 'connecting…', live: 'live', off: 'off', notset: 'not set up',
403
436
  setupTitle: 'Set up public forwarding', setupSub: 'Pick a subdomain prefix and an access password — the two are your credential.',
404
437
  connTitle: 'Public forwarding', prefix: 'Subdomain prefix', password: 'Access password', confirm: 'Confirm password',
@@ -410,7 +443,8 @@ window.__ModuleLoader__.load({
410
443
  savedHint: 'Log in from a phone with this password. Copy/show it here if you forget.',
411
444
  weak: 'weak', fair: 'fair', good: 'good', strong: 'strong',
412
445
  infoRelay: 'Link', infoMode: { direct: 'direct to origin', cloudflare: 'via Cloudflare' },
413
- infoUptime: 'Uptime', infoServed: 'Requests served', infoPort: 'Local port', infoLatency: 'Latency',
446
+ routePremium: 'premium route (accelerated)', routeStandard: 'standard route',
447
+ infoUptime: 'Uptime', infoServed: 'Requests served', infoPort: 'Local port', infoLatency: 'Latency', infoDevice: 'Device name',
414
448
  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.',
415
449
  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.',
416
450
  infoE2E: 'End-to-end encryption', e2eOn: 'on', e2eOff: 'off',
@@ -422,7 +456,8 @@ window.__ModuleLoader__.load({
422
456
  mode: 'Mode', modeOfficial: 'Official ds.hn', modeSelf: 'Self-hosted', yourDomain: 'your-domain', relayHost: 'Relay host',
423
457
  relayHostHint: 'Your own @dshn/relay, e.g. wss://tunnel.example.com. Your subdomain lives under its domain.',
424
458
  relayCa: 'Relay CA (self-signed, optional)',
425
- relayCaHint: 'Only when your relay uses a self-signed cert: paste its PEM to pin trust (leave blank for a public cert / behind Cloudflare).' }
459
+ relayCaHint: 'Only when your relay uses a self-signed cert: paste its PEM to pin trust (leave blank for a public cert / behind Cloudflare).',
460
+ devLabel: 'Device', devSwitch: 'Switch device', devOffline: 'offline', devCurrent: 'current' }
426
461
 
427
462
  function strength(pw) {
428
463
  if (pw.length < MIN_PW) return { score: 0, ok: false }
@@ -445,6 +480,7 @@ window.__ModuleLoader__.load({
445
480
  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')],
446
481
  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')],
447
482
  plug: () => [P('M5 2.3v2.2M9 2.3v2.2'), P('M4 4.6h6v1.9a3 3 0 01-6 0z'), P('M7 9.4v2.3')],
483
+ bolt: () => [P('M7.6 1.8L3.3 7.8h3.1l-.8 4.4 4.3-6h-3.1z')],
448
484
  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' })],
449
485
  clock: () => [h('circle', { key: 'c', cx: 7, cy: 7, r: 5.3 }), P('M7 4.1v3.1l2 1.2')],
450
486
  swap: () => [P('M3.4 5h7.2l-2-2'), P('M10.6 9H3.4l2 2')],
@@ -608,9 +644,13 @@ window.__ModuleLoader__.load({
608
644
  })() : null,
609
645
 
610
646
  configured && s.connected ? h('div', { className: 'dshn-info' },
647
+ // The route is the operator's assignment (premium = accelerated path via
648
+ // the tunnel's own hostname); the mode is how the default relay is reached.
611
649
  h('div', { className: 'dshn-info-row' },
612
- h('span', { className: 'dshn-info-k' }, Icon(s.mode === 'direct' ? 'plug' : 'cloud'), T.infoRelay),
613
- h('span', { className: 'dshn-info-v' }, (T.infoMode[s.mode] || s.mode || '') + (s.relayHost ? ' · ' + s.relayHost : ''))),
650
+ h('span', { className: 'dshn-info-k' }, Icon(s.route === 'premium' ? 'bolt' : s.mode === 'direct' ? 'plug' : 'cloud'), T.infoRelay),
651
+ h('span', { className: 'dshn-info-v', style: s.route === 'premium' ? { color: '#c9930f' } : undefined },
652
+ (s.route === 'premium' ? T.routePremium : s.route === 'standard' ? T.routeStandard + ' · ' + (T.infoMode[s.mode] || s.mode || '') : (T.infoMode[s.mode] || s.mode || ''))
653
+ + (s.relayHost ? ' · ' + s.relayHost : ''))),
614
654
  h('div', { className: 'dshn-info-row' },
615
655
  h('span', { className: 'dshn-info-k' }, Icon('gauge'), T.infoLatency),
616
656
  h('span', { className: 'dshn-info-v', style: { color: latColor(s.latencyMs) } }, s.latencyMs == null ? '—' : s.latencyMs + ' ms')),
@@ -623,6 +663,11 @@ window.__ModuleLoader__.load({
623
663
  s.localPort ? h('div', { className: 'dshn-info-row' },
624
664
  h('span', { className: 'dshn-info-k' }, Icon('server'), T.infoPort),
625
665
  h('span', { className: 'dshn-info-v' }, String(s.localPort))) : null,
666
+ // How this machine shows up in the multi-device switcher when several
667
+ // devices bind one subdomain.
668
+ s.deviceName ? h('div', { className: 'dshn-info-row' },
669
+ h('span', { className: 'dshn-info-k' }, Icon('server'), T.infoDevice),
670
+ h('span', { className: 'dshn-info-v' }, s.deviceName)) : null,
626
671
  h('div', { className: 'dshn-info-row' },
627
672
  h('span', { className: 'dshn-info-k' }, Icon(s.e2eEnabled ? 'lock' : 'unlock'), T.infoE2E),
628
673
  h('span', { className: 'dshn-info-v', style: { color: s.e2eEnabled ? '#3aa675' : undefined } }, s.e2eEnabled ? T.e2eOn : T.e2eOff))) : null,
@@ -779,6 +824,69 @@ window.__ModuleLoader__.load({
779
824
  return store
780
825
  }
781
826
 
827
+ // ── multi-device switcher (remote pages only) ─────────────────────────────
828
+ // On a public host, `/__dshn/devices` is answered by the RELAY (same host,
829
+ // behind the same login cookie): the list of devices bound to this subdomain.
830
+ // `multi` goes true when ≥2 are live — only then does the switcher appear.
831
+ // `/dshn-e2e` (answered by the SERVING device through the tunnel) tells us
832
+ // which device this page is actually on, for when no selection cookie is set.
833
+ // An old relay answers neither with JSON — the switcher just stays hidden.
834
+ const DEV_POLL_MS = 10000
835
+ const devStore = {
836
+ info: null, self: null, started: false, subs: new Set(),
837
+ set(patch) { Object.assign(this, patch); this.subs.forEach((f) => f()) },
838
+ sub(f) { this.subs.add(f); return () => this.subs.delete(f) },
839
+ start() {
840
+ if (this.started || pageLoopback) return
841
+ this.started = true
842
+ const tick = () => fetch('/__dshn/devices', { cache: 'no-store', credentials: 'include', headers: { accept: 'application/json' } })
843
+ .then((r) => (r.ok && String(r.headers.get('content-type') || '').includes('json') ? r.json() : null))
844
+ .then((j) => { if (j && Array.isArray(j.devices)) this.set({ info: j }) })
845
+ .catch(() => {})
846
+ tick(); setInterval(tick, DEV_POLL_MS)
847
+ fetch(E2E_PUB_PATH, { cache: 'no-store', credentials: 'include' })
848
+ .then((r) => (r.ok ? r.json() : null))
849
+ .then((j) => { if (j && j.device) this.set({ self: j.device }) })
850
+ .catch(() => {})
851
+ },
852
+ }
853
+ function DeviceSwitcher() {
854
+ const [, force] = react.useReducer((x) => x + 1, 0)
855
+ react.useEffect(() => devStore.sub(force), [])
856
+ const [open, setOpen] = react.useState(false)
857
+ const [busy, setBusy] = react.useState(false)
858
+ const info = devStore.info
859
+ if (!info || !info.multi) return null
860
+ const devices = info.devices || []
861
+ const currentId = info.current || devStore.self
862
+ const current = devices.find((d) => d.id === currentId) || null
863
+ const pick = (d) => {
864
+ if (busy || !d.online || d.id === currentId) return
865
+ setBusy(true)
866
+ // Set the selection cookie, then a full reload boots the app cleanly
867
+ // against the chosen device (no cross-device state survives).
868
+ fetch('/__dshn/select', { method: 'POST', credentials: 'include',
869
+ headers: { 'content-type': 'application/json', accept: 'application/json' },
870
+ body: JSON.stringify({ device: d.id }) })
871
+ .then((r) => { if (r.ok) location.reload(); else setBusy(false) })
872
+ .catch(() => setBusy(false))
873
+ }
874
+ return h(react.Fragment, null,
875
+ h('button', { className: 'dshn-frow', title: T.devSwitch, 'aria-label': T.devSwitch, onClick: () => setOpen(!open) },
876
+ h('span', { className: 'dshn-frow-ic' }, Icon('server', { width: 16, height: 16 })),
877
+ h('span', { className: 'dshn-frow-label' }, current ? current.name : T.devLabel),
878
+ h('span', { className: 'dshn-frow-trail' }, (info.live || 0) + '/' + devices.length)),
879
+ open ? h('div', { className: 'dshn-devpop' },
880
+ h('div', { className: 'dshn-devpop-title' }, T.devSwitch),
881
+ devices.map((d) => h('button', {
882
+ key: d.id, className: 'dshn-devrow', disabled: busy || !d.online || d.id === currentId,
883
+ onClick: () => pick(d) },
884
+ h('span', { className: 'dshn-dot', 'data-on': d.online ? '1' : '0' }),
885
+ h('span', { className: 'dshn-devrow-name' }, d.name),
886
+ d.id === currentId ? h('span', { className: 'dshn-devrow-tag' }, T.devCurrent)
887
+ : (!d.online ? h('span', { className: 'dshn-devrow-tag' }, T.devOffline) : null)))) : null)
888
+ }
889
+
782
890
  // Open dsh's Settings and land on our section. The settings trigger is a
783
891
  // stable `button[aria-haspopup="dialog"]` (class names are hashed); once it
784
892
  // is open, click our section's nav entry by its label.
@@ -841,7 +949,10 @@ window.__ModuleLoader__.load({
841
949
  // Configuration itself lives in the Settings page, not here.
842
950
  function FooterButton() {
843
951
  useStore()
844
- if (!pageLoopback) return null
952
+ // Remote pages get the device switcher in this slot instead of the local
953
+ // status row (configuration is local-only; switching devices is the one
954
+ // thing a remote visitor can do here).
955
+ if (!pageLoopback) return h(DeviceSwitcher)
845
956
  const s = store.status
846
957
  const connected = s && s.connected
847
958
  const configured = s && s.configured
@@ -871,7 +982,8 @@ window.__ModuleLoader__.load({
871
982
 
872
983
  const inject = ['slots']
873
984
  function apply(ctx) {
874
- store.start()
985
+ if (pageLoopback) store.start()
986
+ else devStore.start()
875
987
  ctx.slots.inject('sidebar.footer.action', () => ctx.slots.register({ name: 'sidebar.footer.action', id: 'dshn-footer', order: 50 }, FooterButton))
876
988
  ctx.slots.inject('settings.section', () => ctx.slots.register({ name: 'settings.section', id: 'dshn', order: 40, label: () => T.navLabel }, DshnSection))
877
989
  // Keep our settings-nav globe applied however the panel is opened (dsh's own
package/lib/index.js CHANGED
@@ -2270,7 +2270,7 @@ var require_websocket = __commonJS({
2270
2270
  var http2 = __require("http");
2271
2271
  var net = __require("net");
2272
2272
  var tls = __require("tls");
2273
- var { randomBytes: randomBytes2, createHash } = __require("crypto");
2273
+ var { randomBytes: randomBytes2, createHash: createHash2 } = __require("crypto");
2274
2274
  var { Duplex, Readable } = __require("stream");
2275
2275
  var { URL } = __require("url");
2276
2276
  var PerMessageDeflate2 = require_permessage_deflate();
@@ -2938,7 +2938,7 @@ var require_websocket = __commonJS({
2938
2938
  abortHandshake(websocket, socket, "Invalid Upgrade header");
2939
2939
  return;
2940
2940
  }
2941
- const digest = createHash("sha1").update(key + GUID).digest("base64");
2941
+ const digest = createHash2("sha1").update(key + GUID).digest("base64");
2942
2942
  if (res.headers["sec-websocket-accept"] !== digest) {
2943
2943
  abortHandshake(websocket, socket, "Invalid Sec-WebSocket-Accept header");
2944
2944
  return;
@@ -3307,7 +3307,7 @@ var require_websocket_server = __commonJS({
3307
3307
  var EventEmitter = __require("events");
3308
3308
  var http2 = __require("http");
3309
3309
  var { Duplex } = __require("stream");
3310
- var { createHash } = __require("crypto");
3310
+ var { createHash: createHash2 } = __require("crypto");
3311
3311
  var extension2 = require_extension();
3312
3312
  var PerMessageDeflate2 = require_permessage_deflate();
3313
3313
  var subprotocol2 = require_subprotocol();
@@ -3614,7 +3614,7 @@ var require_websocket_server = __commonJS({
3614
3614
  );
3615
3615
  }
3616
3616
  if (this._state > RUNNING) return abortHandshake(socket, 503);
3617
- const digest = createHash("sha1").update(key + GUID).digest("base64");
3617
+ const digest = createHash2("sha1").update(key + GUID).digest("base64");
3618
3618
  const headers = [
3619
3619
  "HTTP/1.1 101 Switching Protocols",
3620
3620
  "Upgrade: websocket",
@@ -3703,8 +3703,9 @@ var require_websocket_server = __commonJS({
3703
3703
 
3704
3704
  // packages/agent/lib/index.js
3705
3705
  import http from "node:http";
3706
+ import { createHash } from "node:crypto";
3706
3707
  import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3707
- import { homedir } from "node:os";
3708
+ import { homedir, hostname } from "node:os";
3708
3709
  import { dirname, join } from "node:path";
3709
3710
 
3710
3711
  // node_modules/.pnpm/@deepseek-ai+cosmokit@1.8.2/node_modules/@deepseek-ai/cosmokit/lib/index.js
@@ -4627,7 +4628,8 @@ var CREDS_SCHEMA = Schema.object({
4627
4628
  e2ePassword: Schema.string().role("secret").default(""),
4628
4629
  e2eSalt: Schema.string().default(""),
4629
4630
  relayHost: Schema.string().default(""),
4630
- originCa: Schema.string().default("")
4631
+ originCa: Schema.string().default(""),
4632
+ routeHost: Schema.string().default("")
4631
4633
  });
4632
4634
  function readCredsFile(path) {
4633
4635
  try {
@@ -4639,7 +4641,8 @@ function readCredsFile(path) {
4639
4641
  e2ePassword: typeof raw.e2ePassword === "string" && raw.e2ePassword !== "" ? raw.e2ePassword : void 0,
4640
4642
  e2eSalt: typeof raw.e2eSalt === "string" ? raw.e2eSalt : void 0,
4641
4643
  relayHost: typeof raw.relayHost === "string" && raw.relayHost !== "" ? raw.relayHost : void 0,
4642
- originCa: typeof raw.originCa === "string" && raw.originCa !== "" ? raw.originCa : void 0
4644
+ originCa: typeof raw.originCa === "string" && raw.originCa !== "" ? raw.originCa : void 0,
4645
+ routeHost: typeof raw.routeHost === "string" && raw.routeHost !== "" ? raw.routeHost : void 0
4643
4646
  };
4644
4647
  }
4645
4648
  } catch {
@@ -4659,7 +4662,7 @@ function settingsStore(scope, migrateFrom) {
4659
4662
  const store = {
4660
4663
  load: () => {
4661
4664
  const v = scope.get() ?? {};
4662
- 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;
4665
+ 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;
4663
4666
  },
4664
4667
  save: (creds) => {
4665
4668
  Promise.resolve(scope.update({
@@ -4668,7 +4671,8 @@ function settingsStore(scope, migrateFrom) {
4668
4671
  e2ePassword: creds?.e2ePassword ?? "",
4669
4672
  e2eSalt: creds?.e2eSalt ?? "",
4670
4673
  relayHost: creds?.relayHost ?? "",
4671
- originCa: creds?.originCa ?? ""
4674
+ originCa: creds?.originCa ?? "",
4675
+ routeHost: creds?.routeHost ?? ""
4672
4676
  })).catch(() => {
4673
4677
  });
4674
4678
  }
@@ -4690,6 +4694,16 @@ function settingsStore(scope, migrateFrom) {
4690
4694
  }
4691
4695
  return store;
4692
4696
  }
4697
+ var ROUTE_FAIL_MAX = 3;
4698
+ var ROUTE_FALLBACK_MS = 5 * 6e4;
4699
+ var ROUTE_FALLBACK_MAX_MS = 60 * 6e4;
4700
+ var ROUTE_PROBE_TIMEOUT_MS = 1e4;
4701
+ function isValidRouteHost(raw) {
4702
+ if (typeof raw !== "string" || raw.length === 0 || raw.length > 253)
4703
+ return false;
4704
+ const bare = raw.replace(/^wss?:\/\//, "");
4705
+ 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);
4706
+ }
4693
4707
  function toBuf(data) {
4694
4708
  if (Buffer.isBuffer(data))
4695
4709
  return data;
@@ -4746,6 +4760,18 @@ var AgentTunnel = class {
4746
4760
  e2eKey = null;
4747
4761
  /** Public salt for the current e2e key. */
4748
4762
  e2eSalt = "";
4763
+ /**
4764
+ * Stable device identity for multi-device: several machines may bind the same
4765
+ * subdomain, and the relay tells them apart by this id. Derived, not stored:
4766
+ * hashing the hostname with the state path gives an id that survives restarts
4767
+ * and disconnects, distinguishes two profiles on one machine (different
4768
+ * DSH_HOME/DSHN_STATE), and needs no schema or migration. Two agents sharing
4769
+ * one profile dir collide — deliberately, since sharing a profile already
4770
+ * means fighting over the same credentials.
4771
+ */
4772
+ deviceId;
4773
+ /** Human-readable device name shown in the relay's switcher (env override, else hostname). */
4774
+ deviceName;
4749
4775
  status;
4750
4776
  creds = null;
4751
4777
  /** ms epoch the current tunnel became live (READY), or null when down. */
@@ -4756,10 +4782,22 @@ var AgentTunnel = class {
4756
4782
  latencyMs = null;
4757
4783
  /** ms epoch the outstanding latency ping was sent (0 = none in flight). */
4758
4784
  pingSentAt = 0;
4785
+ /** The authority the current control socket was dialled through. */
4786
+ dialledHost = null;
4787
+ /** Consecutive dials/probes of the premium host that failed. */
4788
+ routeFails = 0;
4789
+ /** Until when (ms epoch) the premium host is skipped in favour of the default relay. */
4790
+ routeFallbackUntil = 0;
4791
+ /** The next fallback period (grows while the premium host keeps failing). */
4792
+ routeFallbackMs = ROUTE_FALLBACK_MS;
4793
+ /** The in-flight probe of the premium host, if one is running. */
4794
+ routeProbe = null;
4759
4795
  constructor(config, localPort, store) {
4760
4796
  this.config = config;
4761
4797
  this.localPort = localPort;
4762
4798
  this.store = store;
4799
+ this.deviceId = createHash("sha256").update(`${hostname()}|${config.statePath}`).digest("hex").slice(0, 12);
4800
+ this.deviceName = (process.env.DSHN_DEVICE_NAME ?? hostname()).trim().slice(0, 40) || this.deviceId;
4763
4801
  this.creds = this.store.load();
4764
4802
  this.refreshE2E();
4765
4803
  this.status = {
@@ -4768,7 +4806,8 @@ var AgentTunnel = class {
4768
4806
  connected: false,
4769
4807
  publicUrl: null,
4770
4808
  subdomain: this.creds?.subdomain ?? null,
4771
- lastError: null
4809
+ lastError: null,
4810
+ route: null
4772
4811
  };
4773
4812
  }
4774
4813
  /**
@@ -4810,7 +4849,14 @@ var AgentTunnel = class {
4810
4849
  return "Password must be at least 8 characters.";
4811
4850
  const rh = typeof relayHost === "string" ? relayHost.trim() : this.creds?.relayHost ?? "";
4812
4851
  const ca = typeof originCa === "string" ? originCa.trim() : this.creds?.originCa ?? "";
4813
- this.creds = { subdomain: label, password: String(password), e2ePassword: this.creds?.e2ePassword, e2eSalt: this.creds?.e2eSalt, relayHost: rh || void 0, originCa: ca || void 0 };
4852
+ const sameTarget = this.creds?.subdomain === label && (this.creds?.relayHost ?? "") === (rh || "");
4853
+ 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 };
4854
+ this.status.route = null;
4855
+ if (!sameTarget) {
4856
+ this.routeFails = 0;
4857
+ this.routeFallbackUntil = 0;
4858
+ this.routeFallbackMs = ROUTE_FALLBACK_MS;
4859
+ }
4814
4860
  this.refreshE2E();
4815
4861
  this.saveCreds(this.creds);
4816
4862
  this.status.configured = true;
@@ -4884,9 +4930,12 @@ var AgentTunnel = class {
4884
4930
  info() {
4885
4931
  const host = this.effectiveRelayHost();
4886
4932
  const direct = /^wss?:\/\//.test(host) || this.effectiveOriginCa() !== null;
4933
+ const live = this.status.connected && this.dialledHost !== null ? this.dialledHost : host;
4887
4934
  return {
4888
- relayHost: host.replace(/^wss?:\/\//, "").replace(/\/.*$/, ""),
4935
+ relayHost: live.replace(/^wss?:\/\//, "").replace(/\/.*$/, ""),
4889
4936
  direct,
4937
+ route: this.status.route,
4938
+ routeHost: this.creds?.routeHost ?? null,
4890
4939
  connectedSince: this.connectedSince,
4891
4940
  served: this.served,
4892
4941
  localPort: this.localPort(),
@@ -4901,6 +4950,10 @@ var AgentTunnel = class {
4901
4950
  this.status.connected = false;
4902
4951
  this.status.publicUrl = null;
4903
4952
  this.status.subdomain = null;
4953
+ this.status.route = null;
4954
+ this.routeFails = 0;
4955
+ this.routeFallbackUntil = 0;
4956
+ this.routeFallbackMs = ROUTE_FALLBACK_MS;
4904
4957
  if (this.reconnectTimer !== null) {
4905
4958
  clearTimeout(this.reconnectTimer);
4906
4959
  this.reconnectTimer = null;
@@ -4919,15 +4972,168 @@ var AgentTunnel = class {
4919
4972
  clearTimeout(this.reconnectTimer);
4920
4973
  if (this.heartbeatTimer !== null)
4921
4974
  clearInterval(this.heartbeatTimer);
4975
+ this.dropStreams();
4976
+ this.control?.close();
4977
+ this.control = null;
4978
+ this.status.connected = false;
4979
+ }
4980
+ /**
4981
+ * Tear down every stream bridged over the current control socket. Must run
4982
+ * whenever that socket is abandoned — on close AND on a deliberate redial —
4983
+ * because the relay numbers streams per connection from 1: a request or
4984
+ * browser socket left over from the old connection would otherwise answer to
4985
+ * the ids of the new one and leak its bytes into a stranger's stream.
4986
+ */
4987
+ dropStreams() {
4922
4988
  for (const req of this.requests.values())
4923
4989
  req.destroy();
4924
4990
  for (const sock of this.sockets.values())
4925
4991
  sock.close();
4926
4992
  this.requests.clear();
4927
4993
  this.sockets.clear();
4928
- this.control?.close();
4994
+ this.reqE2E.clear();
4995
+ }
4996
+ /**
4997
+ * Which authority to dial: the relay-assigned premium host when one is
4998
+ * remembered and not in a fallback period, else the default relay host. The
4999
+ * premium host is only ever set by a route announcement from the relay.
5000
+ */
5001
+ dialHost() {
5002
+ const route = this.creds?.routeHost;
5003
+ if (route !== void 0 && route !== "" && Date.now() >= this.routeFallbackUntil)
5004
+ return route;
5005
+ return this.effectiveRelayHost();
5006
+ }
5007
+ /** Whether the live control socket was dialled through the remembered premium host. */
5008
+ onPremiumPath() {
5009
+ const route = this.creds?.routeHost;
5010
+ return route !== void 0 && route !== "" && this.dialledHost === route;
5011
+ }
5012
+ /** Whether the premium host is currently being skipped after repeated failures. */
5013
+ inRouteFallback() {
5014
+ return Date.now() < this.routeFallbackUntil;
5015
+ }
5016
+ /**
5017
+ * Apply a route announcement (READY or a mid-session ROUTE frame).
5018
+ *
5019
+ * `status.route` reflects the OPERATOR'S ASSIGNMENT, because that is what a
5020
+ * public visitor experiences: enabling premium points the subdomain's DNS at
5021
+ * the accelerator, so browser traffic is accelerated no matter which host the
5022
+ * agent's own control socket happens to use. Moving the control socket onto
5023
+ * the premium host too is a best-effort bonus for the uplink — it may briefly
5024
+ * fail while the fresh DNS record propagates, and if the host stays
5025
+ * unreachable the agent quietly keeps its control socket on the default relay.
5026
+ * Neither case changes the displayed route or breaks the tunnel.
5027
+ *
5028
+ * - `premium` with a usable host: show premium, remember the host, and (unless
5029
+ * in a fallback window) PROBE it; only a host that answers gets the control
5030
+ * socket moved onto it — a working tunnel is never dropped for a dead host.
5031
+ * - `standard`: the operator withdrew the fast path — show standard, forget
5032
+ * the host, and return the control socket to the default relay.
5033
+ */
5034
+ applyRoute(route, routeHost) {
5035
+ if (this.creds === null)
5036
+ return;
5037
+ if (route === "premium" && isValidRouteHost(routeHost)) {
5038
+ this.status.route = "premium";
5039
+ if (this.creds.routeHost !== routeHost) {
5040
+ this.creds = { ...this.creds, routeHost };
5041
+ this.saveCreds(this.creds);
5042
+ this.routeFails = 0;
5043
+ this.routeFallbackUntil = 0;
5044
+ this.routeFallbackMs = ROUTE_FALLBACK_MS;
5045
+ }
5046
+ this.tryPremium();
5047
+ return;
5048
+ }
5049
+ if (route === "standard" || route === "premium") {
5050
+ this.status.route = "standard";
5051
+ this.routeFallbackUntil = 0;
5052
+ this.routeFails = 0;
5053
+ this.routeFallbackMs = ROUTE_FALLBACK_MS;
5054
+ const hadRoute = this.creds.routeHost !== void 0 && this.creds.routeHost !== "";
5055
+ if (hadRoute) {
5056
+ this.creds = { ...this.creds, routeHost: void 0 };
5057
+ this.saveCreds(this.creds);
5058
+ if (this.dialledHost !== this.effectiveRelayHost())
5059
+ this.redial();
5060
+ }
5061
+ }
5062
+ }
5063
+ /**
5064
+ * Move the control socket onto the premium host when that is worth doing:
5065
+ * assigned premium, currently on the default relay, not in a fallback window,
5066
+ * and no probe already running. The host is probed first (a plain WebSocket
5067
+ * handshake, no HELLO — so the relay never sees a second agent) and the live
5068
+ * socket is only redialled once the host has answered. A failed probe counts
5069
+ * like a failed dial; enough of them open a fallback window.
5070
+ */
5071
+ tryPremium() {
5072
+ const host = this.creds?.routeHost;
5073
+ if (host === void 0 || host === "" || this.routeProbe !== null)
5074
+ return;
5075
+ if (!this.status.connected || this.onPremiumPath() || this.inRouteFallback())
5076
+ return;
5077
+ this.routeProbe = this.probeHost(host).then((ok) => {
5078
+ this.routeProbe = null;
5079
+ if (this.stopped || this.creds?.routeHost !== host || this.status.route !== "premium")
5080
+ return;
5081
+ if (!this.status.connected || this.onPremiumPath())
5082
+ return;
5083
+ if (ok)
5084
+ this.redial();
5085
+ else
5086
+ this.noteRouteFailure();
5087
+ });
5088
+ }
5089
+ /** Whether `host` accepts a WebSocket on the agent path right now (no HELLO is sent). */
5090
+ probeHost(host) {
5091
+ return new Promise((resolve2) => {
5092
+ const base = host.includes("://") ? host : `wss://${host}`;
5093
+ let ws;
5094
+ try {
5095
+ ws = new import_websocket.default(`${base}${AGENT_WS_PATH}`, { handshakeTimeout: ROUTE_PROBE_TIMEOUT_MS });
5096
+ } catch {
5097
+ resolve2(false);
5098
+ return;
5099
+ }
5100
+ let done = false;
5101
+ const finish = (ok) => {
5102
+ if (done)
5103
+ return;
5104
+ done = true;
5105
+ if (process.env.DSHN_DEBUG)
5106
+ console.error(`[dshn-agent] premium probe of ${host}: ${ok ? "reachable" : "unreachable"}`);
5107
+ resolve2(ok);
5108
+ if (ok)
5109
+ ws.close();
5110
+ else
5111
+ ws.terminate();
5112
+ };
5113
+ ws.on("open", () => finish(true));
5114
+ ws.on("error", () => finish(false));
5115
+ ws.on("close", () => finish(false));
5116
+ });
5117
+ }
5118
+ /** Count a failed dial/probe of the premium host; enough in a row open a (growing) fallback window. */
5119
+ noteRouteFailure() {
5120
+ this.routeFails++;
5121
+ if (this.routeFails < ROUTE_FAIL_MAX)
5122
+ return;
5123
+ this.routeFails = 0;
5124
+ this.routeFallbackUntil = Date.now() + this.routeFallbackMs;
5125
+ this.routeFallbackMs = Math.min(this.routeFallbackMs * 2, ROUTE_FALLBACK_MAX_MS);
5126
+ }
5127
+ /** Drop the live socket and dial again right away (route change). */
5128
+ redial() {
5129
+ if (process.env.DSHN_DEBUG)
5130
+ console.error(`[dshn-agent] route change \u2192 redialling via ${this.dialHost()}`);
5131
+ this.backoffMs = 1e3;
5132
+ const ws = this.control;
4929
5133
  this.control = null;
4930
- this.status.connected = false;
5134
+ this.dropStreams();
5135
+ ws?.close();
5136
+ this.connect();
4931
5137
  }
4932
5138
  connect() {
4933
5139
  if (this.stopped || this.creds === null)
@@ -4939,11 +5145,12 @@ var AgentTunnel = class {
4939
5145
  clearTimeout(this.reconnectTimer);
4940
5146
  this.reconnectTimer = null;
4941
5147
  }
4942
- const relayHost = this.effectiveRelayHost();
5148
+ const relayHost = this.dialHost();
4943
5149
  const base = relayHost.includes("://") ? relayHost : `wss://${relayHost}`;
5150
+ this.dialledHost = relayHost;
4944
5151
  const wsOpts = { maxPayload: 512 * 1024 * 1024 };
4945
5152
  const ca = this.effectiveOriginCa();
4946
- if (ca !== null)
5153
+ if (ca !== null && relayHost === this.effectiveRelayHost())
4947
5154
  wsOpts.ca = ca;
4948
5155
  const ws = new import_websocket.default(`${base}${AGENT_WS_PATH}`, wsOpts);
4949
5156
  this.control = ws;
@@ -4958,7 +5165,9 @@ var AgentTunnel = class {
4958
5165
  subdomain: this.creds.subdomain,
4959
5166
  password: this.creds.password,
4960
5167
  agent: `dshn-agent/${DSHN_PROTOCOL_VERSION}`,
4961
- protocol: DSHN_PROTOCOL_VERSION
5168
+ protocol: DSHN_PROTOCOL_VERSION,
5169
+ deviceId: this.deviceId,
5170
+ device: this.deviceName
4962
5171
  });
4963
5172
  }
4964
5173
  this.lastPong = Date.now();
@@ -4994,10 +5203,14 @@ var AgentTunnel = class {
4994
5203
  this.control.terminate();
4995
5204
  return;
4996
5205
  }
5206
+ if (this.status.route === "premium")
5207
+ this.tryPremium();
4997
5208
  this.sendPing();
4998
5209
  }, LATENCY_PING_MS);
4999
5210
  }
5000
5211
  onClose() {
5212
+ if (!this.status.connected && this.onPremiumPath())
5213
+ this.noteRouteFailure();
5001
5214
  this.status.connected = false;
5002
5215
  this.connectedSince = null;
5003
5216
  this.latencyMs = null;
@@ -5007,12 +5220,7 @@ var AgentTunnel = class {
5007
5220
  clearInterval(this.heartbeatTimer);
5008
5221
  this.heartbeatTimer = null;
5009
5222
  }
5010
- for (const req of this.requests.values())
5011
- req.destroy();
5012
- for (const sock of this.sockets.values())
5013
- sock.close();
5014
- this.requests.clear();
5015
- this.sockets.clear();
5223
+ this.dropStreams();
5016
5224
  if (this.stopped)
5017
5225
  return;
5018
5226
  if (this.reconnectTimer !== null)
@@ -5054,12 +5262,22 @@ var AgentTunnel = class {
5054
5262
  switch (frame.t) {
5055
5263
  case "ready":
5056
5264
  this.backoffMs = 1e3;
5265
+ this.routeFails = 0;
5266
+ if (this.onPremiumPath())
5267
+ this.routeFallbackMs = ROUTE_FALLBACK_MS;
5057
5268
  this.status.connected = true;
5058
5269
  this.status.publicUrl = frame.publicUrl;
5059
5270
  this.status.subdomain = frame.subdomain;
5060
5271
  this.status.lastError = null;
5061
5272
  this.connectedSince = Date.now();
5062
5273
  this.sendPing();
5274
+ if (frame.route !== void 0)
5275
+ this.applyRoute(frame.route, frame.routeHost);
5276
+ else if (this.creds?.routeHost)
5277
+ this.applyRoute("standard", void 0);
5278
+ break;
5279
+ case "route":
5280
+ this.applyRoute(frame.route, frame.routeHost);
5063
5281
  break;
5064
5282
  case "deny":
5065
5283
  this.status.lastError = frame.reason;
@@ -5278,8 +5496,8 @@ function isLoopbackRequest(req) {
5278
5496
  if (req.headers[TUNNEL_MARKER] !== void 0)
5279
5497
  return false;
5280
5498
  const host = String(req.headers.host ?? "");
5281
- const hostname = host.replace(/:\d+$/, "").replace(/^\[|\]$/g, "").toLowerCase();
5282
- return hostname === "localhost" || hostname === "::1" || hostname.startsWith("127.");
5499
+ const hostname2 = host.replace(/:\d+$/, "").replace(/^\[|\]$/g, "").toLowerCase();
5500
+ return hostname2 === "localhost" || hostname2 === "::1" || hostname2.startsWith("127.");
5283
5501
  }
5284
5502
  function publicApex(relayHost) {
5285
5503
  const bare = relayHost.replace(/^wss?:\/\//, "").replace(/:\d+$/, "");
@@ -5342,6 +5560,9 @@ function apply(ctx, rawConfig) {
5342
5560
  lastError: tunnel.status.lastError,
5343
5561
  configurable: loopback,
5344
5562
  apex: publicApex(info.relayHost),
5563
+ // This machine's identity in the multi-device switcher.
5564
+ deviceId: tunnel.deviceId,
5565
+ deviceName: tunnel.deviceName,
5345
5566
  // The saved password is the only recoverable copy (cloud stores a hash).
5346
5567
  // Only ever handed to a loopback caller — the local machine's owner.
5347
5568
  password: loopback ? tunnel.revealPassword() : null,
@@ -5355,6 +5576,10 @@ function apply(ctx, rawConfig) {
5355
5576
  // Connection details for the panel.
5356
5577
  relayHost: info.relayHost,
5357
5578
  mode: info.direct ? "direct" : "cloudflare",
5579
+ // Which path the relay assigned: 'premium' (accelerated, via routeHost)
5580
+ // or 'standard'; null until a route-aware relay has said.
5581
+ route: info.route,
5582
+ routeHost: info.routeHost,
5358
5583
  connectedSince: info.connectedSince,
5359
5584
  served: info.served,
5360
5585
  localPort: info.localPort,
@@ -5367,7 +5592,7 @@ function apply(ctx, rawConfig) {
5367
5592
  path: E2E_PUB_PATH,
5368
5593
  handler: (_req, res) => {
5369
5594
  const e = tunnel.e2eInfo();
5370
- json(res, 200, { enabled: e.enabled, salt: e.enabled ? e.salt : null });
5595
+ json(res, 200, { enabled: e.enabled, salt: e.enabled ? e.salt : null, device: tunnel.deviceId });
5371
5596
  }
5372
5597
  });
5373
5598
  const disposeConfigure = ctx.webServer.register({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dshn/agent",
3
- "version": "0.1.7",
3
+ "version": "0.3.0",
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": {