外观
安全模型
本文讲系统的安全姿态:鉴权与 token、注册审批闸门、被刻意禁止的控制模型、私钥处理、写盘与容器边界。配置项见 配置参考,接口鉴权见 API 参考。
鉴权边界
| 接口面 | 鉴权 | 说明 |
|---|---|---|
Admin API(/control/v1/admin/*、/control/v1/ui/*) | Authorization: Bearer <凭据>(静态 DN42_CONTROL_ADMIN_TOKEN 或账号会话令牌) | fail-closed:未配置 admin token 时全部 403(含账号登录);凭据错 / 会话过期或被吊销 401 |
账号登录(POST /control/v1/auth/login) | 无 Bearer;用户名 + 密码 + Cap 人机验证服务端硬校验 | 人机验证失败 400;凭据错统一 401 invalid credentials(防枚举);连续失败限速 429;见 管理面 API |
Agent HTTP(/control/v1/agent/*,除 register) | Authorization: Bearer <agent token> | token 绑定 node_id |
Agent WebSocket(/agent/ws/{node_id}) | Bearer,且 principal.node_id == URL node_id | 不符 4403、无效 4401 |
POST /agent/register | 请求体内 enrollment token | 唯一不需 Bearer 的写接口 |
GET /healthz | 无 | DB 探针 |
所有 admin 写操作(含鉴权失败)由中间件记入 admin_audit_log,见 账号、审计与自动对等表。
SQL 注入面由三层约束覆盖:结构上一律走 ORM / Core 参数化表达式,ruff 的 S608 拦 f-string 拼 DML/DQL,AST 纪律测试补 DDL 与动态标识符两处盲区。全仓库的动态 SQL 豁免点可用 grep -rn "sql-ok:" apps/ 列出。完整约束与判据见 SQL 构造与防注入。
token 哈希模型
- 数据库只存 sha256 哈希,不存明文。
- Agent token:格式
<id>.<secret>(id为非密查找键)或固定字面量(literal_token_id从哈希派生 id)。明文只在签发响应里出现一次,不落日志、不入库。resolve()校验未撤销、未过期。rotate()撤旧签新。 - Enrollment token:同哈希模型。
node_id为空 = 任意节点可用,非空 = 仅该节点;一次性(used_at标记消费)。 - 账号会话令牌(
admin_sessions):登录签发secrets.token_urlsafe(32),DB 只存 sha256;TTL 默认 24h,改密吊销该用户全部存活会话。账号密码(admin_users)只存 argon2id 散列;不存在的用户名也跑一次对照校验,抹平时间侧信道。
注册审批闸门
未知节点无法自助上线,必须经管理员放行(agent_http.py register + PendingRegistrationStore):
- agent
POST /agent/register(enrollment token + requested_node_id + inventory)。 - 校验 enrollment token(全局或绑定、未消费、未过期)。
- 查审批状态:
rejected→ 403(显式否决)。pending→ 返回PENDING_APPROVAL,不消费 enrollment token,agent 周期重试。- 未知节点 → 记为 pending 注册申请。
- 已落库且有 generation → 签发 agent token,标记 enrollment 已用。
"approve 只是放行名单,provision 才下发 DesiredState"——两步分离避免误放行即生效。流程见 节点接入。
私钥处理
节点 WireGuard 密钥的单一事实源是控制面的 node_wireguard_keys 表:控制面生成或收编密钥对,materialize 时把字面私钥无条件注入每个 WG 接口。公钥恒由私钥派生,Node.wireguard_public_key 只是它的只读投影。
私钥以明文列存在库里(node_wireguard_keys.private_key),节点不再生成也不再持有独立密钥文件。因此:
⚠️ 控制面数据库泄露等于全 fleet WireGuard 私钥泄露。 库备份、对公网发布的 PostgreSQL 端口、以及任何能读该表的凭据,都要按「持有全网隧道密钥」的级别对待。
节点侧同样是明文落盘。 私钥随 DesiredState 下发后被渲染进 <state-dir>/nodes/<node>/rendered/wireguard/<iface>.conf,再 bind 挂载给 wg-gateway 容器。实测(hkg1):该节点全部 20 个 WG 接口的 conf 携带同一把字面私钥,文件模式 0644、目录链一路 0755——节点上任何本地用户都能读到全网隧道私钥。
⚠️ 这意味着密钥的暴露面是「控制面数据库 + 每一台节点的文件系统」。节点上不应存在不受信任的本地用户;给渲染目录收权限是独立于本文档的改进项。
一致性闸门(始终生效):agent 每轮从 DesiredState 取密钥、推导公钥上报,控制面与权威投影比对——不符返回 409 并回滚事务,agent 中止本轮 reconcile。节点绝不用偏离的密钥拉隧道;要改密钥只能走显式轮换。
离线托管(可选,当前生产未配置):配了 [server] recovery_public_key 时,agent 用它 RSA-OAEP 封装私钥随报上交,控制面存密文。恢复私钥永不在控制面或节点出现,只在离线的 dn42-recover 工具里使用。
托管防的是「数据库整体丢失」,不防「数据库被读取」——密文与明文私钥在同一个库里。
private_key_ref字段只接受字面私钥。历史上的secret://占位符会让 agent 另生成一把托管密钥、造成公钥分叉,该概念已彻底废除,schema 层强制拒绝 URI 形态的取值。
完整模型与恢复演练见 密钥托管与恢复。
禁止的控制模型
系统刻意不提供以下能力,这是核心安全设计而非缺失:
- ❌ 远程 shell / 任意命令执行接口。
- ❌ 控制面直接 push 命令到节点执行。
- ❌ 节点接收并执行控制面下发的脚本片段。
控制面能表达的只有声明式期望状态(DesiredState)。怎么达成完全由节点本地 Agent 基于本机观测决定(见 架构)。即使控制面被攻陷,攻击者也只能改"想要什么",无法直接在节点上执行任意命令——节点渲染的是受 schema 约束、经模板生成的配置,不是可执行指令流。
写盘与容器边界
- Agent 渲染文件路径强校验(禁
..、绝对路径、NUL、Windows 盘符,见dn42_runtime),原子写盘。 - 容器由受管 label(
dn42.managed等)标识;Agent 只动带本节点 label 的容器,不碰其它容器。 - systemd 单元用
ProtectSystem=strict、NoNewPrivileges=true、受限ReadWritePaths(见 部署)。
多管理员 / RBAC
管理面凭据为**静态 admin token(机器与自动化)加管理员账号(人机登录)**双轨。账号侧目前没有细粒度 RBAC——admin_users.role 是预留字段,当前恒为 admin。
生产应妥善保管 admin token、用 TLS 终止反代(全链路无明文段:CDN 代理时 SSL 模式取 Full (strict),源站强制 HTTPS 与 HSTS)、按需收紧 CORS 白名单。
信任域隔离
控制面上并存四种凭据,彼此不可跨越:
| 凭据 | 解锁范围 | 越界表现 |
|---|---|---|
| 静态 admin token / 账号会话 | /admin/*、/ui/* | — |
| agent token | /agent/*,且只限绑定的那个节点 | payload node_id 不符 403;WS 路径 node_id 不符 4403 |
| 门户会话令牌 | /autopeering/*,按会话内 ASN 划权 | 打不进管理面;他人的资源一律 404(防枚举) |
| auth-server 会话 JWT | /autopeering/session 回显 | 与 OAuth access token 有 audience 区隔,互不通用 |
门户侧复用管理面的 builder 函数,但不复用端点——require_admin 挂在路由级,另起一条挂 require_portal_session 的路由,只共享纯函数。对外字段一律白名单投影而非黑名单剥除,将来 builder 新增字段默认不出网。