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 的记录集,以便独立观察容量与截断。实验不依赖公网默认值,也不修改现有解析器的载荷策略:

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 dns.flags
records='\n'.join(f'{name} 30 IN TXT "{i}-'+('x'*200)+'"' for name,count in [('medium',4),('large',8)] for i in range(count))
def ask(name,payload=None,version=0,flags=0):
    q=dns.message.make_query(name+'.lab.test.','TXT',use_edns=False)
    if payload is not None:q.use_edns(edns=version,ednsflags=flags,payload=payload)
    return dns.query.udp(q,'127.0.0.1',port=1053,timeout=2)
def description(response):return {'wire_bytes':len(response.to_wire()),'TC':bool(response.flags&dns.flags.TC),
    'rcode':dns.rcode.to_text(response.rcode()),'EDNS_version':response.edns,'advertised_payload':response.payload,
    'DO':bool(response.ednsflags&dns.flags.DO),'AD':bool(response.flags&dns.flags.AD),'answer_records':sum(len(rr) for rr in response.answer)}
with lab(records,extra='edns-udp-size 1232; max-udp-size 1232;') as (_,update,cache,queries):
    classic=ask('medium');extended=ask('medium',1232);capped=ask('large',4096);do=ask('medium',1232,flags=dns.flags.DO);version=ask('medium',1232,version=1)
    assert classic.flags&dns.flags.TC and len(classic.to_wire())<=512 and classic.edns==-1
    assert not extended.flags&dns.flags.TC and 512<len(extended.to_wire())<=1232 and len(values(extended,'TXT'))==4
    assert extended.edns==0 and extended.opt is not None
    assert capped.flags&dns.flags.TC and len(capped.to_wire())<=1232
    assert do.ednsflags&dns.flags.DO and not do.flags&dns.flags.AD
    assert not any(rr.rdtype==dns.rdatatype.RRSIG for rr in do.answer)
    assert version.rcode()==dns.rcode.BADVERS and version.edns==0
    full=dns.query.tcp(dns.message.make_query('large.lab.test.','TXT'),'127.0.0.1',port=1053,timeout=2)
    assert len(values(full,'TXT'))==8 and len(full.to_wire())>1232
    print(json.dumps({'without_EDNS':description(classic),'EDNS_1232':description(extended),'client_4096_server_1232':description(capped),
        'DO_on_unsigned_zone':description(do),'unsupported_version':description(version),'TCP_full':description(full)},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)

不使用 EDNS 时,中等集合查询得到四百六十三字节的截断回答,只包含两条 TXT;宣告一千二百三十二字节后,四条完整记录以九百零四字节返回,TC 清除。脚本检查记录数量、消息长度和 EDNS 存在,证明这个具体回答跨过传统限制,同时仍在所选 UDP 预算内。它不是只看查询成功,就假定完整集合已经到达。

对于更大集合,客户端宣告四千零九十六,但服务器仍限制在一千二百三十二。实测响应只有四十三字节,设置 TC,没有回答记录;随后 TCP 查询取得全部八条,共一千七百五十二字节。截断回答不一定填满允许的缓冲区,记录分组和响应构造可能产生很短的“还需要完整交换”信号。因此响应短也不能直接等同于完整数据很少。

程序还对这个未签名区域发出带 DO 的查询。回答保留 DO,却没有 RRSIG,也没有表示认证数据的 AD 标志。这符合预期:请求 DNSSEC 数据不会凭空产生签名或信任链。实验分别记录各标志,不把出现 DO 的交换称为验证成功。需要判断认证状态时,应检查实际验证路径,而不是只找到了某个听起来与安全有关的位。

最后,查询宣告 EDNS 版本一,服务器返回 BADVERS,并指示支持版本零。库从完整消息中解码扩展响应码。这个失败对照将不支持的扩展版本,与名称不存在或普通成功空回答区分开,也说明解释扩展相关错误时应保留 OPT。仅查看基础响应头的一部分,可能无法还原服务器真正表达的错误种类。

测量实际交换,不假定统一默认值

记录客户端真实宣告、服务器配置、响应大小、标志和传输方式。不同软件版本和运维策略可能采用不同载荷值,一千二百三十二或四千零九十六都不是标准保证的统一默认。本例的较小值是明确实验配置。回环结果没有测量分片、远端路径 MTU 或公共解析性能,不能把局部可交付性直接外推到所有客户端网络。

诊断大回答失败时,通过同一预期端点比较小查询、较大 EDNS 查询和完整 TCP 查询。区分错误类别:TC 表示还需取得完整数据,BADVERS 表示扩展版本不一致,超时则不能直接指出哪一环丢包。保留足够响应信息,避免把这些不同故障压成“DNS 挂了”。只有分类准确,下一步检查的配置和网络路径才有明确对象。

使用部署实现支持的大小与重试策略,再从重要客户端网络验证。即使大多数观察到的回答都能放入 UDP,也应保留 TCP 访问。EDNS 改善扩展能力,并可能减少部分截断重试,但完整交付仍依赖传输行为、服务器限制和客户端对实际回答的处理。选择值之后,应通过真实结果确认效果,而不是只以配置项存在作为完成依据。

延伸阅读

来源