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,以及预期服务数据,证明传送完成,也证明获得授权的非服务器客户端同样能够请求该操作。调用方身份不是由程序名称自动决定的。
...