部署、存储与可靠性运维
1. 可靠性边界
网关提供以下保证:
- 遥测和命令采用至少一次投递语义。
eventId在单资源通道内幂等;重复请求返回同一sequence。ReliableEventBroker先调用持久化 store,成功后才广播。- MQTT QoS 1 的授权钩子等待持久化完成再 PUBACK;HTTP/WebSocket/WebTransport 同样在落盘后确认。
- 每资源通道序号严格递增。消费者只在处理成功后 ACK,ACK 游标持久化。
5.1 WebSocket 订阅按序连续投递:实时通道只作为「有新数据」的信号,序号不连续时回到持久化日志按游标读取连续段,重复 eventId 不会二次下发。因此单个数字游标始终表示「到此为止都已投递」。
- 资源快照、可靠事件、消费者游标和审计记录分 collection 保存。
- 进程收到 SIGINT/SIGTERM 时关闭监听器、会话、broker 和 storage。
这些保证不是“恰好一次业务执行”。资源或 Agent 的 handler 必须按 eventId/sequence 幂等。事件保存成功但实时扇出失败时,事件仍可重放。
1.1 多实例写入
@ptner/storage-lib 0.7.0 起提供 store.increment() 与 store.compareAndSet(),两者在每个持久化后端都是单条原子语句,不在库内做“先读后写”。网关据此去掉了旧版的进程内通道锁,多个网关实例可以指向同一个数据库并同时写入同一资源通道:
| 写入路径 | 原子原语 | 保证 | | --- | --- | --- | | 事件序号分配 | increment(earth_resources_sequences, channel) | 跨实例唯一且严格递增,不会重号或回退 | | 事件落盘 | compareAndSet(..., expectedRevision: null) | 仅创建;同一 eventId 竞态重试也只产生一条记录 | | 资源快照 | compareAndSet(..., 快照 revision) | 并发上报不丢更新,按观测时间决定胜者 | | 消费者游标 | compareAndSet(..., 游标 revision) | 游标只前进,不因并发 ACK 回退 | | 审计记录 | compareAndSet(..., expectedRevision: null) | 重试不覆盖首次写入的审计条目 |
序号先分配、事件后落盘,因此某个序号可能短暂处于“已分配未落盘”状态。readAfter() 只返回从消费者游标开始连续的一段事件:遇到尚未落盘的空洞就停下,而不是跳过它,避免消费者把游标推过一条仍在写入途中的事件。空洞在 RELIABLE_SEQUENCE_GAP_GRACE_MS(默认 5000 毫秒)之后被视为已废弃(写入方崩溃或 eventId 竞态放弃了该序号),此后跳过,不会永久卡住通道。返回体中 latestSequence 仍表示该通道最高的已落盘序号(客户端在 snapshot-required 后据此重置游标是安全的),新增的 allocatedSequence 表示最高的已分配序号,两者之差就是当前在途的写入。
实时扇出默认只在进程内。设为 CLUSTER_ENABLED=true 后,实例之间通过一条信号总线互相唤醒,跨实例的实时投递和命令下发即可工作——见下一节。
保持关闭时的行为与单实例完全一致:实例 A 写入的事件不会主动推给连接在实例 B 上的订阅者。由于投递本身是「信号 → 按游标从持久化日志读取连续段」,B 上的订阅者不会因此跳过或错乱,只是要等该通道下一次在 B 本地产生事件、或客户端重连时才补齐。命令同理:已持久化,资源重连补拉可以取到,但实时下发到不了另一个实例上的资源。
需要说明的是,“已分配未落盘”的窗口并非多实例独有。进程内通道锁已经移除,同一进程内两个并发 append 同样会交错,短暂空洞照样出现,只是窗口极短,且这些事件会由进程内实时扇出直接推给订阅者,不依赖重放路径。对单实例部署来说,对外可观察的行为与升级前一致。
这个窗口是一切“先取号、后写入”系统的固有属性,不是存储库的缺陷:increment() 只承诺返回唯一且递增的数字,写入是调用方的第二步操作,不在它的原子边界内。PostgreSQL SEQUENCE、MySQL AUTO_INCREMENT 都有同样的现象,逻辑复制和 CDC 组件同样要处理它。要彻底消除窗口,只能把取号与写入合并为一个原子操作——进程内锁(即升级前的单写入实例)、数据库事务,或按通道路由到单一写者。
1.2 多实例集群
CLUSTER_ENABLED=true 打开跨实例协调。它是一个独立开关:关闭时不创建总线、不注册任何回调,所有投递路径与单实例部署逐字节相同,因此这项功能出问题不会波及单实例。
设计只有一句话:
> 总线只传「某通道推进到了序号 N」的唤醒信号,事件正文一律从共享数据库读回。
因此总线不需要保证送达,也不需要保证顺序——丢一条信号的代价是延迟,不是正确性,订阅者仍会在下一次 drain 或重连时补齐。这也是 Redis Pub/Sub 足够用的原因,不需要 Streams、JetStream 或 Kafka。
证书不走这条总线,也不归网关管。 总线只有 event / credential / status / shared 四种信号,每一种都是「去读数据库」的唤醒;应用里没有 ACME 客户端、没有续期定时器,loadCertificate 只在启动时读两个文件。推荐形态下 TLS 终止在代理上,网关根本没有证书——加实例不会让证书变多。只有当「代理」本身有多个(每台 VPS 各跑一个 Caddy)时才需要处理,那时的三种做法、HTTP-01 挑战会落到错误实例这个坑、以及 Caddy 用 Redis 做共享存储(和这条总线是两回事),见仓库 deploy/multi-instance-tls.md。
一次跨实例投递的完整路径:
实例 A 原子写入事件(storage-lib)
↓ 本地扇出给 A 上的连接
↓ 发布信号 {instanceId, channel, eventId, sequence}
总线
↓
实例 B 忽略自己发的信号
↓ 按 eventId 从数据库读回事件
↓ 投递给 B 上的可靠订阅者、租户看板、MQTT / WebTransport 资源会话| 变量 | 默认 | 说明 | | --- | --- | --- | | CLUSTER_ENABLED | false | 总开关 | | CLUSTER_BUS | redis | redis 或 memory;memory 只在单进程内有效,仅供测试 | | CLUSTER_REDIS_URL | — | CLUSTER_BUS=redis 时必填,缺失直接拒绝启动(回落 REDIS_URL) | | CLUSTER_INSTANCE_ID | hostname-pid | 用于忽略自己发出的信号;主机名不稳定时应显式设置,且必须全局唯一 | | CLUSTER_CHANNEL_PREFIX | earth:signal: | Redis 频道前缀 | | CLUSTER_READ_RETRY_MS / CLUSTER_READ_RETRIES | 250 / 4 | 读副本落后于写入方时的重试;读不到就放弃,交给 drain 补齐 |
部署要求:
- 所有实例必须共享同一组
ADMIN_API_KEY和CREDENTIAL_PEPPER。pepper 参与每一个资源/租户密钥的哈希(sha256(pepper \0 apiKey)),两个实例的 pepper 不一致时,在 A 上注册的资源到 B 上会直接 401——而且报错是「凭据无效」,不会提示是配置不一致,排查起来很费时间。注意CREDENTIAL_PEPPER未设置时会回落到ADMIN_API_KEY,所以只改其中一个也会踩到。 - 所有实例必须指向同一个
STORAGE_*数据库,总线不是数据源。 - 不要用 SQLite 做多实例后端。 SQLite 是单写入者的文件数据库:第二个实例打开同一个文件时可能直接以
SQLITE_BUSY启动失败,即使启动成功,每次写入也在互相争抢。实测两个进程同时启动指向同一个.db就会有一个起不来。多实例请用mongodb、d1或bunny;启用集群时仍配 SQLite 会在启动日志里收到告警。 CLUSTER_INSTANCE_ID全局唯一。两个实例用同一个 id 会互相忽略对方的信号。redis是可选依赖,只在CLUSTER_BUS=redis时惰性加载;缺包会在启动时报错而不是静默降级。/readyz在集群启用但总线断开时返回503和status: "degraded",并在cluster字段给出announced/received/relayed/unresolved计数。此时实例仍在服务自己的资源,只是收不到同伴的事件——适合把它移出负载均衡,但不必重启。
本地起两个实例亲自验证:
scripts/cluster-demo.sh up # 自带 Redis,SQLite 存储,够看清机制
scripts/cluster-demo.sh up --store mongo # 换成能并发写入的后端
scripts/cluster-demo.sh verify # 跨实例的事件、命令、看板与凭据检查
scripts/cluster-demo.sh status
scripts/cluster-demo.sh downverify 的每一项断言都跨实例:在 A 上写、在 B 上看,包括用真实 MQTT 客户端连到 B 接收 A 下发的命令。它也会检查上面那条共享密钥要求。
仍未覆盖的一项:MQTT 会话本身没有共享。嵌入式 Aedes 的订阅、retained 消息和离线 QoS 队列保存在各自实例内存里,资源重连到另一个实例会得到 Session Present = false。
在线命令下发不受影响(走上面的 relay,由持有连接的实例投递到它本地的 broker)。离线期间的命令由资源主动补拉:连上后向 $earth/resume 发布自己处理到的 afterSequence,服务端从可靠日志重投未过期的命令(见资源接入手册 4.3)。这条路径跨实例、跨重启都成立,因为事实来源是数据库而不是 broker 内存。
因此是否需要 Aedes 的共享 persistence 只取决于一件事:资源固件能不能改。能改就用 resume,不需要;不能改的资源同样发不出 afterSequence,那就只能在 @ptner/mqtt-trans 接入 Aedes 的共享 persistence 与集群 emitter。不要让两种机制同时生效——资源既依赖 Session Present 又自己补拉,会产生重复和顺序混乱。
2. 存储
所有存储通过一个 createStorage() 实例复用,应用退出时调用 storage.close()。
| 场景 | STORAGE_STORE_BACKEND | 说明 | | --- | --- | --- | | 本地/单机 | sqlite | 默认 ./data/earth-resources.db | | Node 生产服务 | mongodb | 配置 STORAGE_MONGODB_URL、数据库名 | | Cloudflare 数据面 | d1 | Node 服务使用 D1 REST 凭据 | | Bunny Database | bunny | 配置 libSQL URL/token |
当前网关只使用 JSON store,不把实时音频或大文件写入可靠事件日志。blobBackend 仍显式设为 SQLite,避免意外采用未配置的 S3 默认值;将来增加固件/文件上传时可切换到 R2 或 S3-compatible object storage。
collection:
earth_resources_resources:资源、凭据 hash、空间授权、最新快照。earth_resources_spaces:域内的隔离空间。earth_resources_groups:跨租户的共享域及其成员租户。earth_resources_tenants:租户控制台凭据 hash。earth_resources_tickets:一次性 WebSocket 票据,首次使用即删除,过期项由清理任务回收。earth_resources_events:不可变语义的遥测和命令事件。earth_resources_sequences:每通道原子序号计数器(increment)。earth_resources_event_meta:0.7.0 之前的每通道最高序号,仅在升级时读取一次用于初始化计数器。earth_resources_cursors:消费者 ACK 游标。earth_resources_audit:注册、上报和命令审计。
SQLite 备份应使用 SQLite backup API 或在停服后复制数据库及 WAL/SHM 文件,不要在写入期间只复制 .db。远端后端使用供应商的时间点恢复和跨区域备份能力。至少每季度执行一次恢复演练。
3. 保留与清理
DATA_RETENTION_DAYS:可靠事件,默认 90 天。AUDIT_RETENTION_DAYS:审计,默认 365 天。CLEANUP_INTERVAL_MS:清理周期,默认 1 小时。同一个周期顺带回收过期票据。WS_TICKET_TTL_MS:WebSocket 票据有效期,默认 60000 毫秒,范围 5000–600000。票据首次使用后立即销毁,这个值只决定「签发后多久没用就作废」。RELIABLE_SEQUENCE_GAP_GRACE_MS:判定已分配序号被废弃的等待时间,默认 5000 毫秒。单实例部署可设为较小值;多实例部署不应低于一次事件写入的最坏耗时。
清理后,旧游标会收到 snapshot-required。客户端必须加载资源快照并把游标推进到返回的 latestSequence。清理是按服务端事件创建时间,不按资源上传的 timestamp,避免资源时钟错误破坏保留策略。
3.1 AI 判决器的花费
判决器默认关闭(没有 AI_JUDGE_API_KEY 就不启用)。它不在投递路径上,挂掉不影响收发;判决只是标签,不参与鉴权。
花费的控制点按重要性排:
- 判决单位是流,不是消息。
{域, 空间, 发布者, 主题}加 payload 结构指纹。一个每秒 10 条的传感器一天只产生个位数的判决。 AI_JUDGE_MIN_REJUDGE_MS(默认 1 小时):同一结构在窗口内不重判。AI_JUDGE_PER_TENANT_PER_MINUTE/_PER_DAY/AI_JUDGE_GLOBAL_PER_DAY:预扣式配额,计数器存在共享存储里,所以多实例共用同一份预算。AI_JUDGE_HEURISTICS(默认开):带经纬度、带resourceId、主题里是cmd/...这类不需要模型的,按规则判,记为source: "heuristic"。
监控这几个计数器:
earth_resources_ai_verdicts_total{source="model"} 花了钱的
earth_resources_ai_verdicts_total{source="heuristic"} 省下的
earth_resources_ai_dropped_total{reason="budget"} 撞限额的
earth_resources_ai_dropped_total{reason="queue_full"} 判决跟不上流量
earth_resources_ai_errors_total 模型侧失败reason="budget" 持续不为零说明限额设小了或者有租户在刷新结构;queue_full 说明 AI_JUDGE_CONCURRENCY 太低或模型太慢——这两种都只影响标签,不影响数据。
3.2 单台 VPS 部署(一个域名,一个端口)
scripts/cluster-demo.sh 不能用来部署——它自己开头就写了:绑 loopback、密钥存明文、Redis 不带认证,是给笔记本上观察跨实例投递用的演示装置。部署用 scripts/deploy.sh 和 deploy/ 下的三份配置。
形状是这样:外面只开 443,Caddy 终止 TLS 并自动续期,网关绑在 127.0.0.1。
443 (TLS) 127.0.0.1
浏览器 ───────────────┐
资源端 ───────────────┤ Caddy ──┬── /mqtt ──→ :8083 MQTT over WebSocket
│ └── 其余 ──→ :8787 REST / 控制台 / 文档 / 地球为什么网关必须绑 loopback
网关靠 X-Forwarded-Proto 判断请求是不是走了 TLS——代理后面这是对的,但谁能直连那个端口谁就能伪造它。伪造成功意味着:会话 cookie 不再带 Secure、发证端点的 TLS 检查失效。所以 HOST=127.0.0.1 不是保守配置,是这套代理方案成立的前提。scripts/deploy.sh check 会检查这一条。
步骤
# 在 VPS 上
git clone <repo> /opt/earth-resources && cd /opt/earth-resources
scripts/deploy.sh bootstrap # 装 node/caddy/redis/docker、起 MongoDB、建用户、生成密钥
$EDITOR .env # 只剩三个值要你填(见下)
# 把 A 记录指到这台机器
scripts/deploy.sh check # 查一遍,红的都会说清楚为什么
scripts/deploy.sh install # 构建、起两个实例、装好 Caddy 配置bootstrap 把 MongoDB 和 Redis 都跑在容器里(earth-mongo / earth-redis),都只绑 127.0.0.1,而且先探端口再绑:6379 或 27017 已经被别的东西占着时,它往上找一个空的,把实际用到的端口写进 .env,并在输出里说明。已有的 Redis/Mongo 不会被碰,也不会被改配置。
bootstrap 会先打印计划再问你要不要做,不确认就不动任何东西;已经装好的会跳过,所以重跑是安全的。两个密钥分别生成(ADMIN_API_KEY 和 CREDENTIAL_PEPPER 复用同一个值,等于一次泄漏变成两次)。
脚本做不了的三件事:
| 事 | 为什么 | | --- | --- | | DNS 的 A 记录 | 在你的域名服务商那边,机器上看不到 | | ADMIN_EMAILS | 谁能当管理员是你的决定 | | CLOUDFLARE_ACCOUNT_ID + MAIL_API_KEY | 从 Cloudflare 控制台拿 |
另外如果你的服务商有外部防火墙(阿里云安全组、AWS security group 之类),80 和 443 要在那边放行——机器内部的 ufw 管不到它。
以后更新代码:scripts/deploy.sh update,它会一次重启一个实例,所以代理始终有一个健康的可用。
DNS 的 A 记录要在跑 Caddy 之前指到这台 VPS——Let's Encrypt 需要能回访到它。
以后更新代码:scripts/deploy.sh update(重新构建并重启,不动配置)。
MQTT 怎么连
wss://earthmqtt.space/mqtt,这就是一条真正的 MQTT 连接,只是底下换了帧格式。协议、客户端库、broker 全都一样。
原生 MQTT 客户端使用 mqtts://mqtt.earthmqtt.space:8883;浏览器或支持 WebSocket 的客户端也可使用 wss://earthmqtt.space/mqtt。两个实例的明文 1883/1884 只绑定 loopback,绝不能开放到公网。首次启用原生 TLS 前先创建 DNS-only 的 mqtt A 记录,再运行 scripts/deploy.sh mqtts;完整结构和验证方式见仓库 deploy/mqtts.md。
确需兼容不支持 TLS 的旧客户端时,scripts/deploy.sh mqtt-plain 会显式增加 mqtt://mqtt.earthmqtt.space:1883。这是安全性降级:username、密钥、主题和 payload 都不加密;新客户端仍应使用 8883。
运行 scripts/deploy.sh mqtt-ws-plain 后,MQTT WebSocket 的规范地址为 ws://mqtt.earthmqtt.space/mqtt 和 wss://mqtt.earthmqtt.space/mqtt;原 wss://earthmqtt.space/mqtt 保留兼容。明文 WS 不加密,而且不能从 HTTPS 网页使用。
这套方案没覆盖的
- WebTransport:是 HTTP/3 → QUIC → UDP,Caddy 的
reverse_proxy转发的是 HTTP 请求,而一条 WebTransport 会话不是请求,所以转不了——UDP 端口必须直接暴露,由网关自己应答 TLS。而且给网关证书会连带把它的 HTTP 监听器变成 HTTPS(同一个certificate对象喂给两处,WebTransport 没有它直接抛错)。两种可行形态、GLIBC 2.38 的硬性要求、以及「你是不是真的需要它」,见仓库deploy/webtransport.md。默认false。 - 邮件:没有接任何邮件通道,所以邮件链接登录在生产上等于不可用;进得来的路是密码和 Google。
- 多实例:
CLUSTER_ENABLED=false。要扩到多台,所有实例必须共用一个数据库和一条 Redis 总线,而且只能有一个实例开CONNECTORS_ENABLED。
4. TLS、HTTP/2 和 HTTP/3
配置 TLS_CERT_PATH 与 TLS_KEY_PATH 后,REST/MCP 端口使用 ALPN 提供 HTTP/2,并保留 HTTP/1.1 给 WebSocket upgrade。缺少任一文件都会拒绝启动。
WebTransport 使用相同证书、独立 UDP 端口,且需设置 WEBTRANSPORT_ENABLED=true。浏览器要求 secure context。@fails-components/webtransport-transport-http3-quiche 当前 Linux x64 预编译产物要求 GLIBC 2.38;较旧系统应使用满足要求的容器镜像或按上游要求从源码构建。本开发容器 GLIBC 较旧,因此 HTTP/3 模块完成了配置级接入,但端到端测试应在生产目标镜像中执行。
如果在 CDN、Envoy、NGINX 等边缘终止 TLS:
- REST 可由边缘接受 HTTP/3,再以 HTTP/2 回源。
- MQTT TCP 需要四层转发或独立 TLS listener。
- MQTT over WebSocket 需保留
mqtt子协议和/mqtt路径。 - 通用 WebSocket 需转发 Authorization、X-Tenant-Id 和 X-Resource-Id。
- WebTransport 需要代理原生支持,不能当普通 WebSocket 透传。
4.1 网页界面
网关自带地球视图 /、租户控制台 /console 和渲染后的手册 /docs,均由 Node 直接提供,无需额外静态服务器。/app 保留为地球视图的兼容别名。
| 变量 | 默认 | 说明 | | --- | --- | --- | | WEB_ENABLED | true | 设为 false 只保留 API,所有页面路由回落到 404 | | — | — | /docs 只发布在 front matter 里写了 published: true 的文档,见下 | | CONSOLE_DIRECTORY | <仓库>/server/public | 控制台静态资源 | | DOCS_DIRECTORY | <仓库>/docs | /docs 渲染的 Markdown 源 | | APP_DIRECTORY | <仓库>/dist | npm run build 产物;不存在时 / 和 /app 不提供地球视图 |
/docs 是默认不发布的:目录里的 Markdown 必须在 front matter 里显式声明才会上线。
---
published: true
title: 资源与协议接入手册
order: 2
---没有这段的文件既不出现在索引里,也取不到(/docs/{slug} 和 /docs/{slug}.md 都不可达)。这条规则的存在是因为「目录里有什么就发什么」意味着往 docs/ 里放一份设计稿或导出记录就等于公开发布了它。order 决定索引里的顺序,title 覆盖从一级标题推断的标题;front matter 本身不会出现在页面或原始 Markdown 输出里。
这些路由在鉴权之前解析,提供的内容全部是公开的:页面本身不含任何租户数据,数据一律由浏览器带着凭据另行调用 API 获取。路径解析限制在各自根目录内,.. 和编码变体都会被拒绝。/app/assets/* 带内容哈希,按不可变缓存一年;其余页面 no-cache。
反向代理只需把这些路径与 API 一起转发;控制台与 API 同源时不需要配置 CORS_ORIGINS。
4.2 空间与组
消息层不认识「资源」,它认识的是域和空间:
{domain}/{space}/{topic}- 域是租户,或多个租户共享的组。
- 空间是域内的隔离区,由客户自己划,默认空间
-永远存在。 - 一条 MQTT 连接落在一个
{域}/{空间}里,由 username 决定(acme@ops、acme@alliance:market)。进去之后就是普通 broker:任意主题、任意载荷、自收自发照常。
凭据决定能进哪些空间以及消息记在谁名下,不决定主题结构。可靠日志按 space/{域}/{空间}/pub/{发布者} 分通道,因此序号、补拉和审计与之前一样是每发布者独立的。
授权规则:
| 主体 | 可进入 | | --- | --- | | 管理员 | 任意已存在的域和空间 | | 租户 | 自己的域的全部空间;自己所属的组的空间 | | 资源 | 自己租户的域,且只限注册时 spaces 授予的空间(默认只有 -) |
资源进不了组——组是租户之间的约定,不是它们硬件之间的。
管理入口:控制台的「空间」「组」页,或 POST /v1/spaces、POST /v1/groups。
组有自己独立的空间,与成员自己域里的同名空间无关——acme@g:ops 找的是 g 域里的 ops。组建好之后必须在组里再建空间,成员才有地方发消息;这一步成员可以自己做(控制台「我所在的组」里每个组都列出它自己的空间和对应的 username),删除则要管理员,因为那个空间同时属于其他成员。
5. 健康、指标和告警
GET /healthz:进程存活和协议状态,不需要认证。GET /readyz:storage health、协议状态与集群状态,不需要认证。集群启用但总线断开时返回503。GET /metrics:Prometheus 文本,需要管理员 token。GET /api-docs:Swagger UI 人类可读文档与试调界面,不需要认证。GET /openapi.json:机器可读或下载用的 OpenAPI JSON,不需要认证。GET /v1/public-config:浏览器客户端拼接连接串所需的端点和端口,不需要认证,不含任何租户数据。
建议告警:readyz 失败、5xx 比率、MQTT 认证失败突增、遥测速率归零、资源本地待确认队列增长、WebSocket 1013 关闭、磁盘/WAL 增长、最老未处理游标年龄、备份失败。
6. 安全清单
- 生产必须设置随机
ADMIN_API_KEY和独立CREDENTIAL_PEPPER,通过 secret manager 注入。 - TLS 终止后再暴露任何资源凭据;明文 1883 仅用于受控内网。
- 资源密钥按资源隔离,泄漏时重新注册该资源进行轮换。
- 三级凭据边界:管理员密钥只发给平台运维;程序使用租户密钥(
etk_),它无法跨租户、无法读/metrics;资源使用资源密钥(erk_),它无法注册资源、无法给同租户其他资源下发命令。租户密钥泄漏时在控制台「凭据」页单独吊销;其他密钥和资源连接不受影响。 - 控制台使用
HttpOnly账号会话,不保存 API 密钥。浏览器实时订阅走POST /v1/ws-tickets换取一次性票据,票据短时过期且用后即焚。 - 共用电脑上使用控制台后应点「退出」;租户密钥等同于该租户的全部管理权限。
- MQTT 主题按凭据挂载,客户端写不出别的域的主题,隔离不依赖「每条规则都记得校验」。跨租户可见只发生在管理员显式创建的组里,成员名单是唯一的开关。
- 吊销和轮换是即时的,覆盖三个面:新认证被拒;已签发未使用的票据作废(票据记录了签发它的凭据指纹);已建立的 WebSocket / MQTT / WebTransport 会话被主动断开,集群下通过信号总线跨实例断开。这一点在生产上很关键——会话认证只发生在握手那一刻,没有这条路径的话,一个被吊销的凭据可以靠一条不断开的连接无限期继续访问。
- 响应头:所有响应带
x-content-type-options、referrer-policy: no-referrer、x-frame-options: DENY、cross-origin-opener-policy和最小化的permissions-policy;HTML 另带 CSP(script-src 'self',样式因页面使用内联style属性而需要'unsafe-inline')。strict-transport-security只在请求本身走 TLS(或前置代理声明x-forwarded-proto: https)时发送。 - 前置代理仍需负责限流;网关本身不做速率限制。
CORS_ORIGINS使用精确来源列表,不使用*。- 前置网关对注册、命令、认证失败和大流量资源执行速率限制。
- 日志中不输出 Authorization、资源 apiKey 或 WebTransport auth 内容。
- 定期检查
npm audit;原生 WebTransport 包和 SQLite 驱动升级应在与生产相同的镜像里验证。
7. 发布检查
npm ci
npm test
npm run build
NODE_ENV=production npm run server发布前至少执行一次真实证书 HTTP/2 请求、MQTT TLS QoS 1、WebSocket 断线重放、WebTransport 连接与重启恢复演练。package-lock.json 必须随代码发布。