AXFR 传送一个完整 DNS 区域,让另一个服务器或获授权客户端取得数据,副本随后可以依据本地副本提供权威回答。协议不会神奇地识别调用方一定是合法权威服务器,谁能传送由访问控制决定。区域也是明确的权威边界,不等于所有委派子区域的记录,更不是整个组织的 DNS 命名空间。描述复制范围时,应以实际区域内容为准。
坏掉的样子
修改主服务器数据却不更新 SOA serial,可能让副本以相同版本号提供不同内容。重载主服务器,不会强迫所有副本替换看起来没有变化的区域。DNS 序列号比较使用三十二位序列算术,包括回绕规则;看起来像日期的数字只是运维约定,并不独立保证任何数值修改都表示更新。排查副本差异时,应同时看版本比较与真实数据。
不限制区域传送,可以让任意请求客户端一次取得完整区域内容。来源限制与 TSIG 可以控制访问,但配置语义必须正确。尤其在普通 BIND 允许列表里同时列出地址和密钥,不会自动成为两者都必须满足的条件。应审阅实际访问规则,并从相关来源测试未授权请求,不能因为配置里出现两项限制,就假定它们按预想的逻辑组合生效。
主服务器故障时,副本继续回答的能力受 SOA expiry 约束。记录 TTL 控制下游消费者缓存,不等同于副本权威拷贝的过期计时。超过允许刷新失败窗口后,副本必须停止把这份数据当作当前有效的权威内容。已有成功回答不能证明副本可以无限期独立运行,监控也不应只看进程和端口仍在,而忽略区域已经接近过期。
快速验证
将脚本保存为 check-zone-transfer.py,准备 Docker 后执行 python3 check-zone-transfer.py。两个 BIND 9.18.49 进程运行在隔离容器中,随机生成的一次性 TSIG secret 授权传送且不会打印。实验有意采用很短的刷新与过期值,以便及时观察状态变化;这些是测试设置,不是生产策略建议。整个过程不接触真实区域或现有共享密钥:
from pathlib import Path
from tempfile import TemporaryDirectory
import json,subprocess,uuid
recipe='FROM python@sha256:528257d48c1da0dcecc2e725d1ae34498d60c965f1241e39cd6a85a8859bdf84\nRUN apt-get update && apt-get install -y --no-install-recommends bind9=1:9.18.49-1~deb12u2 bind9-dnsutils=1:9.18.49-1~deb12u2 dnsmasq-base=2.90-4~deb12u2 python3-dnspython=2.3.0-1 && rm -rf /var/lib/apt/lists/*\nCMD ["/usr/bin/python3"]\n'
program=r"""
from contextlib import ExitStack,contextmanager
from pathlib import Path
from tempfile import TemporaryDirectory
import json,re,signal,subprocess,time
import dns.exception,dns.message,dns.query,dns.rcode,dns.rdatatype
def run(*args,check=True):
result=subprocess.run(args,capture_output=True,text=True,timeout=15)
if check:assert result.returncode==0,(args,result.returncode,result.stdout,result.stderr)
return result
def query(name,kind='A',port=1053):
return dns.query.udp(dns.message.make_query(name,kind),'127.0.0.1',port=port,timeout=1)
def values(response,kind='A'):
wanted=dns.rdatatype.from_text(kind)
return [item.to_text() for rrset in response.answer if rrset.rdtype==wanted for item in rrset]
def show(response):
def section(records):return [{'name':str(rr.name),'type':dns.rdatatype.to_text(rr.rdtype),'ttl':rr.ttl,'data':[item.to_text() for item in rr]} for rr in records]
return {'rcode':dns.rcode.to_text(response.rcode()),'answer':section(response.answer),'authority':section(response.authority)}
def wait(predicate,timeout=15):
deadline=time.monotonic()+timeout
while time.monotonic()<deadline:
try:
if predicate():return
except (OSError,dns.exception.Timeout):pass
time.sleep(.05)
raise AssertionError('DNS fixture condition did not become true')
def stop(process):
if process.poll() is None:
process.terminate()
try:process.wait(timeout=5)
except subprocess.TimeoutExpired:process.kill();process.wait(timeout=5)
@contextmanager
def lab(records,soa_ttl=8,minimum=4,extra=''):
with TemporaryDirectory(prefix='xbcatm-dns-') as directory,ExitStack() as stack:
root=Path(directory);zone=root/'zone';conf=root/'named.conf';log=root/'named.log';serial=1
def save(body):
zone.write_text(f'$ORIGIN lab.test.\n$TTL 30\n@ {soa_ttl} IN SOA ns.lab.test. hostmaster.lab.test. ({serial} 60 60 3600 {minimum})\n@ IN NS ns.lab.test.\nns IN A 127.0.0.1\n'+body+'\n')
run('named-checkzone','lab.test',str(zone))
save(records)
conf.write_text(f'''options {{ directory "{root}"; listen-on port 1053 {{127.0.0.1;}}; listen-on-v6 {{none;}};
recursion no; dnssec-validation no; notify no; querylog yes; max-cache-size 8m; pid-file "{root}/named.pid"; session-keyfile none;
{extra} }};
controls {{}};
zone "lab.test" {{ type primary; file "{zone}"; }};
''')
run('named-checkconf',str(conf))
output=stack.enter_context(log.open('w'))
authority=subprocess.Popen(['named','-g','-c',str(conf),'-n','1','-U','1'],stdout=output,stderr=subprocess.STDOUT)
stack.callback(stop,authority)
def ready():
assert authority.poll() is None,log.read_text()
response=query('lab.test.','SOA');return response.rcode()==0 and response.answer[0][0].serial==serial
wait(ready)
def update(body):
nonlocal serial
serial+=1;save(body);authority.send_signal(signal.SIGHUP);wait(ready)
def cache(port=1054,upstream=1053,size=150,options=()):
logfile=root/('cache-'+str(port)+'.log');stream=stack.enter_context(logfile.open('w'))
command=['dnsmasq','--no-daemon','--conf-file=/dev/null','--no-resolv','--no-hosts','--bind-interfaces',
'--listen-address=127.0.0.1','--port='+str(port),'--server=127.0.0.1#'+str(upstream),
'--cache-size='+str(size),'--log-queries','--log-facility=-',*options]
process=subprocess.Popen(command,stdout=stream,stderr=subprocess.STDOUT);stack.callback(stop,process)
def cache_ready():
assert process.poll() is None,logfile.read_text()
return query('lab.test.','SOA',port).rcode()==0
wait(cache_ready);return process,logfile
def queries(name,kind='A'):
return sum(bool(re.search(r'query: '+re.escape(name.rstrip('.'))+r' IN '+kind+r'\b',line)) for line in log.read_text().splitlines())
yield root,update,cache,queries
@contextmanager
def named_process(root,config):
root.mkdir(exist_ok=True);conf=root/'named.conf';log=root/'named.log';conf.write_text(config)
run('named-checkconf',str(conf))
with log.open('w') as output:
process=subprocess.Popen(['named','-g','-c',str(conf),'-n','1','-U','1'],stdout=output,stderr=subprocess.STDOUT)
try:
def ready():
assert process.poll() is None,log.read_text()
return re.search(r'\brunning\s*$',log.read_text(),re.M) is not None
wait(ready);yield process,log
finally:stop(process)
import base64,secrets
import dns.tsig,dns.tsigkeyring
with TemporaryDirectory(prefix='xbcatm-xfr-') as directory:
root=Path(directory);primary=root/'primary';secondary=root/'secondary';primary.mkdir();secondary.mkdir()
secret=base64.b64encode(secrets.token_bytes(32)).decode();keyring=dns.tsigkeyring.from_text({'xfr.lab.test.':secret})
key_config=f'key "xfr.lab.test." {{ algorithm hmac-sha256; secret "{secret}"; }};'
zone=primary/'zone'
def save(serial,address):
zone.write_text(f'$ORIGIN lab.test.\n$TTL 2\n@ IN SOA ns.lab.test. hostmaster.lab.test. ({serial} 1 1 6 1)\n@ IN NS ns.lab.test.\nns IN A 127.0.0.1\nservice IN A {address}\n')
run('named-checkzone','lab.test',str(zone))
def options(path,port):return f'''options {{ directory "{path}"; listen-on port {port} {{127.0.0.1;}}; listen-on-v6 {{none;}};
recursion no; dnssec-validation no; max-cache-size 8m; session-keyfile none; pid-file "{path}/named.pid"; lock-file "{path}/named.lock";
min-refresh-time 1; max-refresh-time 2; min-retry-time 1; max-retry-time 2; }}; controls {{}};'''
save(10,'127.0.0.2')
primary_config=key_config+options(primary,1053)+f'''zone "lab.test" {{ type primary; file "{zone}";
allow-transfer {{key "xfr.lab.test.";}}; notify explicit; also-notify {{127.0.0.1 port 1054 key "xfr.lab.test.";}}; }};'''
secondary_config=key_config+options(secondary,1054)+f'''zone "lab.test" {{ type secondary; file "{secondary}/copy";
primaries {{127.0.0.1 port 1053 key "xfr.lab.test.";}}; allow-transfer {{none;}}; }};'''
with named_process(primary,primary_config) as (master,master_log),named_process(secondary,secondary_config):
wait(lambda: values(query('service.lab.test.',port=1054))==['127.0.0.2'])
def transfer(**kwargs):return [rr for message in dns.query.xfr('127.0.0.1','lab.test.',port=1053,timeout=2,lifetime=4,relativize=False,**kwargs) for rr in message.answer]
copied=transfer(keyring=keyring,keyname='xfr.lab.test.')
assert copied[0].rdtype==copied[-1].rdtype==dns.rdatatype.SOA
assert copied[0][0].serial==copied[-1][0].serial==10
assert any(str(rr.name)=='service.lab.test.' and any(str(item)=='127.0.0.2' for item in rr) for rr in copied)
rejected={}
for label,kwargs in [('unsigned',{}),('wrong_secret',{'keyring':dns.tsigkeyring.from_text({'xfr.lab.test.':base64.b64encode(secrets.token_bytes(32)).decode()}),'keyname':'xfr.lab.test.'})]:
try:transfer(**kwargs);raise AssertionError('unauthorized zone transfer succeeded')
except (dns.query.TransferError,dns.tsig.PeerBadSignature) as error:rejected[label]=type(error).__name__
assert values(query('service.lab.test.'))==['127.0.0.2']
save(10,'127.0.0.3');master.send_signal(signal.SIGHUP);wait(lambda: values(query('service.lab.test.'))==['127.0.0.3'])
time.sleep(2.2);unchanged=query('service.lab.test.',port=1054);assert values(unchanged)==['127.0.0.2']
save(11,'127.0.0.3');master.send_signal(signal.SIGHUP)
wait(lambda: query('lab.test.','SOA').answer[0][0].serial==11)
wait(lambda: values(query('service.lab.test.',port=1054))==['127.0.0.3'])
secondary_soa=query('lab.test.','SOA',1054);assert secondary_soa.answer[0][0].serial==11
requested_ixfr=transfer(keyring=keyring,keyname='xfr.lab.test.',rdtype='IXFR',serial=10)
assert requested_ixfr[0][0].serial==11 and requested_ixfr[1].rdtype!=dns.rdatatype.SOA
assert requested_ixfr[-1].rdtype==dns.rdatatype.SOA and requested_ixfr[-1][0].serial==11
stop(master)
while_valid=query('service.lab.test.',port=1054);assert values(while_valid)==['127.0.0.3']
expired=[]
def no_longer_served():
answer=query('service.lab.test.',port=1054);expired.append(answer);return answer.rcode()==dns.rcode.SERVFAIL
wait(no_longer_served,timeout=12)
print(json.dumps({'AXFR_SOA_serials':[copied[0][0].serial,copied[-1][0].serial],'AXFR_record_sets':len(copied),
'unauthorized_transfer_errors':rejected,'same_serial_secondary_address':values(unchanged),'updated_secondary_serial':11,
'IXFR_without_history_returned_AXFR':True,'primary_stopped_secondary_initially_answers':values(while_valid),
'secondary_after_SOA_expire':dns.rcode.to_text(expired[-1].rcode())},sort_keys=True))
"""
name='xbcatm-dns-'+uuid.uuid4().hex[:10]
def docker(*args,**kwargs):return subprocess.run(['docker',*args],capture_output=True,text=True,timeout=300,**kwargs)
try:
with TemporaryDirectory(prefix=name+'-') as directory:
root=Path(directory);(root/'Dockerfile').write_text(recipe)
built=docker('build','-t',name,str(root));assert built.returncode==0,built.stderr
result=docker('run','--rm','-i','--name',name,'--network','none','--memory','384m','--pids-limit','128',name,
'/usr/bin/python3','-',input=program)
assert result.returncode==0,result.stderr
print(json.dumps(json.loads(result.stdout),sort_keys=True))
finally:
docker('rm','-f',name);docker('image','rm',name)
主区域从序列号十开始,service.lab.test 指向 127.0.0.2。真实副本取得区域并回答该地址;使用授权 TSIG 密钥的普通 Python 客户端也能完成 AXFR。五个记录集条目包含序列号均为十的起始与结束 SOA,以及预期服务数据,证明传送完成,也证明获得授权的非服务器客户端同样能够请求该操作。调用方身份不是由程序名称自动决定的。
未签名请求与使用另一份 secret 的请求都失败,尽管它们来自相同回环环境;普通允许的地址查询仍然成功。被测规则要求区域传送使用指定密钥,并没有让所有 DNS 查询都必须携带 TSIG。认证传送操作与提供普通权威答案是不同边界,应分别验证,避免修复复制授权时意外把常规查询服务也改成另一种访问契约。
接着主服务器把服务地址改为 127.0.0.3,但保留序列号十并重载。经过短刷新窗口,副本仍回答 127.0.0.2。将序列号提高到十一并重载后,副本才取得新地址。实验同时验证了分歧与修复,不是只检查两份配置文件都写了同一个主服务器。即使复制连接和认证正常,错误版本管理仍然会让实际回答保持不同。
请求自序列号十以来的 IXFR 变化时,由于主服务器没有保留该更新的增量历史,返回完整 AXFR 形式,脚本通过 SOA 组织判断结果。最后停止主服务器,副本最初继续回答 127.0.0.3,经过短 SOA 过期窗口后返回 SERVFAIL。该状态变化没有通过调整宿主时间或共享网络设置制造,而是由真实副本刷新与过期流程产生。
分别验证复制、授权与保密性
NOTIFY 可以促使更早检查,周期刷新则提供另一种发现新版本的机会,两者都不使复制瞬间完成。监控各权威服务器的序列号和真实回答,并在过期导致服务中断前调查传送失败。修复序列号错误时,应确保副本获得预期完整状态,不能假定任意后续增量都能修复此前形成的所有内容分歧。必要操作应依据已知状态选择并验证。
有合适变化历史时,IXFR 可以节省工作,但请求类型不保证响应一定为增量。AXFR 可以在 TCP 上跨多个 DNS 消息,因此完成验证必须遵循协议边界。部分流或一条匹配记录,不足以证明整个区域已经到达并可正确提供。生产检查应关注完整传送结果及实际查询行为,而不止复制任务曾经启动或日志出现过连接建立。
TSIG 使用共享密钥认证消息并保护完整性,不会加密区域内容。应保护密钥,并在部署需要时另外提供传输保密性。把传送授权、复制成功、普通查询可用和副本过期作为不同检查,才能清楚说明实际边界。不能依赖“区域传送只会发生在可信服务器之间”的假设来代替明确配置和正反请求验证。
延伸阅读
来源
- RFC 5936 AXFR,完整区域传送的消息组织与完成条件,已核验 2026-09-12。
- RFC 1995 IXFR,增量请求与回退完整传送,已核验 2026-09-12。
- RFC 1982 serial number arithmetic,三十二位 DNS 序列号比较与回绕,已核验 2026-09-12。
- RFC 8945 TSIG,共享密钥消息认证与完整性,已核验 2026-09-12。
- BIND 9.18.49 reference,传送访问规则、通知与副本刷新控制,已核验 2026-09-12。