Architecture

Interoperability Testing

Ze validates protocol correctness against production BGP daemons in two complementary ways: live session interop tests (Docker containers running real daemons) and byte-level wire format validation against ExaBGP (Ze's predecessor, a BGP implementation in Python).

For BGP terminology used in this document, see docs/features.md.

ai/rules/interop-and-goal-validation.md states when an interop test is owed. This page is the infrastructure it is owed against.

The suites, one per protocol area

Protocol area Peer implementation Scenario directory Native action
BGP (session, capability, NLRI, community, policy) Docker: FRR, BIRD, GoBGP, StayRTR test/interop/scenarios/ ./le integration interop
IPsec (IKEv2, EAP, MOBIKE) Docker: strongSwan test/interop-ipsec/ ./le integration interop-ipsec
L2TP Docker test/interop-l2tp/ ./le deployment l2tp-test, and ./le deployment l2tp-ppp-test for the full PPP and NCP path
PPPoE (Ze as client) Docker: accel-ppp test/interop-pppoe/ ./le deployment docker-pppoe-accel-test
RADIUS (admin login: PAP, CHAP, EAP, Filter-Id) Docker: FreeRADIUS test/interop-radius/scenarios/ ./le integration interop-radius

Every suite discovers its scenarios the same way. Discover (internal/le/interoplab/discover.go) reads the scenario directory, keeps the subdirectories, sorts the names lexically, and joins each one against the owning checker registry. A scenario with no checker, or a nil checker, is an ERROR rather than a skipped test, so a fixture and its registry cannot silently disagree. Nothing depends on the order: each scenario gets its own setup, check and teardown.

A scenario directory carries only declarative inputs its runner reads: ze.conf plus the peer configuration and argument files that topology needs. Assertions never live there. They are typed Go checkers under internal/le/interoplab/: BGP builds its registry from scenarioOperations in internal/le/interoplab/bgp/checkers.go, and IPsec declares scenarioCheckers in internal/le/interoplab/ipsec/checkers.go. A checker waits for readiness, asserts the protocol behaviour, verifies stability where the scenario needs it, and returns an error on failure.

A scenario directory is NAMED and carries no numeric prefix. The name is the scenario's identity: Discover matches it exactly, ./le integration takes it as a scenario selector, and specs, journal rows and code comments cite it.

Tested Daemons

Daemon Version Image Query Method What It Validates
FRR 10.3.1 quay.io/frrouting/frr:10.3.1 vtysh eBGP, iBGP, route exchange, GR, communities, MD5, route server
BIRD 2.x (Alpine 3.21) Alpine build birdc eBGP, route exchange, triangle topologies
GoBGP 3.31.0 Go builder gobgp CLI eBGP, route injection and verification
StayRTR 0.6.4 Go builder HTTP. /rpki.json RTR (RFC 8210) as the CACHE, so Ze is the client of an implementation that is not its own. Origin validation answers (RFC 6811) against VRPs a third party encoded
pmacct pmbmpd latest pmacct/pmbmpd:latest JSON msglog file BMP (RFC 7854, RFC 9069) as the COLLECTOR. pmacct decodes the per-peer header, the Peer Up Information TLVs and the Peer Down reason itself, so a scenario reads another implementation's reading of Ze's bytes rather than a string Ze emitted
ExaBGP API 6.0.0 contract fixtures Compiled Go wire server Wire byte comparison Byte-for-byte encoding across all address families

Prerequisites

Requirement Used By Notes
Docker Interop tests Containers for FRR, BIRD, GoBGP, Ze
~1.5 GB disk Interop tests Docker images (Go builder, FRR, Alpine)

The interop test network uses 172.30.0.0/24. MD5 authentication scenarios require NET_ADMIN capability (granted automatically by the orchestrator).

Live Interop Tests (test/interop/)

Each scenario runs Ze and one or more peer daemons in Docker containers on a shared network (172.30.0.0/24), establishes real BGP sessions, and asserts correct behavior via each daemon's native CLI.

How It Works

The native internal/le/interoplab/bgp package discovers scenario directories in test/interop/scenarios/. For each scenario, the shared interoplab.Suite engine:

  1. Creates an isolated Docker network.
  2. Starts Ze and the peer daemons declared by the scenario's config files.
  3. Waits for every declared readiness probe.
  4. Runs the scenario's typed checker from the package-local BGP registry.
  5. Tears down every container and network, including after setup or checker failure.

Daemons start conditionally: FRR if frr.conf exists, BIRD if bird.conf exists, GoBGP if gobgp.toml exists. This means each scenario only runs the daemons it needs.

A scenario directory is NAMED, never numbered (owner directive, 2026-08-24). Run order is lexical by scenario directory name and no scenario depends on it. The rule and its reasoning live in ai/rules/interop-and-goal-validation.md, which is always-on, so a spec author planning a scenario meets it without opening this page.

Container Addresses

Daemon IP Container
Ze 172.30.0.2 ze-iop-ze-<pid>
FRR 172.30.0.3 ze-iop-frr-<pid>
BIRD 172.30.0.4 ze-iop-bird-<pid>
GoBGP 172.30.0.5 ze-iop-gobgp-<pid>
Raw injector 172.30.0.9 ze-iop-inject-<pid>
Compiled strict speaker 172.30.0.10 ze-iop-speaker-<pid>
Compiled strict speaker (2nd) 172.30.0.11 ze-iop-speaker2-<pid>
StayRTR 172.30.0.12 ze-iop-stayrtr-<pid>
pmacct pmbmpd 172.30.0.13 ze-iop-pmacct-<pid>

Container names include the runner PID as suffix, so concurrent runs do not conflict.

Scenario Structure

Each scenario is a directory under test/interop/scenarios/:

scenarios/bgp-ebgp-ipv4-frr/
  ze.conf        # Ze configuration (required)
  frr.conf       # FRR configuration (starts FRR container)

Every directory has one typed checker in internal/le/interoplab/bgp. The catalogue uses explicit operations for ordinary session, route, adjacency, log, and negative assertions, plus bespoke checkers for scenarios whose control flow cannot be represented as an ordered operation list.

A bespoke checker is written in two halves. The body in check_rfc.go does the lab I/O and numbers each assertion, so a failure names the assertion that found it. The pure predicate in check_rfc_predicate.go takes the text or the decoded JSON a peer daemon produced, holds no lab handle, and decides. The split is what lets TestBespokeCheckerBranches drive both polarities of every predicate in seconds with no container.

A body MUST NOT call checkScenario (check_engine.go). That function answers scenario %s has no typed assertions for every name absent from scenarioOperations, and a bespoke name is absent from that table by design, so the call is an error that always fires and every line under it is unreachable.

Optional sidecars

A scenario directory may also carry files that start extra containers before Ze:

File Sidecar Purpose
inject.msg ze-test peer (raw injector, 172.30.0.9) Drive Ze with wire bytes no conforming daemon would emit. An optional inject-args file adds flags. Because the injector and Ze start before the peer daemons, an early route exercises Ze's replay-on-peer-up path.
speaker-args (and optional speaker2-args) ze-test interop-bgp speaker (172.30.0.10; second at 172.30.0.11) Dial Ze with an independent strict peer. The compiled speaker negotiates the requested families and ADD-PATH mode, frames BGP itself, applies the named native oracle, and writes a structured verdict to container logs. It catches wire output that Ze's own lenient decoder could accept.
vrps.json StayRTR (172.30.0.12:8282) Serve RPKI VRPs from a real third-party cache, so Ze is the RTR client of an implementation that is not its own. The typed checker asserts each per-prefix validation answer, not merely the RTR session.
pmbmpd.conf pmacct pmbmpd (172.30.0.13:1790) Read Ze's BMP stream with a collector Ze did not write. The file is also the selector: a scenario that carries it starts pmacct INSTEAD of Ze's own collector, because two collectors are two readings of one stream and only the third-party one is interop evidence. The typed checker greps pmacct's JSON msglog, so every needle is a field pmacct printed after decoding.

A scenario carrying frr.conf may also carry its own daemons file. That file names which FRR daemons run and what each one is started with, so a scenario needing a bgpd module (-M bmp for one that drives Ze's BMP receiver) carries its own copy instead of adding the module to every scenario in the suite. Without one, the shared test/interop/daemons is mounted.

A BMP scenario with no pmbmpd.conf starts ze-test interop-bgp bmp-collector. Announcement and observer process plugins use ze-test interop-bgp process <scenario> <plugin>. These personalities are compiled into ze-test; no interpreter or source mount is present in the Ze image.

Prove a scenario discriminates

An interop scenario is evidence only if it goes RED when the behaviour it tests is broken. Before you rely on a new scenario, revert the fix, run the scenario, and confirm that it fails. Then restore the fix and confirm that it passes.

Let the harness build. Do NOT docker build -t ze-interop by hand and then run with NO_BUILD=1. A tag is shared by every run on the host. A build in another session rebinds that tag between yours and your container start. Your mutation run then measures a daemon you did not build, and that inverted a proof twice in one review on 2026-08-05. Docker.Build reads the image ID from docker build -q, and the suite pins every container of the run to that immutable ID. Quote that line beside the result, because it names the binary the run measured.

A scenario that passes either way (common when the peer must accept both the old and the new wire form) proves acceptance, not correctness. Say which one it proves in the spec's Goal Validation, and move the discrimination to a unit or mutation test that CAN fail.

A scenario added to ALREADY-WORKING code never had a red phase, so its discrimination is unproven until you force one. That is not TDD's red-then-green: a regression test and a scenario for existing behaviour both start green.

Five traps make a scenario pass whatever the code does. Check each by its tell before you call the scenario evidence:

Vacuity trap Why it passes anyway The tell
A scenario for a sender-side wire change whose receiver is obliged to accept any form (RFC 7606 Section 5.1: receivers accept any field combination) A conforming peer accepts the old and the new wire equally Reverting the sender change leaves the peer's routing table identical
A test asserting the ABSENCE of something (no log line, no allocation, no route) Deleting the mechanism leaves the same absence Ask what would still be absent if the code were removed
A test whose fixture is at an extreme (all fields set, maximum value) An off-by-one or a partial break still handles the extreme Boundary the fixture: test one below and one above
A test whose data reaches the peer by a DIFFERENT path than the one changed The unchanged path still delivers Trace which code path actually produces the asserted bytes
An assertion whose clauses are all satisfied by ONE stimulus Each clause reads as an independent observation, and they are one observation written twice Name the single event that satisfies every clause. Then ask which clause a peer that did nothing would still satisfy

The fifth trap is what the IPsec suite carried until 2026-09-04, and it is worth reading in full because the shape recurs. verifyTunnelTraffic (internal/le/interoplab/ipsec/helpers.go) pinged from Ze to strongSwan and passed when each peer's ESP byte counters advanced. Both clauses were satisfied by that ONE ping: Ze encrypting the echo request advances Ze's outbound SA, and strongSwan decrypting the same request advances strongSwan's inbound SA. Nothing required strongSwan to have encrypted anything toward Ze.

RFC 4301 Section 4.1 says why the aggregate could not discriminate:

An SA is a simplex "connection" that affords security services to the traffic carried by it.

A protected bidirectional flow is two SAs, and the RECEIVER chooses the SPI, so both peers name one direction by the same SPI value. Measured in psk-site-to-site: Ze holds src 172.28.0.2 dst 172.28.0.3 spi 0xc12fa7e3 and src 172.28.0.3 dst 172.28.0.2 spi 0xf008af63, and strongSwan holds those same two SPIs. A counter map keyed by SPI alone therefore folds the two peers' views of one direction into one entry. verifyESPDirections now reads each simplex SA by its own src/dst header and takes the set of directions its caller can claim, and it also refuses a ping whose % packet loss summary is missing or non-zero.

The IPsec NAT box

A scenario that carries nat.conf starts a third container, nat, at 172.28.0.5. It is keyed on that file exactly as strongSwan is keyed on swanctl.conf and FRR on frr.conf, and it is started FIRST so its addresses answer ARP before either daemon sends a datagram.

It exists because RFC 7296 Section 2.23.1 is only reachable across a REAL translation. natt-transport-inner-checksum and natt-tunnel-inner-checksum reach the UDP-encapsulated path through strongSwan's encap = yes, which fakes the NAT_DETECTION_SOURCE_IP hash with no middlebox on the path. The pre-NAT address and the observed address are therefore equal in those two, every Section 2.23.1 substitution is the identity, and its absence cannot show. That is the fourth vacuity trap above: the asserted bytes reach the peer by a path the mechanism under test does not touch.

nat.conf is one translation per line, <real> <public>. The box gives itself each public address as a SECONDARY address on eth0, then installs one DNAT rule in PREROUTING and one SNAT rule in POSTROUTING per line. Three consequences are load-bearing:

Three scenarios use it, and they differ in one variable each. The two transport scenarios differ in ROLE, because the substitution has two producers and a single scenario cannot fail on one of them. real-nat-tunnel-control differs from them in MODE, and it does two jobs: it proves the substitution stays out of tunnel mode, and it makes a red transport scenario readable, because a broken topology reds it too. Its selectors are INNER addresses the box never sees, which is the one selector pair in this lab a translation cannot move.

The strongSwan lab drop-in

Every scenario that starts a strongSwan peer mounts test/interop-ipsec/strongswan-lab.conf read-only at /etc/strongswan.d/98-lab.conf. It carries the charon settings the whole lab needs, and today that is one: charon.plugins.bypass-lan.load = no.

charon loads bypass-lan by default, and that plugin installs a PASS shunt for every locally attached subnet. This lab puts both containers on 172.28.0.0/24, so the shunt covers the peer. Measured on 2026-08-30 in psk-site-to-site, the shunt sits at priority 175423 against the Child SA policy's 399999, the lower number wins, and every packet strongSwan sends to Ze leaves in the clear. A ping still succeeds under that shunt, which is exactly why a lossless ping is necessary and not sufficient evidence that a tunnel carried anything.

A scenario's own strongswan.conf still mounts at /etc/strongswan.d/99-interop.conf and composes with the lab file. A scenario MUST NOT set bypass-lan itself: TestNoScenarioCarriesItsOwnBypassLanOverride (test/interop-ipsec/parity_test.go) refuses a second copy, because two files setting one value is a disagreement with nothing to arbitrate it.

The FreeRADIUS admin-login suite

internal/le/interoplab/radius/ runs ze's operator login against a real FreeRADIUS server at a pinned tag, pulled through ImageBuild{Pull: true}. It exists because every other RADIUS proof ze holds runs against a mock ze wrote: test/plugin/aaa-radius-admin.ci drives internal/test/mock/radius/radius.go, and the L2TP lab's peer is internal/le/interoplab/l2tp/radiusmock/, which is ze's own Go program in a container. A mock built beside ze's encoder agrees with ze by construction, and ze now computes a CHAP digest a server must reproduce from its own stored password. Only a server ze did not write can disagree.

It is its own suite rather than four more L2TP scenarios. The L2TP lab probes for the l2tp_ppp or pppol2tp kernel module and refuses to run without it, which is correct for a suite that carries PPP sessions. Admin login is ze's SSH listener, a UDP socket and a RADIUS server, so this lab declares no preflight beyond its own ze cross-compile, mounts no module tree, asks for no capability and runs nothing privileged. TestSuiteNeedsNoKernelModule holds that.

Every checker reads BOTH sides. Ze's log saying source=radius is not enough on its own, because a login the local bcrypt backend satisfied produces a line of the same shape and no server traffic at all. The lab therefore mounts a linelog module at /etc/raddb/mods-enabled/ze_request_log that writes one line per answered request to /var/log/freeradius/ze-request.log, carrying the verdict, the User-Name, the PRESENCE of a User-Password, of a CHAP-Password and of an EAP-Message, and the NAS-Identifier. Presence and not value: a fixture must not put a password or a digest in a log file. parseServerRecord refuses a line missing any of the six fields, so a truncated or reformatted line is never read as a partial verdict.

An EAP login is several requests and FreeRADIUS runs no post-auth section for an Access-Challenge, so the reply that asked the question records nothing. A second module, ze_state_echo_log, is called from authorize on a request carrying both an EAP-Message and a State, and writes verdict=state-echo. That is the server's own evidence that ze returned the State unmodified, which RFC 2865 Section 5.24 requires, and that the login was a conversation rather than one request.

Scenario Ze's side The server's side
radius-admin-pap-freeradius An operator logs in over ze's real SSH listener, ze's log says source=radius, the Filter-Id profile denies show bgp, and the local account's own password is refused verdict=accept with a User-Password present, a CHAP-Password absent and ze's NAS-Identifier, then verdict=reject for the wrong password
radius-admin-chap-freeradius The same, with auth-method chap against a Cleartext-Password entry verdict=accept with a CHAP-Password present and NO User-Password beside it, which is what RFC 2865 Section 4.1 demands of an Access-Request
radius-admin-chap-hashed-freeradius The CHAP login is REFUSED, and ze authenticates the user through no backend at all A radclient probe first proves the same entry accepts the same password over PAP, then verdict=reject for the CHAP request
radius-admin-eap-freeradius The same, with auth-method eap-mschapv2. Ze answers the EAP conversation itself from the operator's password, Naks the server's MD5-Challenge toward MSCHAPv2, and returns the State on every later round verdict=state-echo for at least one round carrying the State the server issued, then verdict=accept, both with an EAP-Message present and NEITHER password attribute beside it

The third scenario is the one that proves docs/guide/radius.md is telling the truth. RFC 2865 Section 2.2:

For example, CHAP requires that the user's password be available in cleartext to the server so that it can encrypt the CHAP challenge and compare that to the CHAP response. If the password is not available in cleartext to the RADIUS server then the server MUST send an Access-Reject to the client.

A rejection on its own would also follow from a typo in the user file, which is the fourth vacuity trap wearing another face: the asserted result would be produced by a different cause than the one under test. The radclient PAP probe removes it, because the storage form is then the only thing left to explain the CHAP rejection.

Two credentials share one username on purpose. radiusop exists in the server's user file AND in ze's local account list with a DIFFERENT password, so the scenario can send the local password while RADIUS rejects it: a chain that fell through to local bcrypt would accept that login. localop exists only locally and the server answers nothing for it, which is the positive control that proves the SSH listener and the local backend are both live before any refusal is read as evidence.

Typed checker operations

checkers.go is the complete scenario catalogue. Each operation identifies the peer, exact command, required or forbidden evidence, proof for negative assertions, and a bound. check_engine.go executes those operations through the shared CheckerLab interface. FRR, BIRD, GoBGP, Ze, speaker logs, and kernel state are all queried through explicit typed branches.

An absent value never proves a negative assertion by itself. The operation must also name positive evidence that the query mechanism ran. Failed and empty queries remain errors rather than becoming plausible empty protocol state.

That rule decides which command a negative assertion sends. opBIRDRouteAbsent reads the whole table with birdc show route, and never show route for <prefix>: BIRD answers a lookup for a network it does not hold with "Network not found", and birdc exits 1. A lookup therefore fails in the exact state the assertion exists to observe, and the failure is not absence. Ask a question the peer answers in both states, then read the absence out of the answer.

Scenarios with non-linear behavior register a bespoke checker in specialCheckers (check_special.go). Each one owes a named subtest in TestBespokeCheckerBranches that drives its predicate in BOTH polarities: the true case, and a false case written against one stated wrong reading, such as two tokens matched across two log lines, a peer-originated event passing as a received one, or an absence with no proof that the query ran.

Querying Ze

Ze.cli(command) is the only way to ask the Ze daemon anything. It runs ze cli -c <command> --user ... --format json inside the container.

Two properties of that line are load-bearing and neither is obvious.

ze cli -c rather than the verb form ze show bgp rib status: --user and --format are flags of ze cli, and the verb form has no slot for either.

The daemon starts an SSH listener only when its config asks for one (infraSetup, cmd/ze/hub/infra_setup.go), and ze cli reaches the daemon over SSH. No scenario ze.conf asks. The harness appends ZE_CLI_CONFIG -- the listener plus the account it authenticates against -- to the RENDERED copy of every ze.conf (renderScenario), so no scenario carries the boilerplate and none can forget it. The native IPsec plan appends the same blocks in renderZeConfig (internal/le/interoplab/ipsec/ipsec.go).

Rendered configurations stay under tmp/interop-rendered in the checkout. Docker must share the checkout with its Linux VM. An unshared system-temp path gives the container a directory instead of the configuration file. IPsec and L2TP create private directories there even without an AI session ID.

A Ze helper never converts a failed query into a plausible number. Ze.rib_count raises when the command fails or answers without a routes-in field, because 0 is a legitimate RIB size and a failed query is not (ai/rules/evidence.md). It returned 0 on failure until 2026-08-07 and three separate faults hid behind that one number for three days (spec-fixit-test-harness-fail-open-guards, guard 3). Write new Ze helpers the same way.

A checker asserts on the STRUCTURED answer, never on the rendered text. The IPsec suite asks with show vpn ipsec sa | json and decodes the list of SA records (zeIKESAs, internal/le/interoplab/ipsec/helpers.go). The text rendering is a table, and its column order follows the field names, so a line-anchored <field> <value> regex over that table matches by accident. On 2026-09-06 one new field, behind-nat, took first place from child-sa in that order. Every nested Child SA key moved off the line start, and child-rekey-narrowing, peer-reload-narrowing and initiator-rekey-answer-narrows went red with no daemon behavior changed.

A number two readers print in two bases is normalized to one TYPE, and the decode is typed. The dataplane-readback scenario joins the SPI set show vpn ipsec dataplane sa | json answers against the set ip xfrm state prints in the same container. iproute2 writes an SPI as 0xc1a2b3c4 and Ze answers a JSON number, so a string comparison is false for every SPI. The checker decodes the Ze answer into a struct whose spi field is a uint32, and parses the printed form to the same type. Decoding into map[string]any would route the number through float64, which holds a uint32 today and says nothing about the uint64 byte counter beside it.

An agreement between two readers asserts NON-EMPTY before it asserts EQUAL. Two empty sets are equal, so a read-only comparison passes over a kernel that holds nothing, which is what the dump answers with its whole body deleted. requireSameSPISet refuses an empty side first and names which side was empty. requireSPISetChanged refuses any old SPI that remains after rekey. The checker polls through the bounded old-SA cleanup window until Ze and iproute2 agree on a nonempty replacement set, and checks strongSwan's kernel against that set too. RFC 7296 Section 2.8 permits overlap during rekey; overlap after cleanup fails.

The same scenario reads all four Child SA byte and packet counters as uint64 values and compares them with the directed lifetime-current records from ip -s xfrm state. It samples before and after bidirectional traffic and requires each counter to advance. A bounded one-way probe makes the directions differ, so swapping inbound and outbound counters cannot pass on a symmetric ping.

Dataplane observation also crosses the live operator surfaces. With the Prometheus exporter enabled, the checker scrapes the installed-SA count and clean drift gauge, deletes one kernel SA, and requires CLI drift, degraded show health, and the corresponding gauge transition. It restores the kernel state and waits for recovery. Removing the configured peer then removes both the peer and interface-label series.

An unreadable observation is induced inside the Ze scenario container by temporarily setting only the daemon's soft RLIMIT_NOFILE to zero. The netlink package's default handle opens a socket for each XFRM dump. A Python helper opens and warms an HTTP connection before changing the limit, scrapes that same connection until both dataplane metric families have no series, and restores the original limit in finally. Reconnection is refused during the fault. During the fault, live health must identify an unknown or unreadable dataplane; the preceding known-drift diagnosis cannot satisfy that assertion. Restoring the limit must restore both the drift diagnosis and the published drift gauges. The checker requires published series before the fault and their return after restoration, so an exporter that never publishes cannot satisfy the test. This helper needs Python in the lab image; it changes no production backend.

All session waiters use explicit bounds (default 90 seconds, override via SESSION_TIMEOUT). The harness passes that value into the Ze container so a compiled process helper can size its barriers against the same budget.

A compiled process helper that fails

Scenario process personalities use the Go plugin SDK. A helper failure writes the ZE-OBSERVER-FAIL sentinel to stderr and requests daemon shutdown. The checker failure path reads the last 2,000 Ze log lines and appends that measured cause when the sentinel is present.

An unreadable log is not a plugin verdict. checkerFailure retains the original scenario assertion when the log read fails, so a Docker diagnostic cannot replace the protocol failure that triggered it.

Scenario Inventory

The suite has grown to over 100 scenario directories in test/interop/scenarios/. The table below lists the core BGP scenarios (01-37); beyond these, the suite also covers route reflection, policy import/export, RPKI origin validation, BMP monitoring (bmp-locrib-pmacct puts Ze's RFC 9069 Loc-RIB feed in front of pmacct and requires pmacct to print the configured Peer AS, the configured Peer BGP ID, the global VRF/Table Name TLV and reason code 6 on the Peer Down; bmp-locrib-receiver-frr turns the direction around, so FRR's bmpd drives Ze's BMP receiver and show bmp peers must report the third party's Loc-RIB peer and its address family), PATHS-LIMIT, max-prefix cease, GTSM, AS112, the RFC 7454 Section 9 transit leak (bgp-path-asn-leak-frr gives FRR two prefixes that differ only in their AS_PATH, and requires ze to drop the one reached through a listed transit ASN, keep the other, and keep the session), ADD-PATH re-advertisement (bgp-addpath-readvertise-collision-frr proves a receiver keeps two paths whose sources both chose one Path Identifier, and bgp-addpath-rail-agreement-speaker proves the live forward and the peer-up replay emit the same bytes for one path), the RFC 6793 mixed-width relay (as-path-mixed-width-relay-frr gives ze a route from a two-octet injector whose AS_PATH carries AS_TRANS and whose AS4_PATH carries the real four-octet AS number, and requires FRR to report that AS number and never 23456; as-path-prepend-two-octet-peer turns the direction around, so ze's own non-mappable AS is prepended toward an FRR that refused the four-octet AS capability), and full IS-IS (auth, convergence, dual-stack, LAN DIS, P2P, redistribution) and OSPFv2/OSPFv3 (auth, BFD, TE, LFA/TI-LFA, graceful restart, segment routing, opaque LSAs, stub/NSSA, virtual links, and more) interop families.

# Scenario Daemons What It Tests
01 ebgp-ipv4-frr Ze, FRR Basic eBGP session establishment
02 ebgp-ipv4-bird Ze, BIRD Basic eBGP session with BIRD
03 ibgp-frr Ze, FRR iBGP session (same AS)
04 4byte-asn-frr Ze, FRR 4-byte ASN negotiation (RFC 6793)
05 routes-from-frr Ze, FRR Ze receives routes originated by FRR
06 routes-from-bird Ze, BIRD Ze receives routes originated by BIRD
07 routes-to-frr Ze, FRR FRR receives routes originated by Ze
08 triangle Ze, FRR, BIRD Three-way topology, multi-peer stability
09 route-withdrawal-frr Ze, FRR Route withdrawal propagation
10 ipv6-ebgp-frr Ze, FRR IPv6 eBGP session and route exchange
11 addpath-frr Ze, FRR ADD-PATH capability (RFC 7911)
12 route-refresh-frr Ze, FRR Route Refresh (RFC 2918)
13 graceful-restart-frr Ze, FRR Graceful Restart negotiation (RFC 4724)
14 route-server-frr Ze, FRR, BIRD Route server: forwards without inserting own ASN
15 community-frr Ze, FRR Standard community propagation
16 extended-community-frr Ze, FRR Extended community propagation
17 md5-auth-frr Ze, FRR TCP MD5 authentication (RFC 2385)
18 ebgp-gobgp Ze, GoBGP eBGP session with GoBGP
19 routes-gobgp Ze, GoBGP Route exchange with GoBGP
20 role-frr Ze, FRR RFC 9234 Role capability negotiation
21 role-gobgp Ze, GoBGP RFC 9234 Role capability negotiation
22 evpn-frr Ze, FRR EVPN Type-2 route exchange
23 vpn-frr Ze, FRR VPN (L3VPN) route exchange
24 flowspec-frr Ze, FRR FlowSpec rule exchange
25 ipv6-ebgp-bird Ze, BIRD IPv6 eBGP route exchange
26 ipv6-ebgp-gobgp Ze, GoBGP IPv6 eBGP route exchange
27 multihop-ebgp-frr Ze, FRR Multi-hop eBGP with outgoing-ttl
28 evpn-gobgp Ze, GoBGP EVPN Type-2 route exchange
29 vpn-gobgp Ze, GoBGP VPN (L3VPN) route exchange
30 flowspec-gobgp Ze, GoBGP FlowSpec rule exchange
31 multihop-ebgp-bird Ze, BIRD Multi-hop eBGP with outgoing-ttl
32 multihop-ebgp-gobgp Ze, GoBGP Multi-hop eBGP with outgoing-ttl
33 bfd-frr Ze, FRR BFD opt-in and BFD-triggered BGP teardown
34 ecmp-frr Ze, FRR, GoBGP FRR ECMP selection for the same prefix from Ze and GoBGP
35 srv6-frr Ze, FRR SRv6 VPNv6 route exchange and Prefix-SID handling
36 remove-private-as-frr Ze, FRR, GoBGP remove-private-as export policy to FRR
37 remove-private-as-as4path-frr Ze, FRR, BIRD remove-private-as handling for AS4_PATH private ASNs

Running

./le integration interop
INTEROP_SCENARIO=bgp-ebgp-ipv4-frr ./le integration interop
VERBOSE=1 ./le integration interop
NO_BUILD=1 ./le integration interop
FRR_IMAGE=quay.io/frrouting/frr:10.3 ./le integration interop
BUILD_TIMEOUT=7200 ./le integration interop

Interop tests require Docker and are not part of the offline precommit gate. They are a separate protocol-validation action.

The first run cross-compiles the lab binaries on the host, then builds the Docker images. No Dockerfile.ze carries a Go compiler: each one is an alpine:3.21 base, one apk add, and a COPY of a binary the suite's preflight has already written into the build context (internal/le/interoplab/zebuild.go, StageBinaries). Each lab declares the binaries it needs beside the images it needs, and the bgp lab declares two, because its scenarios also run ze-test from inside the container.

Measured on 2026-09-06 on a 32-core workstation: 6.1s and 4.8s for the two cross-compiles against a warm cache/go-cache, at a peak resident set of 1.03 GiB and 0.98 GiB, then 54.7s for the docker build, which is now context transfer rather than compilation.

The shape before 2026-09-06 is what those numbers are read against. Dockerfile.ze copied the whole tree and compiled ze twice with no cache mount. One colima VM of 2 CPUs and 2 GB built it in 2m48s on 2026-09-04, and the same VM took 40m39s for it earlier that day, when the host disk was full and the guest was thrashing. On 2026-09-06 the kernel killed that build three times on an idle 31 GiB workstation, once with 23 GiB free and nothing else running, so the compiler in the container was the thing that did not fit rather than the machine being busy.

A consequence: docker build -f test/interop/Dockerfile.ze . on a clean checkout now fails at the COPY until ./le integration interop has run its preflight. Each converted Dockerfile's header names the action that writes its binary.

Each build is bounded at 90 minutes, and BUILD_TIMEOUT sets that bound in whole seconds for a machine slower or faster than that one. The bound stops a wedged Docker daemon and is not a budget for the build, so a build that finishes returns at once and a generous bound costs nothing. A value that does not parse, or that is not positive, keeps the 90 minutes.

An image that needs more than the machine bound declares its own ImageBuild.Timeout, and that field only ever LENGTHENS a bound. A number below the machine bound shortens it, which kills a build the machine would finish, so no suite declares one. The PPPoE suite did until 2026-09-04: 10 minutes for its ze image, 15 for accel-ppp and 10 for the client, each written when the shipped default was 10 minutes and each a cap once the default became 90.

Subsequent runs with NO_BUILD=1 skip rebuilds. Once the images exist, the full suite takes roughly 5-10 minutes depending on session establishment times.

Debugging Failures

On failure, the orchestrator automatically dumps the last 20 lines of container logs. For more detail:

Writing a New Scenario

  1. Create a descriptively named directory under test/interop/scenarios/.
  2. Add ze.conf and whichever peer configs the scenario needs.
  3. Add the scenario's ordered assertions to scenarioOperations and scenarioExtras, or register a bespoke checker in specialCheckers when the control flow is non-linear.
  4. For a bespoke checker, put each decision in a pure predicate in check_rfc_predicate.go and add its both-polarity subtest to TestBespokeCheckerBranches.
  5. Run INTEROP_SCENARIO=<name> ./le integration interop.

TestCheckerPopulationMatchesProducer compares every scenario directory with the package-local registry. TestEveryCheckerFailsClosedWithoutPeerEvidence rejects a checker that can pass without reading a peer. Each negative assertion must carry positive proof that its query mechanism ran.

A scenario that reads the RIB attaches the RIB plugin

plugin { internal rib { use bgp-rib; } } loads the plugin. It does NOT feed it. A peer delivers an event to a process only where both halves agree, and the peer's half is its attach block (Server.PeerScopedProcs, internal/component/plugin/server/delivery_graph.go). A peer with no attach process rib block grants nothing, so the plugin sees no peer at all and every RIB question answers empty:

	attach process rib {
		receive [ update state refresh ];
	}

The tell is "peers": 0 from show bgp rib status while show bgp peer list reports both sessions Established. Ze also logs it at startup: "the plugin declared events and no peer attaches it".

Editing a config means recreating the container, not restarting it

Ze persists its configuration, so docker restart on a scenario container runs the peers of the FIRST boot and ignores the edited file the mount now carries. docker exec ... cat /etc/ze/bgp.conf shows the new text while show bgp peer list shows the old peers, which reads as a config that had no effect. Remove the container and start a new one instead. Every restart-based config experiment in a hand-built lab is void, and the harness is unaffected because it creates each container once.

For Ze's configuration syntax, see docs/architecture/config/syntax.md. Copy an existing scenario's ze.conf as a starting point.

ExaBGP Wire Compatibility (test/exabgp-compat/)

A separate test suite validates that Ze's wire encoding matches the reviewed ExaBGP API 6.0.0 contract fixtures. The compiled Go server negotiates each BGP session and compares every received frame byte-for-byte.

What It Tests

The harness migrates each ExaBGP-derived configuration, runs Ze, and compares its wire bytes with the known-good fixture. The 42 .ci cases in test/exabgp-compat/encoding/ use option=file:, option=serial, 1:cmd:, 1:raw:, 1:signal:, and 1:json: records rather than the standard .ci format. option=serial marks process-driven fixtures that must not overlap other ExaBGP harness instances; the runner executes those after the parallel batch.

<prefix>:signal:<NAME> marks the point in a connection's script where the runner reloads Ze. It divides the script: every raw frame written before it must match before the reload happens, and the frames after it are matched only once it has. Each signal step consumes the NEXT option=file: config the case names, so a case owes one config more than it has signals, and the runner refuses a case where those two counts disagree. test/exabgp-compat/api/api-reload.ci is the case that drives this: its withdrawal of 2.0.0.0/24 is produced BY the reload.

The fixtures name ExaBGP's reload signal, SIGUSR1. Ze reloads on SIGHUP, so the runner translates the name (deliverExaBGPReloads), writes the next migrated config over the path Ze reads, and signals Ze's own pid rather than the process group, which also holds the bridge's scripts.

Coverage includes:

Category Examples
Address families IPv4/IPv6 unicast, VPN, FlowSpec, FlowSpec VPN, EVPN, VPLS, MPLS labeled, MUP, MVPN
Path attributes ORIGIN, AS_PATH, NEXT_HOP, MED, LOCAL_PREF, communities (standard, extended, large), AGGREGATOR, ORIGINATOR_ID, PREFIX_SID, SRv6
Capabilities 4-byte ASN, ADD-PATH, link-local next-hop, software version, hostname
Edge cases Generic/unknown attributes, self-referencing routes, group limits, IPv4+IPv6 mixed configs, deferred announcement (watchdog)

Running

./le functional exabgp-test

ExaBGP compatibility is part of the offline precommit gate.

Test Hierarchy

Workflow Includes Interop? Includes ExaBGP? Requires Docker?
Offline precommit gate
Standard functional sweep
./le integration interop
./le functional exabgp-test

Interop tests are intentionally separate from the pre-commit gate because they require Docker and take longer to run. ExaBGP wire compatibility tests run as part of the standard verification suite.

Current Scope

Interop scenarios cover core BGP: session establishment, route exchange, withdrawal, capabilities (4-byte ASN, ADD-PATH, GR, route refresh, PATHS-LIMIT), communities, MD5 auth, route server behavior, route reflection, policy import/export, RPKI origin validation, BMP monitoring, BFD failover, ECMP, SRv6 VPNv6, remove-private-as export policy, GTSM, AS112, and non-unicast address families (EVPN, VPN, FlowSpec). The suite also includes full IS-IS and OSPFv2/OSPFv3 interop families (adjacency, flooding, SPF, dual-stack, authentication, TE, LFA/TI-LFA, graceful restart, and segment routing). ExaBGP compat covers wire encoding for all supported address families.

Not yet covered by interop tests:

Known Vendor Limitations

Vendor Limitation Affected Scenario Workaround
GoBGP 3.31 Deduplicates Multiprotocol capabilities by AFI. When two families share the same AFI (e.g., ipv4-unicast + l3vpn-ipv4-unicast, both AFI=1), GoBGP keeps only one. bgp-vpn-gobgp None from Ze side. Ze's OPEN is correct per RFC 4760. Families with different AFIs (e.g., ipv4-unicast + l2vpn-evpn) work fine.
BIRD 2.15 Enforces next-hop reachability for IPv6 routes. On IPv4-only Docker networks, IPv6 next-hops are unreachable and BIRD rejects routes as invalid (RFC 7606 treat-as-withdraw). bgp-ipv6-ebgp-bird Add multihop; to BIRD config to disable the directly-connected next-hop check.