@dshn/agent 0.2.0 → 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
@@ -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
@@ -417,6 +417,7 @@ window.__ModuleLoader__.load({
417
417
  savedHint: '手机访问用这个密码登录。忘记时点“复制/显示”取回。',
418
418
  weak: '弱', fair: '一般', good: '较强', strong: '强',
419
419
  infoRelay: '线路', infoMode: { direct: '直连源站', cloudflare: '经 Cloudflare' },
420
+ routePremium: '高级线路(加速)', routeStandard: '标准线路',
420
421
  infoUptime: '在线时长', infoServed: '已转发请求', infoPort: '本地端口', infoLatency: '延迟', infoDevice: '设备名',
421
422
  e2eLabel: '端到端密码(可选)', e2eHint: '设置后,会话内容用它加密,云端也看不到;密码不出本机。访问时需在网页再输一次。',
422
423
  e2eApply: '设置端到端密码', e2eUpdate: '更新端到端密码', e2eDisable: '关闭加密', e2eApplied: '✓ 端到端加密已开启', e2eOff2: '✓ 端到端加密已关闭', e2eIndep: '独立设置,不影响上面的连接。',
@@ -442,6 +443,7 @@ window.__ModuleLoader__.load({
442
443
  savedHint: 'Log in from a phone with this password. Copy/show it here if you forget.',
443
444
  weak: 'weak', fair: 'fair', good: 'good', strong: 'strong',
444
445
  infoRelay: 'Link', infoMode: { direct: 'direct to origin', cloudflare: 'via Cloudflare' },
446
+ routePremium: 'premium route (accelerated)', routeStandard: 'standard route',
445
447
  infoUptime: 'Uptime', infoServed: 'Requests served', infoPort: 'Local port', infoLatency: 'Latency', infoDevice: 'Device name',
446
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.',
447
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.',
@@ -478,6 +480,7 @@ window.__ModuleLoader__.load({
478
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')],
479
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')],
480
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')],
481
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' })],
482
485
  clock: () => [h('circle', { key: 'c', cx: 7, cy: 7, r: 5.3 }), P('M7 4.1v3.1l2 1.2')],
483
486
  swap: () => [P('M3.4 5h7.2l-2-2'), P('M10.6 9H3.4l2 2')],
@@ -641,9 +644,13 @@ window.__ModuleLoader__.load({
641
644
  })() : null,
642
645
 
643
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.
644
649
  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 : ''))),
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 : ''))),
647
654
  h('div', { className: 'dshn-info-row' },
648
655
  h('span', { className: 'dshn-info-k' }, Icon('gauge'), T.infoLatency),
649
656
  h('span', { className: 'dshn-info-v', style: { color: latColor(s.latencyMs) } }, s.latencyMs == null ? '—' : s.latencyMs + ' ms')),
package/lib/index.js CHANGED
@@ -4628,7 +4628,8 @@ var CREDS_SCHEMA = Schema.object({
4628
4628
  e2ePassword: Schema.string().role("secret").default(""),
4629
4629
  e2eSalt: Schema.string().default(""),
4630
4630
  relayHost: Schema.string().default(""),
4631
- originCa: Schema.string().default("")
4631
+ originCa: Schema.string().default(""),
4632
+ routeHost: Schema.string().default("")
4632
4633
  });
4633
4634
  function readCredsFile(path) {
4634
4635
  try {
@@ -4640,7 +4641,8 @@ function readCredsFile(path) {
4640
4641
  e2ePassword: typeof raw.e2ePassword === "string" && raw.e2ePassword !== "" ? raw.e2ePassword : void 0,
4641
4642
  e2eSalt: typeof raw.e2eSalt === "string" ? raw.e2eSalt : void 0,
4642
4643
  relayHost: typeof raw.relayHost === "string" && raw.relayHost !== "" ? raw.relayHost : void 0,
4643
- 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
4644
4646
  };
4645
4647
  }
4646
4648
  } catch {
@@ -4660,7 +4662,7 @@ function settingsStore(scope, migrateFrom) {
4660
4662
  const store = {
4661
4663
  load: () => {
4662
4664
  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;
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;
4664
4666
  },
4665
4667
  save: (creds) => {
4666
4668
  Promise.resolve(scope.update({
@@ -4669,7 +4671,8 @@ function settingsStore(scope, migrateFrom) {
4669
4671
  e2ePassword: creds?.e2ePassword ?? "",
4670
4672
  e2eSalt: creds?.e2eSalt ?? "",
4671
4673
  relayHost: creds?.relayHost ?? "",
4672
- originCa: creds?.originCa ?? ""
4674
+ originCa: creds?.originCa ?? "",
4675
+ routeHost: creds?.routeHost ?? ""
4673
4676
  })).catch(() => {
4674
4677
  });
4675
4678
  }
@@ -4691,6 +4694,16 @@ function settingsStore(scope, migrateFrom) {
4691
4694
  }
4692
4695
  return store;
4693
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
+ }
4694
4707
  function toBuf(data) {
4695
4708
  if (Buffer.isBuffer(data))
4696
4709
  return data;
@@ -4769,6 +4782,16 @@ var AgentTunnel = class {
4769
4782
  latencyMs = null;
4770
4783
  /** ms epoch the outstanding latency ping was sent (0 = none in flight). */
4771
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;
4772
4795
  constructor(config, localPort, store) {
4773
4796
  this.config = config;
4774
4797
  this.localPort = localPort;
@@ -4783,7 +4806,8 @@ var AgentTunnel = class {
4783
4806
  connected: false,
4784
4807
  publicUrl: null,
4785
4808
  subdomain: this.creds?.subdomain ?? null,
4786
- lastError: null
4809
+ lastError: null,
4810
+ route: null
4787
4811
  };
4788
4812
  }
4789
4813
  /**
@@ -4825,7 +4849,14 @@ var AgentTunnel = class {
4825
4849
  return "Password must be at least 8 characters.";
4826
4850
  const rh = typeof relayHost === "string" ? relayHost.trim() : this.creds?.relayHost ?? "";
4827
4851
  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 };
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
+ }
4829
4860
  this.refreshE2E();
4830
4861
  this.saveCreds(this.creds);
4831
4862
  this.status.configured = true;
@@ -4899,9 +4930,12 @@ var AgentTunnel = class {
4899
4930
  info() {
4900
4931
  const host = this.effectiveRelayHost();
4901
4932
  const direct = /^wss?:\/\//.test(host) || this.effectiveOriginCa() !== null;
4933
+ const live = this.status.connected && this.dialledHost !== null ? this.dialledHost : host;
4902
4934
  return {
4903
- relayHost: host.replace(/^wss?:\/\//, "").replace(/\/.*$/, ""),
4935
+ relayHost: live.replace(/^wss?:\/\//, "").replace(/\/.*$/, ""),
4904
4936
  direct,
4937
+ route: this.status.route,
4938
+ routeHost: this.creds?.routeHost ?? null,
4905
4939
  connectedSince: this.connectedSince,
4906
4940
  served: this.served,
4907
4941
  localPort: this.localPort(),
@@ -4916,6 +4950,10 @@ var AgentTunnel = class {
4916
4950
  this.status.connected = false;
4917
4951
  this.status.publicUrl = null;
4918
4952
  this.status.subdomain = null;
4953
+ this.status.route = null;
4954
+ this.routeFails = 0;
4955
+ this.routeFallbackUntil = 0;
4956
+ this.routeFallbackMs = ROUTE_FALLBACK_MS;
4919
4957
  if (this.reconnectTimer !== null) {
4920
4958
  clearTimeout(this.reconnectTimer);
4921
4959
  this.reconnectTimer = null;
@@ -4934,15 +4972,168 @@ var AgentTunnel = class {
4934
4972
  clearTimeout(this.reconnectTimer);
4935
4973
  if (this.heartbeatTimer !== null)
4936
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() {
4937
4988
  for (const req of this.requests.values())
4938
4989
  req.destroy();
4939
4990
  for (const sock of this.sockets.values())
4940
4991
  sock.close();
4941
4992
  this.requests.clear();
4942
4993
  this.sockets.clear();
4943
- 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;
4944
5133
  this.control = null;
4945
- this.status.connected = false;
5134
+ this.dropStreams();
5135
+ ws?.close();
5136
+ this.connect();
4946
5137
  }
4947
5138
  connect() {
4948
5139
  if (this.stopped || this.creds === null)
@@ -4954,11 +5145,12 @@ var AgentTunnel = class {
4954
5145
  clearTimeout(this.reconnectTimer);
4955
5146
  this.reconnectTimer = null;
4956
5147
  }
4957
- const relayHost = this.effectiveRelayHost();
5148
+ const relayHost = this.dialHost();
4958
5149
  const base = relayHost.includes("://") ? relayHost : `wss://${relayHost}`;
5150
+ this.dialledHost = relayHost;
4959
5151
  const wsOpts = { maxPayload: 512 * 1024 * 1024 };
4960
5152
  const ca = this.effectiveOriginCa();
4961
- if (ca !== null)
5153
+ if (ca !== null && relayHost === this.effectiveRelayHost())
4962
5154
  wsOpts.ca = ca;
4963
5155
  const ws = new import_websocket.default(`${base}${AGENT_WS_PATH}`, wsOpts);
4964
5156
  this.control = ws;
@@ -5011,10 +5203,14 @@ var AgentTunnel = class {
5011
5203
  this.control.terminate();
5012
5204
  return;
5013
5205
  }
5206
+ if (this.status.route === "premium")
5207
+ this.tryPremium();
5014
5208
  this.sendPing();
5015
5209
  }, LATENCY_PING_MS);
5016
5210
  }
5017
5211
  onClose() {
5212
+ if (!this.status.connected && this.onPremiumPath())
5213
+ this.noteRouteFailure();
5018
5214
  this.status.connected = false;
5019
5215
  this.connectedSince = null;
5020
5216
  this.latencyMs = null;
@@ -5024,12 +5220,7 @@ var AgentTunnel = class {
5024
5220
  clearInterval(this.heartbeatTimer);
5025
5221
  this.heartbeatTimer = null;
5026
5222
  }
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();
5223
+ this.dropStreams();
5033
5224
  if (this.stopped)
5034
5225
  return;
5035
5226
  if (this.reconnectTimer !== null)
@@ -5071,12 +5262,22 @@ var AgentTunnel = class {
5071
5262
  switch (frame.t) {
5072
5263
  case "ready":
5073
5264
  this.backoffMs = 1e3;
5265
+ this.routeFails = 0;
5266
+ if (this.onPremiumPath())
5267
+ this.routeFallbackMs = ROUTE_FALLBACK_MS;
5074
5268
  this.status.connected = true;
5075
5269
  this.status.publicUrl = frame.publicUrl;
5076
5270
  this.status.subdomain = frame.subdomain;
5077
5271
  this.status.lastError = null;
5078
5272
  this.connectedSince = Date.now();
5079
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);
5080
5281
  break;
5081
5282
  case "deny":
5082
5283
  this.status.lastError = frame.reason;
@@ -5375,6 +5576,10 @@ function apply(ctx, rawConfig) {
5375
5576
  // Connection details for the panel.
5376
5577
  relayHost: info.relayHost,
5377
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,
5378
5583
  connectedSince: info.connectedSince,
5379
5584
  served: info.served,
5380
5585
  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.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": {