转发条目可以过期或被清除,本地配置记录仍可以持续提供。

dnsmasq 区分转发缓存与本地配置名称

dnsmasq 可以向上游转发 DNS 查询,在有界缓存中保留支持的回答,也可以提供本地配置的名称。这些是不同的数据来源。重复得到相同回答,可能来自缓存命中,也可能来自本地配置。主机名与权威数据不一致、或者清缓存后仍然解析到原地址时,先确认来源十分重要;否则容易把有效配置误认为没有清干净的缓存。 坏掉的样子 启动 dnsmasq,不会自动让所有应用使用它。客户端必须把 DNS 查询交给预期监听,有的应用还会使用其他解析服务。同样,本地监听正常,也可能转发到错误上游或者读取了意外 hosts 配置。在增加缓存容量或重启服务前,应先确认真实查询路径。如果失败查询根本没到达这个进程,对它反复操作不会改变调用方看到的结果。 本地名称可以有意覆盖上游数据。清理转发缓存不会删除这个配置名称,因此反复清空也不能让上游地址获胜。应把本地记录当作有负责人、有用途的配置管理。即使回答的本地 TTL 为零,dnsmasq 仍可持续返回该值,因为它来自配置生成,不是靠保留某份转发回答维持。零 TTL 与删除本地配置并不是同一件事。 缓存容量和新鲜度解决不同问题。更大缓存可以保留更多合适条目,却不会延长条目的有效 TTL,也不会让上游故障期间所有名称都能查询成功。新名称与过期条目仍需要合适的解析路径。缓存大小也不直接代表可同时发送多少上游查询,更不能单独保证应用可用。应分别观察容量、寿命、并发与上游健康,而不是用一个参数代替所有约束。 快速验证 将脚本保存为 check-dnsmasq.py,准备 Docker 后执行 python3 check-dnsmasq.py。固定环境使用 dnsmasq 2.90 和 BIND 9.18.49。实验转发器忽略环境中的 resolv 和 hosts 文件,明确绑定回环监听,并只转发到自己的临时权威服务器。它不发布宿主端口,没有 DHCP 配置,结束后清理自己的实例,不影响现有服务: 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 with lab('cached 20 IN A 127.0.0.2\nuncached 20 IN A 127.0.0.3\nlocal 20 IN A 127.0.0.4') as (_,update,cache,queries): daemon,log=cache(options=('--host-record=local.lab.test,127.0.0.9',)) for _ in range(3):assert values(query('cached.lab.test.',port=1054))==['127.0.0.2'] wait(lambda: queries('cached.lab.test')==1) cache(port=1055,size=0) for _ in range(3):assert values(query('uncached.lab.test.',port=1055))==['127.0.0.3'] wait(lambda: queries('uncached.lab.test')==3) local=query('local.lab.test.',port=1054);assert values(local)==['127.0.0.9'] and local.answer[0].ttl==0 assert queries('local.lab.test')==0 authoritative=query('local.lab.test.');assert values(authoritative)==['127.0.0.4'] wait(lambda: queries('local.lab.test')==1) update('cached 20 IN A 127.0.0.8\nuncached 20 IN A 127.0.0.3\nlocal 20 IN A 127.0.0.4') stale=query('cached.lab.test.',port=1054);assert values(stale)==['127.0.0.2'] and queries('cached.lab.test')==1 previous=log.read_text().count('cleared cache');daemon.send_signal(signal.SIGHUP) wait(lambda: log.read_text().count('cleared cache')>previous) refreshed=query('cached.lab.test.',port=1054);assert values(refreshed)==['127.0.0.8'] wait(lambda: queries('cached.lab.test')==2) retained=query('local.lab.test.',port=1054);assert values(retained)==['127.0.0.9'] print(json.dumps({'three_lookups_upstream_counts':{'cache_enabled':1,'cache_disabled':3},'local_record':show(local), 'same_name_authority':show(authoritative),'before_SIGHUP':show(stale),'after_SIGHUP':show(refreshed), 'configured_local_record_retained':show(retained)},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) 通过启用缓存的监听查询 cached.lab.test 三次,都返回 127.0.0.2,权威日志却只记录一次。第二个监听使用 cache-size=0,经它对另一个名称查询三次,就产生三次上游请求。这个对照直接验证缓存对转发工作量的影响,不用响应速度猜测是否命中,也不声称测得某个固定延迟改善。回环网络的快慢本身不足以证明数据来源。 ...

四月 15, 2028 · 2 分钟 · XB Field Notes
下游接收剩余 TTL,而不是重新获得完整权威寿命。

DNS TTL 在重复回源查询与保留旧回答之间取舍

DNS TTL 是附着于缓存数据的寿命,不是每秒执行的刷新任务。只要条目仍被保留且有效,缓存可以复用它;过期后的下一次请求通常需要重新解析,除非启用了其他明确行为。无人查询的记录,并不一定在 TTL 归零时主动产生上游请求。因此讨论成本时,要同时看请求到达方式与缓存状态,不能把 TTL 直接当成定时查询频率。 坏掉的样子 持续查询相同数据时,缩短 TTL 可能增加上游工作,却不会让所有权威流量按同一个固定倍数增长。查询频率、解析器共享程度、缓存容量、预取和名称多样性都会影响结果。一个热门名称使用短 TTL,与大量只问一次的名称流,行为不同。没有这些负载条件,就无法仅凭 TTL 数字预测真实查询量或容量需求。 长 TTL 允许在权威数据修改后继续使用先前回答。该缓存仍可能符合最初收到的寿命要求,尽管内容已经不同于最新区域。把所有不一致都叫成过期或损坏,会混淆实际契约。TTL 表示允许保留多久,不承诺持续与权威同步。修改 TTL 同样不能追溯改变已经保存的更长寿命,需要等那些旧副本自然退出普通有效期。 多层转发通常不会让每一跳重新获得原始完整 TTL。缓存返回剩余寿命,下一层再继续计时。按层数累加完整 TTL,会虚构普通合规转发并不产生的传播延迟。本地 TTL 覆盖、提供陈旧回答等是另外的配置行为,存在时应明确识别。不能先假设所有解析器都遵守同一种特殊策略,再用这个假设解释实际观测。 快速验证 将脚本保存为 check-ttl-cost.py,准备 Docker 后执行 python3 check-ttl-cost.py。固定容器使用 BIND 9.18.49 与 dnsmasq 2.90,运行时没有外部网络。权威服务器记录实际查询;最初一个转发器服务两个名称,随后在它后面启动第二个转发器,检查交给下游的 TTL。所有服务和日志都属于临时实验,不修改现有解析基础设施: 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 with lab('short 1 IN A 127.0.0.2\nlong 20 IN A 127.0.0.3\nchain 6 IN A 127.0.0.4') as (_,update,cache,queries): cache();samples=[] for i in range(3): pair={name:show(query(name+'.lab.test.',port=1054)) for name in ('short','long')};samples.append(pair) if i<2:time.sleep(1.2) wait(lambda: queries('short.lab.test')==3 and queries('long.lab.test')==1) first=query('chain.lab.test.',port=1054);assert first.answer[0].ttl==6 wait(lambda: queries('chain.lab.test')==1) time.sleep(2.2);cache(port=1055,upstream=1054) downstream=query('chain.lab.test.',port=1055);upstream=query('chain.lab.test.',port=1054) assert values(downstream)==['127.0.0.4'] and 0<downstream.answer[0].ttl<6 assert abs(downstream.answer[0].ttl-upstream.answer[0].ttl)<=1 assert queries('chain.lab.test')==1 update('short 1 IN A 127.0.0.2\nlong 20 IN A 127.0.0.3\nchain 6 IN A 127.0.0.5') wait(lambda: values(query('chain.lab.test.',port=1055))==['127.0.0.5'],timeout=8) wait(lambda: queries('chain.lab.test')==2) print(json.dumps({'three_lookups_each':samples,'authority_query_counts':{'short':queries('short.lab.test'),'long':queries('long.lab.test')}, 'cache_chain':{'original':show(first),'downstream_first':show(downstream),'upstream_remaining':show(upstream),'authority_queries_after_expiry':queries('chain.lab.test')}},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) short.lab.test 的 TTL 为一秒,long.lab.test 为二十秒。程序各查询三次,轮次间等待一点二秒。权威日志显示短寿命记录收到三次查询,长寿命记录只有一次,而每一轮客户端都得到成功回答。这个差异说明受控请求节奏下的上游工作量,不是应用延迟提升的基准测试,更不能据此推导公网解析性能增加多少倍。 ...

三月 1, 2028 · 2 分钟 · XB Field Notes
AAAA 回答给出 IPv6 目的地址,另一次真实连接才能验证服务是否工作。

AAAA 发布 IPv6 地址,不证明该地址可达

一条 AAAA 资源记录保存一个一百二十八位 IPv6 地址,A 记录则保存 IPv4 地址。同一个名称可以只有其中一类、同时拥有两类,或者在每类中包含多个地址。A 与 AAAA 属于不同记录集,因此可以使用不同 TTL。发布 AAAA 让地址能够通过 DNS 被发现,却不会替你创建接口、路由、监听套接字或可用应用;这些条件需要在实际服务路径上成立。 坏掉的样子 AAAA 查询成功之后,连接仍可能失败。发布的地址可能已经停用,服务可能只监听 IPv4,网络也可能缺少可用 IPv6 路由。两种地址族的防火墙与 TLS 配置也可能不同。只通过 IPv4 做可用性检查,会漏掉这些问题,而双栈用户可能正沿着公开的 IPv6 路径遭遇延迟或错误。记录存在与服务可用是两个需要分别观察的事实。 Happy Eyeballs 通过协调地址发现和连接尝试,减少部分双栈连接延迟,但它属于客户端算法,并不是 DNS 对所有客户端的保证。具体回退行为、截止时间和应用协议仍会影响结果。某个浏览器在一次测试中迅速改用 A 记录成功,不能推出错误 AAAA 对所有调用方无害。尤其对于没有相同行为的库、设备或后台任务,应验证其真实连接策略。 缺少 AAAA 也应准确描述。权威服务器可以对只有 A 的名称返回成功 DNS 状态,却没有 AAAA 数据,这通常称为 NODATA;它不同于表示名称不存在的 NXDOMAIN。仅有 A 的服务也不是在所有 IPv6-only 网络中都绝对不可达:具备相应基础设施时,DNS64 可以合成 AAAA,再通过 NAT64 访问 IPv4 服务器。不能忽略翻译网络这一明确反例。 ...

十二月 30, 2027 · 3 分钟 · XB Field Notes
副本可以提供已传送区域,但刷新与过期规则会要求更新或停止服务。

区域传送按明确授权与序列号规则复制权威数据

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

十二月 5, 2027 · 3 分钟 · XB Field Notes
TLS 保护一段查询路径,所选解析器仍会收到明文 DNS 问题。

DoT 与 DoH 保护通往已认证解析器的连接

DNS over TLS 和 DNS over HTTPS 保护其端点之间加密连接上的 DNS 流量。对于典型客户端,这条连接终止于选定解析器;解析器能够读取问题与回答,它随后发出的查询还可能使用不同传输。加密这一跳,不会自动加密整个递归过程,也不会让解析器失去记录所处理名称的能力。隐私范围应按真实连接边界说明,而不是笼统称为整个 DNS 都已保密。 坏掉的样子 加密套接字如果没有按预期验证证书,也可能连接到错误端点。应同时验证证书信任链和客户端期望的解析器身份;握手成功本身不能证明这两项检查已启用。失败时悄悄改用其他解析器或明文路径的配置,也会改变隐私契约。需要通过真实调用方了解这些行为,不能只看到配置文件中有一个 TLS 或 HTTPS 地址,就认定所有查询都符合预期。 DoH 使用 HTTPS,但四百四十三端口不会让它在所有网络中都无法识别、无法阻止或完全等同于任意网页流量。目的地址、连接时间和其他元信息仍可观察,TLS 终止点还可以检查明文 HTTP。选择解析器和传输策略时,应考虑这些边界,不能假定 HTTPS URL 会消除所有与 DNS 活动有关的信息暴露,也不能由加密推导解析器不会保存日志。 两种协议的消息边界也不同。DoT 通常使用八百五十三端口,在 TLS 内携带具有两字节 TCP 长度前缀的 DNS 消息。DoH 通常使用四百四十三端口上的 HTTPS 端点。标准 DoH 使用 application/dns-message 传递 DNS 线格式:POST 放在正文,GET 使用经 base64url 编码的 dns 参数。它不是任意 JSON 接口只要套上 HTTPS 就符合标准。 ...

十月 7, 2027 · 3 分钟 · XB Field Notes
DNS 沿别名找到地址,HTTP 仍使用用户最初请求的主机名。

CNAME 改变 DNS 解析路径,请求主机名仍保持原样

CNAME 记录把一个别名与另一个 DNS 名称关联起来。通过别名解析地址时,解析器沿目标继续查找,直到获得请求的数据或遇到失败。这可以让公开主机名保持稳定,同时改变它背后的 DNS 依赖。它不会让浏览器跳转到另一个 URL,也不会替换应用正在请求的主机名;是否改变客户端可见地址,应由相应应用协议行为决定。 坏掉的样子 别名解析完全正常,目标服务器仍可能返回错误网站。HTTP 继续标识原始主机名,HTTPS 证书验证也关联所请求的服务身份。如果只配置目标名称对应的虚拟主机或证书,原别名请求未必能够工作。DNS 提供的是获得地址的路径,选中的服务器还必须接受用户和应用真正请求的名称。仅检查最终 IP 相同,无法验证这项应用契约。 CNAME 不能与普通 A、AAAA、TXT 等数据共存于同一个所有者名称。DNSSEC 元数据存在特定例外,但这不会让 CNAME 与 A 共存变得有效。普通区域顶点本来就需要 SOA 和 NS,因此在顶点放传统 CNAME 会产生冲突。服务商提供的别名展平等功能,应依据其实际文档理解,不能把界面里类似的名称直接当成标准 CNAME 规则发生变化。 别名链可能终止于不存在的数据,也可能形成循环。每个依赖还有自己的缓存寿命与管理边界;缩短别名 TTL,不会缩短以另一个记录集缓存的目标地址。同时,并非每增加一个别名都必然增加一次网络往返:服务器可能在同一回答中附带多段链,解析器也可能已经缓存相关数据。延迟分析应结合实际问答,而不是机械按链长计数。 快速验证 将脚本保存为 check-cname.py,准备 Docker 后执行 python3 check-cname.py。固定环境使用 BIND 9.18.49、dnsmasq 2.90 和小型 HTTP 服务。所有名称均属于隔离测试区域;解析配置修改和候选区域文件留在可丢弃容器内部,不改公共域名或宿主解析器。运行结束后,脚本清理自己创建的实例和临时镜像标签: ...

七月 9, 2027 · 3 分钟 · XB Field Notes
DNS 可以返回多个端点,客户端仍可能持续选择其中一个。

多个 DNS 地址不保证流量均匀或端点健康

同一名称下的多条 A 或 AAAA 记录提供一个地址集合。DNS 服务器可能轮换返回顺序,但收到集合后如何使用,由解析器和应用决定。普通地址记录不携带请求负载测量或应用健康结果;仅仅存在多个地址,不能保证均匀流量、自动故障转移,甚至不能保证不同实现看到相同的第一个地址。应将地址发现与后续选择分别理解。 坏掉的样子 DNS 回答分布与应用请求分布的分母不同。一个递归解析器可以用缓存回答许多客户端,一个应用也可能只查询一次,随后发出数千次请求。连接池会进一步强化这种差异。即便权威回答中的首个地址完美交替,实际服务器请求量仍可能严重不均。只统计 DNS 查询数量,不能证明用户业务已经按同样比例分配。 普通静态记录集中的端点失败后,其地址仍会保留,直到 DNS 数据被修改或另一个系统主动修改它。DNS 服务器不会仅因返回地址就知道应用健康状态。有的客户端连接失败后会尝试其他地址,有的会耗尽截止时间或者直接向调用方报错。是否重试、何时重试及哪些操作可重试,都应针对实际客户端和业务验证,不能默认由 DNS 完成。 把这种机制描述成保证公平的抛硬币,也不准确。顺序可能由配置决定、被随机化、被缓存保留,或者被地址选择策略重排。客户端可能选择首项、并发尝试,或复用先前结果。要解释一次分布观察,除了后端请求计数,还需要知道 DNS 配置、解析器行为、连接复用和工作负载;少了这些条件,无法判断不均衡出现在哪一层。 快速验证 将脚本保存为 check-dns-addresses.py,准备 Docker 后执行 python3 check-dns-addresses.py。固定环境使用 BIND 9.18.49,并明确把 rrset-order 设置为循环顺序。两个真实 HTTP 监听在隔离容器中绑定不同回环地址、使用相同临时端口,响应正文标识真正处理请求的端点。脚本不依赖公网域名,也不会访问现有业务服务器: 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 from http.server import BaseHTTPRequestHandler,ThreadingHTTPServer import http.client,threading class Handler(BaseHTTPRequestHandler): def log_message(self,*args):pass def do_GET(self): body=self.server.server_address[0].encode();self.server.hits+=1 self.send_response(200);self.send_header('Content-Length',str(len(body)));self.end_headers();self.wfile.write(body) def request(address,port): connection=http.client.HTTPConnection(address,port,timeout=.5) try: connection.request('GET','/');response=connection.getresponse();assert response.status==200;return response.read().decode() finally:connection.close() with lab('pool 30 IN A 127.0.0.2\npool 30 IN A 127.0.0.3',extra='rrset-order { order cyclic; };') as (_,update,cache,queries): servers=[];threads=[] try: for address in ['127.0.0.2','127.0.0.3']: server=ThreadingHTTPServer((address,0 if not servers else servers[0].server_port),Handler);server.hits=0 thread=threading.Thread(target=server.serve_forever,daemon=True);thread.start();servers.append(server);threads.append(thread) port=servers[0].server_port;orders=[values(query('pool.lab.test.')) for _ in range(6)] assert all(set(order)=={'127.0.0.2','127.0.0.3'} for order in orders) assert {tuple(order) for order in orders}=={('127.0.0.2','127.0.0.3'),('127.0.0.3','127.0.0.2')} saved=values(query('pool.lab.test.'));selected=saved[0] hits=[request(selected,port) for _ in range(12)];assert hits==[selected]*12 distribution={s.server_address[0]:s.hits for s in servers};assert sorted(distribution.values())==[0,12] victim=next(s for s in servers if s.server_address[0]==selected);victim.shutdown();victim.server_close() still_published=values(query('pool.lab.test.'));assert selected in still_published try:request(selected,port);raise AssertionError('stopped HTTP server accepted a connection') except OSError as error:failure=type(error).__name__ alternate=next(a for a in saved if a!=selected);assert request(alternate,port)==alternate print(json.dumps({'cyclic_answer_orders':orders,'one_lookup_twelve_requests':distribution,'after_peer_stopped':still_published, 'selected_address_error':failure,'explicit_alternate_succeeded':alternate},sort_keys=True)) finally: for server in servers:server.shutdown();server.server_close() for thread in threads:thread.join(timeout=2) """ 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) 对 pool.lab.test 进行六次直接查询,实测每次都返回 127.0.0.2 与 127.0.0.3,只是顺序交替。所有回答都包含完整地址集,断言同时检查成员与顺序变化。这证明的是当前权威服务器显式配置后的行为,不代表所有 DNS 服务器都必须轮换,也不保证递归缓存或最终应用会保持这个顺序。完整集合与首项顺序是不同检查点。 ...

七月 6, 2027 · 3 分钟 · XB Field Notes
DNS 发布服务候选项,支持 SRV 的客户端按优先级连接所公布的端点。

SRV 提供目标和端口,客户端必须理解其选择规则

SRV 通过优先级、权重、端口和目标主机名描述服务位置。它的所有者通常组合服务标签、传输标签与域名,例如 _echo._tcp.lab.test。理解该服务的客户端可以先查 SRV,再通过 A 或 AAAA 取得目标地址,连接记录给出的端口。普通主机名查询不会自动执行这套应用相关的发现流程,服务名与查询方式必须由调用方契约明确。 坏掉的样子 发布 SRV 不会让所有现有客户端自动采用它。应用需要规定服务标签,并实际支持相应发现行为。只接受普通主机名和配置端口的浏览器或库,可能根本不询问 SRV。因此,手工查询 SRV 成功,不能证明应用已经改变端点选择,也不能据此宣布今后不再需要更新配置。验证应沿真实调用方进行,不能止于区域里已有记录。 优先级和权重不是可互换字段。在可达目标中,较低数值优先级更优先;权重只影响相同优先级记录之间的选择。较高数值备机即使具有很大权重,也不能越过可用的低数值目标。把所有记录按权重统一排序,或直接采用报文中的首项,都可能反转服务所有者设计的主备关系。解释结果前,应确认客户端是否正确使用这些字段。 发现也不证明健康。监听停止后,目标仍可继续被发布,SRV 与目标地址记录集还具有各自缓存寿命。客户端需要连接截止时间与适当重试策略。连接被拒绝后重试读取,与重复可能已产生副作用的操作不同;端点故障转移必须符合应用协议,不能因为还有候选项,就无条件把每个业务动作再执行一遍。 快速验证 将脚本保存为 check-srv-discovery.py,准备 Docker 后执行 python3 check-srv-discovery.py。固定 BIND 9.18.49 环境发布测试 _echo._tcp 服务,两个真实 HTTP 监听在不同临时端口提供可识别的只读响应。HTTP 只是实验中便于观察的载荷;示例是自定义 SRV 客户端,不代表通用 HTTP 客户端本来就实现了这个服务标签: 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) from http.server import BaseHTTPRequestHandler,ThreadingHTTPServer import http.client,threading class Handler(BaseHTTPRequestHandler): def log_message(self,*args):pass def do_GET(self): body=self.server.identity.encode();self.send_response(200);self.send_header('Content-Length',str(len(body)));self.end_headers();self.wfile.write(body) servers=[];threads=[] try: for identity in ('primary','backup'): server=ThreadingHTTPServer(('127.0.0.1',0),Handler);server.identity=identity thread=threading.Thread(target=server.serve_forever,daemon=True);thread.start();servers.append(server);threads.append(thread) records=f'''_echo._tcp 30 IN SRV 10 1 {servers[0].server_port} first.lab.test. _echo._tcp 30 IN SRV 20 65535 {servers[1].server_port} second.lab.test. _absent._tcp 30 IN SRV 0 0 0 . first 30 IN A 127.0.0.1 second 30 IN A 127.0.0.1''' with lab(records) as (_,update,cache,queries): srv=query('_echo._tcp.lab.test.','SRV');entries=sorted(srv.answer[0],key=lambda r:r.priority) assert [r.priority for r in entries]==[10,20] and [r.weight for r in entries]==[1,65535] def discover(): attempts=[] for record in entries: addresses=values(query(str(record.target))) for address in addresses: client=http.client.HTTPConnection(address,record.port,timeout=.5) try: attempts.append(str(record.target));client.request('GET','/');response=client.getresponse();body=response.read().decode() assert response.status==200;return {'identity':body,'attempted_targets':attempts,'selected_port':record.port} except OSError:continue finally:client.close() raise AssertionError('all service endpoints failed') first=discover();assert first['identity']=='primary' and first['attempted_targets']==['first.lab.test.'] servers[0].shutdown();servers[0].server_close() assert len(query('_echo._tcp.lab.test.','SRV').answer[0])==2 failed_over=discover();assert failed_over['identity']=='backup' and len(failed_over['attempted_targets'])==2 absent=query('_absent._tcp.lab.test.','SRV');assert str(absent.answer[0][0].target)=='.' ordinary=query('lab.test.','A');assert ordinary.rcode()==0 and not ordinary.answer print(json.dumps({'SRV':show(srv),'healthy_selection':first,'after_primary_stopped':failed_over, 'ordinary_A_lookup':show(ordinary),'unavailable_service':show(absent)},sort_keys=True)) finally: for server in servers:server.shutdown();server.server_close() for thread in threads:thread.join(timeout=2) """ 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) 第一目标优先级为十、权重为一,备机优先级为二十、权重为六万五千五百三十五。两者分别公布真实监听端口,目标名都解析到回环地址。客户端读取 SRV,按不同优先级组排序,再解析目标并连接记录端口。两个监听都健康时,只访问第一目标并收到主节点身份,说明备机的大权重不会跨越优先级组改变这次选择。 ...

三月 13, 2027 · 3 分钟 · XB Field Notes