Files
NixMsg/docs/DEVELOPMENT.md
T

1209 lines
77 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# NixMsg 开发说明
读者是实现本期功能的开发组。产品行为以 [PRD.md](./PRD.md) 为准,本文对应 PRD 0.5,规定怎么实现。两者冲突时改代码以 PRD 为准,并把差异写进 `docs/DEVIATIONS.md`(日期、原条款、实际做法、原因),交给后续审核。
文中「必须」是验收项,「不要」是已知会做错的实现。本文对 mochi-mqtt、coder/websocket、autopaho、modernc sqlite 内部行为的说明,已在 2026-09-30 对照它们主分支的源码核对过;升级这些依赖的大版本时要重新核对。
## 1. 总结构
一个 Go 进程,一个可执行文件。端只连一个端口(端接入端口);管理后台默认也在这个端口,可以配置到单独端口。
```mermaid
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. 仓库
```text
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
```go
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
装配约束:
```text
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`,由客户端生成,同一连接内不重复。响应:
```json
{"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 之后,客户端发布:
```json
{"v":1,"type":"hello","rid":"1","max_receive_bytes":262144,"client":"go-sdk/0.1"}
```
`max_receive_bytes` 是这个连接一次能收的最大下行帧字节数(整条 JSON),不小于 1024。省略表示不限,但仍受 CONNECT 里 Maximum Packet Size 的约束。服务器在订阅完成后回复:
```json
{"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 发送
```json
{
"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`。发给自己不校验对话密码。
成功:
```json
{"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 下行消息与确认
```json
{
"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` 帧对所有成员相同,只序列化一次。确认:
```json
{"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 撤回、状态、回执
撤回:
```json
{"v":1,"type":"recall","rid":"4","id":"018f..."}
```
```json
{"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。群很大时也不要在这个响应里列出全部成员,明细走状态查询或后台。
状态:
```json
{"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`。
回执下行:
```json
{"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` 说明原因。客户端确认:
```json
{"v":1,"type":"receipt_ack","rid":"6","receipt_id":"9001"}
```
回执也按窗口推送,默认 64。可能重复,SDK 按 `receipt_id` 去重。
已推送尚未确认就撤回或作废时,下行:
```json
{"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 在线、目录、订阅
```json
{"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` 表示订阅全部。再次调用覆盖本连接的订阅。断线清空。
```json
{"v":1,"type":"presence","id":"a","online":true,"at_ms":1750000000000}
```
上下线通知用 QoS 0 尽力推送,不落库;连接的发送队列满时会被丢弃。应用需要准确状态时重新查询。
### 6.6 对话密码与自己的资料
```json
{"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 群
```json
{"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 尽力推送,不落库,推给当前在线的成员;被移除或退出的那个端也收到自己的那条:
```json
{"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 致命错误
```json
{"v":1,"type":"fatal","reason":"disabled"}
```
`reason`:`disabled`、`deleted`、`password_reset`、`protocol`。发出后断开,SDK 停止重连。被顶号不走这个帧,走 MQTT 5 的 `0x8E`(第 5 节)。管理员「踢下线」也不发这个帧,只断开连接,SDK 会自动重连。
### 6.9 注册(HTTP)
```http
POST /api/client/register
Content-Type: application/json
{"registration_code":"...","id":"","login_password":"","name":"门口","talk_password":""}
```
成功(HTTP 200):
```json
{"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 表
```sql
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 和超时会被静默忽略:
```text
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。方法名按各语言习惯调整,含义不变。
```text
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 配置文件
```yaml
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`。
运行目录:
```text
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 文件。
```yaml
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](./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 服务或独立数据库。