|
|
# OnlineMsgServer
|
|
|
|
|
|
一个基于 WebSocket 的在线消息中转服务,使用 RSA 完成握手、公钥鉴权和业务包加密。
|
|
|
|
|
|
当前版本除了单机广播/私聊,还支持“服务器伪装成普通用户”的 peer 互联模式:
|
|
|
|
|
|
- 客户端外层协议不变
|
|
|
- 服务器之间通过普通 `publickey / forward / broadcast` 连接
|
|
|
- 本地私聊未命中时,服务端可继续向 peer 盲转发
|
|
|
- 广播可在 peer 节点之间扩散
|
|
|
- 服务端内置短期 `seen-cache`,按 `hash(sender + type + key + payload)` 去重
|
|
|
|
|
|
这套 peer 能力更接近“盲转发网络”,不是强一致的用户目录或联邦路由系统。
|
|
|
|
|
|
## 协作规范
|
|
|
|
|
|
- 提交代码前请先阅读 [CONTRIBUTING.md](CONTRIBUTING.md)
|
|
|
- 分支、PR、协议联调、本地证书与调试资产管理都按该文档执行
|
|
|
|
|
|
## 功能概览
|
|
|
|
|
|
- WebSocket 服务,支持 `ws://` 和 `wss://`
|
|
|
- 明文首包下发服务端公钥与一次性 challenge
|
|
|
- 客户端使用自己的 RSA 公钥 + 签名完成鉴权
|
|
|
- 业务消息支持广播和按公钥私聊
|
|
|
- 签名校验、防重放、限流、IP 封禁、消息大小限制
|
|
|
- 可选 peer 网络:广播扩散、私聊 miss 后继续中继
|
|
|
- Android / Web 客户端可直接复用现有协议
|
|
|
|
|
|
## 仓库结构
|
|
|
|
|
|
- `Common/`:协议消息与业务处理器
|
|
|
- `Core/`:安全配置、用户会话、peer 网络、RSA 服务
|
|
|
- `deploy/`:本地测试 / 局域网证书 / 生产准备脚本
|
|
|
- `web-client/`:React Web 客户端
|
|
|
- `android-client/`:Android 客户端
|
|
|
|
|
|
## 运行依赖
|
|
|
|
|
|
- `.NET 8 SDK`
|
|
|
- `Docker`
|
|
|
- `openssl`
|
|
|
|
|
|
本仓库附带的 `deploy/*.sh` 脚本按 macOS 环境编写,依赖:
|
|
|
|
|
|
- `ipconfig`
|
|
|
- `route`
|
|
|
- `awk`
|
|
|
- `base64`
|
|
|
- `tr`
|
|
|
|
|
|
## 快速开始
|
|
|
|
|
|
先进入仓库根目录:
|
|
|
|
|
|
```bash
|
|
|
cd <repo-root>
|
|
|
```
|
|
|
|
|
|
### 1. 本地测试:WS
|
|
|
|
|
|
```bash
|
|
|
bash deploy/deploy_test_ws.sh
|
|
|
```
|
|
|
|
|
|
脚本会:
|
|
|
|
|
|
- 生成或复用协议私钥 `deploy/keys/server_rsa_pkcs8.b64`
|
|
|
- 构建 Docker 镜像
|
|
|
- 以 `REQUIRE_WSS=false` 启动单节点服务
|
|
|
|
|
|
### 2. 局域网测试:WSS
|
|
|
|
|
|
```bash
|
|
|
bash deploy/redeploy_with_lan_cert.sh
|
|
|
```
|
|
|
|
|
|
脚本会:
|
|
|
|
|
|
- 自动探测当前局域网 IP
|
|
|
- 生成包含 LAN IP 的自签名证书
|
|
|
- 生成运行时使用的 `server.pfx`
|
|
|
- 构建镜像并以 `REQUIRE_WSS=true` 启动容器
|
|
|
|
|
|
适合 Android 真机、同网段设备和浏览器本地联调。
|
|
|
|
|
|
Android 客户端 debug 包支持额外信任本地局域网 CA:
|
|
|
|
|
|
- 把局域网 WSS 使用的 CA 证书复制到 `deploy/certs/android-local/local_ca.crt`
|
|
|
- `deploy/certs/` 已在 `.gitignore` 中,只用于本地调试,不应提交到 Git
|
|
|
- `assembleDebug` 会自动把它接入 debug-only 的 `networkSecurityConfig`
|
|
|
- release 构建不会信任这张本地 CA
|
|
|
|
|
|
### 3. 生产准备
|
|
|
|
|
|
```bash
|
|
|
DOMAIN=chat.example.com \
|
|
|
TLS_CERT_PEM=/path/fullchain.pem \
|
|
|
TLS_KEY_PEM=/path/privkey.pem \
|
|
|
TLS_CHAIN_PEM=/path/chain.pem \
|
|
|
CERT_PASSWORD='change-me' \
|
|
|
bash deploy/prepare_prod_release.sh
|
|
|
```
|
|
|
|
|
|
输出默认在 `deploy/output/prod/`,包括:
|
|
|
|
|
|
- `prod.env`
|
|
|
- Docker 镜像 tar(可选)
|
|
|
- 运行示例脚本
|
|
|
- 运行时证书与协议私钥
|
|
|
|
|
|
如果只是临时测试,也可以生成自签名证书:
|
|
|
|
|
|
```bash
|
|
|
DOMAIN=chat.example.com \
|
|
|
SAN_LIST='DNS:www.chat.example.com,IP:10.0.0.8' \
|
|
|
GENERATE_SELF_SIGNED=true \
|
|
|
CERT_PASSWORD='change-me' \
|
|
|
bash deploy/prepare_prod_release.sh
|
|
|
```
|
|
|
|
|
|
## 手动 Docker 启动
|
|
|
|
|
|
### 单节点:WS
|
|
|
|
|
|
```bash
|
|
|
docker run -d --name onlinemsgserver --restart unless-stopped \
|
|
|
-p 13173:13173 \
|
|
|
-v "$(pwd)/deploy/keys:/app/keys:ro" \
|
|
|
-e REQUIRE_WSS=false \
|
|
|
-e SERVER_PRIVATE_KEY_PATH=/app/keys/server_rsa_pkcs8.b64 \
|
|
|
onlinemsgserver:latest
|
|
|
```
|
|
|
|
|
|
### 单节点:WSS
|
|
|
|
|
|
```bash
|
|
|
docker run -d --name onlinemsgserver --restart unless-stopped \
|
|
|
-p 13173:13173 \
|
|
|
-v "$(pwd)/deploy/certs:/app/certs:ro" \
|
|
|
-v "$(pwd)/deploy/keys:/app/keys:ro" \
|
|
|
-e REQUIRE_WSS=true \
|
|
|
-e TLS_CERT_PATH=/app/certs/server.pfx \
|
|
|
-e TLS_CERT_PASSWORD=changeit \
|
|
|
-e SERVER_PRIVATE_KEY_PATH=/app/keys/server_rsa_pkcs8.b64 \
|
|
|
onlinemsgserver:latest
|
|
|
```
|
|
|
|
|
|
### 第二节点:通过 peer 连到第一节点
|
|
|
|
|
|
下面这个例子会启动第二个节点,对外提供 `13174`,并主动连到第一节点:
|
|
|
|
|
|
```bash
|
|
|
docker run -d --name onlinemsgserver-peer2 --restart unless-stopped \
|
|
|
-p 13174:13174 \
|
|
|
-v "$(pwd)/deploy/certs:/app/certs:ro" \
|
|
|
-e REQUIRE_WSS=true \
|
|
|
-e LISTEN_PORT=13174 \
|
|
|
-e TLS_CERT_PATH=/app/certs/server.pfx \
|
|
|
-e TLS_CERT_PASSWORD=changeit \
|
|
|
-e ALLOW_EPHEMERAL_SERVER_KEY=true \
|
|
|
-e PEER_NODE_NAME=peer-node-b \
|
|
|
-e PEER_URLS=wss://host.docker.internal:13173/ \
|
|
|
onlinemsgserver:latest
|
|
|
```
|
|
|
|
|
|
这里有一个很重要的约束:
|
|
|
|
|
|
- 如果客户端访问的是 `wss://host:13174/`
|
|
|
- 那容器内 `LISTEN_PORT` 也应当是 `13174`
|
|
|
|
|
|
`WebSocketSharp` 会校验握手请求里的 `Host: host:port`,容器内监听端口和客户端看到的端口不一致时,可能直接返回 `400 Bad Request`。
|
|
|
|
|
|
## 协议说明
|
|
|
|
|
|
### 加密方式
|
|
|
|
|
|
- 服务端握手公钥:RSA-2048(SPKI / PKCS8)
|
|
|
- 传输加密:`RSA/ECB/OAEPWithSHA-256AndMGF1Padding`
|
|
|
- 明文按 `190` 字节分块加密
|
|
|
- 密文按 `256` 字节分块解密
|
|
|
- WebSocket 上传输的是 base64 字符串
|
|
|
|
|
|
### 通用包结构
|
|
|
|
|
|
客户端发给服务端的明文结构如下,随后再整体用服务端公钥加密:
|
|
|
|
|
|
```json
|
|
|
{
|
|
|
"type": "publickey|forward|broadcast",
|
|
|
"key": "",
|
|
|
"data": {}
|
|
|
}
|
|
|
```
|
|
|
|
|
|
### 首包:服务端 -> 客户端(明文)
|
|
|
|
|
|
客户端建立连接后,服务端立即发送:
|
|
|
|
|
|
```json
|
|
|
{
|
|
|
"type": "publickey",
|
|
|
"data": {
|
|
|
"publicKey": "服务端公钥(base64 SPKI)",
|
|
|
"authChallenge": "一次性挑战值",
|
|
|
"authTtlSeconds": 120,
|
|
|
"certFingerprintSha256": "TLS证书指纹(启用WSS时)"
|
|
|
}
|
|
|
}
|
|
|
```
|
|
|
|
|
|
### 鉴权:`type=publickey`
|
|
|
|
|
|
- `key`:用户名
|
|
|
- `data.publicKey`:客户端公钥
|
|
|
- `data.challenge`:首包中的 `authChallenge`
|
|
|
- `data.timestamp`:Unix 秒级时间戳
|
|
|
- `data.nonce`:随机串
|
|
|
- `data.signature`:客户端私钥签名
|
|
|
|
|
|
示例:
|
|
|
|
|
|
```json
|
|
|
{
|
|
|
"type": "publickey",
|
|
|
"key": "guest-123456",
|
|
|
"data": {
|
|
|
"publicKey": "base64-spki",
|
|
|
"challenge": "challenge-from-server",
|
|
|
"timestamp": 1739600000,
|
|
|
"nonce": "random-string",
|
|
|
"signature": "base64-signature"
|
|
|
}
|
|
|
}
|
|
|
```
|
|
|
|
|
|
签名原文:
|
|
|
|
|
|
```text
|
|
|
publickey
|
|
|
{userName}
|
|
|
{publicKey}
|
|
|
{challenge}
|
|
|
{timestamp}
|
|
|
{nonce}
|
|
|
```
|
|
|
|
|
|
### 私聊:`type=forward`
|
|
|
|
|
|
- `key`:目标用户公钥
|
|
|
- `data.payload`:消息内容
|
|
|
- `data.timestamp` / `data.nonce` / `data.signature`:发送者签名信息
|
|
|
|
|
|
```json
|
|
|
{
|
|
|
"type": "forward",
|
|
|
"key": "target-user-public-key",
|
|
|
"data": {
|
|
|
"payload": "hello",
|
|
|
"timestamp": 1739600000,
|
|
|
"nonce": "random-string",
|
|
|
"signature": "base64-signature"
|
|
|
}
|
|
|
}
|
|
|
```
|
|
|
|
|
|
签名原文:
|
|
|
|
|
|
```text
|
|
|
forward
|
|
|
{targetPublicKey}
|
|
|
{payload}
|
|
|
{timestamp}
|
|
|
{nonce}
|
|
|
```
|
|
|
|
|
|
### 广播:`type=broadcast`
|
|
|
|
|
|
- `key`:通常为空字符串
|
|
|
- `data`:结构与 `forward` 相同
|
|
|
|
|
|
签名原文:
|
|
|
|
|
|
```text
|
|
|
broadcast
|
|
|
{key}
|
|
|
{payload}
|
|
|
{timestamp}
|
|
|
{nonce}
|
|
|
```
|
|
|
|
|
|
### 连接流程
|
|
|
|
|
|
1. 客户端建立 WebSocket 连接。
|
|
|
2. 服务端发送明文 `publickey` 首包。
|
|
|
3. 客户端用自己的私钥签名后发送 `type=publickey` 鉴权包。
|
|
|
4. 服务端返回加密的 `auth_ok`。
|
|
|
5. 客户端开始发送 `forward` / `broadcast`。
|
|
|
|
|
|
## Peer 网络说明
|
|
|
|
|
|
Peer 网络不引入新的客户端外层协议。节点之间也是普通登录用户,只是服务端会把这类会话当成 peer 处理。
|
|
|
|
|
|
当前行为:
|
|
|
|
|
|
- 本地广播:先发给本地普通客户端,再扩散到 peer
|
|
|
- 从 peer 收到广播:投递给本地普通客户端,再继续扩散
|
|
|
- 本地私聊命中:直接投递
|
|
|
- 本地私聊 miss:包装为内部 relay 后继续发给 peer
|
|
|
- peer 收到私聊 relay:本地命中就投递,命不中就继续向其他 peer 转发
|
|
|
|
|
|
当前实现特点:
|
|
|
|
|
|
- 不做用户发现
|
|
|
- 不维护“谁在哪台服务器”的路由表
|
|
|
- 只保证尽力转发
|
|
|
- 依赖短期 `seen-cache` 防止消息在环路里重复扩散
|
|
|
|
|
|
### Peer 命名
|
|
|
|
|
|
为了让客户端界面更像普通聊天用户:
|
|
|
|
|
|
- 服务端内部仍用 `peer:` 前缀区分 peer 会话
|
|
|
- 发给客户端前会去掉这个内部前缀
|
|
|
- 如果显式设置了 `PEER_NODE_NAME=peer-node-b`,客户端看到的是 `peer-node-b`
|
|
|
- 如果没有显式设置 `PEER_NODE_NAME`,默认自动生成 `guest-xxxxxx`
|
|
|
|
|
|
## 环境变量
|
|
|
|
|
|
### 基础运行
|
|
|
|
|
|
- `LISTEN_PORT`:监听端口,默认 `13173`
|
|
|
- `REQUIRE_WSS`:是否启用 WSS,默认 `false`
|
|
|
- `TLS_CERT_PATH`:PFX 证书路径,启用 WSS 时必填
|
|
|
- `TLS_CERT_PASSWORD`:PFX 证书密码,可空
|
|
|
|
|
|
### 协议私钥
|
|
|
|
|
|
- `SERVER_PRIVATE_KEY_B64`:协议私钥(PKCS8 base64)
|
|
|
- `SERVER_PRIVATE_KEY_PATH`:协议私钥文件路径
|
|
|
- `ALLOW_EPHEMERAL_SERVER_KEY`:若未提供私钥,是否允许启动临时内存私钥,默认 `false`
|
|
|
|
|
|
### 安全限制
|
|
|
|
|
|
- `MAX_CONNECTIONS`:最大连接数,默认 `1000`
|
|
|
- `MAX_MESSAGE_BYTES`:单消息最大字节数,默认 `65536`
|
|
|
- `RATE_LIMIT_COUNT`:限流窗口允许消息数,默认 `30`
|
|
|
- `RATE_LIMIT_WINDOW_SECONDS`:限流窗口秒数,默认 `10`
|
|
|
- `IP_BLOCK_SECONDS`:触发滥用后的封禁秒数,默认 `120`
|
|
|
- `CHALLENGE_TTL_SECONDS`:challenge 有效期秒数,默认 `120`
|
|
|
- `MAX_CLOCK_SKEW_SECONDS`:允许时钟偏差秒数,默认 `60`
|
|
|
- `REPLAY_WINDOW_SECONDS`:防重放窗口秒数,默认 `120`
|
|
|
- `SEEN_CACHE_SECONDS`:短期去重缓存秒数,默认 `120`
|
|
|
|
|
|
### Peer
|
|
|
|
|
|
- `PEER_NODE_NAME`:peer 登录名;未显式配置时自动生成 `guest-xxxxxx`
|
|
|
- `PEER_USER_PREFIX`:内部保留前缀,默认 `peer:`
|
|
|
- `PEER_URLS`:要主动连接的 peer 地址,逗号分隔
|
|
|
- `PEER_RECONNECT_SECONDS`:peer 断线后的重连间隔,默认 `5`
|
|
|
|
|
|
## 本地调试建议
|
|
|
|
|
|
### Android 连 `ws://`
|
|
|
|
|
|
Android 9 之后默认禁止明文流量。若用 `ws://` 调试,需要客户端显式允许 cleartext。
|
|
|
|
|
|
### Android 连 `wss://`
|
|
|
|
|
|
若服务端使用自签名证书,需要满足其一:
|
|
|
|
|
|
- 设备/模拟器信任这张 CA
|
|
|
- Android debug 包内置该 CA 的信任配置
|
|
|
|
|
|
### 多实例本地测试
|
|
|
|
|
|
同一台机器上起多个节点时,建议:
|
|
|
|
|
|
- 为每个节点分配不同 `LISTEN_PORT`
|
|
|
- 对外映射端口和 `LISTEN_PORT` 保持一致
|
|
|
- 第一个节点使用固定协议私钥
|
|
|
- 第二个测试节点可使用 `ALLOW_EPHEMERAL_SERVER_KEY=true`
|
|
|
|
|
|
## 排错
|
|
|
|
|
|
### `expected HTTP 101 but was 400`
|
|
|
|
|
|
常见原因:
|
|
|
|
|
|
- 容器内 `LISTEN_PORT` 与客户端访问端口不一致
|
|
|
- 客户端实际访问了错误的 `Host: port`
|
|
|
|
|
|
### Android 显示“未收到服务器首包”
|
|
|
|
|
|
当前服务端已禁用 WebSocket 压缩扩展协商,以避免某些 Android/OkHttp 路径拿不到压缩后的首个 `publickey` Hello。
|
|
|
|
|
|
### Peer 连不上 WSS
|
|
|
|
|
|
当前 peer 出站连接使用 .NET `ClientWebSocket`,可以直连 `wss://` peer。若是自签名测试环境,请确认目标地址可达,并尽量使用稳定的局域网地址或 `host.docker.internal`。
|
|
|
|
|
|
## 相关文档
|
|
|
|
|
|
- Web 客户端:[web-client/README.md](/Users/solux/Codes/OnlineMsgServer/web-client/README.md)
|
|
|
- Android 客户端:[android-client/README.md](/Users/solux/Codes/OnlineMsgServer/android-client/README.md)
|