Skip to content

Add L2TP LAC mode#387

Open
olivier-matz-6wind wants to merge 25 commits into
rtbrick:mainfrom
olivier-matz-6wind:lac_20260427-143853
Open

Add L2TP LAC mode#387
olivier-matz-6wind wants to merge 25 commits into
rtbrick:mainfrom
olivier-matz-6wind:lac_20260427-143853

Conversation

@olivier-matz-6wind
Copy link
Copy Markdown

@olivier-matz-6wind olivier-matz-6wind commented Apr 27, 2026

Bngblaster already supports the LNS role, allowing it to terminate L2TP tunnels from a real LAC under test. This series adds the complementary LAC role: Bngblaster can now act as a LAC, originating PPP sessions encapsulated in L2TP tunnels toward a real LNS under test. This makes it possible to scale-test LNS functionality directly, with full PPP negotiation (LCP, PAP/CHAP, IPCP, IP6CP) running over the L2TP data plane.

Major changes:

  • new top-level l2tp-client array that defines one or more LAC tunnel endpoints, each with a group-id
  • new access interface type "pppol2tp" to tunnel PPP sessions in L2TP instead of PPPoE, they reference a group via l2tp-client-group-id; sessions are spread across all tunnels in the matching group.
  • tunnels are established on demand (SCCRQ/SCCRP/SCCCN), and sessions are opened with ICRQ/ICRP/ICCN. LCP startup is intentionally deferred until the ICCN acknowledgement is received, to give the LNS time to complete its AF_PPPOX socket setup before the first PPP packet arrives. This point is questionnable because it can hide an LNS weakness.
  • teardown is handled gracefully: CDN is sent to the LNS, the PPP session is moved to BBL_TERMINATED, and a StopCCN closes the tunnel when its last session is gone.
  • ARP registration for the LAC client address on the network interface
  • update API schema and documentation

The LAC and LNS roles can be tested locally using a veth pair. An example is included in the documentation (last commit).
We also validated this series with a custom LNS based on accel-ppp.

For the sake of transparency, note that this series was designed with the help of IA agents (mostly claude, but also gemini), this was by the way the opportunity for me to evaluate these tools. All the code was carefully reviewed.

Feel free to send any comment. I know that there are many patches and that reviewing them will be a hard work. If I can help by splitting or reorganizing the pull request, please let me know.

Closes #386

Add a new bbl_l2tp_client_s structure in bbl_l2tp.h, extend the access
interface config with an L2TP client group id (used to associate
sessions with a group of L2TP clients), and parse the new top-level
l2tp-client JSON array into the global context.

Add the related API documentation.

The implementation is done in next commits.

Signed-off-by: Olivier Matz <olivier.matz@6wind.com>
Add a l2tp_tunnel_hostname() static helper that returns the tunnel's
display name (currently server->host_name). Replace all direct
l2tp_tunnel->server->host_name accesses with this wrapper.

The indirection is needed because, in LAC mode, the hostname will come from
client->name instead. The next commit wires that in.

Signed-off-by: Olivier Matz <olivier.matz@6wind.com>
Add the AVP sets required for the five control messages that the LAC
originates during tunnel and session setup:

SCCRQ  Protocol Version, Framing/Bearer Capabilities, Firmware Revision,
       Host Name (from l2tp_client->name), Vendor Name ("bngblaster"),
       Assigned Tunnel ID, Receive Window Size, and optional Challenge.

SCCCN  Challenge Response (when the LNS issued a challenge in its SCCRP).

ICRQ   Assigned Session ID, Call Serial Number (equal to session ID),
       and Bearer Type (analog). The optional calling-number (AVP 22)
       and called-number (AVP 21) fields are encoded if present in the
       configuration.

ICCN   TX Connect Speed (fixed at 100 Mbit/s) and Framing Type
       (synchronous).

StopCCN  Already handled by the existing LNS-mode encoder; no change
         needed beyond reusing it from the LAC path.

The tunnel struct gains an is_lac boolean and a client pointer (alongside
the existing server pointer) so that both modes can share the same
encoding infrastructure.

Link: https://datatracker.ietf.org/doc/html/rfc2661
Signed-off-by: Olivier Matz <olivier.matz@6wind.com>
Extend the existing L2TP code to support the LAC role:

- Check the value of l2tp_tunnel->is_lac to direct towards client or
  server pointer.
- New bbl_l2tp_client_connect(): allocates a tunnel, registers it with the
  client's tunnel list, and sends SCCRQ to initiate the control connection.
- New bbl_l2tp_sccrp_rx(): handles SCCRP from the LNS, decodes AVPs, sends
  SCCCN and moves the tunnel to ESTABLISHED, then triggers the first session.
  The session creation is only there for testing purposes. It will be
  updated in a next commit when PPP will be wired on top of L2TP: the
  sessions will be created dynamically.
- New bbl_l2tp_client_session_connect(): allocates an L2TP session and
  sends ICRQ to open a new session within an established tunnel.
- New bbl_l2tp_icrp_rx(): handles ICRP from the LNS, sends ICCN, and
  mark the L2TP session as established.
- bbl_l2tp_handler_rx(): dispatches SCCRP and ICRP to the new LAC handlers.

Signed-off-by: Olivier Matz <olivier.matz@6wind.com>
Include LAC tunnels in the existing statistics outputs:

- bbl_l2tp_ctrl_tunnels(): iterates over each client's tunnel list and
  appends a JSON object (with client-name and server-address instead of
  server-name) to the l2tp-tunnels response.
- bbl_interactive.c: show the "L2TP LNS/LAC Statistics" block whenever
  either server or client configuration is present.
- bbl_stats.c: apply the same condition to both the stdout and JSON stats
  outputs.

Signed-off-by: Olivier Matz <olivier.matz@6wind.com>
Call bbl_l2tp_client_connect() for every configured l2tp-client entry
during the main initialization sequence in bbl.c, after network
interfaces have been resolved.

This ensures tunnels exist before any PPP session tries to use them, which
is required until session-driven tunnel creation is implemented (in a
latter commit).

Signed-off-by: Olivier Matz <olivier.matz@6wind.com>
Add ACCESS_TYPE_PPPOL2TP to the access_type_t enum and recognize "type":
"pppol2tp" in an access interface section.

Add an ACCESS_TYPE_PPPOL2TP case to the control-job switch to
silence the compiler: session bring-up is wired in a subsequent commit.

Signed-off-by: Olivier Matz <olivier.matz@6wind.com>
Add a sessions_pppol2tp counter in bbl_ctx_ structure, which is
incremented for each ACCESS_TYPE_PPPOL2TP session during
initialization. Include it in the session-counters JSON response, in
the live session summary, and in final statistics.

Signed-off-by: Olivier Matz <olivier.matz@6wind.com>
When an l2tp-client entry specifies a client-address that differs from
the network interface's own IP, BNGBlaster must answer ARP requests for
that address so the LNS can resolve it and send L2TP/UDP packets back.

In bbl_l2tp_client_connect(), if client_address is set and distinct from
the interface address, add it to network_interface->secondary_ip_addresses.
The list is walked first to avoid duplicate entries, since
bbl_l2tp_client_connect() may be called more than once for the same client
(e.g. after a tunnel is torn down and recreated).

Signed-off-by: Olivier Matz <olivier.matz@6wind.com>
Wire ACCESS_TYPE_PPPOL2TP sessions into the L2TP tunnel lifecycle.

In ctrl job, call bbl_l2tp_client_session_get_tunnel() and enqueue the
session via bbl_l2tp_client_session_connect(). Sessions that arrive
before the tunnel reaches ESTABLISHED are queued on a new
pending_session_qhead in the tunnel; they are drained and connected once
bbl_l2tp_sccrp_rx() completes the SCCCN handshake.

Add bbl_l2tp_client_session_get_tunnel(): given a PPPoL2TP session, find
a LAC tunnel associated with the session's configured l2tp-client group
id.

Make bbl_l2tp_client_session_connect() public: it now takes a
bbl_session_s * to associate with the new L2TP session.

Set first_session_tx when SCCRQ is sent from bbl_l2tp_client_connect(),
so that the global setup_time metric is correctly calculated for
PPPoL2TP sessions (analogous to PADI for PPPoE).

Signed-off-by: Olivier Matz <olivier.matz@6wind.com>
Decouple several PPP Rx helpers from the Ethernet header so they can be
called from the L2TP data-plane path where no bbl_ethernet_header_s is
present:

- bbl_access_rx_icmpv6(), bbl_access_rx_icmp(),
  bbl_access_rx_ipv4_mc(): add if(!eth) return guards before any
  eth-dereference.
- bbl_access_rx_ipv4() / bbl_access_rx_ipv6(): replace eth->length
  with eth ? eth->length : ipv4->len for accounting byte counts.
- Export bbl_ppp_rx() (new declaration in bbl_access.h) so the L2TP
  data-RX path can dispatch decoded PPP frames into the same per-session
  handler.
- bbl_access_rx_established_pppoe(): replace the bbl_ethernet_header_s
  *eth parameter with struct timespec *timestamp, removing the last
  implicit dependency on an Ethernet frame in that code path.
- bbl_tcp_ipv4_rx_session() / bbl_tcp_ipv6_rx_session(): drop the unused
  eth parameter; update all call-sites in bbl_access.c, bbl_tcp.c and
  bbl_dhcp.c.

Signed-off-by: Olivier Matz <olivier.matz@6wind.com>
In bbl_l2tp_data_rx(), add a LAC branch before the LNS state machine.
When the tunnel is in LAC mode, incoming PPP frames are forwarded to
bbl_ppp_rx() on the associated bbl_session_s instead of being processed
as LNS responses. This allows LCP/PAP/CHAP/IPCP/IP6CP messages sent
back by the real LNS to drive the PPP client state machine on the LAC
session.

bbl_ppp_rx() already accepts eth=NULL for L2TP-originated frames.

Signed-off-by: Olivier Matz <olivier.matz@6wind.com>
Three changes to complete the session linkage for LAC mode:

- Initialize PPP state (lcp/ipcp/ip6cp, mru, magic number) for PPPOL2TP
  sessions at creation time, mirroring the existing PPPoE model. Without
  this, all PPP states remained at BBL_PPP_DISABLED and the LCP RX handler
  would silently drop packets.

- In bbl_l2tp_client_session_connect(), set l2tp_session->pppoe_session
  and session->l2tp_session so the two structures reference each
  other. This cross-link is required by both the TX and RX paths added in
  subsequent phases.

- After sending ICCN and marking the L2TP session established, start the
  PPP state machine on the associated bbl_session_s, and queue
  BBL_SEND_LCP_REQUEST. Without this the session would remain in
  BBL_L2TP_WAIT indefinitely.

Signed-off-by: Olivier Matz <olivier.matz@6wind.com>
Extract the repeated Ethernet + PPPoE session header construction from
all PPP control-packet encoders into a single bbl_ppp_tx() helper.
This is the TX counterpart of the previously introduced bbl_ppp_rx()
function.

The following encoders are updated to call bbl_ppp_tx():
  bbl_tx_encode_packet_pap_request
  bbl_tx_encode_packet_chap_response
  bbl_tx_encode_packet_lcp_request / _response
  bbl_tx_encode_packet_ipcp_request / _response
  bbl_tx_encode_packet_ip6cp_request / _response

No functional change — bbl_ppp_tx() currently handles ACCESS_TYPE_PPPOE
only, matching the previous inline code exactly. The indirection will
allow additional transports (PPPoL2TP) to be added in one place.

Signed-off-by: Olivier Matz <olivier.matz@6wind.com>
Introduce three transmit helpers to eliminate the remaining duplicated
Ethernet header construction in bbl_tx.c:

bbl_ipoe_tx(session, dst_mac, eth_type, vlan_priority, payload)
  IPoE Ethernet framing for direct-IP sessions.

bbl_pppoe_disc_tx(session, payload)
  PPPoE discovery framing (ETH_TYPE_PPPOE_DISCOVERY) used by
  PADI / PADR / PADT encoders.

bbl_session_tx(session, dst_mac_ipoe, ppp_protocol, ip_payload)
  Transport-agnostic helper that dispatches to bbl_ppp_tx() for
  ACCESS_TYPE_PPPOE or bbl_ipoe_tx() for ACCESS_TYPE_IPOE, using
  ipoe_vlan_priority. Callers that need a non-default VLAN priority
  (like DHCPv6) use bbl_ppp_tx() / bbl_ipoe_tx() directly.

The following encoders are updated:
  bbl_encode_padi / padr / padt     -> bbl_pppoe_disc_tx()
  bbl_tx_encode_packet_igmp         -> bbl_session_tx()
  bbl_tx_encode_packet_icmpv6_rs    -> bbl_session_tx()
  bbl_tx_encode_packet_icmpv6_ns    -> bbl_ipoe_tx()
  bbl_tx_encode_packet_dhcpv6_req   -> bbl_ppp_tx() + bbl_ipoe_tx()
  bbl_tx_encode_packet_dhcp         -> bbl_ipoe_tx()

No functional change.

Signed-off-by: Olivier Matz <olivier.matz@6wind.com>
Make PPP control encoders to work over L2TP tunnels in bbl_ppp_tx()
using the transport helpers introduced in the previous commits.
Because all PPP encoders (LCP, IPCP, IP6CP, PAP, CHAP) already call
bbl_ppp_tx(), they all gain PPPoL2TP support with no further changes.

Add PROTOCOL_QUEUED in bbl_protocols to signal that a packet was
enqueued internally and write_buf must not be sent. In bbl_tx(), it is
converted to EMPTY so the IO layer does not attempt a zero-byte send.

Return EMPTY early in bbl_ppp_tx() when l2tp_session is NULL, silently
dropping the in-flight PPP packet: there is no tunnel left to carry it.
It can happen when an LCP (or other PPP) packet was received while the
L2TP session was about to be torn down.

Signed-off-by: Olivier Matz <olivier.matz@6wind.com>
Relax the restriction that prevents untagged VLANs on access interfaces
when they share a physical interface with a network interface.

For L2TP LAC emulation, the access sessions are logical and will be
encapsulated within an L2TP tunnel on the network interface. Therefore,
a physical VLAN tag on the access interface is not required to separate
subscriber traffic from transport traffic at Layer 2.

Modify bbl_network_interfaces_add() to skip the untagged check when the
access interface type is PPPoL2TP. Store access_type in
bbl_access_interface_s to make this check straightforward.

Signed-off-by: Olivier Matz <olivier.matz@6wind.com>
The bngblaster LNS was originally designed to work with a real LAC that
proxies LCP negotiation from the subscriber side. The LNS only handled
LCP echo-request and term-request, not conf-req/conf-ack.

When bngblaster acts as both LAC and LNS (e.g. for self-testing), the LAC
generates PPP sessions from scratch and sends LCP conf-req to the LNS. Add
LCP conf-req/conf-ack handling to the LNS side so full PPP negotiation can
complete:

- On conf-req: send conf-ack accepting all peer options, then send own
  conf-req with auth=PAP and a per-session magic number.
- On conf-ack: advance lcp_state to OPENED (or send conf-req if not yet
  sent).

Add lcp_state to bbl_l2tp_session_s to track the two-way handshake,
following the same BBL_PPP_LOCAL_ACK / BBL_PPP_PEER_ACK / BBL_PPP_OPENED
pattern already used for IPCP and IP6CP.

Signed-off-by: Olivier Matz <olivier.matz@6wind.com>
Handle graceful teardown of PPPoL2TP sessions on the LAC side:

In bbl_session_clear(), send CDN to the LNS, then call
bbl_l2tp_session_delete() to release the L2TP session.  This is
triggered both from the global teardown path (SIGTERM) and from control
commands (session-stop).

In bbl_l2tp_session_delete(), when an L2TP session is deleted in LAC
mode (CDN received, tunnel torn down, or initiated by
bbl_session_clear), also move the linked PPP session to BBL_TERMINATED
so that timers are stopped and counters are updated correctly. If it is
the last session of the tunnel, close the tunnel with a StopCCN packet.

Finally, in bbl_session_update_state(), reset lcp/ipcp/ip6cp state for
PPPoL2TP sessions, mirroring the existing PPPoE behaviour.

Signed-off-by: Olivier Matz <olivier.matz@6wind.com>
Add bbl_stream_build_pppol2tp_packet() for ACCESS_TYPE_PPPOL2TP
sessions.  This builds an upstream L2TP data packet (LAC -> LNS) with
the LAC's network interface as outer IP source and the client's assigned
IP as inner PPP source, mirroring the existing
bbl_stream_build_l2tp_packet() which covers the downstream
direction (LNS -> LAC).

Wire it into bbl_stream_build_packet() and route PPPoL2TP upstream
streams through tx_network_interface (the tunnel interface) instead of
tx_access_interface in bbl_stream_session_add(), so the stream is
enqueued to the correct IO handle.

Signed-off-by: Olivier Matz <olivier.matz@6wind.com>
Route PPPoL2TP sessions through bbl_ppp_tx() in bbl_session_tx() so
that ICMPv6 RS packets are sent as L2TP data frames rather than falling
into the IPoE Ethernet path.

Extend the IP6CP state guard in bbl_tx_encode_packet_icmpv6_rs() and
bbl_tx_encode_packet_dhcpv6_request() to cover ACCESS_TYPE_PPPOL2TP,
and route the DHCPv6 send dispatch through bbl_ppp_tx() for PPPoL2TP
(matching the existing PPPoE branch).

ICMPv6 NS is IPoE-only (Ethernet-level address resolution) and requires
no changes.

Signed-off-by: Olivier Matz <olivier.matz@6wind.com>
During a test with a real LNS, it appeared that the first LCP packet was
transmitted too early, even before the ICCN packet, marking the end of
the L2TP session setup. This happens because the sending of the ICCN
packet is delayed by L2TP_TX_WAIT_MS (10 ms).

Fixing this was however not enough: the tested LNS was still not able to
handle the LCP packet arriving few microseconds after the ICCN packet,
because the creation of its control socket (AF_PPPOX) happened too
late. While this is an LNS problem, it makes sense to workaround it in
bngblaster by waiting the ICCN ack before starting the LCP layer.

Instead of inserting the session into session_tx_qhead immediately, tag
the ICCN queue entry with the pending PPP session pointer. When the ack
for this packet is received, the session is added in the queue, starting
the PPP state machine.

Signed-off-by: Olivier Matz <olivier.matz@6wind.com>
The upstream IPv4/IPv6 validation in bbl_stream_session_add() requires
a reachable network interface address, an explicit destination, or an
A10NSP interface.  PPPoL2TP sessions have none of these: their inner
addresses come from PPP negotiation, and the packet builders already
handle the missing destination with appropriate fallbacks.

Bypass the strict checks for ACCESS_TYPE_PPPOL2TP so that session-traffic
streams (e.g. "ipv4-pps": 1, "ipv6-pps": 1) can be created without error.

Signed-off-by: Olivier Matz <olivier.matz@6wind.com>
bbl_access_rx_icmpv6() returns immediatly when eth==NULL. The LAC data
receive path always passes eth=NULL (there is no Ethernet frame after
L2TP decapsulation), so Router Advertisements from the LNS are silently
dropped, icmpv6_ra_received stays false, and IPv6 session traffic is
never started.

Remove this guard. The places that actually dereference eth are:
- IPoE RA path (server_mac copy, bbl_access_rx_established_ipoe,
  bbl_access_icmpv6_ns): already inside ACCESS_TYPE_IPOE branch, safe.
- NS handler (bbl_access_icmpv6_na): guarded with individual if(eth).
- Echo-request handler (bbl_access_icmpv6_echo_reply): guarded with if(eth).
- NA / IPoE gateway path (server_mac copy): guarded with if(eth).

Also extend the ACTIVATE_ENDPOINT(session->endpoint.ipv6) call in the RA
prefix handler to cover ACCESS_TYPE_PPPOL2TP alongside
ACCESS_TYPE_PPPOE, so that the IPv6 stream endpoint becomes active after
the RA is received.

Note: the two other !eth early-returns in bbl_access_rx_icmp() and
bbl_access_rx_ipv4_mc() are left in place: ICMPv4 replies and multicast
accounting both require eth and have no meaningful PPPoL2TP equivalent.

Signed-off-by: Olivier Matz <olivier.matz@6wind.com>
Add a new LAC section in L2TP documentation with examples.

Signed-off-by: Olivier Matz <olivier.matz@6wind.com>
@GIC-de
Copy link
Copy Markdown
Member

GIC-de commented May 4, 2026

Thank you very much for this contribution. Although I am currently occupied with other tasks, I will aim to find an opportunity as soon as possible to review and test your merge request. Given the size of these changes, I will require a bit more time to go through everything properly.

@GIC-de GIC-de self-assigned this May 4, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add support for LAC mode to test an LNS

2 participants