A green service can hide a degraded family
For a service with A and AAAA records, a client connecting through IPv4 does not demonstrate IPv6 connectivity. Some clients try different families through mechanisms such as Happy Eyeballs, reducing visible impact from a slow or unavailable path. Do not assume every client has that behavior or the same policy. To accept a dual-stack delivery, identify the family actually used, force separate probes where appropriate and retain results by source, destination and service. An AAAA lookup error, a missing IPv6 route and a TLS failure are distinct hypotheses requiring different observations.
Case: IPv6 batch fails while the general probe passes
A fictional application starts using an IPv6 endpoint. The dashboard’s general probe stays green because it reaches the IPv4 address. In the lab, two routers run OSPFv2 for IPv4 and OSPFv3 for IPv6 over the same transit. One endpoint’s Instance ID is changed only in OSPFv3. The IPv4 test continues responding while the route and probe to the IPv6 endpoint fail. APS should report loss by family and recover the specific configuration while retaining the healthy path. This exercise does not change DNS or run a Happy Eyeballs client; it illustrates a routing failure that an unspecified probe could hide.
Withdraw a prefix without losing the neighbor
In a second experiment, first restore Instance ID and confirm recovery. Then remove only 2001:db8:2::1/128 from R2’s loopback. OSPFv3 adjacency can remain Full because transit is still operational, but the withdrawn prefix stops being advertised and installed on R1. The IPv6 probe fails and the IPv4 probe continues responding. This distinction locates the work: check the service address and its advertisement before restarting neighbors. Restore the address, wait for the installed-route condition and confirm the probe. The runner does not declare success solely because the restore command was accepted.
Run the complete script
Save the example below as run.py. It requires Python 3, running Docker and unused lab subnets: 172.30.243.0/24 and 2001:db8:243::/64. Obtain the image with docker pull quay.io/frrouting/frr@sha256:b0faf7c8f3d8b09aea1e38f77603c745a66dbf5e26ef9718527c2fbb53cc3aed. Run python3 run.py /tmp/encor-ipv6-evidence.json. The runner creates uniquely named resources, removes IPv4 and IPv6 default routes and checks their absence. It mounts no host files, publishes no ports and uses no privileged mode. NET_ADMIN, NET_RAW and SYS_ADMIN are used only inside disposable containers, and created resources are cleaned in finally, including when an assertion fails.
Operational handover criteria
The success file contains performed checks and five observation phases, with addresses, neighbors, LSDB, routes and configuration. Accept it only with a zero exit code. For a real service, add DNS, TCP, TLS and a representative transaction in each required family, using relevant sources and return paths. Define go-live blockers: for example, a required IPv6 endpoint failing even while IPv4 works. The lab does not validate OSPF authentication, multiple OSPFv3 areas, physical resilience or performance. Two instances on one Mac do not equal two physical failure domains.
# 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 responds, OSPFv3 loses the /128 and the IPv6 probe fails: report degradation by family.
Common pitfalls
Aggregate dashboard as a dual-stack test; Full as prefix presence; process restart as address restoration.
Related topics: OSPFv3: identity, link-local and prefixes · OSPFv2: areas and summarization
Accept each required family and service with probes that actually exercise them.
Reference: FRRouting OSPFv3 · 350-401 ENCOR v1.2, effective 2026-03-19; core component of CCNP Enterprise