Um serviço verde pode esconder uma família degradada
Num serviço com registos A e AAAA, um cliente que consiga ligar por IPv4 não demonstra conectividade IPv6. Alguns clientes tentam famílias diferentes através de mecanismos como Happy Eyeballs, reduzindo o impacto visível de um caminho lento ou indisponível. Não assumes que todos os clientes têm esse comportamento ou a mesma política. Para aceitar uma entrega dual stack, identifica a família efetivamente usada, força sondas separadas quando apropriado e conserva resultados por origem, destino e serviço. Um erro na consulta AAAA, uma rota IPv6 ausente e uma falha TLS são hipóteses distintas, que precisam de observações diferentes.
Caso: o batch IPv6 falha e a sonda geral passa
Uma aplicação fictícia passa a usar um endpoint IPv6. A sonda geral do dashboard continua verde porque chega ao endereço IPv4. No laboratório, dois routers executam OSPFv2 para IPv4 e OSPFv3 para IPv6 sobre o mesmo trânsito. O Instance ID de um extremo é alterado apenas em OSPFv3. O teste IPv4 continua a responder, enquanto a rota e a sonda ao endpoint IPv6 falham. A equipa APS deve reportar a perda por família e recuperar a configuração específica, conservando o caminho saudável. Este exercício não altera DNS nem executa um cliente Happy Eyeballs; ilustra a falha de routing que uma sonda inespecífica poderia esconder.
Retirar um prefixo sem perder o vizinho
Numa segunda experiência, repõe primeiro o Instance ID e confirma recuperação. Depois remove apenas 2001:db8:2::1/128 da loopback de R2. A adjacência OSPFv3 pode continuar Full porque o trânsito permanece operacional, mas o prefixo retirado deixa de ser anunciado e instalado em R1. A sonda IPv6 falha e a sonda IPv4 continua a responder. Esta distinção localiza o trabalho: verifica o endereço do serviço e o seu anúncio antes de reiniciar a vizinhança. Repõe o endereço, espera pela condição de rota instalada e confirma a sonda. O runner não declara sucesso apenas porque o comando de reposição foi aceite.
Executar o script completo
Guarda o exemplo abaixo como run.py. Requer Python 3, Docker a funcionar e sub-redes de laboratório livres: 172.30.243.0/24 e 2001:db8:243::/64. Obtém a imagem com docker pull quay.io/frrouting/frr@sha256:b0faf7c8f3d8b09aea1e38f77603c745a66dbf5e26ef9718527c2fbb53cc3aed. Executa python3 run.py /tmp/encor-ipv6-evidence.json. O runner cria recursos com nomes únicos, remove as rotas por omissão IPv4 e IPv6 e verifica a sua ausência. Não monta ficheiros do anfitrião, não publica portas nem usa modo privileged. Usa NET_ADMIN, NET_RAW e SYS_ADMIN apenas nos containers descartáveis e limpa os recursos criados em finally, incluindo quando uma asserção falha.
Critérios de passagem para operação
O ficheiro de sucesso contém as verificações realizadas e cinco fases de observação, com endereços, vizinhos, LSDB, rotas e configuração. Aceita-o apenas com código de saída zero. No serviço real, acrescenta DNS, TCP, TLS e uma transação representativa em cada família exigida, com origem e caminho de retorno relevantes. Define o que impede o go-live: por exemplo, o endpoint IPv6 não responder quando é obrigatório, mesmo que IPv4 funcione. O laboratório não valida autenticação OSPF, múltiplas áreas OSPFv3, resiliência física ou desempenho. Duas instâncias no mesmo Mac também não equivalem a dois domínios de falha físicos.
# Save as run.py; obtain IMAGE and run: python3 run.py /tmp/encor-ipv6-evidence.json
"""Original dual-stack FRR exercise; only disposable Docker resources are modified."""
import datetime,hashlib,json,pathlib,subprocess,sys,time,uuid
IMAGE='quay.io/frrouting/frr@sha256:b0faf7c8f3d8b09aea1e38f77603c745a66dbf5e26ef9718527c2fbb53cc3aed'
prefix='dr-encor6-'+uuid.uuid4().hex[:8];containers=[];networks=[];checks=[];phases=[]
def docker(args,check=True):
r=subprocess.run(['docker',*args],capture_output=True,text=True,timeout=40)
if check and r.returncode:raise RuntimeError(str(args)+'\n'+r.stdout+r.stderr)
return r
def ex(n,*args,check=True):return docker(['exec',prefix+'-'+n,*args],check)
def cli(n,*lines):
args=['vtysh']
for line in lines:args+=['-c',line]
result=ex(n,*args).stdout
if '% Unknown command' in result or '% Ambiguous' in result:raise RuntimeError(result)
return result
def verify(name,condition):
if not condition:raise AssertionError(name)
checks.append(name)
def wait_for(name,fn):
end=time.monotonic()+40
while time.monotonic()<end:
if fn():return
time.sleep(1)
raise AssertionError('Timed out: '+name)
def routes(n,v=6):return json.loads(cli(n,'show '+('ipv6' if v==6 else 'ip')+' route json'))
def remote6():return any(r.get('protocol')=='ospf6' and r.get('installed') for r in routes('r1').get('2001:db8:2::1/128',[]))
def ping6(address='2001:db8:2::1',source='2001:db8:1::1'):return ex('r1','ping','-6','-c','1','-W','1','-I',source,address,check=False).returncode==0
def ping4():return ex('r1','ping','-c','1','-W','1','-I','10.253.1.1','10.253.2.1',check=False).returncode==0
def snapshot(name):
state={n:{'neighbors6':cli(n,'show ipv6 ospf6 neighbor'),'interfaces6':cli(n,'show ipv6 ospf6 interface'),'neighbors4':json.loads(cli(n,'show ip ospf neighbor json')),'routes6':routes(n),'routes4':routes(n,4),'lsdb6':cli(n,'show ipv6 ospf6 database'),'addresses':json.loads(ex(n,'ip','-j','address').stdout),'kernel6':ex(n,'ip','-6','route').stdout,'configuration':cli(n,'show running-config')} for n in ['r1','r2']}
phases.append({'name':name,'observedAt':datetime.datetime.now(datetime.timezone.utc).isoformat(),'routers':state});return state
try:
image=json.loads(docker(['image','inspect',IMAGE]).stdout)[0]
net=prefix+'-transit';docker(['network','create','--ipv6','--subnet','172.30.243.0/24','--subnet','2001:db8:243::/64','--opt','com.docker.network.bridge.enable_ip_masquerade=false','--label','dr.lab=encor-ipv6',net]);networks.append(net)
for i in [1,2]:
n='r'+str(i);name=prefix+'-'+n;docker(['run','-d','--name',name,'--label','dr.lab=encor-ipv6','--network',net,'--ip','172.30.243.1'+str(i),'--ip6','2001:db8:243::1'+str(i),'--memory','192m','--cpus','0.5','--pids-limit','100','--cap-add','NET_ADMIN','--cap-add','NET_RAW','--cap-add','SYS_ADMIN','--entrypoint','/bin/sh',IMAGE,'-c','sleep 3600']);containers.append(name)
ex(n,'ip','route','del','default',check=False);ex(n,'ip','-6','route','del','default',check=False)
verify(n+' has no IPv4 default',not ex(n,'ip','route','show','default').stdout.strip());verify(n+' has no IPv6 default',not ex(n,'ip','-6','route','show','default').stdout.strip())
ex(n,'touch','/etc/frr/frr.conf','/etc/frr/vtysh.conf');ex(n,'chown','frr:frr','/etc/frr/frr.conf')
for daemon in ['zebra','ospfd','ospf6d']:ex(n,'/usr/lib/frr/'+daemon,'-d','-A','127.0.0.1')
wait_for(n+' daemons',lambda n=n:ex(n,'vtysh','-c','show version',check=False).returncode==0)
verify(n+' uses FRR 10.4.5','10.4.5' in cli(n,'show version'))
ex(n,'ip','address','add',f'10.253.{i}.1/32','dev','lo');ex(n,'ip','-6','address','add',f'2001:db8:{i}::1/128','dev','lo')
cli(n,'configure terminal','router ospf',f'ospf router-id {i}.{i}.{i}.{i}')
cli(n,'configure terminal','router ospf6',f'ospf6 router-id {i}.{i}.{i}.{i}')
cli(n,'configure terminal','interface eth0','ip ospf area 0','ip ospf network point-to-point','ip ospf hello-interval 1','ip ospf dead-interval 4','ipv6 ospf6 area 0','ipv6 ospf6 network point-to-point','ipv6 ospf6 hello-interval 1','ipv6 ospf6 dead-interval 4','ipv6 ospf6 instance-id 0')
cli(n,'configure terminal','interface lo','ip ospf area 0','ip ospf passive','ipv6 ospf6 area 0')
wait_for('IPv4 and IPv6 routes',lambda:remote6() and ping4())
verify('baseline IPv6 loopback probe',ping6());verify('baseline IPv4 loopback probe',ping4())
baseline=snapshot('dual-stack-baseline')
verify('IPv6 remote route uses a scoped link-local next hop',any(h.get('ip','').startswith('fe80:') and h.get('interfaceName')=='eth0' for r in routes('r1')['2001:db8:2::1/128'] if r.get('protocol')=='ospf6' for h in r.get('nexthops',[])))
cli('r2','configure terminal','interface eth0','ipv6 ospf6 instance-id 1')
wait_for('instance mismatch withdraws IPv6 route',lambda:not remote6())
verify('instance mismatch loses IPv6 endpoint',not ping6());verify('instance mismatch leaves IPv4 endpoint reachable',ping4())
verify('instance mismatch leaves connected IPv6 reachable',ping6('2001:db8:243::12','2001:db8:243::11'))
snapshot('instance-mismatch')
cli('r2','configure terminal','interface eth0','ipv6 ospf6 instance-id 0')
wait_for('instance rollback restores IPv6 route',remote6)
verify('instance rollback restores IPv6 endpoint',ping6());verify('IPv4 remains healthy after instance rollback',ping4())
snapshot('instance-recovered')
ex('r2','ip','-6','address','del','2001:db8:2::1/128','dev','lo')
wait_for('prefix withdrawal removes IPv6 route',lambda:not remote6())
verify('withdrawn IPv6 endpoint no longer responds',not ping6());verify('IPv4 endpoint survives IPv6 prefix withdrawal',ping4())
verify('OSPFv3 adjacency survives IPv6 prefix withdrawal','Full' in cli('r1','show ipv6 ospf6 neighbor'))
snapshot('ipv6-prefix-withdrawn')
ex('r2','ip','-6','address','add','2001:db8:2::1/128','dev','lo')
wait_for('prefix restoration',remote6)
verify('restored IPv6 endpoint responds',ping6());verify('final IPv4 endpoint responds',ping4())
snapshot('all-changes-reverted')
evidence={'checkedAt':datetime.datetime.now(datetime.timezone.utc).isoformat(),'image':IMAGE,'imageId':image['Id'],'architecture':image['Architecture'],'scriptSha256':hashlib.sha256(pathlib.Path(__file__).read_bytes()).hexdigest(),'checks':checks,'phases':phases,'limitations':['FRRouting 10.4.5, not Cisco IOS XE execution','IPv4 via OSPFv2 and IPv6 via OSPFv3: no OSPFv3 IPv4 address-family exercise','Two logical routers share one host; no physical redundancy measurement','Single normal area; no OSPFv3 multi-area summarization or filtering execution','No authentication, application/TLS or performance acceptance','No-NAT dedicated bridge; IPv4 and IPv6 default routes removed and checked; NET_ADMIN, NET_RAW and SYS_ADMIN limited to disposable containers'],'cleanup':None}
except Exception:
try:snapshot('failure-diagnostic');pathlib.Path(sys.argv[1]+'.failure.json').write_text(json.dumps({'phases':phases},indent=2))
except Exception as error:print('Could not capture diagnostics:',error)
raise
finally:
cleanup=[]
for name in reversed(containers):cleanup.append({'container':name,'exit':docker(['rm','-f',name],False).returncode})
for name in reversed(networks):cleanup.append({'network':name,'exit':docker(['network','rm',name],False).returncode})
if any(x['exit'] for x in cleanup):raise RuntimeError(cleanup)
evidence['cleanup']=cleanup;pathlib.Path(sys.argv[1]).write_text(json.dumps(evidence,indent=2)+'\n');print(json.dumps({'checks':len(checks),'phases':len(phases),'cleanup':True}))
IPv4 responde, OSPFv3 perde o /128 e a sonda IPv6 falha: reporta a degradação por família.
Armadilhas comuns
Dashboard agregado como teste dual stack; Full como prefixo presente; reinício do processo como reposição de endereço.
Tópicos relacionados: OSPFv3: identidade, link-local e prefixos · OSPFv2: áreas e sumarização
Aceita cada família e cada serviço exigidos com sondas que realmente os exercem.
Referência: FRRouting OSPFv3 · 350-401 ENCOR v1.2, effective 2026-03-19; core component of CCNP Enterprise