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/l2tp-interop/
run.py Runner: preflight, image build, scenario selection
lab.py Docker lifecycle, helpers, FRR/PPP verification
Dockerfile.ze Ze LNS image (Alpine + ze + iproute2 + kmod + ppp)
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 + accounting
Scenarios 01/02 (ze = LNS) contain ze.conf, xl2tpd.conf, ppp-options,
l2tp-secrets, and a check.py with a check() function. A scenario that
instead ships its own run.py (like 03) is self-contained: it manages its own
containers and system-under-test, and the runner delegates to it (skipping the
ze=LNS image build and the PPPoL2TP preflight).
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
make ze-deployment-l2tp-ppp-docker-test # all scenarios
python3 test/l2tp-interop/run.py 01-ppp-ipv4 # single scenario
VERBOSE=1 python3 test/l2tp-interop/run.py # debug output
Environment variables: FRR_IMAGE (default quay.io/frrouting/frr:10.3.1),
VERBOSE, NO_BUILD, SESSION_TIMEOUT (default 90s),
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.
Self-contained (run.py): xl2tpd runs in Docker (--network host); ze runs
from bin/ze with isolated filesystem storage. Control-plane only, so it needs
no PPPoL2TP modules and runs unprivileged. xl2tpd cannot answer the OCRQ that
follows because it has no outgoing-call answerer (logs Unimplemented message 7),
so the RPC returns an error by design; the interop proof is the established
control connection. The full OCRQ→OCRP→OCCN call flow is proven functionally by
test/l2tp/lns-outgoing-call.ci. The LAC incoming-call PPP data plane (kernel
channel bridge, A-4) is env-blocked; see the scenario README and
make ze-qemu-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.
Self-contained (run.py): a third container runs a mock RADIUS server on the
lab network, started before the LAC so no session authenticates against an
absent server. Everything else is the shared ze=LNS flow, so it needs the same
PPPoL2TP host support as 01 and 02.
Relationship to Other Evidence
| Target | What it proves | PPPoL2TP required |
|---|---|---|
make ze-deployment-l2tp-test |
Control tunnel + incoming-call session (skip-kernel-probe) | ✕ |
make ze-deployment-l2tp-ppp-test |
Native Linux full PPP/NCP/kernel proof in peer-isolated netns | ✓ |
make ze-deployment-l2tp-ppp-docker-test |
Peer-isolated Docker lab (this) | ✓ |
make ze-deployment-gokrazy-l2tp-ppp-test |
QEMU gokrazy appliance LNS with real 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: the pinned rtr7 kernel has no l2tp support, so
the script validates KERNEL_PKG or materializes the runtime kernel from the
durable cache, and fails fast with the make ze-kernel KERNEL_ARCH=<arch>
command when it cannot.
Design Pattern
Follows the test/interop/ BGP interop pattern: scenario directory with
daemon configs, per-run Docker network with PID suffix, fixed container IPs,
atexit global cleanup, and check.py assertion scripts imported by the
runner. The L2TP lab is a separate module because the BGP interop has
domain-specific names, images, and daemon helpers.