11 KiB
OnlineMsgServer
一个基于 WebSocket 的在线消息中转服务,使用 RSA 完成握手、公钥鉴权和业务包加密。
当前版本除了单机广播/私聊,还支持“服务器伪装成普通用户”的 peer 互联模式:
- 客户端外层协议不变
- 服务器之间通过普通
publickey / forward / broadcast连接 - 本地私聊未命中时,服务端可继续向 peer 盲转发
- 广播可在 peer 节点之间扩散
- 服务端内置短期
seen-cache,按hash(sender + type + key + payload)去重
这套 peer 能力更接近“盲转发网络”,不是强一致的用户目录或联邦路由系统。
协作规范
- 提交代码前请先阅读 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 SDKDockeropenssl
本仓库附带的 deploy/*.sh 脚本按 macOS 环境编写,依赖:
ipconfigrouteawkbase64tr
快速开始
先进入仓库根目录:
cd <repo-root>
1. 本地测试:WS
bash deploy/deploy_test_ws.sh
脚本会:
- 生成或复用协议私钥
deploy/keys/server_rsa_pkcs8.b64 - 构建 Docker 镜像
- 以
REQUIRE_WSS=false启动单节点服务
2. 局域网测试:WSS
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中,只用于本地调试,不应提交到 GitassembleDebug会自动把它接入 debug-only 的networkSecurityConfig- release 构建不会信任这张本地 CA
3. 生产准备
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(可选)
- 运行示例脚本
- 运行时证书与协议私钥
如果只是临时测试,也可以生成自签名证书:
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
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
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,并主动连到第一节点:
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 字符串
通用包结构
客户端发给服务端的明文结构如下,随后再整体用服务端公钥加密:
{
"type": "publickey|forward|broadcast",
"key": "",
"data": {}
}
首包:服务端 -> 客户端(明文)
客户端建立连接后,服务端立即发送:
{
"type": "publickey",
"data": {
"publicKey": "服务端公钥(base64 SPKI)",
"authChallenge": "一次性挑战值",
"authTtlSeconds": 120,
"certFingerprintSha256": "TLS证书指纹(启用WSS时)"
}
}
鉴权:type=publickey
key:用户名data.publicKey:客户端公钥data.challenge:首包中的authChallengedata.timestamp:Unix 秒级时间戳data.nonce:随机串data.signature:客户端私钥签名
示例:
{
"type": "publickey",
"key": "guest-123456",
"data": {
"publicKey": "base64-spki",
"challenge": "challenge-from-server",
"timestamp": 1739600000,
"nonce": "random-string",
"signature": "base64-signature"
}
}
签名原文:
publickey
{userName}
{publicKey}
{challenge}
{timestamp}
{nonce}
私聊:type=forward
key:目标用户公钥data.payload:消息内容data.timestamp/data.nonce/data.signature:发送者签名信息
{
"type": "forward",
"key": "target-user-public-key",
"data": {
"payload": "hello",
"timestamp": 1739600000,
"nonce": "random-string",
"signature": "base64-signature"
}
}
签名原文:
forward
{targetPublicKey}
{payload}
{timestamp}
{nonce}
广播:type=broadcast
key:通常为空字符串data:结构与forward相同
签名原文:
broadcast
{key}
{payload}
{timestamp}
{nonce}
连接流程
- 客户端建立 WebSocket 连接。
- 服务端发送明文
publickey首包。 - 客户端用自己的私钥签名后发送
type=publickey鉴权包。 - 服务端返回加密的
auth_ok。 - 客户端开始发送
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:监听端口,默认13173REQUIRE_WSS:是否启用 WSS,默认falseTLS_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:最大连接数,默认1000MAX_MESSAGE_BYTES:单消息最大字节数,默认65536RATE_LIMIT_COUNT:限流窗口允许消息数,默认30RATE_LIMIT_WINDOW_SECONDS:限流窗口秒数,默认10IP_BLOCK_SECONDS:触发滥用后的封禁秒数,默认120CHALLENGE_TTL_SECONDS:challenge 有效期秒数,默认120MAX_CLOCK_SKEW_SECONDS:允许时钟偏差秒数,默认60REPLAY_WINDOW_SECONDS:防重放窗口秒数,默认120SEEN_CACHE_SECONDS:短期去重缓存秒数,默认120
Peer
PEER_NODE_NAME:peer 登录名;未显式配置时自动生成guest-xxxxxxPEER_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
- Android 客户端:android-client/README.md