整理BIND 9配置:把琐碎的子域合并为一个zone

• 本文约4158字,阅读大致需要9分钟 • SysAdmin

我的权威DNS跑在BIND 9上,采用Hidden Master架构,并开启了DNSSEC的inline-signing。

家里用不同的VLAN和网段做了隔离。早期规划时,我简单地把网段和子域做了一对一映射,每个子域都是一个独立的zone:

flowchart LR
    root["example.net
(主域名)"] sjc["sjc.example.net"] root --> sjc sjc --> guest["guest.sjc.example.net"] sjc --> iot["iot.sjc.example.net"] sjc --> tiot["tiot.sjc.example.net"] sjc --> t["t.sjc.example.net"] sjc --> ut["ut.sjc.example.net"]

这些子域基本都由内网的DHCP服务器通过动态更新(RFC 2136)维护。这样,在内网访问某台机器时,不必去查它拿到了什么IP地址,直接用主机名加上所在的VLAN就行。不过时间长了,这套配置带来了很多不必要的维护负担:

  1. 每个zone都有自己的一套DNSSEC密钥。我配置的dnssec-policy为每个zone生成一把Ed25519(RFC 8080)CSK,对应K*.key、K*.private、K*.state三个文件,目录里还堆满了各种.signed、.jnl和临时的.jbk文件。
  2. 每多委派一个签名子zone,父zone就得多维护一组NS和DS记录。密钥轮换时,如果旧DS还在缓存里,对应的DNSKEY却已经撤下,验证就会失败,客户端看到的就是SERVFAIL。
  3. named.conf里写满了大同小异的zone声明和ACL,Hidden Master后面的每台辅DNS服务器上,也都得再为它们各配一遍。

这些子域都由同一台DHCP服务器、用同一把TSIG密钥更新,拆成独立的zone在安全性方面没有必要。

今天花了些时间撤销这5个子域的独立zone和委派,把记录并进sjc.example.net。主机名不变,例如myhost.iot.sjc.example.net依然按原样解析,只是不再单独维护iot.sjc这个zone。

一、合并操作流程

先把各子域在journal里的最新数据落盘并导出,去掉不再需要的记录后并入父zone,再删掉多余的zone声明。

操作期间没有停掉named,不过这个过程中漏删了委派记录,因此还是造成了一小段时间名字解析失败。

1. 冻结zone

先用rndc冻结相关的zone,防止操作期间有新的DHCP租约写进来:

1
2
3
4
5
rndc sync -clean
rndc freeze sjc.example.net
for sub in guest iot tiot t ut; do
    rndc freeze ${sub}.sjc.example.net
done

冻结期间,DHCP仍可分配租约,但相应的DNS更新会失败。家里用户少,我接受了这个短暂的不一致;解冻也不会自动补回这些失败的更新,但后续租约续约时,DHCP会自动更新记录。

rndc freeze本身会把journal里的变更同步进zone文件,并在解冻之前拒绝所有动态更新;前面的rndc sync -clean会先同步再删除journal,不带zone名时作用于所有zone。它不暂停更新,因此不能代替freeze;这里其实可以省掉。

2. 导出各zone的数据

用named-compilezone 把各个zone的数据导出为full格式。这种格式每条记录都带完整的owner name和TTL,方便用脚本处理。下面读取的是未签名的文本zone文件;如果原文件使用BIND的raw格式,还需要加-f raw。输出文件虽然用了.raw后缀,内容仍是-F text指定的文本:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
cd /usr/local/etc/namedb/dynamic/

# 导出sjc
named-compilezone -F text -s full \
  -o sjc.raw \
  sjc.example.net \
  sjc.example.net

# 依次导出各个子域
for sub in guest iot tiot t ut; do
    named-compilezone -F text -s full \
      -o ${sub}.raw \
      ${sub}.sjc.example.net \
      ${sub}.sjc.example.net
done

3. 拼接记录并规范化

这5个子zone没有再向下委派。合并时去掉各自的SOA、顶点NS和签名相关记录,保留A、AAAA、TXT等业务记录,包括DHCP用于冲突检测的TXT或DHCID记录。由于使用了inline-signing,这里导出的是未签名的那份zone文件,自动生成的DNSKEY、RRSIG和NSEC等记录在签名副本中。下面仍按记录类型排除它们,避免把旧zone的签名数据带进父zone。覆盖回原始zone文件时,别忘了递增SOA的serial:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# 当时漏了父zone里的委派记录,修正命令见下一节
cp sjc.raw sjc.merged

# 过滤掉SOA、NS和DNSSEC相关记录,只保留主机数据
for sub in guest iot tiot t ut; do
    awk '$4 !~ /^(SOA|NS|DNSKEY|CDS|CDNSKEY|NSEC|NSEC3|NSEC3PARAM|RRSIG)$/' \
        ${sub}.raw >> sjc.merged
done

# 先手动递增sjc.merged中的SOA serial,再规范化并覆盖原始文件
named-compilezone -F text -s relative -o sjc.example.net sjc.example.net sjc.merged

📓 注意

上面保留了当时漏删父zone委派记录的步骤,不能原样照抄。cp那一步的修正在下一节。

full格式的第4列是记录类型,按这一列过滤不会误删TXT内容里恰好带有NS等字样的记录。如果子zone还有更下一级的委派,就不能这样删除所有NS,必须保留那些委派及其DS。

旧子域的.jnl、.signed和密钥文件,等主辅服务器都撤销旧zone、确认解析正常后再清理。sjc.example.net自己的.signed文件则不要动:解冻时BIND会比对未签名zone的变化,维护签名副本,不需要手工重建。

4. 修改named.conf与DHCP服务器配置

把named.conf中guest.sjc、iot.sjc等子域的zone块全部删除,只保留sjc.example.net。

原先各个子域都授权DHCP服务器使用的TSIG(RFC 8945)密钥dhcpd-key进行更新。为了让它继续生效,在sjc.example.net的update-policy 中使用zonesub规则:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
zone "sjc.example.net" {
    type primary;
    file "/usr/local/etc/namedb/dynamic/sjc.example.net";
    dnssec-policy ed25519only;
    inline-signing yes;

    update-policy {
        grant dhcpd-key zonesub ANY;
    };

    allow-transfer { trusted; };
};

ed25519only是我另行定义的策略名,trusted和dhcpd-key也需要已有的ACL和密钥定义。zonesub覆盖整个zone,包括zone顶点和下级名字;ANY匹配除NSEC、NSEC3以外的记录类型,仍受DNS动态更新本身的约束。因此dhcpd-key可以继续更新myhost.iot.sjc.example.net,但这项授权也允许修改父zone里的其他记录,范围相当宽。

DHCP服务器的配置也要检查。如果使用nsupdate 且没有用zone指令指定zone时,它会先对要更新的名字发一个SOA查询,从应答中得知实际属于哪个zone,原来属于iot.sjc的名字会自动识别为sjc下的记录。ISC dhcpd则可以在dhcpd.conf中用zone块明确指定更新目标、主服务器和TSIG密钥:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
# 合并前:为每个子域单独指定zone
zone guest.sjc.example.net. {
    primary 192.0.2.1;
    key dhcpd-key;
}
zone iot.sjc.example.net. {
    primary 192.0.2.1;
    key dhcpd-key;
}
# ...

如果原先配置了与各个子域对应的zone,dhcpd就会以这些子zone为更新目标。此时BIND端已经撤销了子域zone,更新就会被拒绝。因此需要把dhcpd.conf里的这些子域声明合并为一个:

1
2
3
4
5
# 合并后:统一指向父zone
zone sjc.example.net. {
    primary 192.0.2.1;
    key dhcpd-key;
}

检查配置并重启dhcpd,使新配置生效。这里改的是正向更新的目标zone,各网段原有的主机名后缀不必改变,反向PTR更新也仍归各自的反向zone。

辅DNS服务器那边,也要把这几个子域的zone声明删掉。

最后执行rndc reconfig,让BIND删掉配置中已经不存在的zone,再执行rndc thaw sjc.example.net解冻。解冻时BIND会从磁盘重新加载zone,加载完成后恢复接受动态更新。

二、踩坑:父zone里残留的NS和DS

解冻之后,用delv验证一下解析:

delv @bns.example.net Pixel-10a.ut.sjc.example.net. A +rtrace

结果直接报错:resolution failed: failure。

查了一下,发现sjc.example.net里还留着各个子域的DS记录:

$ dig @bns.example.net ut.sjc.example.net DS +dnssec
...
;; ANSWER SECTION:
ut.sjc.example.net. 10800 IN DS 39590 15 2 029B9528...

问题出在第三步。我只过滤了子域导出文件中的记录,而委派子域用的NS和DS记录本来就在父zone里,cp sjc.raw sjc.merged时被原封不动地带了过来。

先看NS。它意味着ut.sjc仍然是一个委派点,从它往下的主机记录都被视为委派之下的数据。删除子zone之后,父zone仍会对这些主机名返回指向ut.sjc的referral,而不会把刚合并进去的数据作为权威答案。如果委派指向的服务器都已撤销子zone,递归解析器就找不到有效的权威答案,可能返回SERVFAIL。

DS则为这个委派建立了DNSSEC信任链,要用该zone中与DS对应的DNSKEY去验证(RFC 4035第2.4节)。旧子zone撤销后,原来的DNSKEY也不再由它提供。只要解析器仍沿着旧委派验证,这条信任链就接不上。这里NS和DS都没清干净,仅凭上面的报错和DS查询,还不能把故障单独归因于DS。

解决办法是再冻结一次sjc.example.net,在zone文件中删掉guest、iot、tiot、t、ut这5个原委派点的NS和DS,递增serial后再解冻。清理之后,这些名字就成了sjc.example.net里的普通记录,直接由sjc.example.net的密钥签名,delv也显示; fully validated了。

事后看,第三步的cp应该换成下面这条命令,再追加子zone的数据:

awk '!(($1 ~ /^(guest|iot|tiot|t|ut)\.sjc\.example\.net\.$/) && \
        ($4 == "NS" || $4 == "DS"))' sjc.raw > sjc.merged

这里只删除5个指定名字上的委派记录,保留sjc.example.net自己的顶点NS。切换顺序也要注意:先确认辅服务器已加载合并后的父zone,再撤销它们上面的旧zone。即使权威端已经改好,递归解析器缓存里的旧NS、DS和DNSKEY也不会随之消失。这次的做法接受了短暂失败。要缩短这个窗口,应提前降低相关TTL,等旧TTL过期再切换;仅修改权威端,并不能保证所有缓存立刻跟上。

三、得失总结

合并之后,原先6个zone、6组CSK(18个key/private/state文件)变成了1个zone、1组CSK;5组父子之间的DS委派也没有了,验证链少了一层。废弃的.jbk、.jnl和孤儿密钥清理掉之后,目录也清爽多了,以后给zone做Git版本管理时干扰项少了很多。

当然,这样做也有一些代价:

  1. 更新、签名和传送都集中到一个zone,重新加载或恢复这个zone时,影响范围也比以前大。不过家里的设备不多,分开维护的麻烦更多。
  2. 我用了zonesub ANY这条宽泛的授权,DHCP密钥的权限也随之覆盖整个父zone。这并不是合并zone必然要付出的代价;仍可以用subdomain规则限制到iot.sjc等名字,并只允许A、AAAA、TXT、DHCID等实际需要更新的类型。