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 就符合标准。
快速验证
将脚本保存为 check-encrypted-dns.py,准备 Docker 后执行 python3 check-encrypted-dns.py。固定 BIND 9.18.49 环境通过其 TLS 和 HTTP/2 库支持两种传输。脚本创建一次性证书,成功探测时显式信任它,并使用隔离回环端口八千五百三十一和八千四百四十三。实验不会向宿主系统安装新信任锚,生成的私钥也不作为文章内容输出:
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 socket,ssl,struct
def exact(stream,count):
data=b''
while len(data)<count:
more=stream.recv(count-len(data));assert more,'unexpected EOF';data+=more
return data
with TemporaryDirectory(prefix='xbcatm-dot-') as directory:
root=Path(directory);certificate=root/'certificate.pem';key=root/'key.pem'
run('openssl','req','-x509','-newkey','rsa:2048','-nodes','-days','2','-subj','/CN=resolver.lab.test',
'-addext','subjectAltName=DNS:resolver.lab.test,IP:127.0.0.1','-keyout',str(key),'-out',str(certificate))
zone=root/'zone';zone.write_text('$ORIGIN lab.test.\n$TTL 30\n@ IN SOA ns.lab.test. hostmaster.lab.test. (1 60 60 3600 30)\n@ IN NS ns.lab.test.\nns IN A 127.0.0.1\nservice IN A 127.0.0.2\n')
config=f'''tls local-tls {{ key-file "{key}"; cert-file "{certificate}"; }};
http local-http {{ endpoints {{ "/dns-query"; }}; }};
options {{ directory "{root}"; listen-on port 1053 {{127.0.0.1;}};
listen-on port 8531 tls local-tls {{127.0.0.1;}};
listen-on port 8443 tls local-tls http local-http {{127.0.0.1;}};
listen-on-v6 {{none;}}; recursion no; dnssec-validation no; notify no;
pid-file "{root}/named.pid"; session-keyfile none; max-cache-size 8m; }};
controls {{}}; zone "lab.test" {{ type primary; file "{zone}"; }};
'''
with named_process(root,config):
context=ssl.create_default_context(cafile=str(certificate));context.set_alpn_protocols(['dot'])
with socket.create_connection(('127.0.0.1',8531),timeout=2) as raw,context.wrap_socket(raw,server_hostname='resolver.lab.test') as stream:
q=dns.message.make_query('service.lab.test.','A');wire=q.to_wire();stream.sendall(struct.pack('!H',len(wire))+wire)
count=struct.unpack('!H',exact(stream,2))[0];response=dns.message.from_wire(exact(stream,count))
assert response.id==q.id and values(response)==['127.0.0.2'];tls_version=stream.version();alpn=stream.selected_alpn_protocol()
failures={}
for name,ctx,hostname in [('untrusted',ssl.create_default_context(),'resolver.lab.test'),('wrong_name',context,'different.lab.test')]:
try:
with socket.create_connection(('127.0.0.1',8531),timeout=2) as raw,ctx.wrap_socket(raw,server_hostname=hostname):pass
raise AssertionError('certificate validation unexpectedly succeeded')
except ssl.SSLCertVerificationError as error:failures[name]=error.verify_code
https={}
for mode in ('https-get','https-post'):
output=run('dig','@127.0.0.1','-p','8443','service.lab.test.','A','+'+mode,'+tls-ca='+str(certificate),
'+tls-hostname=resolver.lab.test','+short','+tries=1','+time=2').stdout
assert output.strip()=='127.0.0.2',output;https[mode]=output.strip()
doh_rejections={}
for label,verification in [('untrusted',['+tls-ca','+tls-hostname=resolver.lab.test']),
('wrong_name',['+tls-ca='+str(certificate),'+tls-hostname=different.lab.test'])]:
rejected=run('dig','@127.0.0.1','-p','8443','service.lab.test.','A','+https-get',*verification,'+short','+tries=1','+time=2',check=False)
diagnostic=rejected.stdout+rejected.stderr
assert 'TLS peer certificate verification' in diagnostic and 'failed:' in diagnostic,(label,diagnostic)
assert not re.search(r'^127\.0\.0\.2$',diagnostic,re.M)
doh_rejections[label]={'exit':rejected.returncode,'diagnostic':diagnostic.strip(),'no_DNS_answer':True}
wrong_path=run('dig','@127.0.0.1','-p','8443','service.lab.test.','A','+https=/wrong',
'+tls-ca='+str(certificate),'+tls-hostname=resolver.lab.test','+tries=1','+time=2',check=False)
assert wrong_path.returncode!=0
print(json.dumps({'DoT':{'answer':values(response),'TLS_version':tls_version,'ALPN':alpn},'certificate_rejections':failures,
'DoH_verified_TLS':https,'DoH_certificate_rejections':doh_rejections,'wrong_DoH_path_exit':wrong_path.returncode,
'wrong_DoH_path_diagnostic':wrong_path.stdout+wrong_path.stderr},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)
Python DoT 客户端认证 resolver.lab.test,实测协商 TLS 1.3 与 ALPN dot,再发送具有长度前缀的 DNS 问题。它读取完整响应,核对消息标识,取得 service.lab.test 的 A 地址 127.0.0.2。连接地址使用回环,而期望 TLS 主机名独立指定,使端点位置与认证身份保持清晰,不会因为直接连接某个 IP 就自动跳过名称检查。
两个 DoT 对照都无法通过证书验证:一个只使用系统信任库,没有加入一次性证书;另一个虽然信任证书,却要求不同主机名。这分别验证信任与名称检查。程序捕获明确的证书验证异常,而不是把任意套接字故障都当成“证书检查有效”的证据。只有失败原因与测试条件相符,负向案例才真正证明预期边界。
DoH 部分使用 dig,对 /dns-query 分别进行 HTTPS GET 与 POST,并明确设置 CA 和主机名验证。两者都返回 127.0.0.2。不可信证书和错误名称对照会输出证书校验失败且没有 DNS 回答,但这个版本的退出码仍为零,因此断言检查诊断与回答,不能只依赖退出码。使用有效信任设置请求 /wrong 也失败,说明经过认证的 HTTPS 仍需要正确端点路径。
错误路径诊断表示交换失败,不是 NXDOMAIN,也不能证明 service.lab.test 消失。本例没有从该诊断提取具体 HTTP 状态,而是保留传输或端点失败,与完整 DNS 响应携带 DNS 状态码之间的区别。不能把通用命令失败当成权威否定回答,否则会把修复端点配置的问题误导成反复修改域名记录。
明确解析器与引导配置
客户端连接加密解析器前,需要先知道它的地址。部署可以提供引导地址或其他文档规定的发现路径。本例直接给出回环地址,避免依赖同一解析器形成循环。生产中应验证实际客户端如何处理引导、证书续期、端点变化和连接失败。加密链路建立之前的发现过程,以及失败后的处理方式,都属于完整行为契约的一部分。
确认应用查询实际经过已配置的加密连接。其他进程可能使用系统解析器、另一套浏览器配置或现有缓存。一次成功 DoH 命令只证明这条命令的路径,不代表整机策略已经生效。应观察目标应用和它们的失败行为,再说明哪些名称查询具有相同传输保护。否则局部探测成功,可能掩盖真正业务仍走另一条路径的事实。
最后,DNSSEC 与加密 DNS 处理不同属性。DNSSEC 可以沿信任链认证签名数据,TLS 则保护并认证所选传输端点,两者不会自动提供对方保证。服务契约需要时可以同时使用,但证书验证、实际加密查询、解析器信任和 DNS 数据验证,应保留各自证据。按真实机制说明保护范围,才能避免把一种成功扩大成全部安全属性都已成立。
延伸阅读
来源
- RFC 7858 DNS over TLS,TLS 传输、专用端口与连接消息边界,已核验 2026-09-12。
- RFC 8484 DNS over HTTPS,DoH GET、POST 映射与 application/dns-message,已核验 2026-09-12。
- BIND 9.18.49 tools,dig 加密传输与显式证书验证,已核验 2026-09-12。