Preparar uma experiência que consiga explicar o resultado
O runner abaixo cria uma fixture autoritativa original e inicia Unbound numa porta temporária em loopback. Não altera a configuração DNS do computador. Cada consulta dig indica explicitamente o destino e usa um nome absoluto dentro de fund.test. A fixture regista nome, tipo e momento de cada pedido; esse registo permite distinguir uma resposta reutilizada de uma nova obtenção. Antes de executar, disponibiliza Python 3, Unbound 1.26.1, unbound-checkconf da mesma versão e dig. Guarda o código como dns-cache.py e passa os caminhos dos executáveis nos argumentos. A execução registada usou Python 3.13.1 e dig 9.10.6 em macOS. O script recusa uma conta com .digrc pessoal para evitar opções implícitas. Usa uma conta de laboratório limpa em vez de apagar a configuração de trabalho. Confirma as versões antes de interpretar diferenças.
Comparar publicação e resposta em cache
Na primeira experiência, positive.fund.test aponta para 192.0.2.10 com TTL quatro segundos. Depois da primeira consulta, o runner muda a fixture para 192.0.2.20. A consulta imediata continua a receber o endereço anterior, sem acrescentar um pedido a montante. Depois da espera, surge o novo endereço e o contador aumenta. A conclusão pertence a esta instância e configuração: houve reutilização, seguida de nova obtenção após expiração. Não se mediu convergência de uma organização. O tempo de resposta sozinho não seria prova suficiente, porque uma fixture local também responde rapidamente. Num incidente, preserva os mesmos elementos de evidência: nome, tipo, servidor, origem, instante e estado publicado. Evita alterar novamente a zona apenas porque um resolver mantém dados que adquiriu antes. Primeiro estabelece qual entrada está efetivamente a ser observada.
Criar nomes e acrescentar tipos são experiências distintas
A segunda experiência consulta new.fund.test enquanto o nome ainda não existe. A fixture envia NXDOMAIN e SOA com validade negativa de quatro segundos. Criar A imediatamente não muda a resposta já guardada pelo Unbound; a nova publicação só aparece após renovação. Na terceira experiência, dual.fund.test existe com A, mas ainda sem AAAA. O resultado AAAA é NOERROR sem resposta desse tipo e com SOA. Acrescentar AAAA não remove a ausência anterior, enquanto A continua a ser obtido com sucesso. Regista estas duas situações separadamente no relatório. No banco fictício Ria, chamar “nome inexistente” a qualquer resposta vazia levou a uma proposta de apagar um nome funcional. O exercício pede que corrijas a classificação, acompanhes o tipo afetado e mantenhas a validação da aplicação como passo próprio. Nenhum caso representa procedimentos de um empregador.
Ler a evidência e preparar uma conclusão limitada
O ficheiro JSON produzido contém respostas completas, contadores, configuração e verificações. Na experiência TTL zero, duas consultas independentes obtêm valores diferentes e fazem dois pedidos à fixture. Isso não prova que uma aplicação real nunca conserve um endereço ou uma ligação. Guarda o ficheiro e escreve uma conclusão por experiência com três partes: observação, interpretação e limite. Se uma asserção falhar, mantém a falha e investiga versões, configuração, tempos e pedidos; não substituas a saída pelo resultado esperado. Os processos temporários são terminados no fim e o diretório de trabalho é removido. O JSON permanece no caminho escolhido. Resumo: publicação, cache e consumo pela aplicação são estados distintos. Relaciona esta aula com respostas negativas, diagnóstico de mensagens e planeamento de rollback. A próxima aula usa a mesma instrumentação para mostrar o efeito de políticas locais.
#!/usr/bin/env python3
"""Original local cache experiment. Requires Unbound 1.26.1 and dig; never changes system DNS."""
import argparse, hashlib, json, os, re, socket, socketserver, struct, subprocess, tempfile, threading, time
from pathlib import Path
def name_bytes(name):
return b''.join(bytes([len(x)]) + x.encode('ascii') for x in name.rstrip('.').split('.')) + b'\0'
def question(data):
i, labels = 12, []
while data[i]:
n=data[i]; labels.append(data[i+1:i+1+n].decode('ascii')); i+=n+1
i+=1
kind, cls=struct.unpack('!HH',data[i:i+4]); assert cls==1
return '.'.join(labels)+'.',kind,data[12:i+4]
def rr(name, kind, ttl, payload):
return name_bytes(name)+struct.pack('!HHIH',kind,1,ttl,len(payload))+payload
def main():
parser=argparse.ArgumentParser(description=__doc__)
parser.add_argument('--unbound',required=True); parser.add_argument('--checkconf',required=True)
parser.add_argument('--dig',default='/usr/bin/dig'); parser.add_argument('--output',default='dns-cache-evidence.json')
args=parser.parse_args(); assert not Path.home().joinpath('.digrc').exists(), 'Personal .digrc detected; use an isolated lab account.'
def cmd(argv):
p=subprocess.run(argv,text=True,capture_output=True,timeout=15)
assert p.returncode==0,(argv,p.stdout,p.stderr)
return p.stdout+p.stderr
version=cmd([args.unbound,'-V']); assert re.search(r'Version 1\.26\.1\b',version),version
dig_version=cmd([args.dig,'-v']).strip()
records={}; hits=[]; observations={}; checks=[]; configurations=[]
def check(label, condition):
assert condition,label
checks.append(label)
class Authority(socketserver.BaseRequestHandler):
def handle(self):
data,sock=self.request
name,kind,q=question(data)
assert name.endswith('.fund.test.'),name
hits.append({'name':name,'type':kind,'at':round(time.monotonic(),6)})
row=records.get(name)
answer=b''; auth=b''; rcode=0
if row is None: rcode=3
elif kind in row:
value,ttl=row[kind]
answer=rr(name,kind,ttl,socket.inet_pton(socket.AF_INET if kind==1 else socket.AF_INET6,value))
if not answer:
soa=name_bytes('ns.fund.test.')+name_bytes('hostmaster.fund.test.')+struct.pack('!IIIII',1,60,30,600,4)
# RFC 2308: SOA negative TTL is min(original SOA TTL 9, MINIMUM 4).
auth=rr('fund.test.',6,4,soa)
flags=0x8000|0x0400|(struct.unpack('!H',data[2:4])[0]&0x0100)|rcode
reply=data[:2]+struct.pack('!HHHHH',flags,1,bool(answer),bool(auth),0)+q+answer+auth
sock.sendto(reply,self.client_address)
with tempfile.TemporaryDirectory(prefix='dr-dns-cache-') as tmp:
authority=socketserver.UDPServer(('127.0.0.1',0),Authority)
thread=threading.Thread(target=authority.serve_forever,daemon=True);thread.start()
auth_port=authority.server_address[1]
def count(name,kind=1):return sum(h['name']==name and h['type']==kind for h in hits)
def reserve_port():
with socket.socket() as s:s.bind(('127.0.0.1',0));return s.getsockname()[1]
def run_resolver(label, action, extra=''):
port=reserve_port();conf=Path(tmp)/(label+'.conf');log=Path(tmp)/(label+'.log')
config=f'''server:
interface: 127.0.0.1@{port}
outgoing-interface: 127.0.0.1
access-control: 127.0.0.0/8 allow
do-ip6: no
username: ""
chroot: ""
directory: "{tmp}"
pidfile: "{tmp}/{label}.pid"
use-syslog: no
do-daemonize: no
verbosity: 0
num-threads: 1
module-config: "iterator"
qname-minimisation: no
do-not-query-localhost: no
local-zone: "." refuse
local-zone: "fund.test." transparent
prefetch: no
serve-expired: no
cache-min-ttl: 0
cache-max-ttl: 86400
cache-min-negative-ttl: 0
cache-max-negative-ttl: 3600
{extra}
stub-zone:
name: "fund.test."
stub-addr: 127.0.0.1@{auth_port}
stub-first: no
stub-prime: no
remote-control:
control-enable: no
'''
# Explicit policy replacement avoids duplicate settings.
if extra:
for line in extra.strip().splitlines():
key=line.strip().split(':')[0]
config=re.sub(r'^ '+re.escape(key)+r':[^\n]*\n','',config,flags=re.M)
config=config.replace('stub-zone:',extra+'\nstub-zone:')
conf.write_text(config); validation=cmd([args.checkconf,str(conf)])
configurations.append({'label':label,'config':config,'checkconf':validation})
def query(name,kind='A'):
out=cmd([args.dig,'@127.0.0.1','-p',str(port),name,kind,'+recurse','+nosearch','+noedns','+time=2','+tries=1'])
assert 'status:' in out,out
return out
with log.open('w') as lf:
proc=subprocess.Popen([args.unbound,'-d','-c',str(conf)],stdout=lf,stderr=lf)
try:
for _ in range(100):
assert proc.poll() is None,log.read_text()
try:
with socket.create_connection(('127.0.0.1',port),timeout=.1):break
except OSError:time.sleep(.03)
else:raise AssertionError('Unbound did not listen')
action(query)
finally:
proc.terminate()
try:proc.wait(timeout=5)
except subprocess.TimeoutExpired:proc.kill();proc.wait(timeout=5)
check(label+': process stopped',proc.poll() is not None)
try:
def baseline(query):
n='positive.fund.test.';records[n]={1:('192.0.2.10',4)};first=query(n);loaded=time.monotonic();before=count(n)
records[n]={1:('192.0.2.20',4)};cached=query(n)
check('positive initially fetched',before==1 and '192.0.2.10' in first)
check('positive cache retains old address',count(n)==before and '192.0.2.10' in cached)
time.sleep(max(0,loaded+4.3-time.monotonic()));fresh=query(n)
check('positive expiry causes fetch',count(n)==before+1 and '192.0.2.20' in fresh)
check('recursive response flags',bool(re.search(r'flags: qr rd ra;',cached)))
observations['positive']={'first':first,'cached':cached,'expired':fresh,'upstreamRequests':count(n)}
n='new.fund.test.';first=query(n);loaded=time.monotonic();before=count(n);records[n]={1:('192.0.2.30',30)};cached=query(n)
check('NXDOMAIN initially fetched',before==1 and 'status: NXDOMAIN' in first)
check('creation does not invalidate negative cache',count(n)==before and 'status: NXDOMAIN' in cached)
time.sleep(max(0,loaded+4.3-time.monotonic()));fresh=query(n)
check('negative expiry reveals created name',count(n)==before+1 and '192.0.2.30' in fresh)
observations['negative']={'first':first,'cached':cached,'expired':fresh,'upstreamRequests':count(n)}
n='dual.fund.test.';records[n]={1:('192.0.2.40',30)};first=query(n,'AAAA');loaded=time.monotonic();before=count(n,28)
records[n][28]=('2001:db8::40',30);cached=query(n,'AAAA');a=query(n,'A')
check('NODATA has NOERROR and SOA', 'status: NOERROR' in first and 'ANSWER: 0, AUTHORITY: 1' in first and 'SOA' in first)
check('AAAA absence stays cached',count(n,28)==before and 'ANSWER: 0, AUTHORITY: 1' in cached)
check('AAAA NODATA does not suppress A',count(n,1)==1 and '192.0.2.40' in a)
time.sleep(max(0,loaded+4.3-time.monotonic()));fresh=query(n,'AAAA')
check('AAAA expiry reveals published address',count(n,28)==before+1 and '2001:db8::40' in fresh)
observations['nodata']={'first':first,'cached':cached,'a':a,'expired':fresh,'aaaaRequests':count(n,28),'aRequests':count(n,1)}
n='zero.fund.test.';records[n]={1:('192.0.2.50',0)};first=query(n);records[n]={1:('192.0.2.51',0)};second=query(n)
check('zero TTL returns first data','192.0.2.50' in first)
check('zero TTL refetches next independent query',count(n)==2 and '192.0.2.51' in second)
observations['zeroTTL']={'first':first,'second':second,'upstreamRequests':count(n)}
run_resolver('baseline',baseline)
def cap(query):
n='cap.fund.test.';records[n]={1:('192.0.2.60',30)};first=query(n);loaded=time.monotonic();records[n]={1:('192.0.2.61',30)}
check('maximum TTL lowers client TTL',bool(re.search(r'cap\.fund\.test\.\s+[012]\s+IN\s+A',first)))
time.sleep(max(0,loaded+2.3-time.monotonic()));fresh=query(n)
check('maximum TTL refreshes before original 30 seconds',count(n)==2 and '192.0.2.61' in fresh)
observations['maximumTTL']={'first':first,'expired':fresh,'upstreamRequests':count(n)}
run_resolver('cap',cap,' cache-max-ttl: 2\n')
def floor(query):
n='floor.fund.test.';records[n]={1:('192.0.2.70',1)};first=query(n);loaded=time.monotonic();records[n]={1:('192.0.2.71',1)}
time.sleep(max(0,loaded+1.5-time.monotonic()));cached=query(n)
check('minimum TTL extends local retention',count(n)==1 and '192.0.2.70' in cached)
time.sleep(max(0,loaded+4.3-time.monotonic()));fresh=query(n)
check('minimum TTL eventually refreshes',count(n)==2 and '192.0.2.71' in fresh)
observations['minimumTTL']={'first':first,'afterAuthoritativeTTL':cached,'expired':fresh,'upstreamRequests':count(n)}
run_resolver('floor',floor,' cache-min-ttl: 4\n')
check('all authority requests stay in fixture zone',all(h['name'].endswith('.fund.test.') for h in hits))
finally:
authority.shutdown();authority.server_close();thread.join(timeout=3)
check('authority thread stopped',not thread.is_alive())
evidence={'unboundVersion':version,'digVersion':dig_version,'pythonVersion':__import__('platform').python_version(),'observations':observations,'checks':checks,'authorityRequests':hits,'configurations':configurations,'runnerSha256':hashlib.sha256(Path(__file__).read_bytes()).hexdigest(),'scope':'Actual Unbound 1.26.1 cache with an original UDP authoritative fixture and dig over loopback. Six experiments, three isolated resolver processes. No external DNS, DNSSEC validator, live delegation traversal, operating-system DNS changes or production application. Cache policy experiments are deliberate lab settings, not recommended production defaults.'}
Path(args.output).write_text(json.dumps(evidence,indent=2)+'\n')
print(json.dumps({'passed':len(checks),'experiments':len(observations),'output':args.output}))
if __name__=='__main__':main()
Cais publicou um nome depois de o batch receber NXDOMAIN. Compara a autoridade com o resolver do batch e observa a renovação antes de repetir a operação.
Armadilhas comuns
Usar latência como única prova de cache, confundir NODATA com NXDOMAIN ou encerrar uma falha de aplicação apenas porque DNS voltou a responder.
Tópicos relacionados: Respostas negativas e SOA · Diagnóstico do consumidor
Uma medição útil relaciona resposta, configuração e pedido a montante. O sucesso do laboratório não substitui a aceitação da aplicação.
Referência: Unbound runtime manual and original DR cache experiments · DNS RFC 1034/1035 with RFC 2181, 2308, 3596, 4033, 7766 and 8767; dig BIND 9.20