@dshn/agent 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +24 -20
- package/README.zh.md +110 -0
- package/client.js +9 -2
- package/lib/index.js +222 -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
|
@@ -417,6 +417,7 @@ window.__ModuleLoader__.load({
|
|
|
417
417
|
savedHint: '手机访问用这个密码登录。忘记时点“复制/显示”取回。',
|
|
418
418
|
weak: '弱', fair: '一般', good: '较强', strong: '强',
|
|
419
419
|
infoRelay: '线路', infoMode: { direct: '直连源站', cloudflare: '经 Cloudflare' },
|
|
420
|
+
routePremium: '高级线路(加速)', routeStandard: '标准线路',
|
|
420
421
|
infoUptime: '在线时长', infoServed: '已转发请求', infoPort: '本地端口', infoLatency: '延迟', infoDevice: '设备名',
|
|
421
422
|
e2eLabel: '端到端密码(可选)', e2eHint: '设置后,会话内容用它加密,云端也看不到;密码不出本机。访问时需在网页再输一次。',
|
|
422
423
|
e2eApply: '设置端到端密码', e2eUpdate: '更新端到端密码', e2eDisable: '关闭加密', e2eApplied: '✓ 端到端加密已开启', e2eOff2: '✓ 端到端加密已关闭', e2eIndep: '独立设置,不影响上面的连接。',
|
|
@@ -442,6 +443,7 @@ window.__ModuleLoader__.load({
|
|
|
442
443
|
savedHint: 'Log in from a phone with this password. Copy/show it here if you forget.',
|
|
443
444
|
weak: 'weak', fair: 'fair', good: 'good', strong: 'strong',
|
|
444
445
|
infoRelay: 'Link', infoMode: { direct: 'direct to origin', cloudflare: 'via Cloudflare' },
|
|
446
|
+
routePremium: 'premium route (accelerated)', routeStandard: 'standard route',
|
|
445
447
|
infoUptime: 'Uptime', infoServed: 'Requests served', infoPort: 'Local port', infoLatency: 'Latency', infoDevice: 'Device name',
|
|
446
448
|
e2eLabel: 'End-to-end password (optional)', e2eHint: 'If set, session content is encrypted with it — even the cloud cannot read it, and it never leaves this machine. Visitors enter it again in the browser.',
|
|
447
449
|
e2eApply: 'Set e2e password', e2eUpdate: 'Update e2e password', e2eDisable: 'Turn off', e2eApplied: '✓ End-to-end encryption on', e2eOff2: '✓ End-to-end encryption off', e2eIndep: 'Applied on its own — does not affect the connection above.',
|
|
@@ -478,6 +480,7 @@ window.__ModuleLoader__.load({
|
|
|
478
480
|
P('M7 1.7c2.3 2.3 2.3 8.3 0 10.6'), P('M7 1.7c-2.3 2.3-2.3 8.3 0 10.6')],
|
|
479
481
|
cloud: () => [P('M4.4 10.6a2.6 2.6 0 01.2-5.2 3.4 3.4 0 016.5.9 2.2 2.2 0 01-.4 4.3z')],
|
|
480
482
|
plug: () => [P('M5 2.3v2.2M9 2.3v2.2'), P('M4 4.6h6v1.9a3 3 0 01-6 0z'), P('M7 9.4v2.3')],
|
|
483
|
+
bolt: () => [P('M7.6 1.8L3.3 7.8h3.1l-.8 4.4 4.3-6h-3.1z')],
|
|
481
484
|
gauge: () => [P('M2.2 10.4a5 5 0 019.6 0'), P('M7 10.4l2.4-2.7'), h('circle', { key: 'd', cx: 7, cy: 10.4, r: .5, fill: 'currentColor' })],
|
|
482
485
|
clock: () => [h('circle', { key: 'c', cx: 7, cy: 7, r: 5.3 }), P('M7 4.1v3.1l2 1.2')],
|
|
483
486
|
swap: () => [P('M3.4 5h7.2l-2-2'), P('M10.6 9H3.4l2 2')],
|
|
@@ -641,9 +644,13 @@ window.__ModuleLoader__.load({
|
|
|
641
644
|
})() : null,
|
|
642
645
|
|
|
643
646
|
configured && s.connected ? h('div', { className: 'dshn-info' },
|
|
647
|
+
// The route is the operator's assignment (premium = accelerated path via
|
|
648
|
+
// the tunnel's own hostname); the mode is how the default relay is reached.
|
|
644
649
|
h('div', { className: 'dshn-info-row' },
|
|
645
|
-
h('span', { className: 'dshn-info-k' }, Icon(s.mode === 'direct' ? 'plug' : 'cloud'), T.infoRelay),
|
|
646
|
-
h('span', { className: 'dshn-info-v'
|
|
650
|
+
h('span', { className: 'dshn-info-k' }, Icon(s.route === 'premium' ? 'bolt' : s.mode === 'direct' ? 'plug' : 'cloud'), T.infoRelay),
|
|
651
|
+
h('span', { className: 'dshn-info-v', style: s.route === 'premium' ? { color: '#c9930f' } : undefined },
|
|
652
|
+
(s.route === 'premium' ? T.routePremium : s.route === 'standard' ? T.routeStandard + ' · ' + (T.infoMode[s.mode] || s.mode || '') : (T.infoMode[s.mode] || s.mode || ''))
|
|
653
|
+
+ (s.relayHost ? ' · ' + s.relayHost : ''))),
|
|
647
654
|
h('div', { className: 'dshn-info-row' },
|
|
648
655
|
h('span', { className: 'dshn-info-k' }, Icon('gauge'), T.infoLatency),
|
|
649
656
|
h('span', { className: 'dshn-info-v', style: { color: latColor(s.latencyMs) } }, s.latencyMs == null ? '—' : s.latencyMs + ' ms')),
|
package/lib/index.js
CHANGED
|
@@ -4628,7 +4628,8 @@ var CREDS_SCHEMA = Schema.object({
|
|
|
4628
4628
|
e2ePassword: Schema.string().role("secret").default(""),
|
|
4629
4629
|
e2eSalt: Schema.string().default(""),
|
|
4630
4630
|
relayHost: Schema.string().default(""),
|
|
4631
|
-
originCa: Schema.string().default("")
|
|
4631
|
+
originCa: Schema.string().default(""),
|
|
4632
|
+
routeHost: Schema.string().default("")
|
|
4632
4633
|
});
|
|
4633
4634
|
function readCredsFile(path) {
|
|
4634
4635
|
try {
|
|
@@ -4640,7 +4641,8 @@ function readCredsFile(path) {
|
|
|
4640
4641
|
e2ePassword: typeof raw.e2ePassword === "string" && raw.e2ePassword !== "" ? raw.e2ePassword : void 0,
|
|
4641
4642
|
e2eSalt: typeof raw.e2eSalt === "string" ? raw.e2eSalt : void 0,
|
|
4642
4643
|
relayHost: typeof raw.relayHost === "string" && raw.relayHost !== "" ? raw.relayHost : void 0,
|
|
4643
|
-
originCa: typeof raw.originCa === "string" && raw.originCa !== "" ? raw.originCa : void 0
|
|
4644
|
+
originCa: typeof raw.originCa === "string" && raw.originCa !== "" ? raw.originCa : void 0,
|
|
4645
|
+
routeHost: typeof raw.routeHost === "string" && raw.routeHost !== "" ? raw.routeHost : void 0
|
|
4644
4646
|
};
|
|
4645
4647
|
}
|
|
4646
4648
|
} catch {
|
|
@@ -4660,7 +4662,7 @@ function settingsStore(scope, migrateFrom) {
|
|
|
4660
4662
|
const store = {
|
|
4661
4663
|
load: () => {
|
|
4662
4664
|
const v = scope.get() ?? {};
|
|
4663
|
-
return typeof v.subdomain === "string" && v.subdomain !== "" ? { subdomain: v.subdomain, password: v.password ?? "", e2ePassword: v.e2ePassword || void 0, e2eSalt: v.e2eSalt || void 0, relayHost: v.relayHost || void 0, originCa: v.originCa || void 0 } : null;
|
|
4665
|
+
return typeof v.subdomain === "string" && v.subdomain !== "" ? { subdomain: v.subdomain, password: v.password ?? "", e2ePassword: v.e2ePassword || void 0, e2eSalt: v.e2eSalt || void 0, relayHost: v.relayHost || void 0, originCa: v.originCa || void 0, routeHost: v.routeHost || void 0 } : null;
|
|
4664
4666
|
},
|
|
4665
4667
|
save: (creds) => {
|
|
4666
4668
|
Promise.resolve(scope.update({
|
|
@@ -4669,7 +4671,8 @@ function settingsStore(scope, migrateFrom) {
|
|
|
4669
4671
|
e2ePassword: creds?.e2ePassword ?? "",
|
|
4670
4672
|
e2eSalt: creds?.e2eSalt ?? "",
|
|
4671
4673
|
relayHost: creds?.relayHost ?? "",
|
|
4672
|
-
originCa: creds?.originCa ?? ""
|
|
4674
|
+
originCa: creds?.originCa ?? "",
|
|
4675
|
+
routeHost: creds?.routeHost ?? ""
|
|
4673
4676
|
})).catch(() => {
|
|
4674
4677
|
});
|
|
4675
4678
|
}
|
|
@@ -4691,6 +4694,16 @@ function settingsStore(scope, migrateFrom) {
|
|
|
4691
4694
|
}
|
|
4692
4695
|
return store;
|
|
4693
4696
|
}
|
|
4697
|
+
var ROUTE_FAIL_MAX = 3;
|
|
4698
|
+
var ROUTE_FALLBACK_MS = 5 * 6e4;
|
|
4699
|
+
var ROUTE_FALLBACK_MAX_MS = 60 * 6e4;
|
|
4700
|
+
var ROUTE_PROBE_TIMEOUT_MS = 1e4;
|
|
4701
|
+
function isValidRouteHost(raw) {
|
|
4702
|
+
if (typeof raw !== "string" || raw.length === 0 || raw.length > 253)
|
|
4703
|
+
return false;
|
|
4704
|
+
const bare = raw.replace(/^wss?:\/\//, "");
|
|
4705
|
+
return /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*(:\d{1,5})?$/i.test(bare);
|
|
4706
|
+
}
|
|
4694
4707
|
function toBuf(data) {
|
|
4695
4708
|
if (Buffer.isBuffer(data))
|
|
4696
4709
|
return data;
|
|
@@ -4769,6 +4782,16 @@ var AgentTunnel = class {
|
|
|
4769
4782
|
latencyMs = null;
|
|
4770
4783
|
/** ms epoch the outstanding latency ping was sent (0 = none in flight). */
|
|
4771
4784
|
pingSentAt = 0;
|
|
4785
|
+
/** The authority the current control socket was dialled through. */
|
|
4786
|
+
dialledHost = null;
|
|
4787
|
+
/** Consecutive dials/probes of the premium host that failed. */
|
|
4788
|
+
routeFails = 0;
|
|
4789
|
+
/** Until when (ms epoch) the premium host is skipped in favour of the default relay. */
|
|
4790
|
+
routeFallbackUntil = 0;
|
|
4791
|
+
/** The next fallback period (grows while the premium host keeps failing). */
|
|
4792
|
+
routeFallbackMs = ROUTE_FALLBACK_MS;
|
|
4793
|
+
/** The in-flight probe of the premium host, if one is running. */
|
|
4794
|
+
routeProbe = null;
|
|
4772
4795
|
constructor(config, localPort, store) {
|
|
4773
4796
|
this.config = config;
|
|
4774
4797
|
this.localPort = localPort;
|
|
@@ -4783,7 +4806,8 @@ var AgentTunnel = class {
|
|
|
4783
4806
|
connected: false,
|
|
4784
4807
|
publicUrl: null,
|
|
4785
4808
|
subdomain: this.creds?.subdomain ?? null,
|
|
4786
|
-
lastError: null
|
|
4809
|
+
lastError: null,
|
|
4810
|
+
route: null
|
|
4787
4811
|
};
|
|
4788
4812
|
}
|
|
4789
4813
|
/**
|
|
@@ -4825,7 +4849,14 @@ var AgentTunnel = class {
|
|
|
4825
4849
|
return "Password must be at least 8 characters.";
|
|
4826
4850
|
const rh = typeof relayHost === "string" ? relayHost.trim() : this.creds?.relayHost ?? "";
|
|
4827
4851
|
const ca = typeof originCa === "string" ? originCa.trim() : this.creds?.originCa ?? "";
|
|
4828
|
-
this.creds
|
|
4852
|
+
const sameTarget = this.creds?.subdomain === label && (this.creds?.relayHost ?? "") === (rh || "");
|
|
4853
|
+
this.creds = { subdomain: label, password: String(password), e2ePassword: this.creds?.e2ePassword, e2eSalt: this.creds?.e2eSalt, relayHost: rh || void 0, originCa: ca || void 0, routeHost: sameTarget ? this.creds?.routeHost : void 0 };
|
|
4854
|
+
this.status.route = null;
|
|
4855
|
+
if (!sameTarget) {
|
|
4856
|
+
this.routeFails = 0;
|
|
4857
|
+
this.routeFallbackUntil = 0;
|
|
4858
|
+
this.routeFallbackMs = ROUTE_FALLBACK_MS;
|
|
4859
|
+
}
|
|
4829
4860
|
this.refreshE2E();
|
|
4830
4861
|
this.saveCreds(this.creds);
|
|
4831
4862
|
this.status.configured = true;
|
|
@@ -4899,9 +4930,12 @@ var AgentTunnel = class {
|
|
|
4899
4930
|
info() {
|
|
4900
4931
|
const host = this.effectiveRelayHost();
|
|
4901
4932
|
const direct = /^wss?:\/\//.test(host) || this.effectiveOriginCa() !== null;
|
|
4933
|
+
const live = this.status.connected && this.dialledHost !== null ? this.dialledHost : host;
|
|
4902
4934
|
return {
|
|
4903
|
-
relayHost:
|
|
4935
|
+
relayHost: live.replace(/^wss?:\/\//, "").replace(/\/.*$/, ""),
|
|
4904
4936
|
direct,
|
|
4937
|
+
route: this.status.route,
|
|
4938
|
+
routeHost: this.creds?.routeHost ?? null,
|
|
4905
4939
|
connectedSince: this.connectedSince,
|
|
4906
4940
|
served: this.served,
|
|
4907
4941
|
localPort: this.localPort(),
|
|
@@ -4916,6 +4950,10 @@ var AgentTunnel = class {
|
|
|
4916
4950
|
this.status.connected = false;
|
|
4917
4951
|
this.status.publicUrl = null;
|
|
4918
4952
|
this.status.subdomain = null;
|
|
4953
|
+
this.status.route = null;
|
|
4954
|
+
this.routeFails = 0;
|
|
4955
|
+
this.routeFallbackUntil = 0;
|
|
4956
|
+
this.routeFallbackMs = ROUTE_FALLBACK_MS;
|
|
4919
4957
|
if (this.reconnectTimer !== null) {
|
|
4920
4958
|
clearTimeout(this.reconnectTimer);
|
|
4921
4959
|
this.reconnectTimer = null;
|
|
@@ -4934,15 +4972,168 @@ var AgentTunnel = class {
|
|
|
4934
4972
|
clearTimeout(this.reconnectTimer);
|
|
4935
4973
|
if (this.heartbeatTimer !== null)
|
|
4936
4974
|
clearInterval(this.heartbeatTimer);
|
|
4975
|
+
this.dropStreams();
|
|
4976
|
+
this.control?.close();
|
|
4977
|
+
this.control = null;
|
|
4978
|
+
this.status.connected = false;
|
|
4979
|
+
}
|
|
4980
|
+
/**
|
|
4981
|
+
* Tear down every stream bridged over the current control socket. Must run
|
|
4982
|
+
* whenever that socket is abandoned — on close AND on a deliberate redial —
|
|
4983
|
+
* because the relay numbers streams per connection from 1: a request or
|
|
4984
|
+
* browser socket left over from the old connection would otherwise answer to
|
|
4985
|
+
* the ids of the new one and leak its bytes into a stranger's stream.
|
|
4986
|
+
*/
|
|
4987
|
+
dropStreams() {
|
|
4937
4988
|
for (const req of this.requests.values())
|
|
4938
4989
|
req.destroy();
|
|
4939
4990
|
for (const sock of this.sockets.values())
|
|
4940
4991
|
sock.close();
|
|
4941
4992
|
this.requests.clear();
|
|
4942
4993
|
this.sockets.clear();
|
|
4943
|
-
this.
|
|
4994
|
+
this.reqE2E.clear();
|
|
4995
|
+
}
|
|
4996
|
+
/**
|
|
4997
|
+
* Which authority to dial: the relay-assigned premium host when one is
|
|
4998
|
+
* remembered and not in a fallback period, else the default relay host. The
|
|
4999
|
+
* premium host is only ever set by a route announcement from the relay.
|
|
5000
|
+
*/
|
|
5001
|
+
dialHost() {
|
|
5002
|
+
const route = this.creds?.routeHost;
|
|
5003
|
+
if (route !== void 0 && route !== "" && Date.now() >= this.routeFallbackUntil)
|
|
5004
|
+
return route;
|
|
5005
|
+
return this.effectiveRelayHost();
|
|
5006
|
+
}
|
|
5007
|
+
/** Whether the live control socket was dialled through the remembered premium host. */
|
|
5008
|
+
onPremiumPath() {
|
|
5009
|
+
const route = this.creds?.routeHost;
|
|
5010
|
+
return route !== void 0 && route !== "" && this.dialledHost === route;
|
|
5011
|
+
}
|
|
5012
|
+
/** Whether the premium host is currently being skipped after repeated failures. */
|
|
5013
|
+
inRouteFallback() {
|
|
5014
|
+
return Date.now() < this.routeFallbackUntil;
|
|
5015
|
+
}
|
|
5016
|
+
/**
|
|
5017
|
+
* Apply a route announcement (READY or a mid-session ROUTE frame).
|
|
5018
|
+
*
|
|
5019
|
+
* `status.route` reflects the OPERATOR'S ASSIGNMENT, because that is what a
|
|
5020
|
+
* public visitor experiences: enabling premium points the subdomain's DNS at
|
|
5021
|
+
* the accelerator, so browser traffic is accelerated no matter which host the
|
|
5022
|
+
* agent's own control socket happens to use. Moving the control socket onto
|
|
5023
|
+
* the premium host too is a best-effort bonus for the uplink — it may briefly
|
|
5024
|
+
* fail while the fresh DNS record propagates, and if the host stays
|
|
5025
|
+
* unreachable the agent quietly keeps its control socket on the default relay.
|
|
5026
|
+
* Neither case changes the displayed route or breaks the tunnel.
|
|
5027
|
+
*
|
|
5028
|
+
* - `premium` with a usable host: show premium, remember the host, and (unless
|
|
5029
|
+
* in a fallback window) PROBE it; only a host that answers gets the control
|
|
5030
|
+
* socket moved onto it — a working tunnel is never dropped for a dead host.
|
|
5031
|
+
* - `standard`: the operator withdrew the fast path — show standard, forget
|
|
5032
|
+
* the host, and return the control socket to the default relay.
|
|
5033
|
+
*/
|
|
5034
|
+
applyRoute(route, routeHost) {
|
|
5035
|
+
if (this.creds === null)
|
|
5036
|
+
return;
|
|
5037
|
+
if (route === "premium" && isValidRouteHost(routeHost)) {
|
|
5038
|
+
this.status.route = "premium";
|
|
5039
|
+
if (this.creds.routeHost !== routeHost) {
|
|
5040
|
+
this.creds = { ...this.creds, routeHost };
|
|
5041
|
+
this.saveCreds(this.creds);
|
|
5042
|
+
this.routeFails = 0;
|
|
5043
|
+
this.routeFallbackUntil = 0;
|
|
5044
|
+
this.routeFallbackMs = ROUTE_FALLBACK_MS;
|
|
5045
|
+
}
|
|
5046
|
+
this.tryPremium();
|
|
5047
|
+
return;
|
|
5048
|
+
}
|
|
5049
|
+
if (route === "standard" || route === "premium") {
|
|
5050
|
+
this.status.route = "standard";
|
|
5051
|
+
this.routeFallbackUntil = 0;
|
|
5052
|
+
this.routeFails = 0;
|
|
5053
|
+
this.routeFallbackMs = ROUTE_FALLBACK_MS;
|
|
5054
|
+
const hadRoute = this.creds.routeHost !== void 0 && this.creds.routeHost !== "";
|
|
5055
|
+
if (hadRoute) {
|
|
5056
|
+
this.creds = { ...this.creds, routeHost: void 0 };
|
|
5057
|
+
this.saveCreds(this.creds);
|
|
5058
|
+
if (this.dialledHost !== this.effectiveRelayHost())
|
|
5059
|
+
this.redial();
|
|
5060
|
+
}
|
|
5061
|
+
}
|
|
5062
|
+
}
|
|
5063
|
+
/**
|
|
5064
|
+
* Move the control socket onto the premium host when that is worth doing:
|
|
5065
|
+
* assigned premium, currently on the default relay, not in a fallback window,
|
|
5066
|
+
* and no probe already running. The host is probed first (a plain WebSocket
|
|
5067
|
+
* handshake, no HELLO — so the relay never sees a second agent) and the live
|
|
5068
|
+
* socket is only redialled once the host has answered. A failed probe counts
|
|
5069
|
+
* like a failed dial; enough of them open a fallback window.
|
|
5070
|
+
*/
|
|
5071
|
+
tryPremium() {
|
|
5072
|
+
const host = this.creds?.routeHost;
|
|
5073
|
+
if (host === void 0 || host === "" || this.routeProbe !== null)
|
|
5074
|
+
return;
|
|
5075
|
+
if (!this.status.connected || this.onPremiumPath() || this.inRouteFallback())
|
|
5076
|
+
return;
|
|
5077
|
+
this.routeProbe = this.probeHost(host).then((ok) => {
|
|
5078
|
+
this.routeProbe = null;
|
|
5079
|
+
if (this.stopped || this.creds?.routeHost !== host || this.status.route !== "premium")
|
|
5080
|
+
return;
|
|
5081
|
+
if (!this.status.connected || this.onPremiumPath())
|
|
5082
|
+
return;
|
|
5083
|
+
if (ok)
|
|
5084
|
+
this.redial();
|
|
5085
|
+
else
|
|
5086
|
+
this.noteRouteFailure();
|
|
5087
|
+
});
|
|
5088
|
+
}
|
|
5089
|
+
/** Whether `host` accepts a WebSocket on the agent path right now (no HELLO is sent). */
|
|
5090
|
+
probeHost(host) {
|
|
5091
|
+
return new Promise((resolve2) => {
|
|
5092
|
+
const base = host.includes("://") ? host : `wss://${host}`;
|
|
5093
|
+
let ws;
|
|
5094
|
+
try {
|
|
5095
|
+
ws = new import_websocket.default(`${base}${AGENT_WS_PATH}`, { handshakeTimeout: ROUTE_PROBE_TIMEOUT_MS });
|
|
5096
|
+
} catch {
|
|
5097
|
+
resolve2(false);
|
|
5098
|
+
return;
|
|
5099
|
+
}
|
|
5100
|
+
let done = false;
|
|
5101
|
+
const finish = (ok) => {
|
|
5102
|
+
if (done)
|
|
5103
|
+
return;
|
|
5104
|
+
done = true;
|
|
5105
|
+
if (process.env.DSHN_DEBUG)
|
|
5106
|
+
console.error(`[dshn-agent] premium probe of ${host}: ${ok ? "reachable" : "unreachable"}`);
|
|
5107
|
+
resolve2(ok);
|
|
5108
|
+
if (ok)
|
|
5109
|
+
ws.close();
|
|
5110
|
+
else
|
|
5111
|
+
ws.terminate();
|
|
5112
|
+
};
|
|
5113
|
+
ws.on("open", () => finish(true));
|
|
5114
|
+
ws.on("error", () => finish(false));
|
|
5115
|
+
ws.on("close", () => finish(false));
|
|
5116
|
+
});
|
|
5117
|
+
}
|
|
5118
|
+
/** Count a failed dial/probe of the premium host; enough in a row open a (growing) fallback window. */
|
|
5119
|
+
noteRouteFailure() {
|
|
5120
|
+
this.routeFails++;
|
|
5121
|
+
if (this.routeFails < ROUTE_FAIL_MAX)
|
|
5122
|
+
return;
|
|
5123
|
+
this.routeFails = 0;
|
|
5124
|
+
this.routeFallbackUntil = Date.now() + this.routeFallbackMs;
|
|
5125
|
+
this.routeFallbackMs = Math.min(this.routeFallbackMs * 2, ROUTE_FALLBACK_MAX_MS);
|
|
5126
|
+
}
|
|
5127
|
+
/** Drop the live socket and dial again right away (route change). */
|
|
5128
|
+
redial() {
|
|
5129
|
+
if (process.env.DSHN_DEBUG)
|
|
5130
|
+
console.error(`[dshn-agent] route change \u2192 redialling via ${this.dialHost()}`);
|
|
5131
|
+
this.backoffMs = 1e3;
|
|
5132
|
+
const ws = this.control;
|
|
4944
5133
|
this.control = null;
|
|
4945
|
-
this.
|
|
5134
|
+
this.dropStreams();
|
|
5135
|
+
ws?.close();
|
|
5136
|
+
this.connect();
|
|
4946
5137
|
}
|
|
4947
5138
|
connect() {
|
|
4948
5139
|
if (this.stopped || this.creds === null)
|
|
@@ -4954,11 +5145,12 @@ var AgentTunnel = class {
|
|
|
4954
5145
|
clearTimeout(this.reconnectTimer);
|
|
4955
5146
|
this.reconnectTimer = null;
|
|
4956
5147
|
}
|
|
4957
|
-
const relayHost = this.
|
|
5148
|
+
const relayHost = this.dialHost();
|
|
4958
5149
|
const base = relayHost.includes("://") ? relayHost : `wss://${relayHost}`;
|
|
5150
|
+
this.dialledHost = relayHost;
|
|
4959
5151
|
const wsOpts = { maxPayload: 512 * 1024 * 1024 };
|
|
4960
5152
|
const ca = this.effectiveOriginCa();
|
|
4961
|
-
if (ca !== null)
|
|
5153
|
+
if (ca !== null && relayHost === this.effectiveRelayHost())
|
|
4962
5154
|
wsOpts.ca = ca;
|
|
4963
5155
|
const ws = new import_websocket.default(`${base}${AGENT_WS_PATH}`, wsOpts);
|
|
4964
5156
|
this.control = ws;
|
|
@@ -5011,10 +5203,14 @@ var AgentTunnel = class {
|
|
|
5011
5203
|
this.control.terminate();
|
|
5012
5204
|
return;
|
|
5013
5205
|
}
|
|
5206
|
+
if (this.status.route === "premium")
|
|
5207
|
+
this.tryPremium();
|
|
5014
5208
|
this.sendPing();
|
|
5015
5209
|
}, LATENCY_PING_MS);
|
|
5016
5210
|
}
|
|
5017
5211
|
onClose() {
|
|
5212
|
+
if (!this.status.connected && this.onPremiumPath())
|
|
5213
|
+
this.noteRouteFailure();
|
|
5018
5214
|
this.status.connected = false;
|
|
5019
5215
|
this.connectedSince = null;
|
|
5020
5216
|
this.latencyMs = null;
|
|
@@ -5024,12 +5220,7 @@ var AgentTunnel = class {
|
|
|
5024
5220
|
clearInterval(this.heartbeatTimer);
|
|
5025
5221
|
this.heartbeatTimer = null;
|
|
5026
5222
|
}
|
|
5027
|
-
|
|
5028
|
-
req.destroy();
|
|
5029
|
-
for (const sock of this.sockets.values())
|
|
5030
|
-
sock.close();
|
|
5031
|
-
this.requests.clear();
|
|
5032
|
-
this.sockets.clear();
|
|
5223
|
+
this.dropStreams();
|
|
5033
5224
|
if (this.stopped)
|
|
5034
5225
|
return;
|
|
5035
5226
|
if (this.reconnectTimer !== null)
|
|
@@ -5071,12 +5262,22 @@ var AgentTunnel = class {
|
|
|
5071
5262
|
switch (frame.t) {
|
|
5072
5263
|
case "ready":
|
|
5073
5264
|
this.backoffMs = 1e3;
|
|
5265
|
+
this.routeFails = 0;
|
|
5266
|
+
if (this.onPremiumPath())
|
|
5267
|
+
this.routeFallbackMs = ROUTE_FALLBACK_MS;
|
|
5074
5268
|
this.status.connected = true;
|
|
5075
5269
|
this.status.publicUrl = frame.publicUrl;
|
|
5076
5270
|
this.status.subdomain = frame.subdomain;
|
|
5077
5271
|
this.status.lastError = null;
|
|
5078
5272
|
this.connectedSince = Date.now();
|
|
5079
5273
|
this.sendPing();
|
|
5274
|
+
if (frame.route !== void 0)
|
|
5275
|
+
this.applyRoute(frame.route, frame.routeHost);
|
|
5276
|
+
else if (this.creds?.routeHost)
|
|
5277
|
+
this.applyRoute("standard", void 0);
|
|
5278
|
+
break;
|
|
5279
|
+
case "route":
|
|
5280
|
+
this.applyRoute(frame.route, frame.routeHost);
|
|
5080
5281
|
break;
|
|
5081
5282
|
case "deny":
|
|
5082
5283
|
this.status.lastError = frame.reason;
|
|
@@ -5375,6 +5576,10 @@ function apply(ctx, rawConfig) {
|
|
|
5375
5576
|
// Connection details for the panel.
|
|
5376
5577
|
relayHost: info.relayHost,
|
|
5377
5578
|
mode: info.direct ? "direct" : "cloudflare",
|
|
5579
|
+
// Which path the relay assigned: 'premium' (accelerated, via routeHost)
|
|
5580
|
+
// or 'standard'; null until a route-aware relay has said.
|
|
5581
|
+
route: info.route,
|
|
5582
|
+
routeHost: info.routeHost,
|
|
5378
5583
|
connectedSince: info.connectedSince,
|
|
5379
5584
|
served: info.served,
|
|
5380
5585
|
localPort: info.localPort,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dshn/agent",
|
|
3
|
-
"version": "0.
|
|
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": {
|