Lab details

L2TP PPP/NCP Docker Interop Lab

Peer-isolated Docker lab for full L2TP PPP/NCP/kernel dataplane evidence.

Overview

The lab runs Ze as an LNS, a real xl2tpd/pppd LAC, and optionally FRR as a BGP peer in separate privileged Docker containers on an isolated bridge network. It proves the complete path from L2TP control tunnel through PPP LCP/IPCP, kernel pppN interface creation, dataplane connectivity, and BGP route redistribution from a live PPP session.

Layout

test/interop-l2tp/
  Dockerfile.ze        Ze LNS image (Alpine + iproute2 + kmod + ppp; copies the
                       ze-linux the suite preflight cross-compiles)
  ze-linux             the staged daemon, git-ignored and rewritten each run
  Dockerfile.lac       LAC image (Alpine + xl2tpd + ppp + iproute2)
  daemons              FRR daemons config (zebra + bgpd)
  vtysh.conf           FRR vtysh config
  scenarios/
    01-ppp-ipv4/       PPP IPv4 dataplane proof
    02-ppp-bgp-redistribute-frr/   BGP route redistribution proof
    03-ze-lac-xl2tpd-lns/   ze as initiator (LAC) vs real xl2tpd LNS
    04-radius-acct-attrs/   subscriber attributes in RADIUS auth and accounting
internal/le/interoplab/l2tp/
  l2tp.go              Native topology, images, selection, and lifecycle
  checkers.go          Typed protocol assertions for all four scenarios
  radiusmock/          Independent Go RADIUS peer

Each scenario contains the protocol configuration mounted by its typed plan. The plans choose the Ze, xl2tpd, FRR, and RADIUS peers for that scenario, while the checker map fixes the complete four-scenario population.

Prerequisites

The lab requires Docker and a host kernel with PPPoL2TP support. The preflight check probes for /dev/ppp, ip l2tp, and the l2tp_ppp or pppol2tp kernel module from inside a temporary privileged container. If any requirement is missing, the runner exits non-zero with a clear message.

Docker Desktop on macOS typically cannot pass this check because its Linux VM lacks PPPoL2TP kernel modules. The runner does not skip or downgrade; it fails strictly.

Setting ZE_L2TP_SKIP_KERNEL_PROBE or ze.l2tp.skip-kernel-probe in the environment causes an immediate refusal.

Running

./le test deployment docker-l2tp-ppp-test
ZE_L2TP_INTEROP_SCENARIO=01-ppp-ipv4 ./le test deployment docker-l2tp-ppp-test

Environment variables: FRR_IMAGE (default quay.io/frrouting/frr:10.3.1), NO_BUILD, SESSION_TIMEOUT (default 90s), ZE_L2TP_INTEROP_SCENARIO, and ZE_L2TP_INTEROP_SUFFIX (default PID, for parallel-run isolation).

Scenarios

01-ppp-ipv4

Proves: L2TP tunnel establishment, PPP LCP/IPCP completion, kernel pppN with correct local/peer addresses, dataplane ping from LAC to Ze through the PPP tunnel, route inject/withdraw log presence, and clean L2TP/PPP teardown (both containers return to empty ip l2tp show tunnel and ip link show type ppp).

02-ppp-bgp-redistribute-frr

Proves: FRR establishes BGP with Ze, a PPP-assigned subscriber /32 appears in FRR's BGP table via Ze's redistribute destination bgp { import l2tp } (real RouteObserver and redistribute-orchestrator path), and the route is withdrawn from FRR after LAC session teardown. BGP session stability is verified after withdrawal.

03-ze-lac-xl2tpd-lns

The inverse topology: ze is the L2TP initiator (LAC/dialer) and a real xl2tpd runs as the LNS answerer. This proves ze's initiator half of the tunnel FSM (SCCRQ initiation → SCCRP handling → SCCCN → established) interoperates with an independent RFC 2661 implementation. Both sides confirm it: ze logs tunnel now established (initiator), and xl2tpd logs Connection established ... LNS session is 'default'. ze is triggered to dial by the request l2tp outgoing-call RPC over its token-guarded REST API.

The typed plan starts xl2tpd and Ze in containers on the isolated lab bridge. The checker triggers Ze through its REST API and requires both peers to report the established tunnel. The PPPoL2TP preflight is skipped when this control-plane-only scenario is selected alone. xl2tpd cannot answer the OCRQ that follows because it has no outgoing-call answerer (it logs Unimplemented message 7), so the RPC returns an error by design and the established control connection is the interop proof. The full OCRQ→OCRP→OCCN call flow is proven functionally by test/l2tp/lns-outgoing-call.ci. The LAC incoming-call PPP data plane is covered by ./le test deployment gokrazy-l2tp-ppp-test.

04-radius-acct-attrs

Proves the subscriber attributes ze sends to a RADIUS server for a real PPP session: NAS-Port-Id (RFC 2869 Section 5.17) resolved from the operator's nas-port-id-format in the Access-Request and in the accounting records, and Framed-IP-Address (RFC 2865 Section 5.8) in the Accounting-Start carrying the address pppd actually negotiated (RFC 2866 Section 4.1). The assertion reads what the server decoded, not what ze logged.

The Start record also proves the session attributes ze adds to every Accounting-Request: Event-Timestamp within an hour of the lab clock, and Acct-Delay-Time present. It proves two absences as well. Acct-Terminate-Cause is absent, which RFC 2866 Section 5.10 requires of every record that is not a Stop. Calling-Station-Id is absent because xl2tpd's ICRQ carries Message Type, Assigned Call ID, Call Serial Number and Bearer Type alone, so no Calling Number AVP reaches ze, and RFC 2865 Section 5 forbids sending a text attribute of length zero.

The checker then POSTs clear l2tp session all to ze's REST surface on 127.0.0.1:17012 and reads the Stop record that follows. That teardown is the operator one, so the Stop reports Admin Reset, RFC 2866 Section 5.10 value 6.

The typed plan starts the Go RADIUS peer on 172.29.0.5 before Ze and xl2tpd. The peer answers Access-Request and Accounting-Request packets and writes the decoded attributes that the checker compares. This scenario uses the same PPPoL2TP preflight as 01 and 02.

auth-method chap-md5 in this scenario's ze.conf is load-bearing, unlike the same leaf in test/l2tp/radius-acct-wire.ci. xl2tpd sends no RFC 2661 Section 4.4.5 proxy LCP AVPs, so ze negotiates LCP itself and the leaf picks the method. Read in xl2tpd v1.3.18 and v1.3.19: avpsend.c has no builder for AVP 26, 27 or 28, and the ICRQ and ICCN branches of control.c carry none. The authentication has to reach a credential, because RFC 2865 Section 4.1 admits no Access-Request without one.

Relationship to Other Evidence

Action What it proves PPPoL2TP required
./le test deployment l2tp-test Control tunnel and incoming-call session ✕
./le test deployment l2tp-ppp-test Native Linux full PPP/NCP/kernel proof in peer-isolated netns ✓
./le test deployment docker-l2tp-ppp-test Peer-isolated Docker lab (this) ✓
./le test deployment gokrazy-l2tp-ppp-test QEMU gokrazy appliance LNS with a netns LAC ✓
test/plugin/redistribute-l2tp-*.ci Synthetic BGP UPDATE rendering ✕

The native proof and Docker lab catch different failure shapes. The native proof isolates Ze and the LAC in Linux network namespaces joined by a veth underlay; the Docker lab isolates them across a Docker bridge and adds the FRR BGP redistribution scenario.

The gokrazy appliance proof reuses the native LAC shape but puts Ze behind the same gokrazy/QEMU image used for appliance deployment. The appliance attaches to a host bridge by TAP (user-mode slirp cannot deliver the LAC's inbound UDP 1701), so the LAC namespace still exercises a real host PPPoL2TP kernel path while the appliance kernel provides Ze's LNS-side PPPoL2TP support. The proof resolves that kernel itself because the pinned rtr7 kernel has no L2TP support. It validates an operator-supplied kernel package or materialises the runtime kernel from the durable cache and fails before boot when neither can carry PPPoL2TP.

Design Pattern

The lab follows the repository's native interop pattern: checked-in scenario configuration, a per-run Docker network with a unique suffix, fixed peer addresses, typed lifecycle plans, and typed assertions. L2TP keeps its own package because its peer roles, image mounts, RADIUS server, and PPP checks are specific to this protocol.