Files
NixMsg/docs/DEVELOPMENT.md
T

77 KiB
Raw Blame History

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(CONNACK 0x86),不计入锁定。通过后 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": ""
}

规则:

  • id 1–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 提交

一个写操作内:

  1. 解析请求,算出请求指纹。格式或大小不合法直接返回错误。
  2. 查防重行:同一发送方、消息号已存在时,指纹相同返回原消息的结果,指纹不同返回 conflict,都不再往下做。必须先查防重再做后面的校验:否则第一次其实已经提交成功、只是响应丢了,重试时却因为对方刚停用或刚改了对话密码而报错,发送方会误以为没发出去。
  3. 校验目标、延迟和定时、成员或授权;发送方未完成的消息数达到 max_pending_per_sender 时返回 quota_exceeded。
  4. 若带对了对话密码,写入或更新授权。
  5. 若发送方自己设了对话密码,且这是发给别人的单聊,给对方写一条回复授权(版本等于发送方当前对话密码版本)。群发不写。
  6. 插入消息和正文。send_at 按第 6.2 节计算。
  7. 插入防重行:发送方、消息号、请求指纹、时间。
  8. 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,逐条作为写操作处理:

  1. 解析接收者。
    • 单聊:目标端。目标已停用则这条投递直接 rejected,原因 endpoint_disabled。
    • 群:发送时刻的成员,去掉发送者。已停用的成员投递直接 rejected,原因 endpoint_disabled。发送者已不在群里:不生成投递,消息 completed,reason = sender_left。群里没有其他成员:同样处理,reason = no_recipients。
  2. 每个接收者插入 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。
  3. 消息改为 dispatched。没有未完成的投递则 completed 并删除正文。需要回执的按第 7.6 节写。

now 用服务器时钟。保留时间从这次分发的时间算,不从当初提交的时间算。

7.5 推送

每个已握手连接一个推送循环:

  1. 取该端 pending 且 pushed_conn 为空的投递,按 send_at、seq 排序,数量不超过「窗口减去已推未确认」。
  2. 算出整条 msg 帧的字节数。超过该连接的 max_receive_bytes,或超过 CONNECT 里声明的 Maximum Packet Size 减去 128 字节(主题和包头),投递改为 rejected、原因 too_large,不发布。
  3. 用条件更新写入 pushed_conn、pushed_at、attempts+1(WHERE state='pending' AND pushed_conn IS NULL),提交后再发布。推送、清理、撤回三方靠这个条件更新互斥。崩溃后残留的标记在启动时统一清掉(第 7.8 节)。
  4. 发布失败或 OnPublishDropped:若 pushed_conn 仍是这个连接,清掉,1 秒后再推。
  5. 确认超时(默认 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 启动恢复

服务启动后、开始接受连接前,在一个事务里:

  1. 所有 pending 投递清空 pushed_conn(连接代号不跨进程),expire_at 不早于「启动时间 + grace」,原值为空的也设成这个值。也就是把重启当作所有端刚断线:端在宽限内重连就能收到,包括保留期在停机期间已过的消息(PRD D23)。
  2. 停机前在线的端(online_since 晚于 offline_since,或 offline_since 为空而 online_since 不为空),offline_since 设为启动时间。
  3. 停机期间到点的 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。

连接:

  1. WebSocket 到 路径/mqtt,子协议 mqtt。裸 TCP 只在选项里显式打开时使用。浏览器页面是 HTTPS 时,服务器也必须用 TLS:浏览器不允许 HTTPS 页面连 ws:// 或请求 http://。
  2. 每次连接都带 Clean Start,包括重连。Go 的 autopaho 只在第一次连接使用 CleanStartOnInitialConnection,必须用 ConnectPacketBuilder 在每一次连接把 Clean Start 设为 true,会话过期间隔设为 0。
  3. 订阅 down,发 hello,等到成功响应。连接超时默认 30 秒(autopaho 默认 10 秒,要改):服务器重启后大量端同时重连,密码校验要排队。
  4. 重连退避:1 秒起,加倍,上限 30 秒,加减 30% 抖动。稳定在线 60 秒后把退避恢复到 1 秒。
  5. 会话令牌:用密码连上后,把握手响应里的 session_token 通过 onSession 交给应用,之后自动重连都用这个令牌;应用下次启动可以直接用保存的令牌 connect。SDK 自己不落盘保存令牌和密码。
  6. 停止重连的情况:收到 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),不进仓库。

接入清单(每种语言一份集成测试,对真实服务器二进制):

  1. 登录并收到握手参数。
  2. 单聊收发,应用回调只有一次。
  3. 断开期间发送,重连后送达且不重复。
  4. 发送队列在进程内重试同一消息号;模拟 ack 丢失后服务器重推,SDK 自动再确认且不重复回调。
  5. 10 秒延迟内撤回,对方无回调。
  6. 定时 2 秒后送达。
  7. 离线保留:对方晚 1 秒上线能收到;保留 1 秒且 3 秒后才上线则收不到,发送方收到过期回执。
  8. 建群、两人收到同一内容、发送者自己不收到。
  9. 对话密码:拒绝、解锁、改密后失效、对方先发则可以回复。
  10. 第二处登录把第一处踢下线且第一处不再重连。
  11. 超过 256 KiB 在本地失败。
  12. 注册:关闭时失败;安全码错误失败;成功后能登录;换码后旧码失败、已注册的端照常登录。
  13. 改登录密码后用新密码重连成功、旧密码失败;认证失败后 SDK 不再重连。
  14. 仅 JS:浏览器页面和服务器不同域名时,注册和 WebSocket 连接都成功。
  15. 会话令牌:密码登录后收到令牌;断开后用令牌重连成功;另一处用密码登录后,原来那处用旧令牌重连被拒(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 申请和续签,程序只读文件:

  1. 在 1Panel 的证书管理里申请证书(DNS 账户或 HTTP 验证),打开自动续签。
  2. 勾选「推送证书到本地目录」,指定一个目录,例如 /opt/nixmsg/certs。1Panel 在申请和每次续签后写入 fullchain.pem 和 privkey.pem。
  3. 配置 tls.cert_file: /opt/nixmsg/certs/fullchain.pem、tls.key_file: /opt/nixmsg/certs/privkey.pem。程序每小时检查一次,续签后自动换上新证书,不用重启。
  4. 私钥要让 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 服务或独立数据库。