浅色网格上显示文章标题,旁侧是依次标注 Observe、Decide、Act 的三个相连方框。

有用的主机名应区分本地身份、服务名称与查询范围

有用的名称为人和软件提供一个可以跨地址变化继续使用的引用,但这个引用需要明确范围与负责人。机器的本地主机名、服务 DNS 名称和实例标识可以有意不同。清楚命名有助于理解日志和部署,而不是假定一个标签自动建立系统中的所有其他身份。规划名称时,应先说明它标识的是哪类对象,以及希望保持稳定多久。 坏掉的样子 设置本地主机名,本身不会向权威区域发布 A 或 AAAA。注册可能由独立部署或网络管理软件完成,但那条流程必须实际存在。反过来,修改 DNS 地址也不会重命名内核的本地主机名。混淆两者,会产生本地标签看似正确、客户端却无法发现服务的机器。排障时应分别检查本地状态、注册动作和权威数据,不能用其中一个代替其他证据。 短名称依赖查询上下文。即使两台机器都使用正常 DNS 配置,service 在不同搜索域下仍可能指向不同目的地。复制的运行手册命令因此可能到达与作者测试不同的环境。配置中不能容忍歧义时,应使用完整目标 DNS 名称;交互操作使用短名时,也应保留解释它所需的上下文。名称看起来一样,并不意味着它在所有进程里具有相同范围。 人类可读的含义也有限制。app-prod-01 可以提示角色、环境与实例,却不是权限、归属或当前工作负载的权威证据。复用旧实例名称还可能混淆历史日志。服务名称与替换机器的生命周期不同时,应分别命名,并使用运维元数据记录变化事实,不要试图把所有信息都塞进主机名。方便阅读的前缀也不能成为授予访问权限的依据。 快速验证 将脚本保存为 check-hostnames.py,准备 Docker 后执行 python3 check-hostnames.py。固定环境使用 BIND 9.18.49、dnsmasq 2.90 和 Python 3.11,解析器及 NSS 配置都位于可丢弃容器中。本实验关闭转发缓存,使搜索域和地址变化可以直接观察,不必等待上一代缓存寿命;这项设置用于隔离变量,不代表所有生产客户端都应关闭缓存: 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 import socket records='service 30 IN A 127.0.0.2\nservice.branch 30 IN A 127.0.0.3\n_echo._tcp 30 IN SRV 0 0 8080 service.lab.test.' with lab(records) as (root,update,cache,queries): cache(port=53,size=0) nss=Path('/etc/nsswitch.conf');nss.write_text(re.sub(r'^hosts:.*$','hosts: dns',nss.read_text(),flags=re.M)) def search(domain):Path('/etc/resolv.conf').write_text('nameserver 127.0.0.1\nsearch '+domain+'\noptions timeout:1 attempts:1\n') def address(name): return json.loads(run('/usr/bin/python3','-c','import socket,json,sys;print(json.dumps(sorted({r[4][0] for r in socket.getaddrinfo(sys.argv[1],80,socket.AF_INET,socket.SOCK_STREAM)})))',name).stdout) local_name=socket.gethostname();unpublished=query(local_name+'.lab.test.');assert unpublished.rcode()==dns.rcode.NXDOMAIN lower=query('service.lab.test.');mixed=query('SeRvIcE.LaB.TeSt.');assert values(lower)==values(mixed)==['127.0.0.2'] search('lab.test');first=address('service');absolute_first=address('service.lab.test.');assert first==absolute_first==['127.0.0.2'] search('branch.lab.test');second=address('service');absolute_second=address('service.lab.test.');assert second==['127.0.0.3'] and absolute_second==first update(records.replace('service 30 IN A 127.0.0.2','service 30 IN A 127.0.0.4'));changed=address('service.lab.test.') assert changed==['127.0.0.4'] and socket.gethostname()==local_name invalid=root/'invalid.zone';invalid.write_text((root/'zone').read_text()+'bad_name IN A 127.0.0.1\n') check=run('named-checkzone','-k','fail','lab.test',str(invalid),check=False) assert check.returncode!=0 and 'bad owner name' in check.stdout+check.stderr assert values(query('_echo._tcp.lab.test.','SRV'),'SRV')==['0 0 8080 service.lab.test.'] print(json.dumps({'local_hostname_published_in_zone':False,'case_insensitive_A':values(mixed), 'short_name_by_search_domain':{'lab.test':first,'branch.lab.test':second},'absolute_name_unchanged_by_search':[absolute_first,absolute_second], 'DNS_address_after_change':changed,'local_hostname_unchanged':True,'invalid_host_owner_rejected':check.returncode,'underscore_SRV_owner_accepted':True},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) 脚本读取容器本地主机名,再查询该标签位于 lab.test 下的名称。由于没有发布相应区域记录,权威返回 NXDOMAIN。这说明当前环境缺少这项注册,不是在断言所有安装都不会自动注册主机名。对 service.lab.test 分别使用全小写与混合大小写查询,都返回 127.0.0.2,展示 DNS 中 ASCII 名称比较不区分大小写。 ...

十二月 1, 2026 · 3 分钟 · XB Field Notes
TC 标志说明回答不完整,对应 TCP 交换才能取得完整记录。

UDP 回答被截断时,DNS 需要可工作的 TCP 路径

DNS 同时支持 UDP 和 TCP。服务器因可用消息空间而截断响应时,会设置 TC 标志。客户端收到该标志,需要处理结果不完整的情况,通常通过 TCP 重新查询。这涉及当前使用的 DNS 响应限制,并不直接证明物理网络无法承载任何更大的 UDP 数据报。排障时应先确认协议层发生了什么,再讨论传输路径的实际容量。 坏掉的样子 允许 UDP 五十三端口却阻止 TCP 五十三端口的网络,可能小查询正常,大回答失败。DNSSEC 数据和较大记录集容易暴露这种差异,但 TCP 并不只用于这些特殊情况。通用 DNS 实现需要支持 TCP,客户端也可以直接选择它。因此,一次小型 UDP 查询成功,不能证明整个 DNS 服务的传输路径完整,尤其不能代替对 TCP 监听和访问规则的检查。 被截断的回答仍可能包含部分记录。如果忽略 TC,把这些记录当成完整集合,就可能漏掉有效目的地或必需数据。NOERROR 不会抵消截断标志。当应用看到的记录比另一种传输方式下的权威查询少时,应保留完整问题、响应标志和记录数量,不要只提取一个地址或文本值。仅凭有内容返回,并不能判断这次 DNS 交换已经完整结束。 TCP 也没有取消所有 DNS 消息大小限制。TCP 流中,每个 DNS 消息之前都有两字节长度字段,因此单个消息最多六万五千五百三十五字节;区域传送可以由多个消息组成。流读取可能拆开长度字段或正文,程序应循环读取所需字节数,不能假定一次 recv 就返回整个回答。传输可靠与应用消息边界明确,是两项不同责任。 ...

十一月 19, 2026 · 3 分钟 · XB Field Notes
标题为「双栈地址排序与最终成功的连接选择是不同步骤」的封面,下方是观察、判断与行动三个面板。

双栈地址排序与最终成功的连接选择是不同步骤

发现 A 与 AAAA 记录,只提供可能的目的地,本身不会选出成功连接。地址排序、源地址选择、路由以及应用连接算法都会参与结果。RFC 6724 描述默认源地址和目的地址选择规则,应用仍可作自己的选择。如果程序只请求 IPv4,那么在任何双栈连接策略开始工作前,它就已经缩小了候选范围。解释行为时,应先确认实际调用方请求了什么。 坏掉的样子 地址偏好不是应用健康探测。某个 IPv6 目的地可能符合本地策略和路由条件,远端服务却已经停止,或者更后面的网络跳点会丢包,因此优先候选仍可能失败。如果假定操作系统已经测试了返回列表中每个端点,就会忽略连接截止时间和尝试其他地址的必要性。查询成功只能证明获得候选信息,不能代替服务通信结果。 RFC 6724 的规则是按顺序比较条件,并不是对网络成本或速度打一个总分。相关属性包括可用性、作用域、策略标签、优先顺序和匹配前缀,但它们不测量应用响应时间。目的地址选择还应与路由表最长前缀匹配区分:后者决定已选目的地的数据包如何发送。混用这两个阶段,会把主机偏好错误解释成已经知道整条路径延迟最低。 Happy Eyeballs 属于另一层,通过协调地址发现和错开连接尝试,减少某个地址族出问题时的等待。按顺序逐个尝试地址的客户端,并不自动实现该算法。立即拒绝连接与静默丢弃连接尝试,可能造成非常不同的等待时间,即使最后都能通过 IPv4 成功。不能只看最终用了另一个地址族,就把过程统称为相同的快速竞速机制。 快速验证 将脚本保存为 check-address-selection.py,准备 Docker 后执行 python3 check-address-selection.py。固定环境使用 glibc 2.36、Python 3.11,以及提供 DNS 的 BIND 9.18.49 和 dnsmasq 2.90。两个真实 HTTP 监听分别使用 IPv4 与 IPv6 回环,端口相同。实验没有公网路径,因此观察到的排序明确属于这个本地环境,不作为所有客户端的默认承诺: ...

十一月 4, 2026 · 3 分钟 · XB Field Notes
标题为「EDNS 宣告 UDP 容量,不保证沿途网络交付」的封面,下方是观察、判断与行动三个面板。

EDNS 宣告 UDP 容量,不保证沿途网络交付

EDNS 通过 OPT 伪记录扩展 DNS,其中一项作用是让请求发送方宣告自己准备接收的 UDP 载荷大小,从而允许回答超过传统五百一十二字节 DNS 限制。这是 DNS 交换中的容量宣告,不是网络路径 MTU 发现协议,也不承诺所有中间设备都会交付相同大小的数据报。理解这一区别,才能准确解释大回答为何仍可能失败。 坏掉的样子 客户端宣告四千零九十六字节,不会强迫服务器发送同样大小的响应。服务器可以实施更小的发送上限;需要的回答放不下时,可以返回 TC,客户端仍需可工作的 TCP 路径。增加客户端数字,不能修复服务器有意采用较小预算的策略。应查看两端实际配置与返回内容,而不是把请求里出现的大数值当成双方已承诺一定使用的大小。 反过来,即使回答没有超过宣告容量,也可能在途中丢失。分片、隧道、包过滤和本地网络约束,与 DNS 参与者宣告的能力是不同因素。客户端若根本没有收到响应,也就可能看不到 TC 提示。载荷配置需要按互通性验证,不能当作越大越好的统一性能参数。一次超时无法仅凭宣告值就归因于某一台服务器或某个固定网络环节。 EDNS 还承载不能当作普通缓存记录解释的信息。OPT 是描述本次交换的伪记录,其字段编码载荷容量、版本、扩展响应码和标志;外形对应 TTL 的字段,并不是普通记录寿命。DNSSEC OK 标志 DO 用于请求相关 DNSSEC 数据,却不证明任何一方完成了验证。诊断工具若丢弃这些元信息,可能把不同故障简化成相同空回答。 快速验证 将脚本保存为 check-edns.py,准备 Docker 后执行 python3 check-edns.py。固定 BIND 9.18.49 权威运行在隔离回环网络,明确把 edns-udp-size 和 max-udp-size 都设为一千二百三十二。区域中分别准备四条和八条 TXT 的记录集,以便独立观察容量与截断。实验不依赖公网默认值,也不修改现有解析器的载荷策略: ...

十月 28, 2026 · 3 分钟 · XB Field Notes
标题卡旁有三个方框,分别标注 Observe、Decide 和 Act。

负向 DNS 回答会把不存在的数据记到缓存寿命结束

DNS 可以通过说明数据缺失提供有效信息。NXDOMAIN 表示名称错误,NODATA 则描述成功响应中没有所请求类型的数据。对于没有别名的直接查询,两者区分名称不存在,以及名称存在但缺少该类数据。缓存这些结果,可以避免每次客户端请求都重新进行相同的无结果上游查询。负向回答也是有内容、有寿命的状态,并不是“没有收到任何东西”。 坏掉的样子 部署已经创建正确记录,先前缓存过缺失结果的客户端却可能仍然失败。权威返回新数据,缓存则在寿命结束前继续返回旧否定结果。反复删除重建记录,不会使远端缓存自动失效。这种情况常出现在应用提前查询尚未配置完成的服务名称时;排障应对照查询发生时间和缓存寿命,而不是只检查现在的区域文件是否包含记录。 仅凭回答区为空,不能完整分类 DNS 结果。需要检查响应码、权威区和可能存在的别名链。转介与权威否定回答不同,NXDOMAIN 也可能指向 CNAME 最终目标,而非最初请求的别名本身。把所有空结果都说成“域名不存在”,可能让调查去改错误的记录。应保留原始问答的类型、名称及各区数据,避免过早简化成一句错误描述。 对于 RFC 2308 规定的权威 NXDOMAIN 和 NODATA,负向寿命取 SOA.MINIMUM 与 SOA 记录 TTL 中的较小值。该值放在否定回答的 SOA TTL 上,再由缓存继续递减。MINIMUM 不是所有正向记录 TTL 的统一下限;只改变将来正向记录的 TTL,也不会缩短已经缓存的不存在结论。操作时应确认正在调整哪类数据的哪一代寿命。 快速验证 历史解析器集成示例保持原样,本次没有重跑;当前没有 Docker,也不能建立网络监听。 将脚本保存为 check-negative-dns.py,准备 Docker 后执行 python3 check-negative-dns.py。固定环境使用 BIND 9.18.49 和 dnsmasq 2.90,只有隔离回环网络。第一个区域的 SOA TTL 为八秒、minimum 为四秒,并有一条现存 A 记录。实验在新增缺失数据前后检查真实响应与权威查询次数,不通过模拟字典代替解析器缓存行为: ...

十月 22, 2026 · 3 分钟 · XB Field Notes
DNSSEC 验证文章封面,包含依次相连的 OBSERVE、DECIDE 与 ACT 面板。

DNSSEC 验证签名数据链,不只检查签名是否存在

DNSSEC 为 DNS 数据增加来源认证和完整性检查。RRSIG 对记录集签名,DNSKEY 发布验证密钥,父区 DS 则通过摘要标识一个子区 DNSKEY。父区签名的是自己的 DS 记录集,不是简单直接签署子区公钥。验证从配置的信任锚出发,沿这些关系抵达所请求数据。理解链中每类记录的责任,才能准确定位发布与验证之间的差异。 坏掉的样子 如果委派没有建立预期信任路径,仅发布签名并不足够。缺失 DS 可能让子区处于不安全状态,错误 DS 则可能让原本应安全的子区验证失败;这是不同状态。已经安全证明不存在 DS,并不要求把每个未签名或独立签名的子区回答都当成无效数据拒绝。不能把“没有形成认证链”和“已有安全链却证明失败”混成一种结果。 签名具有独立于普通缓存 TTL 的生效与到期时间。签名过期后,权威区域可能继续回答记录,验证器却会拒绝。错误时钟、不完整密钥轮换和父区旧 DS,都可能中断一个普通查询看似仍正常的区域。只监控 DNSKEY 或 RRSIG 是否存在,会漏掉真正验证边界;应关注当前数据能否在有效时间和正确委派下被实际验证接受。 AD 标志也需要通过受信任解析路径解读。任意未受保护响应里出现该位,不会使回答自动可信,攻击者可以修改没有认证保护的消息。同样,使用 DO 请求 DNSSEC 记录,并不等于执行验证。应先确认哪个组件验证,以及应用如何信任它的回答,再把显示的标志作为证据。不能只凭工具输出里出现两个字母,就扩大为端到端安全保证。 快速验证 将脚本保存为 check-dnssec.py,准备 Docker 后执行 python3 check-dnssec.py。固定 BIND 9.18.49 环境创建一次性密钥和离线签名父子区域。独立验证解析器信任本地 lab.test 锚,只把这个测试命名空间转发给临时权威。私钥留在容器内部,不更改宿主信任配置和系统时间;不同案例使用独立进程,避免此前缓存影响结果: ...

十月 21, 2026 · 3 分钟 · XB Field Notes
应用名称解析文章封面,包含依次相连的 OBSERVE、DECIDE 与 ACT 面板。

解析顺序取决于应用及其名称服务路径

主机名查询首先由具体程序发起。在 Linux 上,使用 glibc 名称服务接口的应用,可以依照 /etc/nsswitch.conf 中 hosts 项列出的服务查询;使用自有 DNS 客户端的程序则可能走另一条路径。并不存在所有应用都遵守的“先缓存、再 hosts、最后互联网”固定顺序。定位问题的起点是实际调用方,而不是凭操作系统名称假定完整流程。 坏掉的样子 当 files 位于 dns 前面时,hosts 文件条目可以覆盖正确的 DNS 回答。修改权威区域不会删除这个本地覆盖。直接 DNS 查询可能已经看到新地址,应用却仍使用本地文件中的旧值;这两项观察可以同时正确,因为它们实际回答的是不同来源的查询。仅凭 dig 正常就认定应用一定使用同一个地址,会漏掉最直接的配置原因。 NSS 的结果动作与服务顺序同样重要。在 files 后写 [NOTFOUND=return],表示文件服务没有找到条目时就停止。即使 DNS 中存在有效记录,也不会继续询问。把这类失败归咎于上游不可用,可能让人反复修改区域或者重启解析服务,却始终没有修正真正阻断查询的策略。应完整读取方括号规则,不能只摘出 files 和 dns 两个单词。 地址族筛选还会制造另一种差异。使用 AI_ADDRCONFIG 时,glibc 根据本地已配置的非回环地址决定返回哪些地址族;只有回环接口的容器因此可能不同于普通主机。两个工具即便都调用 libc,也未必请求相同地址族或标志。空结果不能立即解释为域名不存在,尤其在隔离实验、极简容器和刚启动的网络环境中,应先核对调用参数。 快速验证 将脚本保存为 check-resolution.py,准备 Docker 后执行 python3 check-resolution.py。脚本构建固定的 Debian 环境,使用 BIND 9.18.49、dnsmasq 2.90 和 glibc 2.36。可丢弃容器只有回环网络;hosts、NSS 与解析器配置修改全部发生在容器内部,不把宿主文件作为实验可写状态。每次创建独立实例,并在结束后清理自己的容器和镜像标签: ...

八月 2, 2026 · 3 分钟 · XB Field Notes
DNS 节点围绕一枚时间到活性缓存转盘。

DNS 变更需要一个感知缓存的回滚窗口

提前准备 TTL,观察权威与递归应答,并让两个端点在整个缓存窗口内都有效。

六月 18, 2026 · 1 分钟 · XB Field Notes