
# 部署、存储与可靠性运维

## 1. 可靠性边界

网关提供以下保证：

1. 遥测和命令采用至少一次投递语义。
2. `eventId` 在单资源通道内幂等；重复请求返回同一 `sequence`。
3. `ReliableEventBroker` 先调用持久化 store，成功后才广播。
4. MQTT QoS 1 的授权钩子等待持久化完成再 PUBACK；HTTP/WebSocket/WebTransport 同样在落盘后确认。
5. 每资源通道序号严格递增。消费者只在处理成功后 ACK，ACK 游标持久化。
5.1 WebSocket 订阅按序连续投递：实时通道只作为「有新数据」的信号，序号不连续时回到持久化日志按游标读取连续段，重复 `eventId` 不会二次下发。因此单个数字游标始终表示「到此为止都已投递」。
6. 资源快照、可靠事件、消费者游标和审计记录分 collection 保存。
7. 进程收到 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`。

一次跨实例投递的完整路径：

```text
实例 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` 计数。此时实例仍在服务自己的资源，只是收不到同伴的事件——适合把它移出负载均衡，但不必重启。

本地起两个实例亲自验证：

```bash
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 down
```

`verify` 的每一项断言都跨实例：在 A 上写、在 B 上看，包括用真实 MQTT 客户端连到 B 接收 A 下发的命令。它也会检查上面那条共享密钥要求。

仍未覆盖的一项：MQTT **会话**本身没有共享。嵌入式 Aedes 的订阅、retained 消息和离线 QoS 队列保存在各自实例内存里，资源重连到另一个实例会得到 `Session Present = false`。

在线命令下发不受影响（走上面的 relay，由持有连接的实例投递到它本地的 broker）。**离线期间的命令**由资源主动补拉：连上后向 `$earth/resume` 发布自己处理到的 `afterSequence`，服务端从可靠日志重投未过期的命令（见[资源接入手册](RESOURCE-INTEGRATION.zh-CN.md) 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` 就不启用）。它不在投递路径上，挂掉不影响收发；判决只是标签，不参与鉴权。

花费的控制点按重要性排：

1. **判决单位是流，不是消息。** `{域, 空间, 发布者, 主题}` 加 payload 结构指纹。一个每秒 10 条的传感器一天只产生个位数的判决。
2. **`AI_JUDGE_MIN_REJUDGE_MS`**（默认 1 小时）：同一结构在窗口内不重判。
3. **`AI_JUDGE_PER_TENANT_PER_MINUTE` / `_PER_DAY` / `AI_JUDGE_GLOBAL_PER_DAY`**：预扣式配额，计数器存在共享存储里，所以多实例共用同一份预算。
4. **`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` 会检查这一条。

### 步骤

```bash
# 在 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 里显式声明才会上线。

```markdown
---
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. 发布检查

```bash
npm ci
npm test
npm run build
NODE_ENV=production npm run server
```

发布前至少执行一次真实证书 HTTP/2 请求、MQTT TLS QoS 1、WebSocket 断线重放、WebTransport 连接与重启恢复演练。`package-lock.json` 必须随代码发布。
