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