77 KiB
NixMsg 开发说明
读者是实现本期功能的开发组。产品行为以 PRD.md 为准,本文对应 PRD 0.5,规定怎么实现。两者冲突时改代码以 PRD 为准,并把差异写进 docs/DEVIATIONS.md(日期、原条款、实际做法、原因),交给后续审核。
文中「必须」是验收项,「不要」是已知会做错的实现。本文对 mochi-mqtt、coder/websocket、autopaho、modernc sqlite 内部行为的说明,已在 2026-09-30 对照它们主分支的源码核对过;升级这些依赖的大版本时要重新核对。
1. 总结构
一个 Go 进程,一个可执行文件。端只连一个端口(端接入端口);管理后台默认也在这个端口,可以配置到单独端口。
flowchart LR
sdk[SDK 或裸 MQTT 设备]
admin[浏览器后台]
port[端接入端口]
aport[后台端口 可选]
mux[协议识别]
http[HTTP 注册接口与后台]
mqtt[内置 MQTT]
app[提交 调度 投递 群 授权 注册]
db[(SQLite)]
sdk --> port
admin --> port
admin -.-> aport
port --> mux
aport --> mux
mux --> http
mux --> mqtt
http --> app
mqtt --> app
app --> db
app --> mqtt
设备之间不直连。MQTT 只负责连接、心跳和把字节送到在线端。消息留不留、何时发、能否撤回,都在应用层和 SQLite,不用 MQTT 的会话队列、保留消息或遗嘱。
在线状态以「这个编号当前有没有完成握手的连接」为准,不看遗嘱。
不需要 Redis 或其他中间件:在线状态就是进程内存里的连接表,持久数据都在 SQLite。只有做多机集群才需要共享状态,本期不做。
2. 技术栈
2.1 负责人选定
2026-09-30 由负责人选定。开发组不要自行更换,确有必要先提出,改动写进 docs/DEVIATIONS.md。
| 方面 | 选择 | 说明 |
|---|---|---|
| 服务端语言 | Go,用开发时最新的稳定版,写进 go.mod |
单个可执行文件,CGO_ENABLED=0 交叉编译 |
| HTTP | Go 标准库 net/http |
Go 1.22 起的 ServeMux 支持按方法和路径参数路由(如 GET /api/admin/endpoints/{id})。不用 Gin、chi、Echo 等框架 |
| 数据库 | SQLite,驱动 modernc.org/sqlite(纯 Go) |
嵌在进程里,不另外部署 |
| 数据库访问 | database/sql 手写 SQL |
不用 ORM,也不用代码生成 |
| 缓存和队列 | 不用 | 不引入 Redis、消息队列等中间件(第 1 节) |
| 后台前端 | Vue 3 + TypeScript + Naive UI | Vite 构建成静态文件,go:embed 嵌进服务端程序;运行时不需要 Node.js |
| 部署 | 单文件二进制为主,同时提供 Docker 镜像(linux/amd64、linux/arm64)和 docker-compose 示例 |
第 11 节 |
| 监控 | Prometheus 格式的 /metrics |
访问限制见第 4.3 节 |
| 证书 | 程序读取证书文件,文件变了自动重载 | 生产环境由 1Panel 申请和自动续签,推送到本地目录给程序读(第 11.3 节) |
| SDK 发布 | 发布到 Gitea 包仓库(git.asio.asia,所有者 nixevol):npm @nixevol/nixmsg、PyPI nixmsg、Maven asia.asio.nixmsg:nixmsg-sdk;Go SDK 直接从代码仓库获取,模块 git.asio.asia/nixevol/NixMsg/sdk/go |
第 9 节 |
| Docker 镜像 | 推到 Gitea 容器仓库 git.asio.asia/nixevol/nixmsg |
第 11.4 节 |
| CI | 本期不做;总控合并前在本机跑全量验证 | TASKS.md 第 4.3 节 |
| 许可证和可见性 | 专有许可证,见仓库根目录 LICENSE;源码仓库、SDK 包、镜像都公开可读,公开不代表授权使用 |
各 SDK 的包信息写同样的许可证 |
2.2 按推荐定下的其余部分
开发组如需调整,先提出,并写进 docs/DEVIATIONS.md。
| 方面 | 选择 |
|---|---|
| 内置 MQTT | github.com/mochi-mqtt/server/v2 |
| WebSocket | github.com/coder/websocket |
| 密码哈希 | golang.org/x/crypto/argon2(argon2id) |
| 配置文件 | YAML,库用 go.yaml.in/yaml/v3(YAML 官方组织维护;gopkg.in/yaml.v3 已在 2025 年停止维护,不要用;go.yaml.in/yaml/v4 出正式版后可换) |
| 日志 | 标准库 log/slog,JSON 格式输出到标准输出 |
| 指标 | github.com/prometheus/client_golang |
| 数据库迁移 | 自己实现:嵌入的有序 SQL 文件加 schema_migrations 表(第 7.7 节) |
| 前端工程 | Vite、Vue Router、Pinia;请求用 fetch 薄封装;包管理用 pnpm 并提交 pnpm-lock.yaml;Node.js 用当前 LTS(2026-09 为 24),只在构建时需要 |
| 代码检查 | Go:gofmt、golangci-lint;前端:ESLint、Prettier、vue-tsc 类型检查 |
| 测试 | Go 标准库 testing;弱网用 toxiproxy、netem;前端单元测试用 Vitest;后台端到端测试用 Playwright |
| 构建脚本 | Taskfile(go-task),Windows、Linux、macOS 都能直接用 |
| 容器基础镜像 | gcr.io/distroless/static 的 nonroot 变体 |
主版本写死在 go.mod、package.json 里,并提交锁文件。不要换 EMQX、Mosquitto,不要换成 CGO 版 SQLite。
2.3 SDK 依赖
这里只列各语言 SDK 依赖的 MQTT 客户端库,SDK 的行为见第 9 节。
| SDK | 依赖 | 说明 | 最低支持 |
|---|---|---|---|
| Go | github.com/eclipse/paho.golang/autopaho |
MQTT 5、自动重连、支持 WebSocket | 和服务端相同的 Go 版本 |
| JS/TS | MQTT.js 5.x | 浏览器和 Node 都能用 | Node.js 20;近两年发布的 Chrome、Edge、Firefox、Safari |
| Python | paho-mqtt 2.x,CallbackAPIVersion.VERSION2 |
同步接口为主,另给 asyncio 包装 | Python 3.10 |
| Java/Android | HiveMQ MQTT Client,加上 websocket 模块 | 同一套 jar 给 Java 和 Android 用 | Java 8;Android API 24 |
2.4 后台界面
界面要求:
- 中文。
- 普通表单标题在左、控件在右,间距紧凑;Markdown、多行文本等大块内容标题在上、内容在下。
- 页头在滚动时保持可见,页面级按钮放在页头右侧。
- 帮助说明用标题旁的图标,悬停显示。错误和关键状态直接写出来。
- 弹窗的确定、取消始终可见。
- 表格和长文本在控件内部滚动。
用 Naive UI 的做法:
- 根组件用
n-config-provider设中文(zhCN、dateZhCN),组件尺寸统一用small;外面套n-message-provider、n-dialog-provider,提示和确认框都走它们。 - 表单用
n-form,label-placement="left"并统一label-width;大块内容的表单项用label-placement="top"。 - 布局用
n-layout:页头固定,内容区单独滚动。 - 帮助图标用
n-tooltip;错误和关键状态用n-alert或表单校验信息直接显示。 - 弹窗用
n-modal(preset="card"),按钮放在footer插槽,正文放进n-scrollbar单独滚动。 - 表格用
n-data-table:remote做服务端分页,设max-height让表格内部滚动,长列表开virtual-scroll。 - 所有时间按浏览器本地时区显示,格式
YYYY-MM-DD HH:mm:ss;接口里一律用 Unix 毫秒。 - 只用 Naive UI 一套组件库,不要混用 Element Plus 等其他库。
3. 仓库
cmd/nixmsg/ 主程序
internal/config/
internal/listener/ 端口监听,识别 TLS、HTTP、MQTT
internal/broker/ mochi 装配、钩子、ACL
internal/protocol/ 帧的编码解码和校验
internal/auth/ 密码哈希池、会话令牌和 API 令牌、锁定计数
internal/app/message/ 提交、调度、推送、确认、撤回、回执、清理、启动恢复
internal/app/identity/ 注册、自己的资料、对话密码与授权、停用删除级联
internal/app/group/ 群
internal/app/presence/ 在线、目录、上下线订阅
internal/store/ SQLite、写入队列与迁移
internal/admin/ 管理接口、API 令牌
internal/httpx/ 路由、注册接口、健康检查、指标
web/ Vue 源码;web/embed.go 用 //go:embed all:dist 导出构建产物
deploy/ Dockerfile、docker-compose.yml、配置示例
sdk/go/
sdk/js/
sdk/python/
sdk/java/
docs/
Taskfile.yml
go:embed 不能引用上级目录,所以嵌入写在 web/embed.go 里,由 internal/httpx 引用。开发时前端用 Vite 开发服务器,把 /api 代理到本机的服务端;正式构建先 pnpm build 再 go build,由 Taskfile 串起来。
提交说明使用 type: 中文一句话,type 取 feat、fix、refactor、style、docs、test、chore、revert。
4. 端口与协议识别
4.1 监听
| 配置 | 默认 | 提供 |
|---|---|---|
listen |
:7443 |
端接入端口:WebSocket /mqtt、裸 MQTT TCP、注册接口 /api/client/、/healthz、/readyz。admin_listen 为空时也提供后台 |
admin_listen |
空 | 设置后(例如 127.0.0.1:7444),后台页面和 /api/admin/ 只在这里提供,listen 上这些路径一律 404 |
两个监听用同一套 TLS 设置、同一套识别代码。端只需要知道 listen。
端口写 0 时由系统随机分配。启动后把实际地址写进 <data_dir>/listen.addr(后台单独监听时另写 admin.addr),供测试启动器读取;多个测试同时运行时靠这个避免端口冲突。
4.2 识别
每个连接先读首字节再分流,读过的字节要放回连接。10 秒内读不到首字节就关闭。
| 首字节 | 处理 |
|---|---|
0x16 |
TLS ClientHello。握手后对解密出的首字节再识别一次 |
A–Z(HTTP 方法) |
HTTP |
0x10(MQTT CONNECT) |
裸 MQTT。只在 listen 上接受,admin_listen 上直接关闭 |
| 其他 | 关闭 |
HTTP 连接通过一个自己实现的 net.Listener(从通道里取连接)交给同一个 http.Server。
tls.Config 不设置 NextProtos,HTTP 只走 1.1,WebSocket 走 HTTP/1.1 升级。不要写成 NextProtos = ["http/1.1"]:Go 会拒绝声明了 ALPN 但和服务端没有交集的客户端,例如声明 mqtt 的设备。
配置了证书:默认只接受 TLS,allow_plaintext: true 才同时接受明文。没配证书:只跑明文,启动时打一条警告日志。证书在进程内缓存,每小时看一次文件修改时间,变了就重新加载,已建立的连接不受影响;新证书读取或解析失败时继续用旧证书,并记错误日志。程序本身不申请证书(不做 ACME),续签交给 1Panel 等外部工具(第 11.3 节)。
4.3 HTTP 路径
| 路径 | 用途 |
|---|---|
/mqtt |
WebSocket,子协议必须是 mqtt |
/api/client/ |
端侧接口,本期只有注册(第 6.9 节)。允许跨域 |
/api/admin/ |
管理接口,网页登录或 API 令牌(第 8 节) |
/metrics |
Prometheus 指标,只有计数和耗时,不含正文和编号明细 |
/healthz |
进程活着 |
/readyz |
已能读写数据库 |
| 其余 | 后台静态页,未知前端路由回 index.html |
后台单独监听时,listen 上只保留 /mqtt、/api/client/ 和两个健康检查;admin_listen 上只有 /api/admin/、/metrics、静态页和两个健康检查,/metrics 不要求鉴权。后台和端共用端口时,/metrics 要求请求头 Authorization: Bearer <metrics.token>:没配 metrics.token 返回 404,令牌不对返回 401。
指标至少包括:在线连接数(按 ws、tcp 分)、端总数、待投递数、定时消息数、从到点到推送的耗时分布、确认耗时分布、写队列长度和每次合并提交的耗时、密码哈希排队数、各错误码计数。
4.4 WebSocket
c, err := websocket.Accept(w, r, &websocket.AcceptOptions{
Subprotocols: []string{"mqtt"},
InsecureSkipVerify: true,
})
InsecureSkipVerify关掉的是 Origin 校验。浏览器 SDK 跑在接入方自己的域名下,默认的同源校验会直接回 403。这里认证靠 MQTT 用户名和密码,不用 Cookie,没有跨站伪造的问题。库文档也建议放开任意来源时用这个选项,不要把OriginPatterns写成*。Accept不会因为客户端没带mqtt子协议而拒绝。之后检查c.Subprotocol() == "mqtt",不是就关闭。- 然后
websocket.NetConn(ctx, c, websocket.MessageBinary),把得到的net.Conn交给Server.EstablishConnection("ws", conn)。 ctx用context.Background()派生,在连接关闭时主动 cancel。不要用请求的Context:连接被劫持后继续用请求上下文,行为不可预期(库文档原话)。NetConn的RemoteAddr是 TCP 对端。请求来自trusted_proxies时,用第 4.5 节算出的真实 IP 包一层net.Conn(只改RemoteAddr)再交给 mochi,登录锁定按这个 IP 计。NetConn会把读限制设成不限制。单条大小不要靠这层限制,靠第 5 节的 MQTT 包上限,以及应用层对正文字节的检查。EstablishConnection会阻塞到连接结束。WebSocket 在 handler 里直接调用;裸 TCP 每个连接起一个 goroutine 调用EstablishConnection("tcp", conn)。- 不要使用 mochi 自带的 TCP 或 WebSocket 监听器,否则会多出端口,也绕过了识别和 TLS 设置。
4.5 反向代理
trusted_proxies 列出代理的地址段。来自这些地址的 HTTP 和 WebSocket 请求,取 X-Forwarded-For 从右往左第一个不在 trusted_proxies 里的地址作为客户端 IP,按 X-Forwarded-Proto 判断是否 HTTPS。其他来源的请求忽略这两个头。
对外只露 443 时,代理必须同时支持 WebSocket /mqtt;裸 TCP 设备要么直连 listen,要么改走 WebSocket。不要把裸 MQTT TCP 交给只会转发 HTTP 的代理配置。
5. 内置 MQTT
装配约束:
InlineClient = true
Capabilities:
MaximumClients = 2000
MaximumQos = 1
MaximumPacketSize = 786432
MaximumSessionExpiryInterval = 0
ReceiveMaximum = 1024
MaximumInflight = 1024
MaximumClientWritesPending = 1024
RetainAvailable = 0
WildcardSubAvailable = 0
SharedSubAvailable = 0
TopicAliasMaximum = 0
Compatibilities.ObscureNotAuthorized = true
MaximumSessionExpiryInterval = 0会把客户端申请的会话保留时间压成 0,mochi 在断开时直接删会话(3.1.1 的非 Clean 会话在下一次清理时删)。应用层不要依赖 MQTT 会话排队。- 连接数达到
MaximumClients时,mochi 回「服务器忙」(3.1.1 回「服务器不可用」),SDK 按可重试处理。
客户端必须:
- MQTT 5。3.1.1 可以连,但是被踢原因不如 5 清楚。
CleanStart = true(3.1.1 则CleanSession = true),会话过期间隔 0。- ClientID、Username 都等于端编号。Password 是登录密码,或者上次握手拿到的会话令牌(
nst_开头,见下文「登录与会话令牌」)。 - 心跳 10–600 秒,SDK 默认 30。服务器在
OnConnect里校正:超出范围时改写cl.State.Keepalive并设cl.State.ServerKeepalive = true,MQTT 5 客户端会从 CONNACK 得知服务器指定的心跳。 - 只订阅
nix/c/{端编号}/down,只向nix/c/{端编号}/up发布。 - 应用帧使用 QoS 1。mochi 会把 QoS 2 降成 1,SDK 不要发 QoS 2。
钩子:
| 钩子 | 行为 |
|---|---|
OnConnect |
分配连接代号,校正心跳,然后查编号、停用,按下文「登录与会话令牌」校验会话令牌或登录密码(含锁定),把结论记在这个连接上。数据库出错等内部故障时返回 error:mochi 不回 CONNACK 直接断开,客户端按网络故障重连 |
OnConnectAuthenticate |
只返回 OnConnect 记下的结论。返回 false 时 mochi 回「用户名或密码错误」,SDK 会停止重连,所以只有编号不存在、已停用、密码错误、会话令牌无效或过期、已锁定这几种情况能返回 false |
OnACLCheck |
只允许上面这一对主题。发布检查 write=true;订阅和服务器下发检查 write=false |
OnPublish |
拷贝 topic 和 payload 后交给该端的串行队列。返回 packets.CodeSuccessIgnore,让客户端拿到 PUBACK,同时不把这条转发给任何订阅者 |
OnPublishDropped |
下行没写进连接的发送队列。把对应投递或回执的「已推送」标记清掉,1 秒后重推,别对慢客户端空转 |
OnSessionEstablished |
记下这是该编号的当前连接 |
OnDisconnect |
按第 7.5 节做断线处理:总是清掉这个代号的推送标记;是当前连接才标离线 |
连接代号:同一编号的新旧连接 ClientID 相同,用 *mqtt.Client 指针映射到自己生成的随机代号,写进投递的 pushed_conn。
OnPublish 在客户端读循环里同步执行,PUBACK 在它返回后才发。把 topic 和 payload 拷贝出来再投入该端自己的串行 worker(当前版本每个包已经是新切片,但这不是公开约定,拷贝成本可以忽略)。worker 队列长度 256,满了就堵住读循环形成背压,不要丢帧。worker 里不要同步等待「断开这个同一连接」完成,否则可能和读循环互相等待。踢人放到独立的 goroutine。
CodeSuccessIgnore 会让 publishToSubscribers 直接返回,QoS 1 仍然回 PUBACK。不要返回 ErrRejectPacket:那条路径不回 PUBACK,客户端会在重连后重发。
业务成功以应用帧 resp 为准,不以 PUBACK 为准。resp 必须在数据库提交之后再发。提交成功但还没发出 resp 就断线时,客户端用同一消息号重试,服务器返回原来的结果。
同一编号新连接到来时,mochi 会用「会话被接管」断开旧连接(MQTT 5 原因码 0x8E)。旧连接的 OnDisconnect 可能晚于新连接的握手。连接表必须带连接代号,旧代号的断开事件不能把新连接标成离线,也不能清掉新连接已经写下的推送标记。
SDK 被 0x8E 踢下线:停止重连,事件 kicked,原因 taken_over。管理员停用、删除、重置密码:先发 fatal 帧再断开,SDK 同样停止重连。即使 SDK 没看到这些原因,它的会话令牌也已经作废,重连会被拒绝,不会两处来回互踢。只有每次都用密码登录的设备(通常是收不到 0x8E 的 3.1.1 裸设备)会在重连时再把新连接顶掉;接入文档要写明这类设备不要在两处使用同一编号。
登录与会话令牌:
- Password 以
nst_开头时按会话令牌处理:算 SHA-256,和该端的session_hash常量时间比较,一致且距session_used_at没超过session_idle_days就通过;不一致或过期返回 false(CONNACK0x86),不计入锁定。通过后session_used_at在内存里更新,最多每小时写一次库。 - 否则按登录密码处理:先查锁定,再在 argon2 池里校验。成功后生成新令牌(
nst_加 32 字节随机数的 base64url),用一个写操作把session_hash换成新令牌的 SHA-256,session_issued_at、session_used_at设为现在,提交后才继续;旧令牌从这一刻起作废。新令牌记在这个连接上,在握手响应里交给客户端(第 6.1 节)。失败计入锁定。 - 两种方式通过后,都照常由 mochi 接管同一编号的旧连接(
0x8E)。 - 用令牌重连不换令牌。端自己改登录密码时换新令牌并在响应里返回;管理员重置密码、停用、删除,以及端自己
self.logout时清空session_hash。 - 令牌和 IP 无关:设备换网络、换 IP 后直接用令牌重连。服务器重启后的大批重连也基本走令牌,不需要做密码哈希。
下行大小:mochi 写下行包时,超过客户端在 CONNECT 里声明的 Maximum Packet Size 就直接丢掉,只打一条 debug 日志,不触发任何钩子。所以所有下行帧都要在发布前自己检查大小(第 7.5 节)。
下行 QoS:msg、receipt、revoked、resp、fatal 用 QoS 1;上下线通知和群事件本来就是尽力送达,用 QoS 0,不占 inflight 额度。mochi 在单个客户端的 inflight 达到 MaximumInflight 时会静默丢弃新的 QoS 1 下行,同样不触发钩子,只能靠第 7.5 节的确认超时兜底。
锁定:
- 登录密码按「编号 + 来源 IP」计数:5 分钟内错 10 次,锁定这个组合 5 分钟。
- 登录密码另按编号计总数:1 小时内错 50 次,暂停这个编号的密码登录 1 小时,防止有人不断换 IP 猜密码。
- 这两种锁定只拦密码登录,不拦会话令牌重连:已登录的设备换 IP、断线重连都不受影响,所以按编号计总数不会让别人把在线设备锁死。管理员可以用
unlock清掉某个编号的锁定(第 8 节)。 - 会话令牌不对不计数:令牌是 256 位随机数,猜不中,校验只是一次哈希查表。
- 对话密码按「发送方 + 对方」计数,阈值相同。另按对方计总数:1 小时内错 50 次,暂停这个端的对话密码验证 1 小时,期间
unlock、带密码的发送和拉人进群都返回rate_limited,已有授权不受影响;它改对话密码时清零。 - 管理员登录和注册安全码按来源 IP 计数,阈值相同。
- 计数放内存,重启清零。锁定期内直接拒绝,不做哈希计算。
6. 端协议
MQTT 上的应用帧:UTF-8 JSON,一个 MQTT 发布里一帧。字段名用蛇形。时间用 Unix 毫秒。v 固定为 1,不认识的版本返回 bad_request。
序列化时不要转义非 ASCII 和 HTML 字符:Go 用 json.Encoder 并 SetEscapeHTML(false),Python 用 ensure_ascii=False,其他语言按同样要求。否则中文、emoji、<>& 会膨胀 2 到 6 倍,接近上限的正文会超出帧上限。
上行主题 nix/c/{端编号}/up,下行 nix/c/{端编号}/down。
请求都带 rid,由客户端生成,同一连接内不重复。响应:
{"v":1,"type":"resp","rid":"1","ok":true,"data":{}}
{"v":1,"type":"resp","rid":"1","ok":false,"error":{"code":"talk_password_required","message":"需要对话密码"}}
message 给开发者读,SDK 用 code 分支。
6.1 握手
连接并订阅 down 之后,客户端发布:
{"v":1,"type":"hello","rid":"1","max_receive_bytes":262144,"client":"go-sdk/0.1"}
max_receive_bytes 是这个连接一次能收的最大下行帧字节数(整条 JSON),不小于 1024。省略表示不限,但仍受 CONNECT 里 Maximum Packet Size 的约束。服务器在订阅完成后回复:
{"v":1,"type":"resp","rid":"1","ok":true,"data":{
"server_time_ms": 1750000000000,
"server_version": "0.1.0",
"max_body_bytes": 262144,
"max_meta_bytes": 4096,
"max_frame_bytes": 786432,
"max_ttl_seconds": 2592000,
"max_schedule_seconds": 31536000,
"ack_timeout_seconds": 300,
"session_token": "nst_..."
}}
session_token只在这次连接用登录密码认证时返回。SDK 收到后立刻交给应用保存,之后重连都用它(第 5 节「登录与会话令牌」)。- 没收到这个成功响应之前,服务器拒绝其他请求,错误
not_ready。握手完成才算在线,才开始推送。 - 收到
hello时这个连接还没订阅 down:直接断开(响应发不出去)。 - 连接后 30 秒内没完成握手:断开。
6.2 发送
{
"v": 1,
"type": "send",
"rid": "2",
"id": "018f...",
"to": {"kind": "endpoint", "id": "device-1"},
"body": {"enc": "utf8", "content_type": "text/plain", "data": "hello"},
"meta": {"k": "v"},
"delay_ms": 10000,
"offline": {"keep": true, "ttl_seconds": 86400},
"receipt": true,
"talk_password": ""
}
规则:
id1–64 字符,字母、数字、_、.、-,区分大小写。SDK 用 UUIDv7 的 36 字符形式。to.kind是endpoint或group。body.enc是utf8或base64。大小按解码后的字节,上限max_body_bytes。content_type最长 128 字符。utf8默认text/plain; charset=utf-8,base64默认application/octet-stream。meta是 JSON 对象,序列化后不超过 4096 字节。- 可以带
send_at_ms(指定发送时刻)或delay_ms,最多出现一个,都出现返回bad_request。都不出现时使用该端默认延迟;delay_ms显式为 0 表示立即发送。send_at_ms早于现在则立即发送。两者算出的发送时刻都不能晚于「现在 +max_schedule_seconds」。 offline.keep默认 false。为 true 时ttl_seconds默认 86400,最大max_ttl_seconds。receipt默认 true。- 已有有效授权时忽略
talk_password,不要因为又带了错误密码而失败。没有授权且对方设了密码时,密码正确则写入授权并继续,错误则talk_password_invalid,没带则talk_password_required。发给自己不校验对话密码。
成功:
{"v":1,"type":"resp","rid":"2","ok":true,"data":{"id":"018f...","send_at_ms":1750000010000,"state":"scheduled"}}
state 是返回时的消息状态:还没到发送时刻为 scheduled;立即发送的已在提交时分发,为 dispatched。防重命中时返回与第一次相同的 id、send_at_ms,state 为当前状态。
6.3 下行消息与确认
{
"v": 1,
"type": "msg",
"id": "018f...",
"from": "app-1",
"to": {"kind": "group", "id": "g_ab12cd34"},
"body": {"enc": "utf8", "content_type": "text/plain", "data": "hello"},
"meta": {},
"send_at_ms": 1750000010000
}
单聊的 to.kind 为 endpoint。群消息的 msg 帧对所有成员相同,只序列化一次。确认:
{"v":1,"type":"ack","rid":"3","from":"app-1","id":"018f..."}
- 服务器对
ack回resp。只要这条投递还是 pending 就生效,不要求是推给当前连接的那一次。 - 重复确认已收下的消息,返回成功,不改变结果。
- 确认到达时投递已经撤回、过期、丢弃或作废:
resp成功且data.result为当时的最终状态,SDK 按收到revoked处理。
推送窗口默认 32:同一连接上已推送未确认的消息达到 32 就暂停继续推,确认一笔再推下一笔。确认超时(默认 5 分钟)的处理见第 7.5 节。
6.4 撤回、状态、回执
撤回:
{"v":1,"type":"recall","rid":"4","id":"018f..."}
{"v":1,"type":"resp","rid":"4","ok":true,"data":{
"result": "partial",
"recalled": 2,
"accepted": 1,
"other": 0
}}
result:至少撤回一个且 accepted 为 0 时为 recalled;至少撤回一个且 accepted 大于 0 时为 partial;一个都没撤回为 failed。other 是已过期、丢弃、拒绝的数量,不影响 result。还没到发送时刻的消息撤回时没有投递行,result 为 recalled,各计数为 0。群很大时也不要在这个响应里列出全部成员,明细走状态查询或后台。
状态:
{"v":1,"type":"status","rid":"5","id":"018f...","cursor":"","limit":100}
只能查自己发出的消息。返回消息级 state(scheduled、dispatched、completed)和 reason(第 7.1 节),各投递状态的数量,以及按 cursor 分页的接收端明细 endpoint_id、state、reason,limit 最大 200。正文不返回。记录已按保留天数删掉时返回 not_found。防重标记还在但记录已删时,撤回返回 failed,状态返回 not_found。
回执下行:
{"v":1,"type":"receipt","receipt_id":"9001","id":"018f...","endpoint_id":"device-1","state":"accepted","reason":"","at_ms":1750000012000}
state 取 accepted、expired、dropped、rejected。撤回不发回执。消息级作废(发送者已退群、群已解散、群里没有其他成员)时写一条 endpoint_id 为空、state 为 rejected 的回执,reason 说明原因。客户端确认:
{"v":1,"type":"receipt_ack","rid":"6","receipt_id":"9001"}
回执也按窗口推送,默认 64。可能重复,SDK 按 receipt_id 去重。
已推送尚未确认就撤回或作废时,下行:
{"v":1,"type":"revoked","id":"018f...","from":"app-1","reason":"recalled"}
reason 取 recalled、expired、dropped、left_group、group_dissolved、endpoint_disabled、endpoint_deleted、sender_disabled、sender_deleted。这帧尽力送达,不单独要求确认。接收方若还没把消息交给应用,直接丢弃;已经交给应用但还没确认,则发出「已撤回」事件并不再确认;已经确认的忽略这帧。
6.5 在线、目录、订阅
{"v":1,"type":"presence.get","rid":"7","ids":["a","b"]}
{"v":1,"type":"directory.list","rid":"8","cursor":"","limit":100,"query":""}
{"v":1,"type":"presence.watch","rid":"9","ids":["a"],"all":false}
presence.get的ids最多 200 个。每项:id、online、since_ms。未知编号为not_found。- 目录按编号排序。
query非空时按编号前缀或名称包含匹配,不区分大小写。每项:id、name、online、online_since_ms、offline_since_ms、talk_password_set。limit最大 200。 presence.watch的ids最多 1000 个;all: true表示订阅全部。再次调用覆盖本连接的订阅。断线清空。
{"v":1,"type":"presence","id":"a","online":true,"at_ms":1750000000000}
上下线通知用 QoS 0 尽力推送,不落库;连接的发送队列满时会被丢弃。应用需要准确状态时重新查询。
6.6 对话密码与自己的资料
{"v":1,"type":"unlock","rid":"10","endpoint_id":"b","talk_password":"secret"}
{"v":1,"type":"self.get","rid":"11"}
{"v":1,"type":"self.update","rid":"12","name":"门口","default_delay_ms":10000}
{"v":1,"type":"self.talk_password","rid":"13","talk_password":""}
{"v":1,"type":"self.login_password","rid":"14","old_password":"","new_password":""}
{"v":1,"type":"self.logout","rid":"24"}
talk_password空字符串表示清除。修改对话密码时增加版本号,旧授权全部失效。- 修改登录密码要求旧密码。旧密码错误返回
unauthorized,并计入这个编号的登录密码失败次数(两种锁定都算)。成功时当前连接保留,响应里的data.session_token是换好的新令牌,旧令牌作废。新密码同样不能以nst_开头。 self.logout:服务器清空该端的会话令牌,回resp后断开连接,SDK 停止重连。default_delay_ms不超过max_schedule_seconds对应的毫秒数。unlock对没设密码的端直接成功。
注册不走 MQTT,见第 6.9 节。
6.7 群
{"v":1,"type":"group.create","rid":"15","id":"","name":"一组","members":[{"id":"b","talk_password":"secret"}]}
{"v":1,"type":"group.add","rid":"16","group_id":"g_ab12cd34","members":[{"id":"c","talk_password":""}]}
{"v":1,"type":"group.remove","rid":"17","group_id":"g_ab12cd34","endpoint_id":"c"}
{"v":1,"type":"group.leave","rid":"18","group_id":"g_ab12cd34"}
{"v":1,"type":"group.transfer","rid":"19","group_id":"g_ab12cd34","endpoint_id":"b"}
{"v":1,"type":"group.rename","rid":"20","group_id":"g_ab12cd34","name":"新名"}
{"v":1,"type":"group.dissolve","rid":"21","group_id":"g_ab12cd34"}
{"v":1,"type":"group.list","rid":"22","cursor":"","limit":100}
{"v":1,"type":"group.get","rid":"23","group_id":"g_ab12cd34","cursor":"","limit":100}
建群者成为群主和成员。group.create 和 group.add 的部分成员失败时群仍然创建(或其余成员照常加入),响应里列出失败的编号和 code,例如 talk_password_required、talk_password_invalid、endpoint_disabled、invalid_target。群编号已存在返回 id_taken。
group.list 分页返回我加入的群:编号、名称、群主、成员数。group.get 返回群信息和分页的成员(编号、名称、是否在线),limit 最大 200。非成员调用 group.get 返回 not_member。
群事件用 QoS 0 尽力推送,不落库,推给当前在线的成员;被移除或退出的那个端也收到自己的那条:
{"v":1,"type":"group_event","group_id":"g_ab12cd34","event":"member_added","endpoint_id":"c","at_ms":1750000000000}
event:member_added、member_removed、left、owner_changed、renamed、dissolved。
6.8 致命错误
{"v":1,"type":"fatal","reason":"disabled"}
reason:disabled、deleted、password_reset、protocol。发出后断开,SDK 停止重连。被顶号不走这个帧,走 MQTT 5 的 0x8E(第 5 节)。管理员「踢下线」也不发这个帧,只断开连接,SDK 会自动重连。
6.9 注册(HTTP)
POST /api/client/register
Content-Type: application/json
{"registration_code":"...","id":"","login_password":"","name":"门口","talk_password":""}
成功(HTTP 200):
{"ok":true,"data":{"id":"e_ab12cd34","login_password":"只在请求里留空时返回"}}
失败时用与 resp 相同的 error 结构:
| HTTP | code | 情况 |
|---|---|---|
| 400 | bad_request |
字段不合法 |
| 403 | registration_closed |
注册未开启 |
| 403 | registration_code_invalid |
安全码错误 |
| 409 | id_taken |
编号已存在 |
| 429 | rate_limited |
该 IP 输错安全码次数过多,已临时锁定 |
| 503 | busy |
服务器忙,可重试 |
规则:
- 只在
listen上提供。允许跨域:回Access-Control-Allow-Origin: *,处理OPTIONS预检,不读也不设 Cookie。 - 请求体不超过 4 KiB。字段规则同后台开通(PRD F01)。新端的
source记为self。 - 处理顺序:开关 → 该 IP 是否锁定 → 常量时间比较安全码(错误计入锁定)→ 字段校验 → 哈希密码 → 插入。编号冲突返回
id_taken。 - 日志只记结果、编号和来源 IP,不记安全码和密码。
SDK 从连接地址推出注册地址:wss://host:port/mqtt 对应 https://host:port/api/client/register,ws:// 对应 http://;裸 TCP 的 mqtts://host:port 对应 https://host:port/api/client/register,mqtt:// 对应 http://。
6.10 错误码
| code | 含义 |
|---|---|
bad_request |
字段不合法 |
not_ready |
还没握手 |
unauthorized |
旧密码错误等身份校验失败 |
forbidden |
没有群主权限等 |
not_found |
消息、群或查询的编号不存在 |
invalid_target |
发送目标不存在 |
conflict |
消息号相同但请求内容不同 |
id_taken |
编号已存在(注册、开通、建群) |
body_too_large |
正文超限 |
meta_too_large |
自定义字段超限 |
frame_too_large |
整帧超过 max_frame_bytes。只在 SDK 本地产生:服务器收到超过包上限的 MQTT 包会直接断开连接,回不了 resp |
response_too_large |
响应超过本连接声明的接收上限,换小一点的分页再查 |
talk_password_required |
需要对话密码 |
talk_password_invalid |
对话密码错误 |
rate_limited |
请求过快,或因多次输错被临时锁定 |
not_member |
不是群成员 |
owner_cannot_leave |
群主不能直接退出 |
group_full |
超过成员上限 |
quota_exceeded |
自己未完成的消息已达上限(max_pending_per_sender) |
endpoint_disabled |
对方已停用 |
registration_closed |
注册未开启(仅注册接口) |
registration_code_invalid |
注册安全码错误(仅注册接口) |
busy |
服务器过载,可重试 |
请求频率:除 ack、receipt_ack 外的请求共用一个桶,默认每个端每秒 50 个,突发容量 100,超出返回 rate_limited。
7. 状态与数据库
7.1 状态
消息:
| 状态 | 含义 |
|---|---|
scheduled |
已提交,没到发送时刻 |
dispatched |
已生成各接收端的投递 |
completed |
没有未完成的投递,正文已删 |
消息级 reason:正常分发的为空;发送前就结束的记原因:recalled、sender_left、no_recipients、group_dissolved、sender_disabled、sender_deleted,以及单聊目标被停用或删除时的 endpoint_disabled、endpoint_deleted。
投递:
| 状态 | 含义 |
|---|---|
pending |
还没收下。pushed_conn 为空表示还没推;非空表示已推给那个连接 |
accepted |
已确认 |
recalled |
撤回成功 |
expired |
保留的消息到期,reason 为 ttl |
dropped |
不保留的消息没送到,reason 为 offline(宽限结束仍离线)或 not_acked(推送后确认超时) |
rejected |
退群、解散、停用、删除、超限、发送者停用等,看 reason |
所有变更都用带条件的 UPDATE ... WHERE state = ...。受影响行数是 0 就表示别人先改了,按当前状态结束,不要覆盖。
7.2 写入
- 写连接
SetMaxOpenConns(1),由一个写 goroutine 独占。所有写操作排队交给它;它把排队的操作合并进一个事务(最多 256 个,或凑满 2 毫秒),提交成功后再逐个回复。每个操作用SAVEPOINT包住,单个失败只回滚自己。 - 必须合并:
synchronous=FULL下每次提交都要等磁盘落盘,而一条消息从提交、分发、推送标记、确认到回执要写好几次。一操作一事务,在普通云盘上撑不住每秒 200 条。 - 读用另一个连接池(多个连接),DSN 不带
_txlock=immediate。WAL 下读不挡写。
7.3 提交
一个写操作内:
- 解析请求,算出请求指纹。格式或大小不合法直接返回错误。
- 查防重行:同一发送方、消息号已存在时,指纹相同返回原消息的结果,指纹不同返回
conflict,都不再往下做。必须先查防重再做后面的校验:否则第一次其实已经提交成功、只是响应丢了,重试时却因为对方刚停用或刚改了对话密码而报错,发送方会误以为没发出去。 - 校验目标、延迟和定时、成员或授权;发送方未完成的消息数达到
max_pending_per_sender时返回quota_exceeded。 - 若带对了对话密码,写入或更新授权。
- 若发送方自己设了对话密码,且这是发给别人的单聊,给对方写一条回复授权(版本等于发送方当前对话密码版本)。群发不写。
- 插入消息和正文。
send_at按第 6.2 节计算。 - 插入防重行:发送方、消息号、请求指纹、时间。
send_at不晚于现在的,在同一操作里做第 7.4 节的分发。
提交后才发 resp,并唤醒有新投递的端的推送循环。
请求指纹是下列字段规范化后的 SHA-256:to.kind、to.id、body.enc、content_type、解码后的正文、meta(按键排序后序列化)、send_at_ms、delay_ms、offline.keep、offline.ttl_seconds、receipt。不含 talk_password 和 rid。
7.4 到点发送(分发)
调度循环按最早的 send_at 定时唤醒,提交新消息时也唤醒。按 send_at、seq 的顺序取 state = scheduled AND send_at <= now,逐条作为写操作处理:
- 解析接收者。
- 单聊:目标端。目标已停用则这条投递直接
rejected,原因endpoint_disabled。 - 群:发送时刻的成员,去掉发送者。已停用的成员投递直接
rejected,原因endpoint_disabled。发送者已不在群里:不生成投递,消息completed,reason = sender_left。群里没有其他成员:同样处理,reason = no_recipients。
- 单聊:目标端。目标已停用则这条投递直接
- 每个接收者插入
pending投递,带上send_at和keep:- 接收者排队未收下的投递已达
max_pending_per_receiver:这条投递直接rejected,原因queue_full。 - 对方有连接(含握手中):
keep时expire_at = now + ttl;不keep时expire_at为空。推送循环马上推。 - 对方没有连接且
keep:expire_at = now + ttl。 - 对方没有连接且不
keep:从offline_since算起没超过宽限,expire_at = offline_since + grace;否则投递直接dropped,原因offline。
- 接收者排队未收下的投递已达
- 消息改为
dispatched。没有未完成的投递则completed并删除正文。需要回执的按第 7.6 节写。
now 用服务器时钟。保留时间从这次分发的时间算,不从当初提交的时间算。
7.5 推送
每个已握手连接一个推送循环:
- 取该端
pending且pushed_conn为空的投递,按send_at、seq排序,数量不超过「窗口减去已推未确认」。 - 算出整条
msg帧的字节数。超过该连接的max_receive_bytes,或超过 CONNECT 里声明的 Maximum Packet Size 减去 128 字节(主题和包头),投递改为rejected、原因too_large,不发布。 - 用条件更新写入
pushed_conn、pushed_at、attempts+1(WHERE state='pending' AND pushed_conn IS NULL),提交后再发布。推送、清理、撤回三方靠这个条件更新互斥。崩溃后残留的标记在启动时统一清掉(第 7.8 节)。 - 发布失败或
OnPublishDropped:若pushed_conn仍是这个连接,清掉,1 秒后再推。 - 确认超时(默认 5 分钟)由这个循环在内存里计时:
- 不
keep:投递改为dropped,原因not_acked,尽力发revoked。 keep且已过expire_at:改为expired,尽力发revoked。- 否则清掉
pushed_conn重推。
- 不
大帧(超过 64 KiB)全局同时在发的不超过 64 个:拿到名额才发布,客户端回 PUBACK(mochi 的 OnQosComplete)、确认超时或连接断开时释放。否则一条 256 KiB 的消息发给 1000 人的群,每个连接各编码一份,服务器会瞬时多占几百 MB 内存。
其他下行帧发布前也检查大小:resp 超限改发 response_too_large 错误;回执、事件等超限直接丢弃并记日志(它们远小于 1024 字节,正常不会发生)。
握手完成时:online_since 设为现在;该端不 keep 的 pending 投递清空 expire_at(在线期间不按宽限作废,排队等窗口也不会被丢);然后开始推送。
任一连接断开时(包括被新连接顶掉的旧连接),在一个写操作里:
- 断开的是该端的当前连接时,先做三件事:
offline_since设为现在并推送下线通知;该端不keep的pending投递,expire_at设为now + grace(原值更晚则保留原值);keep且pushed_conn是这个代号的,expire_at早于now + grace时延长到now + grace。 - 不论是不是当前连接,都清掉
pushed_conn等于这个代号的标记,丢掉这个连接的确认计时。该端已经有新连接时,唤醒新连接的推送循环,把这些投递重推过去。只按代号清,不会碰到新连接写下的标记。
清理循环大约每秒一次:pending、未推送、expire_at <= now 的投递,keep 为 true 改 expired(原因 ttl),否则改 dropped(原因 offline)。已推送的由推送循环的确认超时处理,清理循环不管。
7.6 确认、撤回、回执、收尾
确认:按 (from, id) 找到消息的 seq,UPDATE deliveries SET state='accepted' WHERE seq=? AND endpoint_id=? AND state='pending'(endpoint_id 是确认方)。成功则写回执并尝试收尾;失败则读出现状返回。
撤回:
- 消息仍是
scheduled:改为completed,reason = recalled,删正文,响应recalled,计数为 0。 - 已分发:把仍是
pending的投递改为recalled,其中已推送的尽力发revoked。条件更新失败的是刚被确认或刚结束的。按第 6.4 节算result。
回执:投递进入 accepted、expired、dropped、rejected 时写回执(消息要求回执、且发送方仍存在时)。recalled 不写。消息级作废也写一条(第 6.4 节)。发送方不在线则回执留到 receipt_retention_days(默认 7 天)。
收尾:没有 pending 投递时,消息 completed,删除正文行。记录保留天数是 0 则同一事务删除消息和投递行。回执表独立保留,不随消息行删除。
对话密码版本变化只影响之后的新提交,已经 scheduled 或 pending 的不追溯作废。
作废(各自在一个写操作里完成;已推送的投递改状态后尽力发 revoked):
| 事件 | 处理 |
|---|---|
| 成员退群或被踢 | 这个成员在该群消息里的 pending 投递改 rejected,原因 left_group |
| 解散群 | 该群消息的 pending 投递改 rejected,原因 group_dissolved;发往该群的 scheduled 消息改 completed、reason = group_dissolved,要回执的写消息级回执 |
| 停用端 X | 清空 X 的会话令牌;发给 X 的 pending 投递改 rejected(endpoint_disabled);发给 X 的 scheduled 单聊改 completed,要回执的写回执;X 发出的 scheduled 消息改 completed(sender_disabled),X 发出的消息的 pending 投递改 rejected(sender_disabled),这两项不写回执 |
| 删除端 X | 同停用,原因换成 endpoint_deleted、sender_deleted;再退出所有群并处理群主;删除 X 相关的双向授权、X 的待送回执、X 的防重行、X 发出的消息记录 |
每小时清理一次:删除过期的记录、回执和防重行。防重行只删「超过 idempotency_hours 且对应消息记录已不存在」的。分批删除(每批几千行),不要一条语句删几百万行卡住写队列。清理后执行一次 PRAGMA wal_checkpoint(TRUNCATE),让已删的正文尽快从 WAL 文件里消失。
7.7 表
CREATE TABLE endpoints (
id TEXT PRIMARY KEY,
name TEXT NOT NULL DEFAULT '',
remark TEXT NOT NULL DEFAULT '',
source TEXT NOT NULL DEFAULT 'admin', -- admin | self
login_hash TEXT NOT NULL,
talk_hash TEXT,
talk_version INTEGER NOT NULL DEFAULT 0,
default_delay_ms INTEGER NOT NULL DEFAULT 0,
enabled INTEGER NOT NULL DEFAULT 1,
created_at INTEGER NOT NULL,
online_since INTEGER,
offline_since INTEGER,
session_hash TEXT, -- 当前会话令牌的 SHA-256;空表示没有有效会话
session_issued_at INTEGER,
session_used_at INTEGER
);
CREATE TABLE settings (
key TEXT PRIMARY KEY, -- admin_password_hash | registration_enabled | registration_code
value TEXT NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE talk_grants (
sender_id TEXT NOT NULL,
target_id TEXT NOT NULL,
target_talk_version INTEGER NOT NULL,
kind TEXT NOT NULL, -- password | reply
created_at INTEGER NOT NULL,
PRIMARY KEY (sender_id, target_id)
);
CREATE TABLE groups (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
owner_id TEXT NOT NULL,
created_at INTEGER NOT NULL
);
CREATE TABLE group_members (
group_id TEXT NOT NULL,
endpoint_id TEXT NOT NULL,
joined_at INTEGER NOT NULL,
PRIMARY KEY (group_id, endpoint_id)
);
CREATE TABLE messages (
seq INTEGER PRIMARY KEY,
id TEXT NOT NULL,
sender_id TEXT NOT NULL,
dest_kind TEXT NOT NULL,
dest_id TEXT NOT NULL,
meta TEXT NOT NULL DEFAULT '{}',
content_type TEXT NOT NULL,
body_enc TEXT NOT NULL,
send_at INTEGER NOT NULL,
keep INTEGER NOT NULL,
ttl_seconds INTEGER NOT NULL DEFAULT 0,
receipt INTEGER NOT NULL,
state TEXT NOT NULL,
reason TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL,
UNIQUE (sender_id, id)
);
CREATE TABLE message_bodies (
seq INTEGER PRIMARY KEY REFERENCES messages(seq) ON DELETE CASCADE,
body BLOB NOT NULL
);
CREATE TABLE deliveries (
seq INTEGER NOT NULL REFERENCES messages(seq) ON DELETE CASCADE,
endpoint_id TEXT NOT NULL,
send_at INTEGER NOT NULL,
keep INTEGER NOT NULL,
state TEXT NOT NULL,
reason TEXT NOT NULL DEFAULT '',
expire_at INTEGER,
pushed_conn TEXT,
pushed_at INTEGER,
attempts INTEGER NOT NULL DEFAULT 0,
updated_at INTEGER NOT NULL,
PRIMARY KEY (seq, endpoint_id)
);
CREATE TABLE receipts (
receipt_id INTEGER PRIMARY KEY,
sender_id TEXT NOT NULL,
msg_id TEXT NOT NULL,
endpoint_id TEXT NOT NULL DEFAULT '',
state TEXT NOT NULL,
reason TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL,
acked INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE send_keys (
sender_id TEXT NOT NULL,
msg_id TEXT NOT NULL,
request_sha256 BLOB NOT NULL,
created_at INTEGER NOT NULL,
PRIMARY KEY (sender_id, msg_id)
);
CREATE TABLE admin_sessions (
token_hash TEXT PRIMARY KEY,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL
);
CREATE TABLE api_tokens (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
token_hash TEXT NOT NULL UNIQUE,
enabled INTEGER NOT NULL DEFAULT 1,
created_at INTEGER NOT NULL,
last_used_at INTEGER
);
CREATE TABLE schema_migrations (
version INTEGER PRIMARY KEY,
applied_at INTEGER NOT NULL
);
CREATE INDEX idx_messages_due ON messages(state, send_at);
CREATE INDEX idx_messages_dest ON messages(dest_kind, dest_id, state);
CREATE INDEX idx_messages_created ON messages(created_at);
CREATE INDEX idx_messages_sender ON messages(sender_id, created_at);
CREATE INDEX idx_messages_sender_state ON messages(sender_id, state);
CREATE INDEX idx_deliveries_outbox ON deliveries(endpoint_id, state, send_at, seq);
CREATE INDEX idx_deliveries_expire ON deliveries(state, expire_at);
CREATE INDEX idx_receipts_outbox ON receipts(sender_id, acked, receipt_id);
CREATE INDEX idx_talk_grants_target ON talk_grants(target_id);
CREATE INDEX idx_group_members_endpoint ON group_members(endpoint_id);
正文和消息分开,避免后台扫记录时读到正文。后台查询只访问 messages 和 deliveries。
打开库的 DSN 必须用 modernc 的写法,否则 WAL 和超时会被静默忽略:
file:<data_dir>/nixmsg.db?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)&_pragma=synchronous(FULL)&_pragma=foreign_keys(ON)&_pragma=secure_delete(ON)&_txlock=immediate
- 读连接池去掉
_txlock=immediate。 secure_delete(ON)让删除的正文被覆盖,而不是只把页标成空闲。synchronous=FULL是为了进程崩溃和断电后尽量不丢已返回成功的提交。可以配置成NORMAL,运维说明里写清断电可能丢掉最后几秒。
迁移是嵌入的有序 SQL。启动时若有未应用的版本,先 VACUUM INTO 一份 <data_dir>/backup/pre-migrate-<时间>.db,再迁移。失败则退出,不带半新半旧的库继续服务。
内存里的连接表:编号、连接代号、是否已握手、max_receive_bytes、CONNECT 声明的 Maximum Packet Size、订阅的上下线编号、下行窗口计数、各已推送投递的确认计时。重启后全部由客户端重连重建。
7.8 启动恢复
服务启动后、开始接受连接前,在一个事务里:
- 所有
pending投递清空pushed_conn(连接代号不跨进程),expire_at不早于「启动时间 + grace」,原值为空的也设成这个值。也就是把重启当作所有端刚断线:端在宽限内重连就能收到,包括保留期在停机期间已过的消息(PRD D23)。 - 停机前在线的端(
online_since晚于offline_since,或offline_since为空而online_since不为空),offline_since设为启动时间。 - 停机期间到点的
scheduled消息由调度循环正常处理,立即分发。
停止:收到停止信号后先停止接受新连接,等写队列里已排队的操作提交完(最多 10 秒),再断开所有连接并退出。
写库失败(例如磁盘满):对应请求返回 busy,/readyz 返回失败并记错误日志。不要把没提交成功的操作当成功回给客户端。
8. 管理接口
Cookie 名 nixmsg_admin,HttpOnly,SameSite=Lax,HTTPS 时(含经受信任代理转来的 HTTPS)加 Secure。会话服务端只存令牌哈希,默认 12 小时。
网页登录(Cookie)发起的改变状态的请求必须带请求头 X-Nixmsg-Request: 1,否则 403。这是为了挡住浏览器跨站表单。管理接口不开跨域。
API 令牌,给接入方后台用程序调用管理接口:
- 管理员在后台创建令牌并填名称。令牌形如
nxm_加 32 字节随机数的 base64url 编码,只在创建时显示一次;库里只存 SHA-256(令牌本身是高熵随机数,不需要 argon2)。 - 请求带
Authorization: Bearer <令牌>。带令牌的请求不看 Cookie,也不要求X-Nixmsg-Request头。 - 权限等同管理员,但不能调用
/api/admin/password和/api/admin/tokens:改管理员密码和管理令牌只能在网页登录后做。 - 令牌不过期,停用或删除后立即失效。
last_used_at在内存里合并,最多每分钟写一次库。 - 用错误令牌请求,按来源 IP 计入管理员登录锁定。
- 令牌只在管理接口所在的监听上有效:后台单独监听时,接入方后台要能访问
admin_listen。
每个改变状态的管理请求写一条结构化日志:操作者(admin 或 token:<名称>)、动作、对象编号、结果、来源 IP。不写密码、令牌和正文。
| 方法 | 路径 | 作用 |
|---|---|---|
| POST | /api/admin/login |
用户名固定 admin,密码来自初始化 |
| POST | /api/admin/logout |
作废会话 |
| GET | /api/admin/me |
当前管理员 |
| POST | /api/admin/password |
改管理员密码,要旧密码 |
| GET | /api/admin/overview |
数量和版本 |
| GET/POST | /api/admin/endpoints |
列表(可按 source 筛选)、开通 |
| POST | /api/admin/endpoints/import |
批量开通 |
| POST | /api/admin/endpoints/batch |
多选批量停用、启用、删除 |
| GET/PATCH/DELETE | /api/admin/endpoints/{id} |
详情、修改、删除 |
| POST | /api/admin/endpoints/{id}/kick |
踢下线(只断开,不阻止重连) |
| POST | /api/admin/endpoints/{id}/reset-login-password |
重置,响应里的新密码只出现一次;会话令牌作废,当前连接收到 fatal 后断开 |
| POST | /api/admin/endpoints/{id}/unlock |
解除这个编号的登录锁定(两种都清) |
| PUT | /api/admin/endpoints/{id}/talk-password |
设置或清除,同样增加对话密码版本号,旧授权失效 |
| GET/PUT | /api/admin/registration |
注册设置。GET 返回 enabled、code、updated_at;PUT 可改 enabled,可传 code,或传 generate: true 让服务器生成 16 位 |
| GET/POST | /api/admin/tokens |
令牌列表(名称、状态、创建时间、最近使用时间,不含令牌本身)、创建(响应里的令牌只出现一次)。只接受网页登录 |
| PATCH/DELETE | /api/admin/tokens/{id} |
改名、停用或启用、删除。只接受网页登录 |
| GET/POST | /api/admin/groups |
列表、创建 |
| PATCH/DELETE | /api/admin/groups/{id} |
改名、解散 |
| POST | /api/admin/groups/{id}/members |
加人,后台不要求对话密码 |
| DELETE | /api/admin/groups/{id}/members/{endpointId} |
移除 |
| POST | /api/admin/groups/{id}/transfer |
转让 |
| GET | /api/admin/messages |
记录列表,筛选见 PRD |
| GET | /api/admin/messages/{seq} |
各接收端结果,无正文 |
| GET | /api/admin/settings |
只读运行参数 |
批量开通:POST /api/admin/endpoints/import 接收 CSV(UTF-8,可带 BOM),表头 id,name,login_password,talk_password,default_delay_seconds,remark,最多 1000 行。先整体校验,任何一行出错就一行都不建,返回出错的行号和原因;全部通过才在一个事务里创建。响应里给出每行的编号和本次生成的密码,前端提供一次性下载。密码哈希在第 12 节的并发池里算,1000 行可能要十几秒:前端显示进度,服务端这个接口的写超时要够长。
所有 JSON 响应在序列化前去掉正文。测试里对管理接口的响应做一次「不含 body 字段」的检查。
nixmsg serve 发现库里没有管理员密码时拒绝启动,提示先运行 nixmsg admin init。不要自动生成密码再打到日志里。admin init 生成 20 位密码,只在终端打印一次,库里存哈希;已经初始化过的库拒绝执行,改用 admin set-password。管理员密码至少 12 位,admin set-password 和 /api/admin/password 都要校验。
9. SDK
四种语言同一行为。应用只调用下面这些概念,不接触主题和 JSON。方法名按各语言习惯调整,含义不变。
register(url, registrationCode, options) -> {id, loginPassword?} // 静态方法,不需要先连接
connect(url, endpointId, credential, options) // credential 为 {password} 或 {sessionToken}
onSession(handler) // 拿到新的会话令牌时回调,应用负责保存
send(to, body, options) -> SendResult
recall(id) -> RecallResult
status(id, cursor?)
unlock(endpointId, talkPassword)
onMessage(handler)
ack(message) // 仅手动确认模式
onReceipt / onRevoked / onPresence / onGroupEvent
onConnection(state) // connecting online reconnecting offline kicked auth_failed
presence(ids)
directory(cursor, query)
watchPresence(ids | all)
getSelf / updateSelf / setTalkPassword / changeLoginPassword(old, new)
groups... // 与第 6.7 节一一对应
logout() // 作废会话令牌并断开,不再重连
close()
send 的 options 含 sendAt、delay、keep、ttl、receipt、talkPassword、contentType、meta。register 的 options 含 id、loginPassword、name、talkPassword。
连接:
- WebSocket 到
路径/mqtt,子协议mqtt。裸 TCP 只在选项里显式打开时使用。浏览器页面是 HTTPS 时,服务器也必须用 TLS:浏览器不允许 HTTPS 页面连ws://或请求http://。 - 每次连接都带 Clean Start,包括重连。Go 的 autopaho 只在第一次连接使用
CleanStartOnInitialConnection,必须用ConnectPacketBuilder在每一次连接把 Clean Start 设为 true,会话过期间隔设为 0。 - 订阅 down,发
hello,等到成功响应。连接超时默认 30 秒(autopaho 默认 10 秒,要改):服务器重启后大量端同时重连,密码校验要排队。 - 重连退避:1 秒起,加倍,上限 30 秒,加减 30% 抖动。稳定在线 60 秒后把退避恢复到 1 秒。
- 会话令牌:用密码连上后,把握手响应里的
session_token通过onSession交给应用,之后自动重连都用这个令牌;应用下次启动可以直接用保存的令牌connect。SDK 自己不落盘保存令牌和密码。 - 停止重连的情况:收到
fatal;被0x8E接管;CONNACK 为0x86用户名或密码错误、0x87未授权、0x8A已封禁(3.1.1 为返回码 4、5)。这时事件auth_failed带原因:用令牌连接被拒为session_invalid(在别处登录过、令牌过期、密码被改或重置、已退出登录、编号被停用或删除),用密码被拒为bad_credentials。其他失败,包括网络错误、0x88服务器不可用、0x89服务器忙、连接被直接关闭,都继续重连。
发送:
- 发送队列在内存,默认最多 1000 条,包括已发出但没收到
resp的。连不上时send入队;重连后按原消息号、原请求内容再交。 sendAt在调用send时就换算成send_at_ms,重交时不重算,否则请求指纹变了会被当成冲突。- 同时在途(已发出、没收到
resp)的请求不超过 100 个。发送收到rate_limited时按退避自动重交,不算失败;其他请求收到rate_limited直接返回给应用。 - 进程退出则队列丢失。SDK 停止重连(被踢、认证失败、
logout、close)时,队列里的发送全部以对应错误结束。 - 其他调用在未握手时返回未连接。
接收:
- 按「from + id」记录处理状态,默认容量 10000,超出丢最旧的:
- 已确认过的再次到达:直接再发一次
ack,不交给应用。上次的ack可能丢了,不回ack服务器会一直重推。 - 已交给应用、还没确认的再次到达:忽略。
- 自动模式下回调抛错:删掉这条的记录,等服务器重推时重新处理。
- 已确认过的再次到达:直接再发一次
- 回调串行。
- 自动模式:回调正常返回后发
ack。回调抛错则不发,打出错误,等服务器重推:保留的消息在保留期内大约每 5 分钟重推一次,不保留的消息确认超时后被服务器丢弃。永久性失败要改用手动确认并自己ack。 - 手动模式:只有应用调用
ack才确认。 ack的resp里data.result不是accepted时,按收到revoked处理。
时间:sendAt 使用「本机时间 + 服务器偏差」。偏差 = server_time_ms −(发 hello 的本机时刻 + 收到响应的本机时刻)/ 2,每次握手更新。
本地检查:正文超限返回 body_too_large,与服务器相同;整帧超过握手给出的 max_frame_bytes 返回 frame_too_large。服务器收到超过包上限的 MQTT 包会直接断开连接,不会回 resp;不在本地拦住的话,SDK 重连后重交同一帧会无限循环。
注册:按第 6.9 节调用 HTTP 接口。返回的生成密码只出现这一次,SDK 原样交给应用,不自己保存。
各语言包装:
| 语言 | 包名 | 获取方式 | 接口形态 |
|---|---|---|---|
| Go | 模块 git.asio.asia/nixevol/NixMsg/sdk/go,包名 nixmsg |
go get git.asio.asia/nixevol/NixMsg/sdk/go@v0.1.0(仓库打 sdk/go/v0.1.0 标签) |
context,方法返回 error |
| JS/TS | @nixevol/nixmsg |
Gitea npm 仓库 https://git.asio.asia/api/packages/nixevol/npm/ |
Promise,ESM 和 CJS 都发 |
| Python | nixmsg(导入名也是 nixmsg) |
Gitea PyPI 仓库 https://git.asio.asia/api/packages/nixevol/pypi/simple/ |
同步为主;asyncio 包装放在同一包 |
| Java | asia.asio.nixmsg:nixmsg-sdk(Java 包 asia.asio.nixmsg) |
Gitea Maven 仓库 https://git.asio.asia/api/packages/nixevol/maven |
CompletableFuture。长连接由 Android 应用自己放到前台服务 |
- Go SDK 放在
sdk/go,但go是关键字,包名用nixmsg。它是仓库里单独的 Go 模块,版本标签带目录前缀(sdk/go/v0.1.0)。仓库公开,接入方直接go get;公共代理访问不到 git.asio.asia 时,设GOPRIVATE=git.asio.asia直连。 - 接入方的配置:npm 在
.npmrc里写@nixevol:registry=https://git.asio.asia/api/packages/nixevol/npm/;pip 用--index-url指向上面的 PyPI 地址;Gradle 或 Maven 加上面的仓库地址。包是公开的,下载不用登录。 - 包信息里的许可证:npm 写
"license": "SEE LICENSE IN LICENSE";PyPI 用license = { file = "LICENSE" }并加分类License :: Other/Proprietary License;Maven 的<licenses>写Proprietary。每个包都带上仓库根目录的LICENSE。 - 发布只在阶段 3 由总控执行(TASKS.md Z3)。用 Gitea 个人访问令牌,只给
package读写权限;令牌存进 MemRelay 密码库,只写在本机用户级配置里(用户目录下的.npmrc、.pypirc、.gradle/gradle.properties),不进仓库。
接入清单(每种语言一份集成测试,对真实服务器二进制):
- 登录并收到握手参数。
- 单聊收发,应用回调只有一次。
- 断开期间发送,重连后送达且不重复。
- 发送队列在进程内重试同一消息号;模拟
ack丢失后服务器重推,SDK 自动再确认且不重复回调。 - 10 秒延迟内撤回,对方无回调。
- 定时 2 秒后送达。
- 离线保留:对方晚 1 秒上线能收到;保留 1 秒且 3 秒后才上线则收不到,发送方收到过期回执。
- 建群、两人收到同一内容、发送者自己不收到。
- 对话密码:拒绝、解锁、改密后失效、对方先发则可以回复。
- 第二处登录把第一处踢下线且第一处不再重连。
- 超过 256 KiB 在本地失败。
- 注册:关闭时失败;安全码错误失败;成功后能登录;换码后旧码失败、已注册的端照常登录。
- 改登录密码后用新密码重连成功、旧密码失败;认证失败后 SDK 不再重连。
- 仅 JS:浏览器页面和服务器不同域名时,注册和 WebSocket 连接都成功。
- 会话令牌:密码登录后收到令牌;断开后用令牌重连成功;另一处用密码登录后,原来那处用旧令牌重连被拒(
session_invalid)且不再重连;logout后令牌失效。
10. 无 SDK 的设备
使用 MQTT 5 客户端(例如 ESP-IDF 的 mqtt,走 WebSocket 或 TCP)。ClientID 和用户名是端编号,密码是登录密码。订阅 nix/c/{编号}/down,向 nix/c/{编号}/up 发第 6 节的 JSON。收到 msg 后处理完发 ack。用「from + id」去重,已确认过的再到达时再回一次 ack。在 hello 里按自己的接收缓冲声明 max_receive_bytes(不小于 1024)。
不要开持久会话,不要订阅通配符,不要发保留消息。能发 HTTP 请求的设备可以按第 6.9 节自助注册,不能的由管理员开通。设备可以每次都用登录密码连接(每次都算新登录、会换令牌),也可以把握手拿到的 session_token 存起来,重连时当作密码用。3.1.1 客户端看不到被顶号的原因;每次都用密码登录的设备不要和别的设备共用编号,否则会来回互踢。
11. 配置与部署
11.1 配置文件
listen: ":7443" # 端接入端口
admin_listen: "" # 后台单独监听,如 "127.0.0.1:7444";空表示后台也在 listen 上
tls:
cert_file: ""
key_file: ""
allow_plaintext: false
trusted_proxies: [] # 反向代理的地址段,如 ["127.0.0.1/32"]
data_dir: "./data"
limits:
max_body_bytes: 262144 # 只能调小
max_meta_bytes: 4096
max_frame_bytes: 786432
max_ttl_seconds: 2592000
max_schedule_seconds: 31536000
max_group_members: 1000
grace_seconds: 60
ack_timeout_seconds: 300
delivery_window: 32
receipt_window: 64
requests_per_second: 50 # 除 ack、receipt_ack 外,突发容量为 2 倍
max_pending_per_sender: 10000 # 等待发送或投递中的消息数,群消息算一条;0 表示不限
max_pending_per_receiver: 10000 # 排队未收下的投递数;0 表示不限
session_idle_days: 30 # 会话令牌多少天没用就失效;0 表示不失效
record_retention_days: 7 # 0 表示完成后不留记录
idempotency_hours: 24
receipt_retention_days: 7
sqlite_synchronous: FULL # 或 NORMAL
metrics:
token: "" # 后台和端共用端口时,访问 /metrics 要带它;空表示共用端口时不提供
log:
level: info
注册开关、注册安全码和 API 令牌存在库里,在后台修改,不在配置文件里。
环境变量 NIXMSG_CONFIG 指向配置文件,默认 ./config.yaml。check-config 要拒绝 max_body_bytes 大于 262144 的配置。
11.2 命令与构建
命令:
| 命令 | 作用 |
|---|---|
nixmsg serve |
启动 |
nixmsg admin init |
生成管理员密码(只能执行一次) |
nixmsg admin set-password |
重置管理员密码 |
nixmsg backup --out file.db |
对运行中的库 VACUUM INTO |
nixmsg check-config |
检查配置 |
nixmsg healthcheck |
请求本机的 /healthz,成功退出码 0;给 Docker 健康检查用(镜像里没有 curl) |
构建:Taskfile 先在 web/ 里 pnpm install、pnpm build(产物在 web/dist),再 CGO_ENABLED=0 go build,目标 linux/amd64、linux/arm64、windows/amd64。
运行目录:
config.yaml
data/nixmsg.db
data/backup/
- 程序本身不做定时备份。用 1Panel 计划任务或 cron 定时执行
nixmsg backup --out <路径>;Docker 部署时执行docker compose exec nixmsg /nixmsg backup --out /data/backup/<文件名>.db。旧备份按需要自己清理。 - 备份文件包含备份当时还没送完的正文,要按敏感数据保管。
- systemd 示例只需要
ExecStart、WorkingDirectory、Restart=on-failure。Windows 上用 WinSW、NSSM 之类的工具托管成服务,本期不在程序里内置服务安装。 - 服务器开启 NTP 对时。
- 对公网开放时建议设置
admin_listen,让后台只监听内网或本机地址。 - 运维说明写清:服务器时钟被人为大改时,定时消息按新时钟触发。
11.3 证书与 1Panel
生产服务器装有 1Panel 时,证书由 1Panel 申请和续签,程序只读文件:
- 在 1Panel 的证书管理里申请证书(DNS 账户或 HTTP 验证),打开自动续签。
- 勾选「推送证书到本地目录」,指定一个目录,例如
/opt/nixmsg/certs。1Panel 在申请和每次续签后写入fullchain.pem和privkey.pem。 - 配置
tls.cert_file: /opt/nixmsg/certs/fullchain.pem、tls.key_file: /opt/nixmsg/certs/privkey.pem。程序每小时检查一次,续签后自动换上新证书,不用重启。 - 私钥要让 NixMsg 进程能读:在 1Panel 的「申请证书后执行脚本」里调整
privkey.pem的属主或权限。Docker 部署时容器用户是 nonroot(uid 65532)。
不要用 1Panel 的 OpenResty 反向代理来终止 TLS:它的 TCP/UDP 代理不做 TLS,裸 TCP 设备就没法加密,而且端会被拆到两个端口。只有全部端都走 WebSocket 时,才可以改成「OpenResty 终止 HTTPS/WSS,NixMsg 只监听本机明文」;这时要配 trusted_proxies,反向代理里设置 proxy_http_version 1.1、Upgrade、Connection,并用 Host $http_host(1Panel 默认生成的 $host 会丢端口)。
开发环境可以不配证书跑明文,或用 mkcert 之类的工具生成本机信任的证书。
11.4 Docker
- 镜像多阶段构建:Node 构建前端 → Go 编译(嵌入前端)→
gcr.io/distroless/static的nonroot变体。入口是/nixmsg。发布linux/amd64、linux/arm64两个架构。 - 镜像名
git.asio.asia/nixevol/nixmsg。每次发布打版本号标签(如0.1.0)和latest,用docker buildx一次推送两个架构。推送前docker login git.asio.asia,用和 SDK 发布同一个 Gitea 令牌。镜像是公开的,部署服务器拉取不用登录。 - 容器里的路径:配置
/etc/nixmsg/config.yaml(NIXMSG_CONFIG指向它),数据目录/data(配置里写data_dir: /data),证书目录/certs只读挂载。 - 首次使用先执行
docker compose run --rm nixmsg admin init,记下只打印一次的管理员密码,再docker compose up -d。 - 容器以 uid 65532 运行:挂载的数据目录要可写,证书私钥要可读。
- 装有 1Panel 的服务器可以直接在它的容器编排里使用下面的 compose 文件。
services:
nixmsg:
image: git.asio.asia/nixevol/nixmsg:0.1.0
restart: unless-stopped
command: ["serve"]
environment:
NIXMSG_CONFIG: /etc/nixmsg/config.yaml
ports:
- "7443:7443"
volumes:
- ./config.yaml:/etc/nixmsg/config.yaml:ro
- ./data:/data
- /opt/nixmsg/certs:/certs:ro
healthcheck:
test: ["CMD", "/nixmsg", "healthcheck"]
interval: 30s
timeout: 5s
retries: 3
12. 安全
- 登录密码、对话密码、管理员密码都用 argon2id,参数:内存 19 MiB、迭代 2、并行 1(OWASP 密码存储速查表给出的最低配置)。用 PHC 字符串格式保存,参数随哈希一起存;以后调高参数时,在下次校验成功后重新哈希。比较用常量时间。
- 所有 argon2 计算走一个并发池,默认大小等于 CPU 核数,其余排队。每次计算约占 19 MiB 内存,池大小决定峰值内存。服务器重启后的大批重连基本都用会话令牌,只是一次哈希查表;只有用密码登录的连接才排队算 argon2(SDK 连接超时 30 秒)。
- 会话令牌是 32 字节随机数,库里只存 SHA-256,比较用常量时间;不绑定 IP;登录密码不能以
nst_开头。 - 注册安全码明文存库,只在后台注册设置里返回;比较用
crypto/subtle.ConstantTimeCompare。 - 日志、崩溃转储、管理接口里不出现正文、任何密码和注册安全码。
- 端不能订阅别人的 down,不能向上行以外的主题发布。
- 后台 Cookie 按第 8 节。对公网开放时用
admin_listen把后台放到内网或本机地址。 - 生产配置了证书就不要开
allow_plaintext。对公网开放注册时必须配证书:注册请求里有安全码和密码。 - API 令牌只存 SHA-256,只在创建时显示一次,不能用来改管理员密码或管理令牌。
/metrics只有计数和耗时,不含正文、编号等明细;后台和端共用端口时必须带metrics.token。
13. 测试
- 状态机用表驱动测试:提交、到点、宽限、保留到期、确认超时(保留和不保留两种)、确认与撤回竞态、退群、解散、停用、删除、防重与冲突、改密不影响旧消息、启动恢复。
- 集成测试起真实进程和真实 MQTT 客户端,覆盖 PRD 第 10 节。端口按两种配置各跑一遍:只有
listen;listen加admin_listen。 - 弱网:用 toxiproxy 做延迟、带宽、超时和断开。Linux 上再用 netem 做 20% 丢包。保留期内消息全部送达,应用层去重后无重复。
- 崩溃:提交返回成功后杀掉进程,重启后续传;推送中杀掉进程,重启后不保留的消息在宽限内重连仍能送到。
- 下行超限:声明
max_receive_bytes: 1024的连接收到too_large,发送方收到回执,连接不断、不循环重推。 - 锁定:登录、对话密码、注册安全码、管理员登录各自到达阈值后被拒;换一个 IP 用密码登录不受影响;多个 IP 轮流猜同一编号的登录密码,达到按编号计的总数后暂停密码登录,但已登录设备用令牌重连不受影响,
unlock后恢复;多个账号轮流猜同一个端的对话密码,达到按对方计的总数后被暂停。来源 IP 可以通过受信任代理的X-Forwarded-For模拟。 - 会话令牌:密码登录拿到令牌;用令牌重连不换令牌;另一处用密码登录后,旧令牌被拒、旧连接收到
0x8E;改密码返回新令牌;重置密码、停用、删除、self.logout后令牌失效;闲置超过session_idle_days后失效。 - 配额:发送方未完成消息达到上限后返回
quota_exceeded;接收端排队满后新投递为queue_full,发送方收到回执。 - 服务器故障:数据库不可读时,客户端连接被直接关闭,而不是收到「用户名或密码错误」。
- 管理接口响应体搜索不到测试正文。
- API 令牌:带令牌能调用管理接口;调用
/api/admin/password、/api/admin/tokens被拒;停用后立即失效。 /metrics:后台单独监听时可直接抓取;共用端口时不带令牌返回 401,没配令牌返回 404。- 证书重载:替换证书文件后 1 小时内新连接用上新证书,已有连接不断。
- Docker:两个架构的镜像都能按第 11.4 节初始化、启动并通过健康检查。
- 后台主路径用浏览器自动化走一遍:登录、开通、开启注册并设置安全码、建群、看到未完成记录。
- 压测:1000 个连接保持,每秒 200 条 1 KiB 单聊,持续 10 分钟无失败。1000 人群发一条,5 秒内全部入推送队列。服务器重启后 1000 个端在 1 分钟内全部重新上线。
14. 里程碑
每段都可以单独交给审核。具体的任务拆分、分工和多 Agent 协作方式见 TASKS.md。
| 段 | 内容 |
|---|---|
| M0 | 仓库、Taskfile、配置、空库、管理员初始化、健康检查,前端打包嵌入流程跑通 |
| M1 | 端口识别(含后台单独监听)、证书加载与自动重载、内置 MQTT、登录与锁定、顶号、在线、注册接口 |
| M2 | 单聊、确认、离线保留、宽限、延迟、定时、撤回、回执、正文删除、配额、启动恢复 |
| M3 | 对话密码、群 |
| M4 | 管理接口和网页(Naive UI;含注册设置、批量开通、API 令牌、操作日志)、/metrics |
| M5 | 四种 SDK 与接入清单 |
| M6 | 弱网、崩溃、压测,Docker 镜像和部署说明(含 1Panel 证书),以及 docs/DEVIATIONS.md |
15. 不要这样做
- 不要用 MQTT 保留消息或离线会话来实现「可选保留」和过期时间。那做不到按条选择,也做不到改保留时长。
- 不要把 PUBACK 当成对方已收下。
- 不要让别的 goroutine 长期持有 broker 给的 payload 切片,先拷贝。
- 不要在旧连接的断开回调里无条件把编号标成离线。
- 不要在
OnConnectAuthenticate里把数据库错误等内部故障返回 false:客户端会收到「用户名或密码错误」并停止重连。 - 不要只按编号锁定登录,也不要让密码锁定拦住会话令牌重连;不要把会话令牌绑定 IP;令牌重连不要走 argon2。
- 不要用 mochi 自带的监听器再占端口。
- 不要依赖 mochi 拦截超过客户端上限的下行包,它只会静默丢弃。
- 不要把
coder/websocket的NetConn绑在 HTTP 请求的 context 上;不要保留它默认的 Origin 校验;不要指望Accept替你拒绝错误的子协议。 - 不要在 TLS 配置里写
NextProtos = ["http/1.1"]。 - 不要给 SQLite 写
?_journal_mode=WAL这种 mattn 语法。modernc 会悄悄忽略,库会运行在没有超时的回滚日志上。 - 不要一操作一事务地写库;读连接池不要用
_txlock=immediate;不要一条语句删几百万行。 - 不要为了分块去改协议。超过 256 KiB 就拒绝。
- 不要在四种 SDK 里各写一套不一样的重连、去重或确认。
- Go SDK 不要只在第一次连接设置 Clean Start。
- SDK 去重命中已确认的消息时不要静默丢弃,要再回一次
ack;重交发送时不要重算send_at_ms。 - 不要把正文写进日志和管理接口;不要把首次生成的管理员密码打进日志。
- 后端不要引入 Web 框架或 ORM;前端不要混用第二套 UI 组件库;不要为了部署再加 Redis、Node 服务或独立数据库。