@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 +29 -20
- package/README.zh.md +110 -0
- package/client.js +129 -17
- package/lib/index.js +251 -26
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
# dshn — DeepSeek Harness Network
|
|
2
2
|
|
|
3
|
+
**English** · [中文](./README.zh.md)
|
|
4
|
+
|
|
5
|
+
[](https://awesome-dsh-plugin.com/)
|
|
6
|
+
[](https://www.npmjs.com/package/@dshn/agent)
|
|
3
7
|
[](./LICENSE)
|
|
4
|
-
[](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
|
-
|
|
|
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
|
|
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
|
-
|
|
123
|
-
|
|
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
|
-
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
|
144
|
-
|
|
145
|
-
| `
|
|
146
|
-
| `
|
|
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
|
+
[](https://awesome-dsh-plugin.com/)
|
|
6
|
+
[](https://www.npmjs.com/package/@dshn/agent)
|
|
7
|
+
[](./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
|
|
173
|
-
// device only — never transmitted (E2E is intact).
|
|
174
|
-
//
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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)) {
|
|
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
|
-
|
|
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
|
-
|
|
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'
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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
|
|
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:
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
|
5282
|
-
return
|
|
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.
|
|
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": {
|