# 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 时由系统随机分配。启动后把实际地址写进 `/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` 返回 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:/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` 一份 `/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 的 `` 写 `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 服务或独立数据库。