@dsh-remote/relay 0.0.0-stage → 3.1.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/LICENSE +21 -0
- package/README.md +64 -2
- package/SECURITY.md +19 -0
- package/deploy/README.md +125 -0
- package/deploy/docker/Dockerfile +150 -0
- package/deploy/docker/README.md +192 -0
- package/deploy/docker/compose.yaml +116 -0
- package/deploy/nginx/drc.conf +69 -0
- package/deploy/systemd/dsh-remote-control.service +66 -0
- package/deploy/windows/drc-relay-install.cmd +113 -0
- package/dist/bundle/main.js +26031 -0
- package/package.json +57 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 providcc
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,65 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @dsh-remote/relay
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
DSH Remote Control 的**零知识 WebSocket 中继**:在宿主插件与已配对的手机之间转发**密封记录**,
|
|
4
|
+
只认帧名 / 会话 id / 能力协商,**从不解析载荷**——密文对它就是一个 base64 字符串。
|
|
5
|
+
|
|
6
|
+
生产形态是**单文件产物** `dist/bundle/main.js`(`ws` / `zod` / 协议层全部内联,运行时零依赖);
|
|
7
|
+
部署只有两种:拷这个单文件 + 起进程(systemd / docker / nginx 配方见下),或从源码构建。
|
|
8
|
+
|
|
9
|
+
## 自托管
|
|
10
|
+
|
|
11
|
+
完整配方(环境变量表、systemd / docker / nginx、排错、升级与回滚)见
|
|
12
|
+
[`docs/SELF-HOSTING.md`](docs/SELF-HOSTING.md)。最速验证:
|
|
13
|
+
|
|
14
|
+
```sh
|
|
15
|
+
DRC_HOST_TOKEN=<与主机插件一致> node dist/bundle/main.js
|
|
16
|
+
curl -s http://127.0.0.1:8787/healthz
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
### 常用配置
|
|
20
|
+
|
|
21
|
+
| 变量 | 必填 | 默认 | 说明 |
|
|
22
|
+
| ----------------------------- | ---- | ----------- | ------------------------------------------------------------------------------------------------ |
|
|
23
|
+
| `DRC_HOST_TOKEN` | ✅ | — | 主机出站认证凭据。`openssl rand -hex 32` 生成。**与主机插件那份必须逐字一致** |
|
|
24
|
+
| `DRC_PORT` | | `8787` | 监听端口。`0` = 让系统分配(测试与容器用) |
|
|
25
|
+
| `DRC_BIND` | | `127.0.0.1` | 绑定地址。默认只绑回环:TLS 由反代终止。**只有自己就是边缘(容器直接发布端口)时才设 `0.0.0.0`** |
|
|
26
|
+
| `DRC_PUBLIC_URL` | | 空 | `/api/info` 返回的对外地址。公网填 `wss://你的域名` |
|
|
27
|
+
| `DRC_LOG_LEVEL` | | `info` | `debug` / `info` / `warn` / `error` / `silent`。**区分大小写** |
|
|
28
|
+
| `DRC_PAIR_TTL_MS` | | `120000` | 配对码的**服务端权威**寿命(毫秒) |
|
|
29
|
+
| `DRC_STATE_FILE` | | 空(关闭) | 会话表落盘路径。默认关闭 = 纯内存;配上后启动先读它、会话表有增删时写回 |
|
|
30
|
+
| `DRC_STATE_SAVE_MS` | | `60000` | 周期补写状态文件的间隔(只为刷新 `lastActivityAt`) |
|
|
31
|
+
| `DRC_MAX_BUFFERED_BYTES` | | `1048576` | 慢消费者阈值:发送缓冲区**连续**超限达**本角色窗口**才 1008 断开 |
|
|
32
|
+
| `DRC_SLOW_CONSUMER_HOST_MS` | | `10000` | **主机**侧慢消费者窗口(毫秒)。主机卡住就是所有人卡住,所以窗口短 |
|
|
33
|
+
| `DRC_SLOW_CONSUMER_CLIENT_MS` | | `45000` | **客户端**侧慢消费者窗口(毫秒)。**必须大于手机自己的重连周期**(≈42.5 秒) |
|
|
34
|
+
|
|
35
|
+
### 端点
|
|
36
|
+
|
|
37
|
+
| 路径 | 说明 |
|
|
38
|
+
| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
39
|
+
| `GET /healthz` | 运维契约(**二十三个字段**):`ok` / `version` / `uptimeSec` / `hosts` / `clients` / `conversations` / `pendingPairs` / `droppedFrames` / `slowConsumers` / `rejectedPairs` / `shutdownForced` / `persistence` / `stateRestored` / `stateSavedAtSec` / `stateWrites` / `stateWriteFailures` / `lastPingAgo` / `shuttingDown` / `protocolSelf` / `protocolMin` / `peerProtocolMin` / `peerProtocolMax` / `peersNoProtocol` |
|
|
40
|
+
| `GET /api/info` | `{"publicUrl","protocol"}` |
|
|
41
|
+
| `GET /api/pair-status` | **默认 404**。无需认证地回答"配对码 N 是否有效"等于给 6 位码空间装了扫描 oracle;确需调试时 `DRC_PAIR_STATUS=1`,用完关掉 |
|
|
42
|
+
| WebSocket | 路径不设限,任意路径都能升级 |
|
|
43
|
+
|
|
44
|
+
落盘五字段(`persistence` / `stateRestored` / `stateSavedAtSec` / `stateWrites` /
|
|
45
|
+
`stateWriteFailures`)是"还在落盘"的唯一读数——换机器先看 `stateWriteFailures`,不是 0 就是没在写。
|
|
46
|
+
|
|
47
|
+
## 开发
|
|
48
|
+
|
|
49
|
+
```sh
|
|
50
|
+
pnpm install
|
|
51
|
+
pnpm build # tsc + 单文件打包
|
|
52
|
+
pnpm test # tsc + 打包 + node --test(含产物级判据)
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## 红线
|
|
56
|
+
|
|
57
|
+
- **不 import 载荷**:依赖图里只有协议层的 `frames` / `ids` / `negotiate` / `outbound`;
|
|
58
|
+
构建产物里不许出现任何密码学实现——`tests/bundle.test.mjs` 直接读产物断言。
|
|
59
|
+
- 路由表是**内存的**:重启即全部作废(客户端会看到 `unknown_session`),这是设计不是缺陷。
|
|
60
|
+
- 日志永不落载荷、落完整配对码、落 hostToken(有判据锁住)。
|
|
61
|
+
- **本包不发 npm**:`private: true`,发布流程里不许出现发包步骤与 id-token(有判据)。
|
|
62
|
+
|
|
63
|
+
## 许可
|
|
64
|
+
|
|
65
|
+
MIT
|
package/SECURITY.md
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# Security Policy
|
|
2
|
+
|
|
3
|
+
## 报告漏洞
|
|
4
|
+
|
|
5
|
+
用本仓 GitHub 的私密渠道:**Security → Report a vulnerability**(`/security/advisories/new`)。
|
|
6
|
+
不要在公开 issue 里贴可利用细节。
|
|
7
|
+
|
|
8
|
+
## 本仓的范围
|
|
9
|
+
|
|
10
|
+
- 负责:WebSocket 路由与鉴权(`hostToken`)、配对码表(TTL / 一次性 / 限流)、
|
|
11
|
+
帧分级与体积闸(`ws.maxPayload`)、慢消费者处置、日志的凭据卫生。
|
|
12
|
+
- 不负责:载荷内容——**结构上看不见**;密码学在中继的依赖图上不可达(有产物级判据)。
|
|
13
|
+
|
|
14
|
+
## 零知识(可核对的说法)
|
|
15
|
+
|
|
16
|
+
- 构建产物 `dist/bundle/main.js` 里**没有**任何密码学实现:`tests/bundle.test.mjs`
|
|
17
|
+
直接读产物,对 `xsalsa20` / `secretbox` / `tweetnacl` 等标记逐条断言。
|
|
18
|
+
- 日志约定:只记 id / 计数 / reason;配对码与 `hostToken` 永不整值落日志(有判据)。
|
|
19
|
+
- 部署加固(TLS 由反代终止、绑定与防火墙、状态/凭据文件权限)见 `docs/SELF-HOSTING.md`。
|
package/deploy/README.md
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# 部署记录与复用步骤
|
|
2
|
+
|
|
3
|
+
本目录是**实际生产部署**的配置,不是示例。
|
|
4
|
+
|
|
5
|
+
## 当前部署
|
|
6
|
+
|
|
7
|
+
| 形态 | **Docker**(`deploy/docker/compose.yaml`;2026-10-07 从 systemd 切过来,nginx 不动) |
|
|
8
|
+
|
|
9
|
+
| 项 | 值 |
|
|
10
|
+
| -------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
11
|
+
| 公网入口 | `wss://drc.provid.cc` |
|
|
12
|
+
| 中继 | 镜像 `dsh-remote-relay`(里面是**一个自包含单文件**),compose 服务 `relay`。systemd 单元 `dsh-remote-control.service` 仍保留可用,**二选一** |
|
|
13
|
+
| 反向代理 | nginx,`/etc/nginx/conf.d/drc.conf` |
|
|
14
|
+
| 证书 | Let's Encrypt(acme.sh + 阿里云 DNS-01),ECC,自动续期 |
|
|
15
|
+
| 绑定 | 中继只监听 `127.0.0.1:8787`,公网只暴露 80/443 |
|
|
16
|
+
| 密钥 | `/etc/dsh-remote-control.env`(600),同一份 token 也写在主机插件的 profile 配置里 |
|
|
17
|
+
|
|
18
|
+
中继现在部署的是 `pnpm build` 打出来的 `dist/bundle/main.js`
|
|
19
|
+
(`ws` 与协议层都内联进去了,`bufferutil`/`utf-8-validate` 是可选原生加速、缺了照常跑)。
|
|
20
|
+
于是生产只有"拷一个文件 + 一个 env 文件",没有 `npm install`,也就没有
|
|
21
|
+
"服务器上的依赖树和 CI 不一样"这一类故障。
|
|
22
|
+
|
|
23
|
+
> 完整的环境变量表、日志契约、重启语义、升级回滚见 [`../docs/SELF-HOSTING.md`](../docs/SELF-HOSTING.md)。
|
|
24
|
+
|
|
25
|
+
## 从零复现
|
|
26
|
+
|
|
27
|
+
### 1. 服务器准备
|
|
28
|
+
|
|
29
|
+
```sh
|
|
30
|
+
dnf install -y nodejs nginx # Alibaba Cloud Linux 4 自带 node 22
|
|
31
|
+
mkdir -p /opt/dsh-remote-control/server /etc/nginx/ssl
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
### 2. 部署中继(Docker)
|
|
35
|
+
|
|
36
|
+
> 生产现在跑的是容器,完整步骤在 [`docker/README.md`](docker/README.md)(含镜像源拉不到时的三条路)。 下面这份是**裸机 systemd** 那条路,仍然可用,但两条**别同时开**——同一个端口只能有一个进程在听。
|
|
37
|
+
|
|
38
|
+
#### 2b. 裸机 systemd
|
|
39
|
+
|
|
40
|
+
```sh
|
|
41
|
+
# 本地先构建产物
|
|
42
|
+
pnpm install && pnpm build
|
|
43
|
+
|
|
44
|
+
scp dist/bundle/main.js root@HOST:/opt/dsh-remote-control/server/relay.mjs
|
|
45
|
+
scp deploy/systemd/*.service root@HOST:/etc/systemd/system/
|
|
46
|
+
|
|
47
|
+
# token:与主机插件那份必须逐字一致
|
|
48
|
+
cat > /etc/dsh-remote-control.env <<'EOF'
|
|
49
|
+
DRC_HOST_TOKEN=<openssl rand -hex 32 的输出>
|
|
50
|
+
DRC_PORT=8787
|
|
51
|
+
DRC_PUBLIC_URL=wss://drc.provid.cc
|
|
52
|
+
DRC_PAIR_TTL_MS=90000
|
|
53
|
+
DRC_LOG_LEVEL=info
|
|
54
|
+
# 会话表落盘(默认关闭 = 纯内存模式,重启丢配对;配上之后会话表重启恢复,见 CHANGELOG 1.0.6)
|
|
55
|
+
# 标准生产值(相对路径按 systemd WorkingDirectory 解析):
|
|
56
|
+
# DRC_STATE_FILE=/var/lib/dsh-remote-control/state.json
|
|
57
|
+
EOF
|
|
58
|
+
chmod 600 /etc/dsh-remote-control.env
|
|
59
|
+
|
|
60
|
+
systemctl daemon-reload && systemctl enable --now dsh-remote-control
|
|
61
|
+
curl -s http://127.0.0.1:8787/healthz # version 必须是包的 version,不是 0.0.0
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
### 3. DNS 证书(DNS-01,不需要占用 80 端口)
|
|
65
|
+
|
|
66
|
+
acme.sh 已保存阿里云 DNS 凭证,所以换域名只需一条命令:
|
|
67
|
+
|
|
68
|
+
```sh
|
|
69
|
+
acme.sh --issue --dns dns_ali -d drc.provid.cc --server letsencrypt
|
|
70
|
+
acme.sh --install-cert -d drc.provid.cc \
|
|
71
|
+
--key-file /etc/nginx/ssl/drc.provid.cc.key \
|
|
72
|
+
--fullchain-file /etc/nginx/ssl/drc.provid.cc.crt \
|
|
73
|
+
--reloadcmd "systemctl reload nginx"
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
### 4. nginx
|
|
77
|
+
|
|
78
|
+
```sh
|
|
79
|
+
scp deploy/nginx/drc.conf root@HOST:/etc/nginx/conf.d/drc.conf
|
|
80
|
+
ssh root@HOST 'nginx -t && systemctl enable --now nginx'
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
`drc.conf` 里包含**按 IP 限流**(`limit_req` 20r/s、`limit_conn` 8)——中继本身
|
|
84
|
+
只有全局和单连接配额,看不到真实客户端 IP,所以这一层必须由反代来做。
|
|
85
|
+
|
|
86
|
+
### 5. 验证
|
|
87
|
+
|
|
88
|
+
```sh
|
|
89
|
+
curl -s https://drc.provid.cc/healthz
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
`/healthz` 的 `version` 必须等于包版本。真要端到端验一遍,得在这台机器之外跑一次真配对
|
|
93
|
+
(出码 → 手机扫码 → 发指令拿到流式回复),见 `../docs/SELF-HOSTING.md` §10.4。
|
|
94
|
+
|
|
95
|
+
## 运维
|
|
96
|
+
|
|
97
|
+
```sh
|
|
98
|
+
systemctl status dsh-remote-control
|
|
99
|
+
journalctl -u dsh-remote-control -f # 一行一个 JSON
|
|
100
|
+
journalctl -u dsh-remote-control -o cat | grep '"level":"warn"' # 按 JSON 里的 level 过滤
|
|
101
|
+
curl -s https://drc.provid.cc/healthz
|
|
102
|
+
tail -f /var/log/nginx/drc.access.log
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
> 别用 `journalctl -p warning`:中继写到 stdout 的 JSON 不带 syslog 级别前缀,这些行进
|
|
106
|
+
> journal 时优先级一律是 `info`,按优先级过滤很可能一条都出不来。按 JSON 里的 `level` 过滤。
|
|
107
|
+
|
|
108
|
+
改中继代码后:
|
|
109
|
+
|
|
110
|
+
```sh
|
|
111
|
+
pnpm build
|
|
112
|
+
scp dist/bundle/main.js root@HOST:/opt/dsh-remote-control/server/relay.mjs
|
|
113
|
+
ssh root@HOST 'systemctl restart dsh-remote-control'
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
中继默认是纯内存的,未配 `DRC_STATE_FILE` 落盘时重启会丢掉配对关系:客户端拿旧 convId 发帧会撞上
|
|
117
|
+
`error{unknown_session}`,手机上就是中文的"会话已失效,请重新配对"。
|
|
118
|
+
|
|
119
|
+
## 安全组
|
|
120
|
+
|
|
121
|
+
```
|
|
122
|
+
TCP 22, 80, 443 0.0.0.0/0 Accept
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
8787 **不要**对公网开放 —— 中继只绑 `127.0.0.1`,nginx 在同一台机器上转发。
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
# dsh-remote-relay —— 中继的容器形态。
|
|
2
|
+
#
|
|
3
|
+
# ## 这份 Dockerfile 刻意很短,因为它要成立的前提是上游已经打过包
|
|
4
|
+
#
|
|
5
|
+
# 中继的分发形态是**一个自包含单文件**(pnpm build 产出的 dist/bundle/main.js:ws / zod /
|
|
6
|
+
# 协议层全内联,运行时依赖为空,tests/bundle.test.mjs 有产物级断言)。于是镜像只有两个阶段:
|
|
7
|
+
#
|
|
8
|
+
# build —— 装依赖、打包,**只为了产出那一个文件**;
|
|
9
|
+
# runtime —— 只 COPY 那一个文件,没有 node_modules、没有 npm install。
|
|
10
|
+
#
|
|
11
|
+
# 这条"运行时零依赖"不是省事,是**故障类别的消除**:镜像里不可能出现"服务器上的依赖树和
|
|
12
|
+
# CI 不一样",也不可能出现某个传递依赖换个 Node 小版本就变行为这一类只在生产才显形的问题。
|
|
13
|
+
# 可选原生加速(bufferutil / utf-8-validate)在 bundle 里是 external、且 ws 对它们是
|
|
14
|
+
# try/catch,缺了照常跑纯 JS —— 与裸机部署同形。
|
|
15
|
+
#
|
|
16
|
+
# ## 版本号在镜像里有三份,且互为对账
|
|
17
|
+
#
|
|
18
|
+
# 1. 产物内(esbuild define 注入)→ /healthz 的 version 字段,**运维唯一认的**;
|
|
19
|
+
# 2. OCI 标签 org.opencontainers.image.version → docker inspect / SBOM 看得到;
|
|
20
|
+
# 3. 镜像标签 dsh-remote-relay:<version> → 部署脚本与人看的。
|
|
21
|
+
#
|
|
22
|
+
# 构建阶段末尾那条断言(产物里必须找得到包版本)让 1 与 2 不会各说各话:版本号打错时
|
|
23
|
+
# **构建就失败**,而不是等上线后靠 /healthz 才发现。
|
|
24
|
+
#
|
|
25
|
+
# ## 两条 FROM 为什么写死而不是用 ARG
|
|
26
|
+
#
|
|
27
|
+
# 早期版本写成 ARG NODE_IMAGE=… + FROM ${NODE_IMAGE},那份在 BuildKit(Docker 23+ 默认)下
|
|
28
|
+
# 没问题,但在**只有 legacy builder** 的机器上直接构建失败(NotFound: content digest)。
|
|
29
|
+
# 换镜像源这件事有更标准的做法,见下面「拉不到 Docker Hub 怎么办」,所以这里不为了一个
|
|
30
|
+
# 方便性参数牺牲可构建性。tests/docker.test.mjs 会钉住两条 FROM 指向同一个镜像。
|
|
31
|
+
#
|
|
32
|
+
# ## 拉不到 Docker Hub 怎么办(生产那台机器就是拉不到的)
|
|
33
|
+
#
|
|
34
|
+
# 与其把某个第三方镜像站硬写进 FROM(那是把供应链交给一个别人随时可能下线或被投毒的域名),
|
|
35
|
+
# 不如让 FROM 保持标准写法,可达性交給环境去解决:
|
|
36
|
+
#
|
|
37
|
+
# 1) 配 daemon 级镜像加速(/etc/docker/daemon.json 的 registry-mirrors),**推荐**;
|
|
38
|
+
# 2) 或从可达的镜像站拉一份再本地打标签:docker pull <mirror>/library/node:22-alpine
|
|
39
|
+
# 后 docker tag 成 node:22-alpine —— docker build 在本地有这个名字时不会再解析远端;
|
|
40
|
+
# 3) 或在能上网的机器上 build,再 docker save | docker load 搬过去
|
|
41
|
+
# (deploy/docker/README.md 有完整的离线步骤;生产那次就是这么发的)。
|
|
42
|
+
#
|
|
43
|
+
# Node 22 与 CI、与 HANDOFF 的工具链口径一致(pnpm 11 要求 >= 22.13)。
|
|
44
|
+
FROM node:22-alpine AS build
|
|
45
|
+
WORKDIR /src
|
|
46
|
+
|
|
47
|
+
# 先只拷锁文件再装:pnpm 的安装缓存按 lockfile 命中,改 src 不必重装依赖树。
|
|
48
|
+
# --frozen-lockfile 是刻意的——镜像里装的必须与提交的 lockfile 逐位一致,
|
|
49
|
+
# "构建时顺手升了某个传递依赖"这类漂移正是要消掉的。
|
|
50
|
+
#
|
|
51
|
+
# ## v3 单仓:这里的 COPY 必须带 packages/ 前缀(2026-10-08 修)
|
|
52
|
+
#
|
|
53
|
+
# v2 时这个仓是**独立仓**,build 上下文(compose 的 `context: ../..`)就是本仓根,
|
|
54
|
+
# 所以 `COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./` 拷的是"本包 + 本包锁"。
|
|
55
|
+
# v3 变成 monorepo 后,同一行拷的是 **monorepo 根**的三个文件——而根 package.json 里
|
|
56
|
+
# 没有 relay 的依赖,于是 pnpm 报的错是「lockfile 里有 ^22.9.0、manifest 要 ^26.6.3」
|
|
57
|
+
# 这类**看起来毫不相关**的 mismatch(真实原因:那份 package.json 不是这个包的)。
|
|
58
|
+
#
|
|
59
|
+
# 症状与判据:`docker compose build` 失败,而 `pnpm install` 在本机完全正常。
|
|
60
|
+
# 所以下面显式列出三个路径,让"上下文是单仓根"这件事变成 Dockerfile 自己保证的。
|
|
61
|
+
# ⚠️ 下面几行的行尾**不许带注释** —— Dockerfile 会把 `#` 之后的内容也当成拷贝路径
|
|
62
|
+
# (报错形态:`"/需要它要解": not found`,看起来像文件不存在,其��是把注释当路径了)。
|
|
63
|
+
# 要说明就写在上一行。
|
|
64
|
+
COPY pnpm-workspace.yaml ./
|
|
65
|
+
COPY pnpm-lock.yaml ./
|
|
66
|
+
# monorepo 根清单:pnpm 需要它来解 workspace(里面没有 relay 的依赖,只负责解析结构)
|
|
67
|
+
COPY package.json ./
|
|
68
|
+
#
|
|
69
|
+
# ⚠️ **pnpm-workspace.yaml 声明的每一个包都要有 package.json 在场**,少一个都不行。
|
|
70
|
+
# 缺的那几个不会报"文件不存在",而是报 `specifiers in the lockfile don't match`,
|
|
71
|
+
# 并把**缺的包的依赖名**列出来(第一次栽在这里:漏了 plugin/client/e2e,于是报错说
|
|
72
|
+
# 「lockfile 有 ^22.9.0、manifest 要 ^26.6.3」——那是 plugin 的 @types/node,
|
|
73
|
+
# 与 relay 一点关系都没有 ⇒ 看起来像 lockfile 坏了,其��是清单没拷全)。
|
|
74
|
+
# 镜像只需要 relay + protocol 的源码,但**清单必须全在场**,否则 pnpm 在 install 阶段就退出了。
|
|
75
|
+
COPY packages/relay/package.json ./packages/relay/
|
|
76
|
+
COPY packages/protocol/package.json ./packages/protocol/
|
|
77
|
+
#
|
|
78
|
+
# ⚠️ **不需要**把 workspace 里其他包(plugin / client / e2e)的 package.json 也拷进来。
|
|
79
|
+
# 我一度以为"声明的包必须清单齐备",为此加了三条 COPY —— 那三条**无效**:
|
|
80
|
+
# 删掉之后 `pnpm install --frozen-lockfile` 照样通过(2026-10-08 在服务器上实测过:
|
|
81
|
+
# 只放 protocol + relay 两份清单,frozen 检查顺利过,只在下一步因缺 protocol 的
|
|
82
|
+
# tsconfig 而失败)。pnpm 只校验**它要解析的那些 importer**。
|
|
83
|
+
#
|
|
84
|
+
# 那次「lockfile 有 ^22.9.0、manifest 要 ^26.6.3」的 mismatch 真相不是"清单没拷全",
|
|
85
|
+
# 而是**根 package.json 被污染**:上传源码时用 `tar xzf`,relay 的 package.json
|
|
86
|
+
# 覆盖到了仓库根,于是 pnpm 拿 relay 的依赖去比根的 lock ⇒ 报出 relay 的依赖名。
|
|
87
|
+
# ⇒ **报错指错地方时,先怀疑"文件被覆盖",别急着加 COPY。**
|
|
88
|
+
#
|
|
89
|
+
# protocol 的 src + tsconfig 必须在 install **之前**进来:
|
|
90
|
+
# 它的 `prepare: tsc -p tsconfig.json` 是 install 生命周期的一环,
|
|
91
|
+
# 顺序写反的报错是 `TS5058: path does not exist`,出现在 install 阶段。
|
|
92
|
+
COPY packages/protocol/tsconfig.json ./packages/protocol/
|
|
93
|
+
COPY packages/protocol/src ./packages/protocol/src
|
|
94
|
+
RUN corepack enable && pnpm install --frozen-lockfile
|
|
95
|
+
|
|
96
|
+
COPY packages/relay/tsconfig.json ./packages/relay/
|
|
97
|
+
COPY packages/relay/src ./packages/relay/src
|
|
98
|
+
COPY packages/relay/scripts ./packages/relay/scripts
|
|
99
|
+
# protocol 必须**先构建**:它是 workspace 依赖,`pnpm install` 只软链它的 package.json,
|
|
100
|
+
# 而 bundle 脚本按 node_modules 解析 ⇒ 拿到的必须是 `dist/src/**`(main 字段指向那里)。
|
|
101
|
+
# 少这一步的报错形态是 esbuild 找不到 `@dsh-remote/protocol/dist/src/index.js`。
|
|
102
|
+
RUN pnpm --filter @dsh-remote/protocol build
|
|
103
|
+
# 在 relay 包目录里构建:它的 tsconfig 与 bundle 脚本都以包根为基准
|
|
104
|
+
WORKDIR /src/packages/relay
|
|
105
|
+
RUN pnpm build
|
|
106
|
+
|
|
107
|
+
# 产物级断言。两条取自 tests/bundle.test.mjs 的同款断言,写在这里是因为
|
|
108
|
+
# **镜像构建不该依赖 CI 跑过了**:本机或临时机器上 docker build 时 CI 并不在场。
|
|
109
|
+
# - shebang 在第一行:chmod +x 之后能直接 ./relay.mjs 起;
|
|
110
|
+
# - 包版本真的进了产物:否则 /healthz 的 version 会是 0.0.0,而运维正是靠它
|
|
111
|
+
# 判断线上跑的是哪一版(那就成了"看起来正常、其实根本判不出来")。
|
|
112
|
+
RUN set -e; head -1 dist/bundle/main.js | grep -q '^#!/usr/bin/env node'; node -e 'const {readFileSync}=require("node:fs");const v=JSON.parse(readFileSync("package.json","utf8")).version;const b=readFileSync("dist/bundle/main.js","utf8");if(!b.includes(JSON.stringify(v)))throw new Error("产物里没有包版本 "+v);console.log("[docker] bundle ok, version "+v)'
|
|
113
|
+
|
|
114
|
+
# ── 阶段 2:运行 ──────────────────────────────────────────────────────────────
|
|
115
|
+
FROM node:22-alpine AS runtime
|
|
116
|
+
|
|
117
|
+
ARG DRC_VERSION=0.0.0
|
|
118
|
+
ARG VCS_REF=unknown
|
|
119
|
+
LABEL org.opencontainers.image.title="dsh-remote-relay" org.opencontainers.image.description="Zero-knowledge WebSocket relay for DSH Remote Control" org.opencontainers.image.source="https://github.com/providcc/dsh-remote-server" org.opencontainers.image.licenses="MIT" org.opencontainers.image.version="${DRC_VERSION}" org.opencontainers.image.revision="${VCS_REF}"
|
|
120
|
+
|
|
121
|
+
WORKDIR /app
|
|
122
|
+
COPY --from=build /src/packages/relay/dist/bundle/main.js /app/relay.mjs
|
|
123
|
+
|
|
124
|
+
# 只有这两个。**故意不设 DRC_STATE_FILE**:镜像里的默认值要与裸机(systemd 单元)一致——
|
|
125
|
+
# 不配就是纯内存、配了才落盘。落盘路径属于"部署形态"而不是"程序形态",它与卷是一对,
|
|
126
|
+
# 所以由 compose 显式配在一起(compose.yaml 里 env 与 volume 并排)。这样
|
|
127
|
+
# "docker run 一个裸镜像"与"systemd 跑同一个文件"语义相同,也避免了"忘了挂卷 →
|
|
128
|
+
# 状态写进容器可写层 → 重建即丢"这条静默失败。
|
|
129
|
+
#
|
|
130
|
+
# DRC_BIND=0.0.0.0 不能省:代码默认只绑 127.0.0.1,容器里那样绑等于"-p 发布了没人监听"。
|
|
131
|
+
# 公网面仍然是宿主机 nginx 的 443,因为 compose 只把端口发布到**回环**。
|
|
132
|
+
ENV DRC_PORT=8787 DRC_BIND=0.0.0.0
|
|
133
|
+
|
|
134
|
+
# 非 root。uid/gid 写死成 10001,于是状态目录的属主在容器与裸机两套部署形态之间是同一组
|
|
135
|
+
# 数字,切换时只需要一次 chown(deploy/docker/README.md 第 3 节)。
|
|
136
|
+
RUN addgroup -g 10001 -S drc && adduser -u 10001 -S -G drc -h /app drc && mkdir -p /data && chown 10001:10001 /data
|
|
137
|
+
USER 10001:10001
|
|
138
|
+
|
|
139
|
+
EXPOSE 8787
|
|
140
|
+
STOPSIGNAL SIGTERM
|
|
141
|
+
|
|
142
|
+
# 健康检查用 node 自己发请求,不装 curl/wget——运行阶段本来就只有 node,为一个探针多装一个
|
|
143
|
+
# 二进制不值得,而且它会是这个镜像里唯一一个外部依赖。
|
|
144
|
+
# 判 j.ok === true 而不是只看 HTTP 200:停机过程中 /healthz 明确回 ok:false 且状态码 503,
|
|
145
|
+
# 那一刻容器应当立刻变成 unhealthy,而不是继续被当成健康的。
|
|
146
|
+
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 CMD node -e 'fetch("http://127.0.0.1:"+(process.env.DRC_PORT||8787)+"/healthz").then(r=>r.json()).then(j=>process.exit(j.ok===true?0:1)).catch(()=>process.exit(1))'
|
|
147
|
+
|
|
148
|
+
# ENTRYPOINT 固定、参数透传:docker run … dsh-remote-relay --version 就能问出镜像里是哪一版,
|
|
149
|
+
# 不必为此起一个会监听端口的进程。
|
|
150
|
+
ENTRYPOINT ["node", "/app/relay.mjs"]
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
# 容器部署(Docker / Compose)
|
|
2
|
+
|
|
3
|
+
生产实例现在跑的就是这一份。形态与裸机(systemd)**同形**:同一个单文件产物、同一套
|
|
4
|
+
`DRC_*` 环境变量、同一个 `/healthz`。变的只有"谁负责把它拉起来"。
|
|
5
|
+
|
|
6
|
+
```sh
|
|
7
|
+
# 1. 配置(Compose 自动读本目录的 .env)
|
|
8
|
+
cat > deploy/docker/.env <<EOF
|
|
9
|
+
DRC_HOST_TOKEN=$(openssl rand -hex 32) # 与主机插件那份逐字一致
|
|
10
|
+
DRC_PUBLIC_URL=wss://drc.provid.cc
|
|
11
|
+
DRC_DATA_DIR=/var/lib/dsh-remote-control
|
|
12
|
+
DRC_IMAGE_TAG=2.0.15
|
|
13
|
+
EOF
|
|
14
|
+
chmod 600 deploy/docker/.env
|
|
15
|
+
|
|
16
|
+
# 2. 状态目录:属主必须是镜像里的非 root 用户 uid 10001
|
|
17
|
+
mkdir -p /var/lib/dsh-remote-control && chown 10001:10001 /var/lib/dsh-remote-control
|
|
18
|
+
|
|
19
|
+
# 3. 起
|
|
20
|
+
docker compose -f deploy/docker/compose.yaml up -d --build
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## 为什么「nginx 一行都不用改」
|
|
24
|
+
|
|
25
|
+
端口只发布到**宿主机回环**(`127.0.0.1:8787:8787`),所以宿主机上的
|
|
26
|
+
`/etc/nginx/conf.d/drc.conf`(`proxy_pass http://127.0.0.1:8787`)原样继续工作,TLS 仍由
|
|
27
|
+
nginx 终止。切容器化不动证书、不动反代,只换进程托管方式。
|
|
28
|
+
|
|
29
|
+
三个必须自己记住的点:
|
|
30
|
+
|
|
31
|
+
| 项 | 值 | 不这么做会怎样 |
|
|
32
|
+
| ------------ | ------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------- |
|
|
33
|
+
| `DRC_BIND` | 镜像里已经是 `0.0.0.0`,compose 里显式写死 | 容器内绑 `127.0.0.1` → `-p` 发布成功但**外面连不上** |
|
|
34
|
+
| 端口发布 | `127.0.0.1:8787:8787` | 绑 `0.0.0.0:8787:8787` → 8787 直接暴露公网,绕过 nginx 的按 IP 限流 |
|
|
35
|
+
| `/data` 属主 | `10001:10001` | 状态写不进去。2.x 起启动时会直接记一条 `state file is not writable` 的 **error**;1.x 时代只能等第一条 `state file write failed` 的 warn |
|
|
36
|
+
|
|
37
|
+
## 运维
|
|
38
|
+
|
|
39
|
+
```sh
|
|
40
|
+
docker compose -f deploy/docker/compose.yaml ps # STATUS 列会显示 healthy
|
|
41
|
+
docker compose -f deploy/docker/compose.yaml logs -f relay
|
|
42
|
+
docker compose -f deploy/docker/compose.yaml restart # 优雅停机 → 最多 15s
|
|
43
|
+
curl -s http://127.0.0.1:8787/healthz # version 必须是包的 version
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
日志是 **docker logs** 而不是 journald,过滤方式因此变了一点:
|
|
47
|
+
|
|
48
|
+
```sh
|
|
49
|
+
docker compose … logs relay | grep '"level":"warn"' # 按 JSON 里的 level 过滤
|
|
50
|
+
docker compose … logs relay | jq 'select(.msg=="counters")'
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
> 别用 `docker logs … | grep ERROR`:中继写到 stdout 的 JSON 不带级别前缀,
|
|
54
|
+
> 按 "ERROR" 过滤很可能一条都出不来。按 JSON 里的 `level` 过滤。
|
|
55
|
+
|
|
56
|
+
`docker logs` 有**条数上限**(每个容器默认保留若干 MB),所以 compose 里配了
|
|
57
|
+
json-file 轮转(`max-size: 10m` / `max-file: 5`)。要长期留存就装一个日志驱动转发出去,
|
|
58
|
+
别依赖 `docker logs`。
|
|
59
|
+
|
|
60
|
+
## 升级与回滚
|
|
61
|
+
|
|
62
|
+
```sh
|
|
63
|
+
# 升级:构建 → 重启 → 看版本号变了没有
|
|
64
|
+
docker compose -f deploy/docker/compose.yaml up -d --build
|
|
65
|
+
curl -s http://127.0.0.1:8787/healthz | grep -o '"version":"[^"]*"'
|
|
66
|
+
|
|
67
|
+
# 回滚:把标签切回去再 up(镜像还在本地)
|
|
68
|
+
docker tag dsh-remote-relay:2.0.14 dsh-remote-relay:2.0.15
|
|
69
|
+
docker compose -f deploy/docker/compose.yaml up -d
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
**回滚必须成对**(中继产物 + 主机插件产物):两代握手帧名不兼容,只回滚一边会
|
|
73
|
+
"永远配不上对"或"静默不上线"。见 `../SELF-HOSTING.md` §10.2 的对照表。
|
|
74
|
+
|
|
75
|
+
## 拉不到 Docker Hub 怎么办
|
|
76
|
+
|
|
77
|
+
生产那台机器 `registry-1.docker.io` 超时(而 npm registry 通)。三条路,按推荐顺序:
|
|
78
|
+
|
|
79
|
+
1. **配 daemon 级镜像加速**(一劳永逸,`FROM` 不用改)—— **生产走的就是这条**:
|
|
80
|
+
|
|
81
|
+
```json
|
|
82
|
+
// /etc/docker/daemon.json
|
|
83
|
+
{ "registry-mirrors": ["https://6faa0280329c285d57dd15ba1333bdeb.d.1ms.run"] }
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
```sh
|
|
87
|
+
systemctl daemon-reload && systemctl restart docker
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
> ⚠️ **`daemon.json` 要合并,不能覆盖。** 那台机器上原本已经有三条现网配置
|
|
91
|
+
> (`log-driver` + `log-opts`、`live-restore: true`、`default-address-pools`)。
|
|
92
|
+
> 写坏它会让 **docker 起不来**,而它是那台机器上所有容器的地基。
|
|
93
|
+
> 判据:`tests/docker.test.mjs` 里有「compose 不许覆盖 daemon 级设置」那条。
|
|
94
|
+
>
|
|
95
|
+
> 配完**必须核对 digest**,不能只看「拉下来了」:加速站是第三方,
|
|
96
|
+
> 拉到的内容是不是官方的,靠 `RepoDigests` 说话。
|
|
97
|
+
>
|
|
98
|
+
> ```sh
|
|
99
|
+
> docker image inspect node:22-alpine --format '{{index .RepoDigests 0}}'
|
|
100
|
+
> # node@sha256:0a7108bf6c7bf5de370ffb1a3ed6be93d405b43ff159f681a8d18c0e2bc2e402
|
|
101
|
+
> ```
|
|
102
|
+
>
|
|
103
|
+
> 与直连 Docker Hub 拉到的是**同一个 digest**,才是可信的(2026-10-07 实测)。
|
|
104
|
+
>
|
|
105
|
+
> **加速地址会变**:它是按账号发的,换账号/换机器就是另一个串。
|
|
106
|
+
> 上面那个是 2026-10-07 生产实测可用的那一个;失效时按本文另一条路走。
|
|
107
|
+
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
2. **本地打标签**:从可达的镜像站拉一份再改名,docker build 就不解析远端了。
|
|
113
|
+
|
|
114
|
+
```sh
|
|
115
|
+
docker pull <mirror>/library/node:22-alpine
|
|
116
|
+
docker tag <mirror>/library/node:22-alpine node:22-alpine
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
3. **离线搬运**(生产那次就是这么发的):在能上网的机器上构建并导出,拷过去导入。
|
|
120
|
+
|
|
121
|
+
```sh
|
|
122
|
+
docker build -f deploy/docker/Dockerfile -t dsh-remote-relay:2.0.15 .
|
|
123
|
+
docker save dsh-remote-relay:2.0.15 | gzip > relay-2.0.15.tar.gz
|
|
124
|
+
scp relay-2.0.15.tar.gz root@HOST:/opt/dsh-remote-control/
|
|
125
|
+
ssh root@HOST 'gunzip -c /opt/dsh-remote-control/relay-2.0.15.tar.gz | docker load'
|
|
126
|
+
ssh root@HOST 'cd /opt/dsh-remote-control/dsh-remote-server && docker compose -f deploy/docker/compose.yaml up -d'
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
> 目标机架构要和构建机一致(或构建时加 `--platform`)。arm64 机器上构建出来的
|
|
130
|
+
> 镜像在 x86_64 上起不来,而这类错误在 `docker compose up` 之前不会报。
|
|
131
|
+
|
|
132
|
+
## 镜像里没有什么
|
|
133
|
+
|
|
134
|
+
- **没有 node_modules、没有 npm install**:运行阶段只有 node 与那一个产物文件。
|
|
135
|
+
- **没有 curl/wget**:健康检查用 node 自己发请求。
|
|
136
|
+
- **没有 bash**:基础镜像是 alpine(用的是 busybox 的 `/bin/sh`,HEALTHCHECK 的 shell
|
|
137
|
+
形式用它)。
|
|
138
|
+
- **没有状态文件**:`/data` 是挂进来的卷,且镜像**故意不设 `DRC_STATE_FILE`**——
|
|
139
|
+
落盘路径属于部署形态,与卷由 compose 并排配在一起。
|
|
140
|
+
|
|
141
|
+
## 与 systemd 并存
|
|
142
|
+
|
|
143
|
+
`deploy/systemd/dsh-remote-control.service` 仍然保留并可用,两条路共用同一个产物。
|
|
144
|
+
**别同时开**(同一个端口只能有一个进程在听)。切回 systemd:
|
|
145
|
+
|
|
146
|
+
```sh
|
|
147
|
+
docker compose -f deploy/docker/compose.yaml down
|
|
148
|
+
chown -R root:root /var/lib/dsh-remote-control # 单元以 root 跑
|
|
149
|
+
systemctl enable --now dsh-remote-control
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
### ⚠️ 从 systemd 切过来时,属主要在**停服务之后**改
|
|
153
|
+
|
|
154
|
+
2026-10-07 第一次切容器时踩了,这个坑值得单独写一节,因为它**静默丢数据**。
|
|
155
|
+
|
|
156
|
+
`daemon.json` / compose 都不管属主,容器以 uid 10001 跑,于是切换那一步要
|
|
157
|
+
`chown -R 10001:10001 /var/lib/dsh-remote-control`。**但那必须放在 `systemctl stop` 之后**:
|
|
158
|
+
|
|
159
|
+
单元里有 `StateDirectory=dsh-remote-control`,而 systemd 会在**每次停止/启动时**
|
|
160
|
+
按单元的 `User=` 把那个目录的属主**改回去**。顺序反了就是:
|
|
161
|
+
|
|
162
|
+
```
|
|
163
|
+
chown 10001 → systemctl stop(把属主改回 root)
|
|
164
|
+
→ 起容器(uid 10001 读一个 root 拥有的 600 文件)
|
|
165
|
+
→ EACCES → state file unreadable, starting empty
|
|
166
|
+
→ 30 秒后第一次落盘,把空状态写回去
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
最后那步是**不可逆**的:原来的配对记录被空状态覆盖,`conversations: 0`,
|
|
170
|
+
用户必须重新扫码。实测这次就是这样丢了一条会话。
|
|
171
|
+
|
|
172
|
+
正确顺序:
|
|
173
|
+
|
|
174
|
+
```sh
|
|
175
|
+
systemctl stop dsh-remote-control # 先停,StateDirectory 就不再改属主
|
|
176
|
+
chown -R 10001:10001 /var/lib/dsh-remote-control
|
|
177
|
+
chmod 700 /var/lib/dsh-remote-control
|
|
178
|
+
docker compose -f deploy/docker/compose.yaml up -d
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
**判据**:切完必看这三样,`stateRestored` 那个最要紧 ——
|
|
182
|
+
`stateRestored:0` 而 `conversations:0` 就是"读不到旧状态、被当成空启动",
|
|
183
|
+
此时 `/healthz` 仍然是 `ok:true`,配对表面也正常,只有一处能看出来。
|
|
184
|
+
|
|
185
|
+
```sh
|
|
186
|
+
curl -s 127.0.0.1:8787/healthz | grep -o '"stateRestored":[0-9]*'
|
|
187
|
+
docker logs dsh-remote-relay-relay-1 2>&1 | grep -i unreadable # 必须没有
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
> `read_only: true` + `cap_drop: ALL` 下,容器**写不进** `/data` 时只会在
|
|
191
|
+
> `/healthz` 的 `stateWriteFailures` 上体现,而配对表面完全正常 ——
|
|
192
|
+
> 与 §0.1 记的那个老坑是同一族,容器只是给它多了一个成因(忘了挂卷 / 属主不对)。
|