把 DNS 服务变为一个聊天室

阮一峰周刊 369 期 中这样一篇文章 破解加拿大航空的飞机上网 当时看了之后觉得很有意思,但没有额外作什么思考。过了两周的某一天早上在上班路上突然想到了这个文章,因为是上班路上有点无聊,所以就思考了一下这个问题: 如果在某一网络中,只有 DNS 协议的请求能成功,那么在这个网络中的人们能否自由彼此通信?

先说结论,肯定是可以的,只需要单建一个特殊的 DNS 服务器就可以了。

架构概览

┌──────────┐    DNS TXT Query     ┌──────────┐    DNS TXT Query     ┌──────────┐
Client A ├─────────────────────>│ Server │<─────────────────────┤ Client B │
│ │<─────────────────────┤ (盲转发) ├─────────────────────>│ │
└──────────┘ DNS TXT Reply └──────────┘ DNS TXT Reply └──────────┘
│ │
│ E2E Key = HMAC-SHA256("dnsay-key-v1", group) │
└───────────────────────────── 共享密钥 ──────────────────────────────┘
  • Client: 负责 E2E 加密/解密、DNS 编码、TUI 交互、昵称注册
  • Server: DNS 服务器,负责会话管理、消息中继、昵称去重,不接触加密密钥

密钥与路由分离

group 名同时用于路由和加密,但通过不同的 HMAC 路径派生,实现域分离

用途 派生方式 长度 可见性
路由 ID HMAC-SHA256("dnsay-route", group)[:8] 8 字节 DNS 流量中可见 (base32 编码)
加密密钥 HMAC-SHA256("dnsay-key-v1", group) 32 字节 仅客户端持有,不传输

安全性: DNS 观察者只能看到路由 ID (8 字节哈希) ,无法反推 group 名,也无法推导加密密钥。

DNS 查询名格式 (QName)

所有通信通过 DNS TXT 查询实现。查询名 (qname) 的标签格式

<RouteID>.<SID>.<Dir>.<Seq-Total>.<QueryNonce>[.<Payload>...].
标签位置 内容 编码 说明
0 路由 ID base32, 无 padding, 小写 8 字节 HMAC 哈希,用于会话匹配
1 会话 ID base32, 无 padding, 小写 8 字节随机值,客户端启动时生成
2 方向 明文 u/p/j/n/l
3 序号-总数 明文 上传 seq-total (如 0-3) ,其他方向为 0
4 查询 Nonce base32, 无 padding, 小写 12 字节随机值,防 DNS 缓存命中
5+ 载荷 base32, 无 padding, 每 30 字符一个标签 uj 方向需要

重要: 载荷使用 base32 而非 base64。原因是 base64 的字母表包含 - 而 DNS 标签 不允许以 - 开头 RFC 1035

方向 (Dir) 说明

方向 含义 载荷 服务端响应
u 上传消息 E2E 密文分块 ok (已注册) / unreg (未注册)
p 轮询消息 base32 编码的多消息帧
j 注册昵称 base32(昵称字节) ok (成功) / dup (重名) / bad (空昵称)
l 离开 ok

E2E 加密流程

加密 (发送方)

plaintext = nickname + '\x00' + message_text


msgNonce = random(12 bytes)


e2e_ct = AES-256-GCM.Encrypt(
key = HMAC-SHA256("dnsay-key-v1", group), // 32 字节
nonce = msgNonce, // 12 字节
data = plaintext,
aad = group // 原始 group 名作为附加认证数据
)


wire_data = msgNonce(12) || e2e_ct(len + 16)

解密 (接收方)

wire_data

├─ msgNonce = wire_data[:12]
├─ e2e_ct = wire_data[12:]


plaintext = AES-256-GCM.Decrypt(
key = HMAC-SHA256("dnsay-key-v1", group),
nonce = msgNonce,
data = e2e_ct,
aad = group
)

├─ nickname = plaintext[:null_byte_index]
└─ message = plaintext[null_byte_index+1:]

加密参数

参数
算法 AES-256-GCM
密钥长度 32 字节
Nonce 长度 12 字节 (随机)
Tag 长度 16 字节
AAD group 名原始字节
每消息开销 28 字节 (12 nonce + 16 tag)

分块传输机制

发送方分块

E2E 密文按 80 字节 分块,每块作为独立 DNS 查询发送。每块重试最多 3 次:

wire_data (nonce || ciphertext || tag)

▼ 按 80 字节切割
┌──────────┐ ┌──────────┐ ┌──────────┐
│ chunk 0 │ │ chunk 1 │ │ chunk 2 │ ...
│ (80B) │ │ (80B) │ │ (<=80B) │
└────┬─────┘ └────┬─────┘ └────┬─────┘
│ │ │
▼ ▼ ▼
DNS Query DNS Query DNS Query
seq=0-3 seq=1-3 seq=2-3
(重试3次) (重试3次) (重试3次)

每个分块的 DNS 查询:

  1. 生成 12 字节随机 QueryNonce (仅防 DNS 缓存)
  2. 分块 base32 编码,每 30 字符一个 DNS 标签
  3. 组装 qname: RouteID.SID.u.seq-total.QueryNonce.payload_labels...
  4. 发送 DNS TXT 查询;失败则生成新 QueryNonce 重试 (最多 3 次)

服务端重组

  • (sid, total) 缓冲分块
  • seq == 0 时重置缓冲区 (处理前一条未完成的消息)
  • 全部块到齐后按序拼接,广播完整 E2E 密文到组内其他会话

大小限制

参数
最大消息载荷 4096 字节
分块大小 80 字节
分块重试次数 3 次
DNS 标签段长 30 字符
DNS 标签上限 63 字符
DNS qname 上限 253 字符
TXT 单条字符串上限 200 字符 (base32)

Poll 响应帧格式

服务端返回多条消息时使用长度前缀帧格式:

┌─────────────────────────────────────────────────────────┐
2B len │ E2E blob 12B len │ E2E blob 2 │ ... │
└─────────────────────────────────────────────────────────┘
│ │
▼ ▼
nonce (12 字节) nonce (12 字节)
ct + tag (N+16) ct + tag (N+16)
  • 长度前缀: 2 字节大端序 uint16 表示后续 E2E blob 长度
  • E2E blob: msgNonce(12) || AES-GCM-Ciphertext || Tag(16)
  • 多条消息顺序拼接
  • 空响应时返回空字符串 TXT 记录

响应大小限制

参数
每次 poll 最大消息数 10
每次 poll 最大字节数 4096
TXT 记录最大长度 200 字符 base32

DNS 传输

帧数据 → base32 编码 → 按 200 字符切分 → 每段作为一条 TXT 记录回复。

客户端: 拼接 TXT 记录 → base32 解码 → 解析帧 → 逐条 E2E 解密。

自适应轮询

         收到消息

┌──────────▼──────────┐
│ interval = 250ms │◀─── 立即回到快速轮询
│ (baseInterval) │
└──────────┬──────────┘
│ 无消息

┌─────────────────────┐
│ interval *= 2
│ cap at 2s │
│ (maxInterval) │
└──────────┬──────────┘
│ 无消息

继续倍增直到 2s
  • 活跃聊天: 每 250ms 轮询一次
  • 空闲时: 250ms → 500ms → 1s → 2s (封顶)
  • 任意消息到达: 立即回到 250ms

昵称管理

注册流程

  1. 客户端启动时调用 RegisterName
  2. 客户端发送 j 方向请求带上昵称
  3. 服务端检查同组 (routeID) 下是否已存在该昵称:
    • 不存在 → 在该 SID 上注册昵称,回复 ok
    • 已存在 → 回复 dup
  4. 客户端收到 dup:
    • 命令行未指定 --name (auto 模式): 自动重新生成,最多重试 10 次
    • 命令行显式指定 --name: 直接退出,提示昵称冲突

离开流程

  • 主动关闭 (ESC / Ctrl+C) : 客户端发送 l 方向请求,服务端立即删除会话和昵称
  • 被动断开 (崩溃 / 断网)会话最后活跃时间停止更新,超过 --timeout (默认 300 秒) 后被 cleanup 删除,昵称随之释放

会话过期自动恢复

如果会话被服务端清理 (如长时间网络中断后恢复) ,客户端再次发送消息时:

  1. 服务端 touch 创建新 session (无昵称)
  2. 服务端回复 unreg 而不是 ok
  3. 客户端检测到 unreg,自动重新注册昵称

完整消息生命周期

 Client A (发送方)                   Server                    Client B (接收方)
────────────────── ──────── ──────────────────
1. 用户输入 "你好"
2. 构建 payload:
"飞翔的开拓者\x00你好"
3. E2E 加密:
nonce(12) || AES-GCM(...)
4. 分块 (80B each)
5. 每块 → DNS TXT Query
qname: RouteID.SID.u.0-1...
(失败自动重试最多 3 次)
6. 解析 qname (base32 解码)
7. addChunk 重组
8. broadcast → B.downq
9. 回复 "ok"/"unreg"
10. Poll Query
qname: RouteID.SID.p.0...
11. popMessages
12. 帧编码 + base32
13. TXT 记录回复
14. base32 解码
15. 解析帧
16. E2E 解密
17. 解析 name\x00message
18. TUI 显示:
"飞翔的开拓者: 你好"

服务端会话状态

每个 SID 对应一个 session

type session struct {
grp []byte // 路由 ID (同组归属)
name string // 注册的昵称 (可能为空)
downq [][]byte // 待投递消息队列
last int64 // 最后活跃时间戳
msgBuf *msgBuffer // 多块上传重组缓冲
}

服务端职责

  • 解析 DNS qname 提取路由 ID 和会话 ID
  • 按路由 ID 匹配同组会话
  • 重组分块消息
  • 广播 E2E 密文到组内其他会话
  • 注册和检查昵称唯一性
  • 管理会话生命周期 (10 秒一次 cleanup,超时清理)
  • 不持有任何加密密钥,不解密消息内容

全流程

服务端收到 DNS 查询

├─ 有 Question? ──否──> 静默丢弃
│ │
│ |是
│ │
├─ 查询类型 TXT? ──否──> TXT "ok"
│ │
│ |是
│ │
├─ 解析域名标签 (parseQName)
│ ├─ 标签数 ≥ 5? ──否──> TXT "ok"
│ ├─ labels[0] = group routeID (Base32)
│ ├─ labels[1] = sid (Base32)
│ ├─ labels[2] = dir (u/p/j/l)
│ ├─ labels[3] = "seq[-total]"
│ ├─ labels[4] = query nonce (DNS 去重,不使用)
│ ├─ labels[5..] = payload (Base32,可空)
│ └─ 任一解码/解析失败? ──> TXT "ok"

├─ 更新会话 touch(sid, grp)
│ └─ 若不存在则创建会话;记录 grp 与 last 时间戳
│ (注: 服务端不持有密钥,不解密任何 payload)

├─ 按 dir 分发
│ │
│ ├─ dir = "u" 上行消息 (E2E 密文)
│ │ │
│ │ ├─ total ≤ 1 (单包)
│ │ │ └─ broadcast(grp, sid, payload)
│ │ │ └─ 把密文塞进同组其他会话的 downq
│ │ │
│ │ ├─ total > 1 (分片)
│ │ │ ├─ addChunk(sid, seq, total, payload)
│ │ │ ├─ addChunk(sid, seq, total, payload)
│ │ │ │ ├─ total 变化或 seq=0 ──> 重置缓冲
│ │ │ │ └─ 收齐全部分片 ──> 拼接整包
│ │ │ └─ 拼齐后 broadcast(grp, sid, full)
│ │ │
│ │ └─ 已注册昵称? ── 是 ──> TXT "ok"
│ │ └─ 否 ──> TXT "unreg"
│ │
│ ├─ dir = "p" 轮询拉取
│ │ │
│ │ ├─ popMessages(sid, maxPollCount=10, maxPollBytes=4096)
│ │ │ └─ 取出 downq 内若干条,受条数与字节上限约束
│ │ │
│ │ ├─ 拼装帧: [len_hi, len_lo, msg] × N (无消息则空 buf)
│ │ │
│ │ └─ replyData
│ │ ├─ buf 为空 ──> TXT ""
│ │ └─ 否则 Base64URL → 按 maxTXTLength=200 切片
│ │ └─ 多条 TXT "<片1>" "<片2>" ...
│ │
│ ├─ dir = "j" 加入群组 / 注册昵称
│ │ │
│ │ ├─ payload = 昵称
│ │ ├─ 昵称为空? ──> TXT "bad"
│ │ └─ register(sid, grp, name)
│ │ ├─ 同组内已存在同名(他人)? ──> TXT "dup"
│ │ └─ 否则写入 grp+name ──> TXT "ok"
│ │
│ ├─ dir = "l" 离开群组
│ │ ├─ remove(sid) 删除会话
│ │ └─ TXT "ok"
│ │
│ └─ dir = 其他 ──> TXT "noop"

└─ 发送 DNS 响应
(replyText: 单条字符串;replyData: 长数据 Base64URL + 多 TXT 分片)

并行: 周期性清理 (每 10s)
└─ 移除 last 早于 timeout 的会话

最后就是实现