diff --git a/easytier/src/dns/readme.md b/easytier/src/dns/readme.md new file mode 100644 index 00000000..64b74dab --- /dev/null +++ b/easytier/src/dns/readme.md @@ -0,0 +1,562 @@ +# EasyTier DNS 模块设计说明(重构版) + +> 本文档基于 `easytier/src/dns` 当前代码实现与 `plan.md`。 +> 当前先给出第一部分:**整体架构与基础逻辑**。 + +## 1. 模块目标与定位 + +`dns` 模块是对旧 `instance/dns_server` 方案的重构,目标是把 DNS 能力从“单点功能”升级为“可同步、可扩展、可自治”的子系统。它同时承担三类职责: + +1. **本机 DNS 服务能力**: + - 能监听配置中的 `listeners`(UDP/TCP)作为标准 DNS server。 + - 能对配置中的 `addresses` 做流量劫持(UDP DNS + ICMP echo)。 + +2. **多 Peer DNS 配置同步能力**: + - 每个实例作为 `DnsNode` 生成快照并定期心跳。 + - 机器上被选举出的 `DnsServer` 聚合所有 Node 快照并动态重建 Catalog。 + +3. **系统 DNS 接入能力**(tun 场景): + - 把 DNS nameserver/search/match domain 写入系统配置(当前主要是 Windows/macOS,Linux 仍在演进中)。 + +--- + +## 2. 顶层架构(角色分层) + +从职责上看,模块分成 4 层: + +- **配置层**(`config/*`) + - 解析 TOML 的 `[dns]`、`[[dns.zone]]`、策略字段。 + - 产出 `DnsConfig`、`ZoneConfig`,并提供默认值(如默认域名 `et.net`、默认地址 `100.100.100.101:53`)。 + +- **节点层(控制面)**(`node.rs` + `peer_mgr.rs`) + - `DnsNode`:本实例的 DNS 控制器,负责选举、心跳、事件监听、重建 snapshot。 + - `DnsPeerMgr`:维护远端 peer 的 DNS 摘要与配置拉取,拼装 `DnsSnapshot`。 + +- **服务层(数据面)**(`server.rs` + `node_mgr.rs` + `zone.rs`) + - `DnsServer`:真正处理 DNS 请求、维护监听 socket、管理 hijack addresses。 + - `DnsNodeMgr`:服务端的快照管理器,接收 Node 心跳,维护节点 TTL 与 dirty 状态。 + - `Zone`/`ZoneGroup`:把 records + forwarders 变成 Hickory `Authority` 并装配 `Catalog`。 + +- **系统集成层**(`system/*`) + - 将当前 DNS 配置下发到 OS(`SystemConfigurator` 抽象)。 + - 服务退出/变更时负责清理或覆盖。 + +--- + +## 3. 关键对象与数据模型 + +- **`DnsConfig`**(`config/dns.rs`) + - 核心字段:`zones`、`policies`、`name`、`domain`、`addresses`、`listeners`。 + - `get_fqdn()` 用 `name + domain` 生成本机 FQDN。 + +- **`ZoneData` / `Zone`**(`proto/dns.proto` + `zone.rs`) + - `ZoneData` 是网络传输模型(protobuf),含 `id/origin/ttl/records/forwarders`。 + - `Zone` 是运行期模型: + - `records -> InMemoryAuthority` + - `forwarders -> ForwardAuthority` + - 同 origin 可链式共存(ChainedAuthority 语义)。 + +- **`DnsSnapshot`**(`proto/dns.proto`) + - Node 发给 Server 的完整状态:`zones + addresses + listeners`。 + +- **`HeartbeatRequest`** + - 发送 `id + digest + optional snapshot`。 + - digest 一致时可只发轻量心跳,不带 snapshot。 + +- **`DirtyFlag`**(`utils/dirty.rs`) + - 全模块统一的“脏标记 + 通知器”,用于节流和增量触发(不是每次事件都全量重建)。 + +--- + +## 4. 基础运行逻辑(主链路) + +### 4.1 本地节点启动 + +`Instance` 在 `magic-dns` feature 下创建并启动 `DnsNode`。`DnsNode` 启动后并行跑两个循环: + +1. **选举循环**(`run_election`) + - 周期尝试绑定固定 RPC 地址 `tcp://127.0.0.1:49813`。 + - 绑定成功者成为本机 `DnsServer` 持有者;失败者继续只做 `DnsNode`。 + +2. **主循环**(`run`) + - 监听配置变更/IP 变化/PeerInfo 更新。 + - 维护 dirty 状态并按节奏发送 heartbeat。 + +### 4.2 快照构建与同步 + +`DnsPeerMgr::snapshot()` 组装快照: + +- 本机 zones:`dns_iter_zones()`(包含“自有专用 zone” + 用户配置 zone)。 +- 远端 zones:从 peer RPC 拉取并缓存的 export zones。 +- 本机 `addresses/listeners`:来自 `DnsConfig`。 + +Node 发送 heartbeat 时: + +- dirty 或首包 -> 带 `snapshot` 全量发送。 +- 未 dirty -> 只发 `digest`(轻量心跳)。 +- Server 返回 `resync=true` -> 立刻补发全量 snapshot。 + +### 4.3 服务端聚合与生效 + +`DnsNodeMgr` 收到 heartbeat 后: + +- 若 snapshot digest 改变:更新节点缓存并标记 dirty(catalog/addresses/listeners 分开标记)。 +- 若仅 digest 且本地无该节点或不一致:返回 `resync=true`。 + +`DnsServer::run()` 有三个独立 reload 循环: + +- `reload_catalog`:替换 `DynamicCatalog`。 +- `reload_addresses`:更新 hijack 地址,并尝试下发系统 DNS。 +- `reload_listeners`:重绑 DNS listener socket。 + +这三个循环彼此解耦,避免单一失败阻塞全部 DNS 功能。 + +--- + +## 5. 数据面请求路径(DNS/ICMP 劫持) + +`DnsServer` 作为 `NicPacketFilter` 挂入 packet pipeline: + +1. 检查目的 IP 是否命中 `addresses`。 +2. UDP: + - 解析 DNS 请求 -> 投递给 `catalog.handle_request()`。 + - 用响应覆盖原 UDP payload,修正长度与校验和。 +3. ICMP: + - 对 EchoRequest 直接改写为 EchoReply。 +4. 最后交换源/目的 IP,并把包回注到本机 peer pipeline。 + +这使得 `addresses` 不要求真实 bind/listen,也能作为“虚拟 DNS 入口地址”。 + +--- + +## 6. 可靠性与收敛机制 + +- **服务高可用(单机维度)**: + - 任何实例都可竞选 Server;现任退出后其余实例会重试接管。 + +- **配置高效同步(全网维度)**: + - `RoutePeerInfo` 只传播 DNS digest,不直接携带全量记录。 + - digest 变化后才通过 RPC 拉取详情,降低路由泛洪压力。 + +- **自动过期清理**: + - `DnsNodeMgr` 通过 `moka::Cache` TTL 自动淘汰失联节点配置(心跳过期)。 + +- **回环防护**: + - 重建 zones 时会从 forwarders 中剔除本地 `addresses/listeners`,避免显式自环。 + +--- + +## 7. 当前实现状态(对应 plan.md) + +从代码可见,以下主干能力已经落地: + +- Node/Server 双角色、选举、心跳与 resync。 +- 快照机制(zone/addresses/listeners)与 digest 驱动同步。 +- 自有专用 zone 自动生成与 export。 +- Catalog 动态替换、listener/address 分离热更新。 +- UDP DNS 劫持 + ICMP 响应。 +- forwarder 的本地回环剔除。 + +仍在计划中的重点: + +- 系统 DNS 配置改造(尤其 Linux 路径统一与清理语义完善)。 +- 更完整的单元测试覆盖与 CLI 状态输出。 + +--- + +## 8. 配置层详解(`config/*`) + +这一层负责把 TOML 配置映射成可校验、可传播、可计算 digest 的运行模型。 + +### 8.1 常量与默认值(`config/mod.rs`) + +- `DNS_DEFAULT_TLD = et.net.`:`domain` 缺省值。 +- `DNS_DEFAULT_ADDRESS = udp://100.100.100.101:53`:`addresses` 缺省值。 +- `DNS_SERVER_RPC_ADDR = tcp://127.0.0.1:49813`:本机 DNS Server 选举地址。 +- `DNS_SERVER_ELECTION_INTERVAL = 5s`:选举重试周期。 +- `DNS_SUPPORTED_PROTOCOLS = [Udp, Tcp]`:地址/转发器协议白名单。 + +### 8.2 `DnsConfig`(`config/dns.rs`) + +`DnsConfig` 是 `[dns]` 根配置,关键点如下: + +- `zones: Vec` 对应 `[[dns.zone]]`。 +- `policies: HashMap` 用 `#[serde(flatten)]` 承接 `[dns."origin".import]` 形式策略。 +- `name/domain` 组合 FQDN。 +- `addresses/listeners` 使用 `NameServerAddrGroup`(支持 `ip`、`ip:port`、`udp://`、`tcp://` 解析)。 + +约束与语义: + +- `deserialize_addresses()` 强制 `addresses` 只能是 UDP(与当前 hijack 数据面能力一致)。 +- `get_name()`:若 `name` 为空,回退系统 hostname。 +- `get_fqdn()`:将 `name` 拼接 `domain` 得到完整域名。 +- `set_fqdn()`:反向拆分 FQDN 到 `name` 和 `domain`。 + +### 8.3 `ZoneConfig` 与专用 Zone(`config/zone.rs`) + +`ZoneConfig` 由两部分构成: + +- `ZoneData`:用于 protobuf 传输(`id/origin/ttl/records/forwarders`)。 +- `ZoneConfigInner`:配置层字段(含 policy)。 + +关键设计: + +- `TryFrom for ZoneConfig` 会立即调用 `Zone::try_from(&ZoneData)` 做语法校验,确保“能进配置就能进运行时”。 +- `ZoneConfig::dedicated(...)` 用于自动生成“本节点专用 zone”: + - `origin = 节点 fqdn` + - records 自动填充 `@ IN A/AAAA ...` + - `policy.export = Some(default)`,默认可导出给 peers。 + +### 8.4 策略结构体现状(`config/policy.rs`) + +策略模型已就位,但功能并未完全落实到执行路径: + +- `AclPolicy { whitelist, blacklist }` +- `FunctionalityPolicy { disabled }` +- `DnsPolicy { recursive }` + +目前代码中的直接使用点主要是: + +- `dns_export_config()` 只检查 `zone.policy.export.is_some()` 决定是否导出。 +- `import/recursive/acl` 仍处于待完整落地状态(与 `plan.md` 的 TODO 对齐)。 + +### 8.5 `DnsGlobalCtxExt`:配置到发布面的桥(`config/dns.rs`) + +`GlobalCtx` 被扩展出 3 个关键方法: + +- `dns_self_zone()`:基于当前 IP 与 FQDN 生成专用 zone。 +- `dns_iter_zones()`:`self_zone + 用户配置 zones`。 +- `dns_export_config()`:从 `dns_iter_zones()` 中筛选可导出的 zones,并附加本机 `fqdn`。 + +这三个方法是后续 `RoutePeerInfo.dns` digest 与 RPC 拉取的源头。 + +--- + +## 9. 节点控制面详解(`node.rs` + `peer_mgr.rs`) + +### 9.1 `DnsNode` 初始化与 RPC 注册 + +`DnsNode::new(...)` 会创建 `DnsPeerMgr`,并把 `DnsPeerMgrRpcServer` 注册到 peer RPC registry。 +这使“我给别人提供 DNS 导出配置”与“我向别人拉取导出配置”在同一组件闭环。 + +### 9.2 选举循环(`DnsNode::run_election`) + +选举逻辑是“抢占固定地址”的单机 leader 机制: + +1. 周期或被 `elect.notify_one()` 触发。 +2. 尝试 `StandAloneServer(TcpTunnelListener(DNS_SERVER_RPC_ADDR)).serve()`。 +3. 绑定成功 -> 启动 `DnsServer`,注册 `DnsNodeMgrRpc`,并挂载 NIC packet pipeline。 +4. `DnsServer` 退出后清理 pipeline,回到选举循环。 + +要点: + +- 不依赖外部分布式锁,仅利用本机 socket 独占。 +- 失败不是错误态,意味着“已有实例担任 Server”。 + +### 9.3 主循环(`DnsNode::run`) + +主循环负责“何时重建、何时发全量、何时触发重选举”: + +- 维护 `HeartbeatRequest { id, digest, snapshot? }`。 +- 基于 `DirtyFlag` 动态调整心跳节奏: + - dirty 时更积极(`rr_interval`) + - clean 时更快短轮询(`rr_interval / 8`) +- 监听 `GlobalCtxEvent`: + - `PeerInfoUpdated` -> 并发调用 `mgr.refresh(peer_id)` + - IP 变化、配置变化、事件丢失(lagged)-> `dirty.mark()` +- 心跳失败 -> 触发一次选举通知(可能是 Server 挂了)。 + +### 9.4 心跳协议(`DnsNode::heartbeat`) + +发送策略: + +- 首次或 dirty -> `heartbeat.update(self.mgr.snapshot())`,发送全量 snapshot。 +- 非 dirty -> 尽量只发 digest(轻量包)。 + +服务端响应: + +- `resync = true` 时,客户端立刻再发一次带 snapshot 的心跳。 + +这实现了“正常轻量保活 + 状态漂移时快速自愈”。 + +### 9.5 `DnsPeerMgr`:远端配置拉取与去抖 + +`DnsPeerMgr` 核心职责: + +- 本地缓存:`Cache`(TTL = 3s)。 +- `refresh(peer_id)`: + - 先读路由里的 `route.dns` digest。 + - 若与本地缓存一致则跳过 RPC。 + - 不一致才调用 `fetch(peer_id)` 拉取 `GetExportConfigResponse`。 +- `snapshot()`:拼接 + - 本机 zones(`dns_iter_zones()`) + - 所有远端缓存 zones + - 本机 addresses/listeners + +这正是 `plan.md` 中“RoutePeerInfo 仅携带 hash,详情按需拉取”的落地实现。 + +--- + +## 10. 服务聚合与数据面详解(`node_mgr.rs` + `server.rs`) + +### 10.1 `DnsNodeMgr`:服务器侧状态机 + +`DnsNodeMgr` 保存每个 Node 的最新状态: + +- `nodes: Cache`(TTL = 5s,心跳过期即自动淘汰)。 +- `DnsNodeInfo = digest + zones + addresses + listeners`。 +- `dirty` 分三类:`catalog`、`addresses`、`listeners`。 + +`heartbeat()` 判定逻辑: + +- 请求带 snapshot: + - 反序列化为 `DnsNodeInfo`。 + - digest 变化才更新缓存并打脏标记。 + - 此分支返回 `resync = false`。 +- 请求不带 snapshot: + - 若本地没有该 node 或 digest 不一致 -> `resync = true`。 + +### 10.2 Catalog 构建(`DnsNodeMgr::catalog/collect_zones`) + +构建步骤: + +1. 聚合全部节点 zones。 +2. 追加 `Zone::system()` 作为 root zone。 +3. 收集本地所有 `addresses + listeners` 形成 `local` 集合。 +4. 遍历每个 zone 的 forwarders,剔除命中 `local` 的 nameserver(避免显式回环)。 +5. 以 `origin -> authorities[]` 方式 `upsert` 到 Hickory `Catalog`。 + +### 10.3 `DnsServer::run`:三路热重载 + +`DnsServer` 使用 3 个异步循环处理不同脏标记: + +- `reload_catalog`:`DynamicCatalog::replace(...)` 原子替换目录。 +- `reload_addresses`:更新劫持地址集合,并尝试下发系统 DNS。 +- `reload_listeners`:重建 `ServerFuture` 的 UDP/TCP 监听 socket。 + +每路失败都会重新 `mark()` 自己,避免瞬时错误导致永久失效。 + +### 10.4 listener/address 的行为边界 + +- `listeners`:真正 bind 的服务地址;单个地址 bind 失败会打印错误并跳过,不导致整体停机。 +- `addresses`:仅用于劫持匹配,不需要 bind;可用于 `no_tun=false` 下的虚拟 DNS 入口。 +- `addresses` 与 `listeners` 分离,符合 `plan.md` 中“hijack 地址不等于监听地址”的设计。 + +### 10.5 NIC 数据面处理(`NicPacketFilter`) + +处理链: + +1. `handle_ip_packet()` 解析 IPv4 头并检查目标 IP 是否属于 hijack 地址集合。 +2. UDP 分支: + - `MessageRequest::from_bytes` 解包 DNS 查询。 + - 交给 `catalog.handle_request(...)` 获取响应。 + - 回填 payload,修正 UDP/IP 长度与 checksum。 +3. ICMP 分支: + - EchoRequest 改写为 EchoReply。 +4. 统一收尾:交换 src/dst IP,并把包路由回本机 `peer_id`。 + +该路径让 DNS 响应无需经过用户态 socket recv/send,直接在 packet pipeline 内完成。 + +--- + +## 11. Zone 组装与权威链详解(`zone.rs`) + +### 11.1 `Zone` 运行时模型 + +`Zone` 包含: + +- `id: Uuid`(来源于配置/网络数据) +- `origin: LowerName` +- `records: BTreeMap` +- `forward: Option` + +`PartialEq` 对 `forward` 使用自定义比较(只比较 nameserver 序列),避免与无关字段耦合。 + +### 11.2 反序列化与校验(`TryFrom<&ZoneData>`) + +转换过程: + +1. 必须有 `id`,否则报错。 +2. 用 Hickory `Parser` 解析 zone 文本(origin + RR)。 +3. 把 `forwarders` URL 转成 `NameServerAddr`,为空则 `forward=None`。 + +这确保网络收到的 `ZoneData` 能直接映射成可执行 authority。 + +### 11.3 Authority 构建策略 + +- `create_memory_authority()`:仅当 records 非空时创建 `InMemoryAuthority`。 +- `create_forward_authority()`:仅当 forward 非空时创建 `ForwardAuthority`。 + +因此允许 3 种 zone 形态: + +1. 纯记录(权威回答) +2. 纯转发(forward-only) +3. 记录 + 转发(链式) + +### 11.4 `ZoneGroup` 与同源链式行为 + +- `ZoneGroup::into_groups()` 按 `origin` 分组。 +- `iter_authorities()` 对每个 zone 按顺序产出:先 memory,再 forward。 +- `DnsNodeMgr::catalog()` 把同 origin 的多个 zone authority 以数组形式 `upsert`。 + +结果是同 origin 下可自然形成 ChainedAuthority,不做“硬合并单 Zone”,与 `plan.md` 一致。 + +### 11.5 `Zone::system()` 的作用边界 + +`Zone::system()` 读取系统 resolver 作为 root zone forwarders。 +在当前文档范围内仅关注它在 catalog 聚合中的语义:**兜底递归出口**。 + +--- + +## 12. 文档后续范围 + +后续若继续扩写,将集中在以下主题(不再展开 `system/*`): + +1. 策略执行链路补齐:`import/recursive/acl` 如何从配置走到查询路径。 +2. 测试矩阵梳理:单元测试、集成测试与故障注入测试的覆盖面。 +3. CLI 状态输出:如何观测 node/server 角色、snapshot digest、zone 来源与健康状态。 + +--- + +## 13. 策略执行链路现状与缺口 + +本节专门回答一个容易误解的问题:**配置里有策略字段,不等于运行时已经完全执行**。 + +### 13.1 已生效的策略相关行为 + +当前代码中,和策略直接相关且已生效的路径主要有一条: + +- `GlobalCtx::dns_export_config()` 在导出 zones 时仅检查: + - `zone.policy.export.is_some()` + +也就是说,当前“导出/不导出”是可工作的,但粒度仍偏粗。 + +### 13.2 已建模但尚未完整落地的策略字段 + +以下字段在 `config/policy.rs` 已定义,但执行链路尚未完全打通: + +- `import.whitelist / import.blacklist` +- `import.disabled` +- `import.recursive` +- `export` 内更细粒度 ACL + +从调用路径看: + +- `DnsPeerMgr::snapshot()` 只做本地 + 远端 zones 拼接,不做 import/export ACL 过滤。 +- `DnsNodeMgr::collect_zones()` 只做聚合与回环剔除,不做来源级策略裁剪。 +- `DnsServer::handle_ip_packet()` 是纯查询执行,不做请求来源与策略绑定。 + +### 13.3 代码中的明确信号(TODO) + +当前有两个关键 TODO 信号: + +- `dns_export_config()` 里标注了 `TODO: check policies of parent zones`。 +- `policy.rs` 中 `AclPolicy`、`recursive` 旁边保留了 TODO 注释。 + +这说明作者已经把策略模型前置到配置层,但执行面仍属于“进行中”。 + +### 13.4 文档使用建议(给维护者) + +在策略彻底落地前,建议把语义按两层理解: + +1. **已可依赖**:`zone.policy.export.is_some()` 控制是否导出。 +2. **暂不可依赖**:import/export ACL、recursive、disabled 的全链路行为。 + +--- + +## 14. 测试体系与覆盖面 + +本模块测试不是集中在一个文件,而是“按组件就地内嵌”。 + +### 14.1 测试分布 + +- `dns/tests.rs`:测试基建与辅助函数(构造环境、启动 `DnsNode`、DNS 查询断言工具)。 +- `dns/server.rs`:数据面与 server 行为主测试集。 +- `dns/node_mgr.rs`:聚合 catalog 的基本可用性测试。 +- `dns/zone.rs`:配置解析、记录转换、authority 装配测试。 + +> 说明:`system/*` 也有测试,但本轮文档按约定不展开。 + +### 14.2 `server.rs` 覆盖要点 + +`server.rs` 的测试集中验证了以下核心行为: + +- `DynamicCatalog::replace()` 可安全替换。 +- hijack 判定:`is_hijacked_ip` / `is_hijacked_addr`。 +- ICMP 改写:EchoRequest -> EchoReply。 +- UDP DNS 包内联处理:解析请求、生成应答、回填 payload。 +- 一个基础端到端路径:真实 UDP listener + Hickory client 查询。 + +这些测试对应模块里最复杂、最容易回归的包处理逻辑。 + +### 14.3 `node_mgr.rs` 覆盖要点 + +`node_mgr.rs` 的测试重点是: + +- 人工插入节点 zone 后,`catalog()` 能查到预期记录。 + +它验证了“快照聚合 -> Catalog 查询可用”的最小闭环,但尚未覆盖复杂心跳时序、TTL 过期后的清理行为。 + +### 14.4 `zone.rs` 覆盖要点 + +`zone.rs` 的测试覆盖了: + +- TOML `DnsConfig` 解析。 +- `ZoneConfig -> ZoneData -> Zone` 转换链。 +- record 解析/TTL 基本行为。 +- memory/forward authority 构建,以及通过 server 查询验证。 + +该测试更多是“模型与解析正确性”,不是策略执行链路完整验证。 + +### 14.5 当前测试缺口 + +结合 `plan.md` 与现有测试,仍建议补充: + +- `DnsNode` 心跳 + resync + 重选举的并发时序测试。 +- `DnsNodeMgr` TTL 过期淘汰与脏标记联动测试。 +- 策略字段(import/export ACL、recursive)的行为测试。 +- 多 peer、同 origin 多 zone 的优先级/去重回归测试。 + +--- + +## 15. CLI 与可观测性现状 + +### 15.1 CLI 现状 + +从当前代码看,`easytier/src/easytier-cli.rs` 没有 DNS 专用子命令。 +因此“查看 DNS 子系统状态”主要依赖日志与通用状态接口,而非专门 CLI 面板。 + +### 15.2 日志观测点(已存在) + +`dns` 子系统已经布置了较多 `tracing` 埋点,关键入口包括: + +- `DnsNode election loop` +- `DnsNode main loop` +- `DnsServer main loop` +- `DnsNodeMgr::heartbeat`(含来源 id 与 snapshot 信息) + +可用于定位: + +- 当前实例是否赢得选举。 +- 心跳是否失败、是否触发 `resync`。 +- catalog/addresses/listeners 是否持续重载失败。 + +### 15.3 当前可观测性短板 + +- 缺少 DNS 专项 CLI 展示: + - 本机角色(Node/Server) + - 当前 snapshot digest + - zone 来源与数量 + - 监听地址与 hijack 地址状态 +- 缺少结构化指标(metrics),目前偏日志驱动排障。 + +### 15.4 建议的最小可观测面 + +后续若补 CLI,可先实现一个最小 DNS 状态视图: + +1. 角色与选举状态(是否持有 `DNS_SERVER_RPC_ADDR`)。 +2. 最近心跳时间、`resync` 次数。 +3. 已装载 zone 数量(按本地/远端分组)。 +4. listeners 与 addresses 当前集合。 + +该视图不改变数据面行为,但能显著降低线上排障成本。